@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.
@@ -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
- /** The site event's synced fields. A series' changed dates count from the link's start date on. */
265
- export function siteFields(e: CalEvent, link: Pick<GcalLink, 'rules' | 'startDate'>, ctx: MapContext): SiteFields {
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 when: When = e.allDay ? { allDay: true, start: e.start, end: e.end || e.start, tz: '' } : { allDay: false, start: e.start, end: e.end || e.start, tz };
268
- const repeat: Repeat = e.rrule ? { rrule: cleanRrule(e.rrule), exdates: [...new Set(e.exdates)].sort() } : { rrule: '', exdates: [] };
269
- const place = locationLine(e.location);
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
- for (const key of e.cancelledDates) if (key >= link.startDate && findOccurrence(series, key, ctx.settings)) dates[key] = 'cancelled';
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] || !findOccurrence(series, key, ctx.settings)) continue;
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
- return `acms${hex}${again ? `r${again.toString(32)}` : ''}`;
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
- export const hiddenLink = (eventId: string, origin: string) => ({ private: { acmsEventId: eventId, acmsSite: origin } });
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
- ['time-repeat', 'suggested', (e, x) => e.startAt === x.shape.startAt && e.allDay === x.f.when.allDay && e.rrule === x.f.repeat.rrule],
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); return; }
100
- await updateCopy(ctx, link, e, main, mine.some((i) => i.kind === 'segment'));
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 from a Google split go with it
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 body = googleBody(e, f, link, ctx.map, { desc: true });
158
- const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0);
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: '', etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
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: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
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, split: boolean) {
212
- let f = siteFields(e, link, ctx.map);
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; f = siteFields(e, link, ctx.map);
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
- // a series Google split ("this and following") keeps its time and rule until the site has "from this date on"
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 (!split && inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
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.filter((n) => !held.includes(n)).map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
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
- function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
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 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 });
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. Plain HTML like the staff
6
- // booking emails, plus a text part. Other languages fall back to English.
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) })] : [])];
@@ -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 new ApiError(403, 'Please submit from this site.');
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 new ApiError(413, 'This form is too large.'); } chunks.push(value); }
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 new ApiError(400, 'Could not read this form.'); }
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 new ApiError(400, e.message); throw e; }
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 new ApiError(400, 'Could not submit this form.');
25
- if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw new ApiError(429, 'Too many submissions. Please try again in a minute.');
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 new ApiError(400, 'Reload the form and try again.');
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
  }
@@ -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 and the submission
40
- // guard compare the exact JSON. Read them through the helpers below.
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';
@@ -1,8 +1,34 @@
1
1
  import { ApiError } from '../api';
2
2
  import { sha256Hex } from '../api-keys';
3
- import { FormDefinition, answerFields, type FormResponse } from './schema';
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 new ApiError(409, 'This form has changed. Review the updated questions and submit again.');
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 new ApiError(400, 'Too many answers.');
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 new ApiError(400, e.message); throw e; }
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
- export function displayAnswers(response: FormResponse): Record<string, string> {
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 ?? '');
@@ -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) !== JSON.stringify(response))) throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new 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) 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,JSON.stringify(response.definition)] }, ...(also?.(at) ?? [])]);
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) !== JSON.stringify(response)) throw new ApiError(409, 'This form changed. Reload and try again.');
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).