@acms-subir/core 0.3.58 → 0.3.60
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 +26 -0
- package/docs/calendar.md +5 -2
- package/docs/google-calendar.md +22 -9
- package/package.json +1 -1
- package/src/lib/booking/service.ts +3 -2
- package/src/lib/calendar/google/inbound.ts +245 -45
- package/src/lib/calendar/google/map.ts +54 -14
- package/src/lib/calendar/google/match.ts +3 -2
- package/src/lib/calendar/google/outbound.ts +71 -25
- package/src/lib/calendar/ics-write.ts +3 -2
- package/src/lib/editor/site-data.ts +7 -3
- package/src/lib/forms/alert-mail.ts +7 -4
- package/src/lib/forms/http.ts +10 -8
- package/src/lib/forms/schema.ts +17 -2
- package/src/lib/forms/service.ts +50 -7
- package/src/lib/forms/store.ts +15 -5
- package/src/lib/forms/validate.ts +17 -11
- package/src/lib/forms/words.ts +64 -6
- package/src/lib/mobile/bookings.ts +7 -1
- package/src/lib/mobile/forms.ts +32 -26
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/pages/admin/calendar/bookings.astro +3 -2
- package/src/pages/admin/forms/[id]/submissions.astro +4 -1
- package/src/pages/events/[slug].astro +7 -5
- package/src/pages/forms/[id].astro +7 -5
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import type { CalEvent, Settings } from '../../schema';
|
|
19
19
|
import { EventLocation, hasTag } from '../../schema';
|
|
20
|
-
import { addWallDays, eventInstants, eventTimeZone, expandEvents, findOccurrence, formatWall, isValidTimeZone, locationLine, normalizeEventTimes, parseWall } from '../events';
|
|
20
|
+
import { addWallDays, cutsAt, eventInstants, eventTimeZone, expandEvents, findOccurrence, formatWall, isValidTimeZone, locationLine, normalizeEventTimes, parseWall, seriesParts, type SeriesPart } from '../events';
|
|
21
|
+
import { ruleUntil } from '../ics-write';
|
|
21
22
|
import { tzParts, zonedToUtc } from '../ics';
|
|
22
23
|
import { cleanRrule, describeRepeat, rruleMap } from '../rrule';
|
|
23
24
|
import type { GcalLink, GoogleEvent, GoogleTime, Hashes, PrefixRule } from './types';
|
|
@@ -261,18 +262,30 @@ export function repeatFromGoogle(lines: string[] | undefined, tz: string, allDay
|
|
|
261
262
|
|
|
262
263
|
// ---- each side's fields ----
|
|
263
264
|
|
|
264
|
-
/**
|
|
265
|
-
|
|
265
|
+
/**
|
|
266
|
+
* The site event's synced fields: of its series' first part, or of `part`, a
|
|
267
|
+
* later one from "from this date on" (CalEvent.segments), which Google keeps
|
|
268
|
+
* as a series of its own. A part's rule stops where the next one takes over,
|
|
269
|
+
* as in the site's feeds. A series' changed dates count from the link's start
|
|
270
|
+
* date on, each with the part it belongs to.
|
|
271
|
+
*/
|
|
272
|
+
export function siteFields(e: CalEvent, link: Pick<GcalLink, 'rules' | 'startDate'>, ctx: MapContext, part?: SeriesPart): SiteFields {
|
|
266
273
|
const tz = eventTimeZone(e, ctx.settings);
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
const
|
|
274
|
+
const parts = seriesParts(e);
|
|
275
|
+
const p = part ?? parts[0];
|
|
276
|
+
const when: When = e.allDay ? { allDay: true, start: p.start, end: p.end || p.start, tz: '' } : { allDay: false, start: p.start, end: p.end || p.start, tz };
|
|
277
|
+
// the part's own skipped dates: from where it takes over until the next part does
|
|
278
|
+
const mine = (key: string) => (!p.from || key.slice(0, 10) >= p.from.slice(0, 10)) && !cutsAt(p.until, key);
|
|
279
|
+
const rule = p.until ? ruleUntil(e, p, ctx.settings.timezone) ?? cleanRrule(p.rrule) : cleanRrule(p.rrule);
|
|
280
|
+
const repeat: Repeat = e.rrule ? { rrule: rule, exdates: [...new Set(e.exdates)].filter(mine).sort() } : { rrule: '', exdates: [] };
|
|
281
|
+
const place = locationLine(p.location ?? e.location);
|
|
270
282
|
const dates: Record<string, DateState> = {};
|
|
271
283
|
if (e.rrule) {
|
|
272
284
|
const series = { ...e, movedDates: {} };
|
|
273
|
-
|
|
285
|
+
const inPart = (key: string) => { const o = findOccurrence(series, key, ctx.settings); return !!o && (o.segment ?? '') === p.from; };
|
|
286
|
+
for (const key of e.cancelledDates) if (key >= link.startDate && inPart(key)) dates[key] = 'cancelled';
|
|
274
287
|
for (const [key, m] of Object.entries(e.movedDates)) {
|
|
275
|
-
if (key < link.startDate || dates[key] || !
|
|
288
|
+
if (key < link.startDate || dates[key] || !inPart(key)) continue;
|
|
276
289
|
dates[key] = { start: m.start, end: m.end, place: m.location ? locationLine(m.location) : place };
|
|
277
290
|
}
|
|
278
291
|
}
|
|
@@ -430,6 +443,31 @@ export function isBooking(g: GoogleEvent, link: Pick<GcalLink, 'bookingTitles'>)
|
|
|
430
443
|
/** A later part made by Google's "this and following" ("<series id>_R<when>"): the series it was split from. */
|
|
431
444
|
export const splitFrom = (googleId: string) => /^(.+)_R\d{8}(?:T\d{6}Z?)?$/.exec(googleId)?.[1] ?? null;
|
|
432
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Where a Google split takes over, from its id: "_R" and the original start
|
|
448
|
+
* of the date it was split at (UTC, as people see Google write it), as the
|
|
449
|
+
* site's occurrence key in the series' zone; null when the id has no time.
|
|
450
|
+
*/
|
|
451
|
+
export function splitKey(googleId: string, when: Pick<When, 'allDay' | 'tz'>): string | null {
|
|
452
|
+
const m = /_R(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(googleId);
|
|
453
|
+
if (!m) return null;
|
|
454
|
+
if (when.allDay) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
455
|
+
if (!m[4]) return null;
|
|
456
|
+
return wallOfMs(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]), when.tz || 'UTC');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Two rules alike but for their end (UNTIL or COUNT). */
|
|
460
|
+
export function sameButEnd(a: string, b: string): boolean {
|
|
461
|
+
const rest = (r: string) => JSON.stringify(Object.entries(rruleMap(r)).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT').sort());
|
|
462
|
+
return !!a && !!b && rest(a) === rest(b);
|
|
463
|
+
}
|
|
464
|
+
/** A rule without its end, or with another rule's end. */
|
|
465
|
+
export function withEndOf(rule: string, endFrom: string): string {
|
|
466
|
+
const keep = Object.entries(rruleMap(rule)).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT');
|
|
467
|
+
const end = Object.entries(rruleMap(endFrom)).filter(([k]) => k === 'UNTIL' || k === 'COUNT');
|
|
468
|
+
return cleanRrule([...keep, ...end].map(([k, v]) => `${k}=${v}`).join(';'));
|
|
469
|
+
}
|
|
470
|
+
|
|
433
471
|
// ---- what Google gets ----
|
|
434
472
|
|
|
435
473
|
/**
|
|
@@ -438,23 +476,25 @@ export const splitFrom = (googleId: string) => /^(.+)_R\d{8}(?:T\d{6}Z?)?$/.exec
|
|
|
438
476
|
* again instead of making a second one. A copy made again after Google
|
|
439
477
|
* deleted the first gets a new id (deleted ids stay taken).
|
|
440
478
|
*/
|
|
441
|
-
export function googleIdFor(eventId: string, again = 0): string | undefined {
|
|
479
|
+
export function googleIdFor(eventId: string, again = 0, segment = ''): string | undefined {
|
|
442
480
|
const hex = eventId.toLowerCase().replace(/-/g, '');
|
|
443
481
|
if (!/^[0-9a-f]{32}$/.test(hex)) return undefined;
|
|
444
|
-
|
|
482
|
+
// a later part's copy ("from this date on"): its `from` key, digits only
|
|
483
|
+
return `acms${hex}${segment ? `s${segment.replace(/\D/g, '')}` : ''}${again ? `r${again.toString(32)}` : ''}`;
|
|
445
484
|
}
|
|
446
485
|
|
|
447
|
-
|
|
486
|
+
/** The hidden link on every Google copy (a later part's also names its `from`), so a re-link or a full resync finds the pairs again. */
|
|
487
|
+
export const hiddenLink = (eventId: string, origin: string, segment = '') => ({ private: { acmsEventId: eventId, acmsSite: origin, ...(segment ? { acmsSegment: segment } : {}) } });
|
|
448
488
|
|
|
449
|
-
/** A whole new Google event for the site event (description left out while it's Google's). */
|
|
450
|
-
export function googleBody(e: CalEvent, f: SiteFields, link: Pick<GcalLink, 'rules'>, ctx: MapContext, opts: { desc: boolean }): Record<string, unknown> {
|
|
489
|
+
/** A whole new Google event for the site event, or for one later part of its series (description left out while it's Google's). */
|
|
490
|
+
export function googleBody(e: CalEvent, f: SiteFields, link: Pick<GcalLink, 'rules'>, ctx: MapContext, opts: { desc: boolean; segment?: string }): Record<string, unknown> {
|
|
451
491
|
return {
|
|
452
492
|
summary: googleTitle(e, link.rules, ctx.words),
|
|
453
493
|
...(opts.desc ? { description: googleDescription(f.desc, f.link, ctx.words) } : {}),
|
|
454
494
|
location: f.place,
|
|
455
495
|
...googleTimes(f.when),
|
|
456
496
|
...(f.repeat.rrule ? { recurrence: googleRecurrence(f.repeat, f.when) } : {}),
|
|
457
|
-
extendedProperties: hiddenLink(e.id, ctx.origin),
|
|
497
|
+
extendedProperties: hiddenLink(e.id, ctx.origin, opts.segment),
|
|
458
498
|
};
|
|
459
499
|
}
|
|
460
500
|
|
|
@@ -16,7 +16,7 @@ import { hasTag } from '../../schema';
|
|
|
16
16
|
import type { SyncContext } from './context';
|
|
17
17
|
import { processEvents, listChanges, pairExisting } from './inbound';
|
|
18
18
|
import { queueLinkCalendar } from './links';
|
|
19
|
-
import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, windowStart } from './map';
|
|
19
|
+
import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, sameButEnd, windowStart } from './map';
|
|
20
20
|
import type { GcalLink, MatchPair, MatchState } from './types';
|
|
21
21
|
|
|
22
22
|
const REASON_WORDS: Record<MatchPair['reason'], string> = {
|
|
@@ -41,7 +41,8 @@ export async function buildMatch(ctx: SyncContext, link: GcalLink): Promise<Matc
|
|
|
41
41
|
['hidden-id', 'linked', (e, x) => x.g.extendedProperties?.private?.acmsEventId === e.id && x.g.extendedProperties?.private?.acmsSite === ctx.origin],
|
|
42
42
|
['uid', 'linked', (e, x) => !!e.sourceUid && e.sourceUid === x.g.iCalUID],
|
|
43
43
|
['title-time', 'linked', (e, x) => e.startAt === x.shape.startAt && !!e.rrule === !!x.f.repeat.rrule && comparableTitle(e.title, link.rules) === comparableTitle(x.f.title, link.rules)],
|
|
44
|
-
|
|
44
|
+
// a series either side changed from a date on ends sooner: the same series all the same
|
|
45
|
+
['time-repeat', 'suggested', (e, x) => e.startAt === x.shape.startAt && e.allDay === x.f.when.allDay && (e.rrule === x.f.repeat.rrule || sameButEnd(e.rrule, x.f.repeat.rrule))],
|
|
45
46
|
];
|
|
46
47
|
for (const [reason, state, same] of passes) {
|
|
47
48
|
for (const x of google) {
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
// title. Only the fields the sync owns are patched; guests are never added
|
|
9
9
|
// and Google never emails anyone (sendUpdates=none).
|
|
10
10
|
//
|
|
11
|
+
// A series changed "from this date on" (CalEvent.segments) is one Google
|
|
12
|
+
// series per part, as Google does for "this and following": the first part's
|
|
13
|
+
// rule stops where the next takes over, and each later part is its own
|
|
14
|
+
// series (a segment pair, its hidden link naming its `from`). A cancelled
|
|
15
|
+
// part has no Google copy (its dates are removed in Google, like a cancelled
|
|
16
|
+
// date's instance), and a part the site took back is removed from Google.
|
|
17
|
+
//
|
|
11
18
|
// A change that already matches what the pair agreed is never sent, which is
|
|
12
19
|
// what stops loops: the site's write of a change that came from Google leaves
|
|
13
20
|
// nothing to send back to that link (only to the event's other links).
|
|
14
21
|
import { hasTag, type CalEvent } from '../../schema';
|
|
22
|
+
import { seriesParts, type SeriesPart } from '../events';
|
|
15
23
|
import type { SyncContext } from './context';
|
|
16
24
|
import { entry } from './context';
|
|
17
25
|
import { GoogleError } from './client';
|
|
18
|
-
import { applyGoogleChange } from './inbound';
|
|
26
|
+
import { applyGoogleChange, applySegmentChange } from './inbound';
|
|
19
27
|
import {
|
|
20
28
|
BOOKING_TAG, NONE, changed, dateHash, fieldHashes, fieldWord, googleBody, googleFields, googleIdFor, googlePatch, inWindow, instanceBody,
|
|
21
29
|
instanceState, keyStart, linkCarries, originalStart, siteFields, windowStart, type DateState, type Fields,
|
|
@@ -96,8 +104,43 @@ async function pushToLink(ctx: SyncContext, link: GcalLink, e: CalEvent | null,
|
|
|
96
104
|
// deleted from this Google calendar while the event stays in its other copies: it stays out
|
|
97
105
|
if (main.leftOut) return;
|
|
98
106
|
// deleted in Google (and so cancelled here, Q6): made again only once the site brings it back
|
|
99
|
-
if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main);
|
|
100
|
-
await updateCopy(ctx, link, e, main
|
|
107
|
+
if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main); }
|
|
108
|
+
else await updateCopy(ctx, link, e, main);
|
|
109
|
+
if (e.rrule || mine.some((i) => i.kind === 'segment')) {
|
|
110
|
+
// as they are now (taking Google's changes may have written the event)
|
|
111
|
+
const fresh = await ctx.repo.getEvent(e.id);
|
|
112
|
+
if (fresh) await pushParts(ctx, link, fresh, (await ctx.repo.gcal.itemsForEvent(e.id)).filter((i) => i.linkId === link.id));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The later parts of a series changed from a date on: one Google series each. */
|
|
117
|
+
async function pushParts(ctx: SyncContext, link: GcalLink, e: CalEvent, mine: GcalItem[]) {
|
|
118
|
+
const parts = e.rrule ? seriesParts(e).slice(1) : [];
|
|
119
|
+
const segments = mine.filter((i) => i.kind === 'segment');
|
|
120
|
+
for (const p of parts) {
|
|
121
|
+
const item = segments.find((i) => i.segmentFrom === p.from);
|
|
122
|
+
if (p.cancelled) {
|
|
123
|
+
// its dates are cancelled: removed in Google (kept as the pair, so restoring the part makes it again)
|
|
124
|
+
if (item && !item.gone) {
|
|
125
|
+
await ctx.client.deleteEvent(link.googleId, item.googleId);
|
|
126
|
+
await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, leftOut: false, syncedAt: ctx.now });
|
|
127
|
+
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `Dates from ${p.from.replace('T', ' ')} on cancelled on the site: removed from ${link.googleName}.` })], ctx.now);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!item) await createCopy(ctx, link, e, undefined, p);
|
|
132
|
+
else if (item.gone) await createCopy(ctx, link, e, item, p);
|
|
133
|
+
else await updateCopy(ctx, link, e, item, p);
|
|
134
|
+
}
|
|
135
|
+
for (const item of segments) {
|
|
136
|
+
if (parts.some((p) => p.from === item.segmentFrom)) continue;
|
|
137
|
+
// a split Google had at the first match, never on the site: Google keeps it as it is
|
|
138
|
+
if (!Object.keys(item.site).length) continue;
|
|
139
|
+
// the site took that change back ("Undo"): its Google series goes
|
|
140
|
+
if (!item.gone) await ctx.client.deleteEvent(link.googleId, item.googleId);
|
|
141
|
+
await ctx.repo.gcal.deleteItem(link.id, item.googleId);
|
|
142
|
+
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `The change from ${item.segmentFrom.replace('T', ' ')} on was undone on the site: its series was removed from ${link.googleName}.` })], ctx.now);
|
|
143
|
+
}
|
|
101
144
|
}
|
|
102
145
|
|
|
103
146
|
/** Google won't let the account change this event there: listed once, and the site's change counts as seen (no retries). */
|
|
@@ -115,7 +158,7 @@ async function unpair(ctx: SyncContext, link: GcalLink, item: GcalItem, e: CalEv
|
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
async function removeCopy(ctx: SyncContext, link: GcalLink, item: GcalItem, all: GcalItem[], e: CalEvent | null) {
|
|
118
|
-
// the event's later parts
|
|
161
|
+
// the event's later parts (their own Google series) go with it
|
|
119
162
|
for (const i of all) {
|
|
120
163
|
if (!i.gone) await ctx.client.deleteEvent(link.googleId, i.googleId);
|
|
121
164
|
await ctx.repo.gcal.deleteItem(link.id, i.googleId);
|
|
@@ -151,18 +194,19 @@ function written(patch: Record<string, unknown>, rules: GcalLink['rules']): stri
|
|
|
151
194
|
}
|
|
152
195
|
const masterHashes = (h: Hashes) => Object.fromEntries(Object.entries(h).filter(([k]) => !k.startsWith('date:')));
|
|
153
196
|
|
|
154
|
-
/** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates. */
|
|
155
|
-
export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem) {
|
|
156
|
-
const f = siteFields(e, link, ctx.map);
|
|
157
|
-
const
|
|
158
|
-
const
|
|
197
|
+
/** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates; `part`: a later part's own series. */
|
|
198
|
+
export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem, part?: SeriesPart) {
|
|
199
|
+
const f = siteFields(e, link, ctx.map, part);
|
|
200
|
+
const segment = part?.from ?? '';
|
|
201
|
+
const body = googleBody(e, f, link, ctx.map, { desc: true, segment });
|
|
202
|
+
const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0, segment);
|
|
159
203
|
let g: GoogleEvent;
|
|
160
204
|
try { g = await ctx.client.insertEvent(link.googleId, id ? { id, ...body } : body); }
|
|
161
205
|
catch (err) {
|
|
162
206
|
if (!(err instanceof GoogleError && err.code === 'conflict' && id)) throw err;
|
|
163
207
|
// that id is taken: our own copy from a run that stopped before saving the pair, or one Google deleted (ids stay taken)
|
|
164
208
|
const found = await ctx.client.getEvent(link.googleId, id).catch(() => null);
|
|
165
|
-
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id
|
|
209
|
+
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id && (found.extendedProperties.private.acmsSegment ?? '') === segment
|
|
166
210
|
? await ctx.client.patchEvent(link.googleId, id, body)
|
|
167
211
|
: await ctx.client.insertEvent(link.googleId, body);
|
|
168
212
|
}
|
|
@@ -177,12 +221,13 @@ export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent,
|
|
|
177
221
|
else google[`date:${key}`] = dateHash(after);
|
|
178
222
|
}
|
|
179
223
|
const item: GcalItem = {
|
|
180
|
-
linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: e.rrule ? 'series' : 'single', segmentFrom:
|
|
224
|
+
linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: segment ? 'segment' : e.rrule ? 'series' : 'single', segmentFrom: segment, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
|
|
181
225
|
site: fieldHashes(f), google, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: previous?.createdAt ?? ctx.now,
|
|
182
226
|
};
|
|
183
227
|
if (previous) await ctx.repo.gcal.replaceItem(previous.googleId, item); else await ctx.repo.gcal.putItem(item);
|
|
184
228
|
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'created',
|
|
185
|
-
summary:
|
|
229
|
+
summary: segment ? `From ${segment.replace('T', ' ')} on, a series of its own in ${link.googleName} (“this and following”).`
|
|
230
|
+
: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
|
|
186
231
|
}
|
|
187
232
|
|
|
188
233
|
/** Sets one date of the Google series; returns its state there afterwards (null: Google has no such date). */
|
|
@@ -207,9 +252,11 @@ async function writeDate(ctx: SyncContext, link: GcalLink, masterId: string, key
|
|
|
207
252
|
}
|
|
208
253
|
}
|
|
209
254
|
|
|
210
|
-
/** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field). */
|
|
211
|
-
async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem,
|
|
212
|
-
|
|
255
|
+
/** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field); `part`: a later part's series. */
|
|
256
|
+
async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem, part?: SeriesPart) {
|
|
257
|
+
const partFrom = part?.from;
|
|
258
|
+
const partNow = (ev: CalEvent) => (partFrom === undefined ? undefined : seriesParts(ev).find((p) => p.from === partFrom));
|
|
259
|
+
let f = siteFields(e, link, ctx.map, part);
|
|
213
260
|
if (!pendingSite(fieldHashes(f), item, link, {}).ch.length) return;
|
|
214
261
|
let g: GoogleEvent | null = await ctx.client.getEvent(link.googleId, item.googleId).catch((err) => {
|
|
215
262
|
if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null;
|
|
@@ -223,10 +270,12 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
223
270
|
let gf = googleFields(g, link, ctx.settings);
|
|
224
271
|
// Google changed fields too: take those first (the site wins where both changed, Q4), then send what's left
|
|
225
272
|
if (link.direction === 'both' && gf && changed(masterHashes(fieldHashes(gf)), masterHashes(item.google)).length) {
|
|
226
|
-
await applyGoogleChange(ctx, link, item, g);
|
|
273
|
+
await (part ? applySegmentChange(ctx, link, item, g) : applyGoogleChange(ctx, link, item, g));
|
|
227
274
|
const [again, fresh] = await Promise.all([ctx.repo.gcal.getItem(link.id, item.googleId), ctx.repo.getEvent(e.id)]);
|
|
228
275
|
if (!again || !fresh || again.gone) return;
|
|
229
|
-
item = again; e = fresh;
|
|
276
|
+
item = again; e = fresh;
|
|
277
|
+
if (part) { part = partNow(e); if (!part || part.cancelled) return; }
|
|
278
|
+
f = siteFields(e, link, ctx.map, part);
|
|
230
279
|
}
|
|
231
280
|
const cur = fieldHashes(f);
|
|
232
281
|
const pending = pendingSite(cur, item, link, gf ? fieldHashes(gf) : {});
|
|
@@ -239,10 +288,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
239
288
|
descGoogle = false;
|
|
240
289
|
notes.push(entry(link, item, e.title, { direction: 'info', action: 'description', summary: 'Description edited on the site: from now on Google gets the site’s text (Q8).' }));
|
|
241
290
|
}
|
|
242
|
-
|
|
243
|
-
const held: string[] = split ? ch.filter((n) => n === 'when' || n === 'repeat') : [];
|
|
244
|
-
if (held.length) notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${held.map(fieldWord).join(' and ')} changed on the site, but Google split this series (“this and following”), so ${held.length > 1 ? 'they weren’t' : 'it wasn’t'} sent. Change the later part in Google, or both by hand.` }));
|
|
245
|
-
const fields = ch.filter((n) => !n.startsWith('date:') && !held.includes(n) && !((n === 'desc' || n === 'link') && descGoogle));
|
|
291
|
+
const fields = ch.filter((n) => !n.startsWith('date:') && !((n === 'desc' || n === 'link') && descGoogle));
|
|
246
292
|
const patch = fields.length ? googlePatch(e, f, link, ctx.map, fields, { desc: !descGoogle, googleSummary: g.summary ?? '' }) : {};
|
|
247
293
|
if (Object.keys(patch).length) g = await ctx.client.patchEvent(link.googleId, item.googleId, patch);
|
|
248
294
|
gf = googleFields(g, link, ctx.settings) ?? gf;
|
|
@@ -252,7 +298,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
252
298
|
|
|
253
299
|
// single dates, once the series itself is right (read in the site event's zone, where its keys are)
|
|
254
300
|
const inSiteZone = googleFields(g, link, ctx.settings, f.when.tz);
|
|
255
|
-
if (
|
|
301
|
+
if (inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
|
|
256
302
|
const key = n.slice(5);
|
|
257
303
|
if (keyStart(key, f.when, ctx.settings) <= ctx.now) continue;
|
|
258
304
|
const want = f.dates[key] ?? 'none';
|
|
@@ -265,8 +311,8 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
265
311
|
for (const n of ch) { if (cur[n] === undefined) delete site[n]; else site[n] = cur[n]; }
|
|
266
312
|
// the clash note stays until the site edits that field again (sending the site's winning value isn't an edit)
|
|
267
313
|
const conflict = !item.conflict || !ch.includes(item.conflict.field) ? item.conflict : item.conflict.sent ? null : { ...item.conflict, sent: true };
|
|
268
|
-
await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: e.rrule ? 'series' : 'single', syncedAt: ctx.now });
|
|
269
|
-
const sent = [...new Set(ch.
|
|
270
|
-
if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')} → ${link.googleName}` }));
|
|
314
|
+
await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: part ? 'segment' : e.rrule ? 'series' : 'single', syncedAt: ctx.now });
|
|
315
|
+
const sent = [...new Set(ch.map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
|
|
316
|
+
if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')}${part ? ` (from ${part.from.replace('T', ' ')} on)` : ''} → ${link.googleName}` }));
|
|
271
317
|
await ctx.repo.gcal.log(notes, ctx.now);
|
|
272
318
|
}
|
|
@@ -93,8 +93,9 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
93
93
|
// A part's rule for the feed: stopped just before the next part's `from`
|
|
94
94
|
// (RFC 5545 UNTIL is inclusive: the instant before it for timed events, the
|
|
95
95
|
// day before for all-day ones). COUNT becomes that UNTIL when the rule would
|
|
96
|
-
// still have dates there; null when the part has none left before it.
|
|
97
|
-
|
|
96
|
+
// still have dates there; null when the part has none left before it. The
|
|
97
|
+
// Google Calendar sync gives each part's Google series the same rule.
|
|
98
|
+
export function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
|
|
98
99
|
const clean = cleanRrule(p.rrule);
|
|
99
100
|
if (!p.until || !clean) return clean;
|
|
100
101
|
const cut = parseWall(p.until);
|
|
@@ -3,6 +3,7 @@ import type { Link } from '../schema';
|
|
|
3
3
|
import { LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { displayAnswers } from '../forms/service';
|
|
6
|
+
import { siteLocale } from '../dates';
|
|
6
7
|
import type { EditorContext } from './http';
|
|
7
8
|
import { isAdmin, requireAdmin } from './roles';
|
|
8
9
|
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
@@ -35,13 +36,16 @@ export async function formResponsesFor(c: EditorContext, id: string, q: z.infer<
|
|
|
35
36
|
await requirePrincipal(c.repo, c.who, 'forms.responses');
|
|
36
37
|
const form = await c.repo.forms.get(id);
|
|
37
38
|
if (!form || form.kind !== 'form') throw new ApiError(404, 'Form not found.');
|
|
38
|
-
const rows = await
|
|
39
|
+
const [rows, settings] = await Promise.all([
|
|
40
|
+
c.repo.forms.searchSubmissions(id, { q: q.q, since: q.since ? Date.parse(q.since) : undefined, limit: q.limit + 1, offset: (q.page - 1) * q.limit }),
|
|
41
|
+
c.repo.getSettings(),
|
|
42
|
+
]);
|
|
39
43
|
return {
|
|
40
44
|
form: { id: form.id, name: form.name },
|
|
41
45
|
items: rows.slice(0, q.limit).map((s) => ({
|
|
42
46
|
id: s.id, createdAt: iso(s.createdAt)!,
|
|
43
|
-
// labelled answers from the questions as they were when submitted
|
|
44
|
-
answers: Object.entries(displayAnswers(s.response)).map(([label, value]) => ({ label, value })),
|
|
47
|
+
// labelled answers from the questions as they were when submitted, times as the site writes them
|
|
48
|
+
answers: Object.entries(displayAnswers(s.response, siteLocale(settings))).map(([label, value]) => ({ label, value })),
|
|
45
49
|
})),
|
|
46
50
|
pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null },
|
|
47
51
|
};
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// and its push: in the site's main language, like other staff emails. With
|
|
3
3
|
// the answers, every question in order with its label; without them (forms
|
|
4
4
|
// that ask for sensitive things), only that a response arrived and when, so
|
|
5
|
-
// the email holds nothing about the person.
|
|
6
|
-
//
|
|
5
|
+
// the email holds nothing about the person. Times read as the site writes
|
|
6
|
+
// them ("6:30 PM" in en-US). Plain HTML like the staff booking emails, plus a
|
|
7
|
+
// text part. Other languages fall back to English.
|
|
7
8
|
import type { ContentRepository } from '../repo/types';
|
|
8
9
|
import type { Settings } from '../schema';
|
|
9
10
|
import type { Mail } from '../email/types';
|
|
@@ -12,6 +13,7 @@ import { siteLang, siteLocale } from '../dates';
|
|
|
12
13
|
import { I18N } from '../i18n';
|
|
13
14
|
import { fill } from '../live/words';
|
|
14
15
|
import { answerFields, type FormField, type FormResponse } from './schema';
|
|
16
|
+
import { timeText } from './service';
|
|
15
17
|
|
|
16
18
|
/** alerts one form sends in a day (the site's); the last one says so */
|
|
17
19
|
export const ALERTS_PER_DAY = 50;
|
|
@@ -64,9 +66,10 @@ function whenText(at: number, locale: string, tz: string, year: boolean) {
|
|
|
64
66
|
// names can't break the subject line
|
|
65
67
|
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
66
68
|
|
|
67
|
-
function shown(f: FormField, value: string | undefined, w: AlertWords) {
|
|
69
|
+
function shown(f: FormField, value: string | undefined, w: AlertWords, locale: string) {
|
|
68
70
|
if (f.type === 'checkbox' || f.type === 'consent') return value === 'yes' ? w.yes : w.no;
|
|
69
71
|
if (f.type === 'checkboxes') return value ? value.split('\n').join(', ') : '—';
|
|
72
|
+
if (f.type === 'time' && value) return timeText(value, locale);
|
|
70
73
|
return value || '—';
|
|
71
74
|
}
|
|
72
75
|
/** the answer to the first email question, when it's a usable address (else '') */
|
|
@@ -94,7 +97,7 @@ export async function formAlertMail(repo: ContentRepository, to: string, p: Aler
|
|
|
94
97
|
const name = oneLine(form.name);
|
|
95
98
|
const heading = fill(w.subject, { form: name });
|
|
96
99
|
const intro = fill(w.received, { when: whenText(s.createdAt, locale, settings.timezone, true) });
|
|
97
|
-
const rows: [string, string][] = answers ? answerFields(s.response.definition).map((f) => [f.label, shown(f, s.response.answers[f.id], w)]) : [];
|
|
100
|
+
const rows: [string, string][] = answers ? answerFields(s.response.definition).map((f) => [f.label, shown(f, s.response.answers[f.id], w, locale)]) : [];
|
|
98
101
|
// replies reach the person only when the email shows their answers
|
|
99
102
|
const replyTo = answers ? replyAddress(s.response) : '';
|
|
100
103
|
const notes = [...(replyTo ? [fill(w.reply, { email: replyTo })] : []), ...(p.last ? [fill(w.last, { max: String(ALERTS_PER_DAY) })] : [])];
|
package/src/lib/forms/http.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
import { ApiError } from '../api';
|
|
2
1
|
import { sha256Hex } from '../api-keys';
|
|
3
2
|
import type { ContentRepository } from '../repo/types';
|
|
4
3
|
import type { FormDefinition } from './schema';
|
|
5
4
|
import { AnswerError, answersFrom } from './validate';
|
|
5
|
+
import { refuse } from './service';
|
|
6
|
+
// Refusals say why (service.ts refuse): the public form and event pages word
|
|
7
|
+
// them in the visitor's language (formError); admin pages show the English.
|
|
6
8
|
export function checkOrigin(request: Request) {
|
|
7
9
|
const origin = request.headers.get('origin');
|
|
8
|
-
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') throw
|
|
10
|
+
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') throw refuse(403, 'otherSite');
|
|
9
11
|
}
|
|
10
12
|
export async function readForm(request: Request): Promise<FormData> {
|
|
11
13
|
checkOrigin(request);
|
|
12
14
|
// Read the stream with an actual bound (Content-Length is not trustworthy).
|
|
13
15
|
const reader = request.body?.getReader(); let length = 0; const chunks: Uint8Array[] = [];
|
|
14
|
-
if (reader) for (;;) { const { value, done } = await reader.read(); if (done) break; length += value.length; if (length > 100_000) { await reader.cancel(); throw
|
|
16
|
+
if (reader) for (;;) { const { value, done } = await reader.read(); if (done) break; length += value.length; if (length > 100_000) { await reader.cancel(); throw refuse(413, 'tooLarge'); } chunks.push(value); }
|
|
15
17
|
const bytes = new Uint8Array(length); let at = 0; for (const c of chunks) { bytes.set(c, at); at += c.length; }
|
|
16
18
|
try { return await new Response(bytes, { headers: { 'Content-Type': request.headers.get('content-type') || 'application/x-www-form-urlencoded' } }).formData(); }
|
|
17
|
-
catch { throw
|
|
19
|
+
catch { throw refuse(400, 'unreadable'); }
|
|
18
20
|
}
|
|
19
21
|
export function readAnswers(data: FormData, definition: FormDefinition) {
|
|
20
22
|
try { return answersFrom(name => data.getAll(name), definition); }
|
|
21
|
-
catch (e) { if (e instanceof AnswerError) throw
|
|
23
|
+
catch (e) { if (e instanceof AnswerError) throw refuse(400, e.reason, e.field); throw e; }
|
|
22
24
|
}
|
|
23
25
|
export async function submissionGuard(repo: ContentRepository, data: FormData, address: string, scope: string) {
|
|
24
|
-
if (data.get('website')) throw
|
|
25
|
-
if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw
|
|
26
|
+
if (data.get('website')) throw refuse(400, 'spam');
|
|
27
|
+
if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw refuse(429, 'tooFast');
|
|
26
28
|
const key = String(data.get('requestId') ?? '');
|
|
27
|
-
if (!/^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(key)) throw
|
|
29
|
+
if (!/^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(key)) throw refuse(400, 'reload');
|
|
28
30
|
return key;
|
|
29
31
|
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -36,8 +36,8 @@ export const FormField = z.object({
|
|
|
36
36
|
// Checkbox answers are stored newline-joined, so a choice is one line.
|
|
37
37
|
options: z.array(z.string().trim().min(1).max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).default([]),
|
|
38
38
|
// Newer props are optional (not defaulted) on purpose: parsing must not add
|
|
39
|
-
// keys to stored definitions, because form versions
|
|
40
|
-
//
|
|
39
|
+
// keys to stored definitions, because form versions hash the exact JSON.
|
|
40
|
+
// Read them through the helpers below.
|
|
41
41
|
placeholder: z.string().max(120).optional(),
|
|
42
42
|
width: z.enum(['full', 'half']).optional(),
|
|
43
43
|
allowOther: z.boolean().optional(),
|
|
@@ -86,6 +86,21 @@ export const SavedForm = z.object({
|
|
|
86
86
|
export type SavedForm = z.infer<typeof SavedForm>;
|
|
87
87
|
export const FormSubmission = z.object({ id: z.string(), formId: z.string(), response: FormResponse, createdAt: z.number() });
|
|
88
88
|
export type FormSubmission = z.infer<typeof FormSubmission>;
|
|
89
|
+
// JSON with every object's keys in one order (and undefined left out, as
|
|
90
|
+
// JSON.stringify does), so the same data reads the same however it was written
|
|
91
|
+
const sorted = (v: unknown): string => Array.isArray(v) ? `[${v.map((x) => (x === undefined ? 'null' : sorted(x))).join(',')}]`
|
|
92
|
+
: v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => (v as Record<string, unknown>)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${sorted((v as Record<string, unknown>)[k])}`).join(',')}}`
|
|
93
|
+
: JSON.stringify(v) ?? 'null';
|
|
94
|
+
const meaning = (schema: z.ZodTypeAny, v: unknown) => { const r = schema.safeParse(v); return sorted(r.success ? r.data : v); };
|
|
95
|
+
/**
|
|
96
|
+
* Whether two definitions are the same form, by meaning rather than as
|
|
97
|
+
* written: both parsed (defaults filled in), keys in one order. One stored
|
|
98
|
+
* straight in the database or imported may order its keys differently from
|
|
99
|
+
* how the site writes it.
|
|
100
|
+
*/
|
|
101
|
+
export const sameDefinition = (a: unknown, b: unknown) => meaning(FormDefinition, a) === meaning(FormDefinition, b);
|
|
102
|
+
/** The same for two responses (its definition, version and answers). */
|
|
103
|
+
export const sameResponse = (a: unknown, b: unknown) => meaning(FormResponse, a) === meaning(FormResponse, b);
|
|
89
104
|
const field = (id: string, label: string, type: FormField['type'], required = false, options: string[] = []): FormField => ({ id, label, type, required, help: '', options });
|
|
90
105
|
export function starter(key: string): FormDefinition {
|
|
91
106
|
const contact = key === 'contact', interest = key === 'interest';
|
package/src/lib/forms/service.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import { ApiError } from '../api';
|
|
2
2
|
import { sha256Hex } from '../api-keys';
|
|
3
|
-
import {
|
|
3
|
+
import type { Booking } from '../schema';
|
|
4
|
+
import { isExtraLocale } from '../translations';
|
|
5
|
+
import { FormDefinition, answerFields, type FormField, type FormResponse } from './schema';
|
|
4
6
|
import { AnswerError, checkAnswer } from './validate';
|
|
5
|
-
import { withoutWords } from './localize';
|
|
7
|
+
import { localizeForm, withoutWords } from './localize';
|
|
8
|
+
import { formErrorText, type FormErrorReason } from './words';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A refusal the person sending a form reads (the web form, an event's RSVP
|
|
12
|
+
* questions, the app API): why, and the question when it's about one. The
|
|
13
|
+
* message is the English one; the page or app words it in the person's
|
|
14
|
+
* language with formError. Not `code`: the app API sends an ApiError's code
|
|
15
|
+
* as the error code, and these keep theirs (invalid_request, conflict…).
|
|
16
|
+
*/
|
|
17
|
+
export type FormRefusal = ApiError & { refusal: { reason: FormErrorReason; field?: Pick<FormField, 'id' | 'label'> } };
|
|
18
|
+
export const refuse = (status: number, reason: FormErrorReason, field?: Pick<FormField, 'id' | 'label'>, retryAfter?: number): FormRefusal =>
|
|
19
|
+
Object.assign(new ApiError(status, formErrorText(reason, field?.label), retryAfter), { refusal: { reason, field: field && { id: field.id, label: field.label } } });
|
|
20
|
+
const isRefusal = (e: unknown): e is FormRefusal => e instanceof ApiError && 'refusal' in e;
|
|
21
|
+
/**
|
|
22
|
+
* A form's refusal in `lang` (the page's language, or the app's ?locale=),
|
|
23
|
+
* naming the question as `definition` reads in it: the label the person saw.
|
|
24
|
+
* Same status; anything else comes back as it is.
|
|
25
|
+
*/
|
|
26
|
+
export function formError<E>(e: E, lang: string | null | undefined, definition?: FormDefinition): E | FormRefusal {
|
|
27
|
+
if (!isRefusal(e)) return e;
|
|
28
|
+
const { refusal } = e, id = refusal.field?.id;
|
|
29
|
+
const shown = id && definition ? localizeForm(definition, isExtraLocale(lang) ? lang : '').fields.find((f) => f.id === id) : undefined;
|
|
30
|
+
return Object.assign(new ApiError(e.status, formErrorText(refusal.reason, shown?.label ?? refusal.field?.label, lang), e.retryAfter), { refusal });
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
// The version is the questions and their choices in each language, not the
|
|
8
34
|
// other translated words: rewording a label, help or title doesn't change
|
|
@@ -15,17 +41,31 @@ export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.string
|
|
|
15
41
|
export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
|
|
16
42
|
definition = FormDefinition.parse(definition);
|
|
17
43
|
const current = await formVersion(definition);
|
|
18
|
-
if (version !== undefined && version !== current) throw
|
|
44
|
+
if (version !== undefined && version !== current) throw refuse(409, 'changed');
|
|
19
45
|
const answers: Record<string, string> = Object.create(null);
|
|
20
|
-
if (Object.keys(input).length > 30) throw
|
|
46
|
+
if (Object.keys(input).length > 30) throw refuse(400, 'tooManyAnswers');
|
|
21
47
|
for (const f of answerFields(definition)) {
|
|
22
48
|
try { answers[f.id] = checkAnswer(f, input[f.id] ?? ''); }
|
|
23
|
-
catch (e) { if (e instanceof AnswerError) throw
|
|
49
|
+
catch (e) { if (e instanceof AnswerError) throw refuse(400, e.reason, e.field); throw e; }
|
|
24
50
|
}
|
|
25
51
|
return { version: current, definition, answers };
|
|
26
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A stored time ("18:30") as `locale` writes times: "6:30 PM" in en-US,
|
|
55
|
+
* "6:30 p.m." in es-MX, "18:30" in es-ES. Without a locale (or for anything
|
|
56
|
+
* that isn't HH:mm) it stays as it is.
|
|
57
|
+
*/
|
|
58
|
+
export function timeText(value: string, locale?: string | null): string {
|
|
59
|
+
const m = /^(\d{2}):(\d{2})$/.exec(value);
|
|
60
|
+
if (!m || !locale) return value;
|
|
61
|
+
try { return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(Date.UTC(2000, 0, 1, Number(m[1]), Number(m[2]))); }
|
|
62
|
+
catch { return value; }
|
|
63
|
+
}
|
|
27
64
|
// Labels are a compatibility view only; snapshots are the authoritative record.
|
|
28
|
-
|
|
65
|
+
// locale: staff screens and emails show times as the site writes them
|
|
66
|
+
// (timeText); without one they stay as stored, as RSVPs keep them in
|
|
67
|
+
// Booking.answers. Exports never pass one.
|
|
68
|
+
export function displayAnswers(response: FormResponse, locale?: string): Record<string, string> {
|
|
29
69
|
const out: Record<string, string> = Object.create(null);
|
|
30
70
|
for (const f of answerFields(response.definition)) {
|
|
31
71
|
const v = response.answers[f.id];
|
|
@@ -33,10 +73,13 @@ export function displayAnswers(response: FormResponse): Record<string, string> {
|
|
|
33
73
|
let n = 0;
|
|
34
74
|
while (Object.hasOwn(out, label)) label = `${f.label} (${f.id}${n++ ? `-${n}` : ''})`;
|
|
35
75
|
out[label] = f.type === 'checkbox' || f.type === 'consent' ? v === 'yes' ? 'Yes' : 'No'
|
|
36
|
-
: f.type === 'checkboxes' ? (v ? v.split('\n').join(', ') : '—') : v || '—';
|
|
76
|
+
: f.type === 'checkboxes' ? (v ? v.split('\n').join(', ') : '—') : f.type === 'time' && v ? timeText(v, locale) : v || '—';
|
|
37
77
|
}
|
|
38
78
|
return out;
|
|
39
79
|
}
|
|
80
|
+
/** A booking's answers as staff read them: an RSVP form's with times in `locale` (displayAnswers), else the stored ones. */
|
|
81
|
+
export const staffAnswers = (b: Pick<Booking, 'answers' | 'formResponse'>, locale: string) =>
|
|
82
|
+
b.formResponse ? displayAnswers(b.formResponse, locale) : b.answers;
|
|
40
83
|
export function csv(rows: unknown[][]): string {
|
|
41
84
|
return '\uFEFF' + rows.map(row => row.map(cell => {
|
|
42
85
|
let s = String(cell ?? '');
|
package/src/lib/forms/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
2
|
import { ApiError } from '../api';
|
|
3
|
-
import { FormAlerts, FormDefinition, FormResponse, FormSubmission, SavedForm } from './schema';
|
|
3
|
+
import { FormAlerts, FormDefinition, FormResponse, FormSubmission, SavedForm, sameDefinition, sameResponse } from './schema';
|
|
4
|
+
import { refuse } from './service';
|
|
4
5
|
const row = (r: Record<string, unknown>) => SavedForm.parse({ ...r, definition: JSON.parse(String(r.definition)), draft: r.draft ? JSON.parse(String(r.draft)) : null, createdAt: r.created_at, updatedAt: r.updated_at, alerts: JSON.parse(String(r.alerts || '{}')) });
|
|
5
6
|
const submission = (r: Record<string, unknown>) => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at });
|
|
6
7
|
export const formInsert = (f: SavedForm): SqlStatement => ({ sql: 'INSERT INTO forms (id,name,kind,purpose,status,definition,draft,revision,created_at,updated_at,alerts) VALUES (?,?,?,?,?,?,?,?,?,?,?)', args: [f.id,f.name,f.kind,f.purpose,f.status,JSON.stringify(f.definition),f.draft ? JSON.stringify(f.draft) : null,f.revision,f.createdAt,f.updatedAt,JSON.stringify(f.alerts)] });
|
|
@@ -36,7 +37,7 @@ export class FormStore {
|
|
|
36
37
|
const r = (await (await this.driver()).all('SELECT * FROM form_submissions WHERE id=? AND form_id=?', [id, formId]))[0];
|
|
37
38
|
return r ? submission(r) : null;
|
|
38
39
|
}
|
|
39
|
-
/** the response this request id already saved to this form, if any (as stored JSON) */
|
|
40
|
+
/** the response this request id already saved to this form, if any (as stored JSON; compare it with sameResponse) */
|
|
40
41
|
async receipt(formId: string, requestId: string) {
|
|
41
42
|
const r = (await (await this.driver()).all('SELECT response FROM form_submissions WHERE id=? AND form_id=?', [requestId, formId]))[0];
|
|
42
43
|
return r ? String(r.response) : null;
|
|
@@ -51,11 +52,20 @@ export class FormStore {
|
|
|
51
52
|
const db = await this.driver();
|
|
52
53
|
// The receipt is also the unique request identity, so retries don't duplicate responses.
|
|
53
54
|
const old = (await db.all('SELECT form_id,response FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
54
|
-
if (old && (old.form_id !== formId || String(old.response)
|
|
55
|
+
if (old && (old.form_id !== formId || !sameResponse(JSON.parse(String(old.response)), response))) throw refuse(409, 'resent');
|
|
55
56
|
const at = Date.now();
|
|
56
|
-
if (!old)
|
|
57
|
+
if (!old) {
|
|
58
|
+
// The form must still take responses and be the one they answered,
|
|
59
|
+
// compared by meaning: a definition written straight into the database
|
|
60
|
+
// or imported may order its keys differently. The insert then checks
|
|
61
|
+
// the stored text it just read, so a publish or archive in between
|
|
62
|
+
// still refuses it (a draft save leaves the live definition alone).
|
|
63
|
+
const live = (await db.all("SELECT definition FROM forms WHERE id=? AND status='published' AND kind='form' AND purpose='general'", [formId]))[0];
|
|
64
|
+
if (!live || !sameDefinition(JSON.parse(String(live.definition)), response.definition)) throw refuse(409, 'changedAtSave');
|
|
65
|
+
await db.batch([{ sql: 'INSERT OR IGNORE INTO form_submissions (id,form_id,response,created_at) SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM forms WHERE id=? AND status=\'published\' AND kind=\'form\' AND purpose=\'general\' AND definition=?)', args: [requestId,formId,JSON.stringify(response),at,formId,String(live.definition)] }, ...(also?.(at) ?? [])]);
|
|
66
|
+
}
|
|
57
67
|
const saved = (await db.all('SELECT form_id,response,created_at FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
58
|
-
if (!saved || saved.form_id !== formId || String(saved.response)
|
|
68
|
+
if (!saved || saved.form_id !== formId || !sameResponse(JSON.parse(String(saved.response)), response)) throw refuse(409, 'changedAtSave');
|
|
59
69
|
return !old && Number(saved.created_at) === at;
|
|
60
70
|
}
|
|
61
71
|
// Archived → draft (the definition is kept; publishing reopens it).
|