@acms-subir/core 0.3.56 → 0.3.58
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 +29 -0
- package/cli/migrations.generated.mjs +24 -5
- package/docs/calendar.md +130 -2
- package/docs/server.md +33 -1
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +75 -15
- package/src/lib/booking/service.ts +4 -2
- package/src/lib/calendar/api.ts +29 -8
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +16 -7
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +2 -1
- package/src/lib/calendar/request-mail.ts +2 -1
- package/src/lib/calendar/request-page.ts +2 -1
- package/src/lib/calendar/request-service.ts +7 -7
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/display/board.ts +6 -5
- package/src/lib/editor/schedule.ts +4 -2
- package/src/lib/mobile/calendar.ts +6 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/mobile/search.ts +2 -1
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +13 -3
- package/src/lib/repo/sql.ts +34 -13
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +34 -0
- package/src/lib/sections.ts +3 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +9 -8
- package/src/pages/admin/calendar/requests.astro +5 -5
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +87 -7
- package/src/server/index.ts +46 -3
- package/src/styles/editor.css +2 -0
|
@@ -32,6 +32,11 @@ export const Page = ContentSummary.extend({
|
|
|
32
32
|
export const EventRoom = z.object({ slug: str, name: str, color: str.nullable() });
|
|
33
33
|
// one date moved here from `from` (null when it wasn't); location: its own place, null = the event's
|
|
34
34
|
export const OccurrenceMoved = z.object({ from: z.object({ start: date, end: date }), note: str, location: z.object({ name: str, address: str, url: str }).nullable() }).nullable();
|
|
35
|
+
// a date's own place (a moved date's, or from its event's change from a date on); null = the event's
|
|
36
|
+
export const OccurrencePlace = z.object({ name: str, address: str, url: str }).nullable();
|
|
37
|
+
// the series from one date on (CalEvent.segments): `from` is the occurrence key where it took over; location null = the event's
|
|
38
|
+
export const EventSegment = z.object({ from: str, start: str, end: str, rrule: str, repeats: str, cancelled: bool, note: str,
|
|
39
|
+
location: z.object({ name: str, address: str, url: str }).nullable(), rooms: z.array(EventRoom) });
|
|
35
40
|
export const Event = CalEvent.pick({
|
|
36
41
|
id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
|
|
37
42
|
allDay: true, timezone: true, rrule: true, exdates: true, cancelledDates: true, location: true, cta: true, tags: true, fields: true,
|
|
@@ -40,18 +45,20 @@ export const Event = CalEvent.pick({
|
|
|
40
45
|
rooms: z.array(EventRoom),
|
|
41
46
|
// single dates at another time, by original occurrence key (wall-clock, like start/end)
|
|
42
47
|
movedDates: z.record(z.object({ start: str, end: str, note: str })),
|
|
48
|
+
// changed from a date on, in order ([] = never); `repeats` above is the part in effect now
|
|
49
|
+
segments: z.array(EventSegment),
|
|
43
50
|
// push topics told about date, time or place changes (Admin → Mobile app → When an event changes); [] = none
|
|
44
51
|
notify: z.object({ changes: strings }),
|
|
45
52
|
});
|
|
46
53
|
export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
|
|
47
|
-
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom), event: Event })),
|
|
54
|
+
occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom), event: Event })),
|
|
48
55
|
});
|
|
49
56
|
const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
|
|
50
57
|
const ContentHit = ContentSummary.extend({ snippet: Snippet });
|
|
51
|
-
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
58
|
+
export const EventDetail = z.object({ locale, occurrence: z.object({ id: str, start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom) }).nullable(), event: Event });
|
|
52
59
|
export const SearchResults = z.object({ locale, query: str,
|
|
53
60
|
posts: z.array(ContentHit), pages: z.array(ContentHit),
|
|
54
|
-
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
61
|
+
events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool, cancelled: bool, moved: OccurrenceMoved, location: OccurrencePlace, rooms: z.array(EventRoom) }).nullable(), event: Event })),
|
|
55
62
|
more: z.object({ posts: bool, pages: bool, events: bool }),
|
|
56
63
|
});
|
|
57
64
|
const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import { CONTENT } from '../content';
|
|
4
4
|
import { normalizePageLocale } from '../i18n';
|
|
5
5
|
import { siteOrigin } from '../seo';
|
|
6
|
+
import { siteLang } from '../dates';
|
|
6
7
|
import { SEARCH } from '../search-config';
|
|
7
8
|
import { CALENDAR } from '../calendar/config';
|
|
8
9
|
import { eventIsListed, featuredOccurrence } from '../calendar/events';
|
|
@@ -85,7 +86,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
85
86
|
return {
|
|
86
87
|
snippet: plainSnippet(snippet),
|
|
87
88
|
// the next occurrence, or the latest one when the event is over
|
|
88
|
-
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources)) : null,
|
|
89
|
+
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources, locale || siteLang(settings))) : null,
|
|
89
90
|
event: publicEventDto(e, origin, listed, settings, locale, resources),
|
|
90
91
|
};
|
|
91
92
|
}),
|
package/src/lib/push/service.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ContentRepository } from '../repo/types';
|
|
|
5
5
|
import type { CalEvent, Calendar, Post, Settings } from '../schema';
|
|
6
6
|
import { CONTENT, collectionUrl } from '../content';
|
|
7
7
|
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
8
|
-
import { eventIsListed, eventTimeZone, expandEvents, findOccurrence, type Occurrence } from '../calendar/events';
|
|
8
|
+
import { cutsAt, eventIsListed, eventTimeZone, expandEvents, findOccurrence, partOf, type Occurrence } from '../calendar/events';
|
|
9
|
+
import { describeRepeat } from '../calendar/rrule';
|
|
9
10
|
import { tzParts } from '../calendar/ics';
|
|
10
11
|
import { I18N, stripLocale } from '../i18n';
|
|
11
12
|
import { localizeDoc } from '../translations';
|
|
@@ -98,28 +99,30 @@ interface Words {
|
|
|
98
99
|
cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
|
|
99
100
|
// one date of a series moved (CalEvent.movedDates), or several: their new days
|
|
100
101
|
dateMoved: (to: string, was: string) => string; datesMoved: (d: string) => string;
|
|
102
|
+
// the series changed from a date on (CalEvent.segments): "From Wed, Jan 16: Weekly on Wednesday, 5:00 PM"
|
|
103
|
+
fromDate: (d: string, what: string) => string;
|
|
101
104
|
}
|
|
102
105
|
const STRINGS: Record<string, Words> = {
|
|
103
106
|
en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
|
|
104
107
|
moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
|
|
105
108
|
cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more`,
|
|
106
|
-
dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}` },
|
|
109
|
+
dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}`, fromDate: (d, w) => `From ${d}: ${w}` },
|
|
107
110
|
es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
|
|
108
111
|
moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
|
|
109
112
|
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más`,
|
|
110
|
-
dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}` },
|
|
113
|
+
dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}`, fromDate: (d, w) => `Desde el ${d}: ${w}` },
|
|
111
114
|
fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
|
|
112
115
|
moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
|
|
113
116
|
cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres`,
|
|
114
|
-
dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}` },
|
|
117
|
+
dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}`, fromDate: (d, w) => `À partir du ${d} : ${w}` },
|
|
115
118
|
de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
|
|
116
119
|
moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
|
|
117
120
|
cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere`,
|
|
118
|
-
dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}` },
|
|
121
|
+
dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}`, fromDate: (d, w) => `Ab ${d}: ${w}` },
|
|
119
122
|
pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
|
|
120
123
|
moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
|
|
121
124
|
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}`,
|
|
122
|
-
dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}` },
|
|
125
|
+
dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}`, fromDate: (d, w) => `A partir de ${d}: ${w}` },
|
|
123
126
|
};
|
|
124
127
|
export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
|
|
125
128
|
|
|
@@ -299,7 +302,8 @@ export const CHANGE_SOON = 3 * HOUR;
|
|
|
299
302
|
export function eventSnapshot(e: CalEvent): EventSnapshot {
|
|
300
303
|
return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
|
|
301
304
|
cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() },
|
|
302
|
-
movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }]))
|
|
305
|
+
movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }])),
|
|
306
|
+
segments: (e.segments ?? []).map(({ from, start, end, rrule, cancelled }) => ({ from, start, end, rrule, cancelled })).sort((a, b) => (a.from < b.from ? -1 : 1)) };
|
|
303
307
|
}
|
|
304
308
|
function readSnapshot(raw: string | null): EventSnapshot | null {
|
|
305
309
|
try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
|
|
@@ -317,6 +321,10 @@ export interface EventChange {
|
|
|
317
321
|
cancelledDates?: Slot[];
|
|
318
322
|
/** single dates of a repeating event at a new time (moved, moved again or moved back), by their new time */
|
|
319
323
|
movedDates?: { from: Slot; to: Slot }[];
|
|
324
|
+
/** the series changed from a date on (or back): its first new date, and the rule it follows from there */
|
|
325
|
+
fromDate?: { at: Slot; rrule: string; start: string };
|
|
326
|
+
/** every date from this one on cancelled (a cancelled segment) */
|
|
327
|
+
cancelledFrom?: Slot;
|
|
320
328
|
/** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
|
|
321
329
|
at: number;
|
|
322
330
|
}
|
|
@@ -342,7 +350,24 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
|
|
|
342
350
|
if (!upcoming) return null;
|
|
343
351
|
if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
|
|
344
352
|
// the series as people knew it, with today's moved dates (those are compared one by one below)
|
|
353
|
+
// (its segments are today's: a change to them is told on its own, below)
|
|
345
354
|
const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
|
|
355
|
+
// changed from a date on (or back): one notice from the first date it touches; the dates it carried over aren't news on their own
|
|
356
|
+
const segs = (list: EventSnapshot['segments']) => new Map(list.map((g) => [g.from, JSON.stringify(g)]));
|
|
357
|
+
const before = segs(was.segments), after = segs(eventSnapshot(e).segments);
|
|
358
|
+
const touched = [...new Set([...before.keys(), ...after.keys()])].filter((k) => before.get(k) !== after.get(k)).sort()[0];
|
|
359
|
+
// (when the whole series moved, its segments moved with it: that's told as a move below)
|
|
360
|
+
const sameSeries = was.start === e.start && was.end === e.end && was.rrule === e.rrule && was.allDay === e.allDay && was.timezone === e.timezone;
|
|
361
|
+
if (touched !== undefined && sameSeries) {
|
|
362
|
+
const first = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
|
|
363
|
+
.find((o) => o.start > now && (cutsAt(touched, o.key) || (o.segment ?? '') >= touched));
|
|
364
|
+
if (!first) return null;
|
|
365
|
+
const part = partOf(e, first);
|
|
366
|
+
const slot = { start: first.start, allDay: first.allDay };
|
|
367
|
+
const place = { name: e.location.name.trim(), address: e.location.address.trim() };
|
|
368
|
+
const placeChange = (place.name || place.address) && (place.name !== was.location.name || place.address !== was.location.address) ? { place: { from: was.location, to: place } } : {};
|
|
369
|
+
return part.cancelled ? { cancelledFrom: slot, at: slot.start, ...placeChange } : { fromDate: { at: slot, rrule: part.rrule, start: part.start }, at: slot.start, ...placeChange };
|
|
370
|
+
}
|
|
346
371
|
// a date taken out of the series (dragged to its own event) or put back isn't a move
|
|
347
372
|
const exdates = [...new Set([...was.exdates, ...e.exdates])];
|
|
348
373
|
const from = slots({ ...then, exdates }, settings, now).next;
|
|
@@ -418,10 +443,11 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
|
|
|
418
443
|
const zone = (x: Slot) => (x.allDay ? siteTz : tz);
|
|
419
444
|
const dates = change.cancelledDates ?? [];
|
|
420
445
|
const movedDates = change.movedDates ?? [];
|
|
421
|
-
const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
|
|
446
|
+
const kind = change.cancelled ? 'cancelled' : change.cancelledFrom ? 'cancelledFrom' : change.fromDate ? 'fromDate' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
|
|
422
447
|
// one cancelled or moved date opens that date (?on= is its day in the site's zone, like calendar links)
|
|
423
448
|
let on = '';
|
|
424
|
-
const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to
|
|
449
|
+
const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to
|
|
450
|
+
: change.fromDate?.at ?? change.cancelledFrom ?? null;
|
|
425
451
|
if (one) {
|
|
426
452
|
const p = tzParts(one.start, siteTz);
|
|
427
453
|
on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
|
|
@@ -432,7 +458,13 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
|
|
|
432
458
|
const day = (x: Slot) => when(x.start, true, zone(x), locale);
|
|
433
459
|
const parts: string[] = [];
|
|
434
460
|
if (change.cancelled) parts.push(ev.rrule ? s.cancelledFrom(day(change.cancelled)) : s.cancelled(at(change.cancelled)));
|
|
435
|
-
else
|
|
461
|
+
else if (change.cancelledFrom) parts.push(s.cancelledFrom(day(change.cancelledFrom)));
|
|
462
|
+
else if (change.fromDate) {
|
|
463
|
+
const { at: first, rrule, start } = change.fromDate;
|
|
464
|
+
const rule = describeRepeat(rrule, start, undefined, locale);
|
|
465
|
+
parts.push(s.fromDate(day(first), first.allDay || !rule ? rule || at(first) : `${rule}, ${when(first.start, false, zone(first), locale, 'time')}`));
|
|
466
|
+
if (change.place) parts.push(s.place(ev.location.name.trim() || ev.location.address.trim()));
|
|
467
|
+
} else {
|
|
436
468
|
const loc = { name: ev.location.name.trim(), address: ev.location.address.trim() };
|
|
437
469
|
// a new name is enough; the same name at a new address needs both
|
|
438
470
|
const place = !change.place ? '' : change.place.to.name !== change.place.from.name ? loc.name || loc.address : [loc.name, loc.address].filter(Boolean).join(', ');
|
package/src/lib/push/types.ts
CHANGED
|
@@ -40,6 +40,8 @@ export const EventSnapshot = z.object({
|
|
|
40
40
|
cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
|
|
41
41
|
// single dates at another time (key → new wall times); notices queued before moved dates have none
|
|
42
42
|
movedDates: z.record(z.object({ start: z.string(), end: z.string() })).default({}),
|
|
43
|
+
// the series from a date on (CalEvent.segments); notices queued before segments have none
|
|
44
|
+
segments: z.array(z.object({ from: z.string(), start: z.string(), end: z.string(), rrule: z.string(), cancelled: z.boolean() })).default([]),
|
|
43
45
|
});
|
|
44
46
|
export type EventSnapshot = z.infer<typeof EventSnapshot>;
|
|
45
47
|
|
|
@@ -5,7 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
|
5
5
|
import { PUSH_MIGRATION } from '../push/migration';
|
|
6
6
|
import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
7
7
|
import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
|
|
8
|
-
import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION } from '../calendar/change-migration';
|
|
8
|
+
import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION, SEGMENTS_MIGRATION } from '../calendar/change-migration';
|
|
9
9
|
import { PLACES_MIGRATION } from '../maps/migration';
|
|
10
10
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
11
11
|
import { SYNC_MIGRATION } from '../sync/migration';
|
|
@@ -738,14 +738,24 @@ export const MIGRATIONS: Migration[] = [
|
|
|
738
738
|
// two-way Google Calendar sync: the account, links, pairs, outgoing queue
|
|
739
739
|
// (triggers on events) and Recent changes. Not public, so no cache trigger.
|
|
740
740
|
GCAL_MIGRATION,
|
|
741
|
+
// a series changed from one date on (CalEvent.segments): a column on the
|
|
742
|
+
// row, so the triggers on events see it, like moved_dates
|
|
743
|
+
SEGMENTS_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'])[2]),
|
|
744
|
+
// a room's name in other languages (Resource.translations): public, so a
|
|
745
|
+
// change refreshes cached pages and API answers
|
|
746
|
+
{ version: 71, statements: [
|
|
747
|
+
`ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
748
|
+
`DROP TRIGGER content_version_resources_update`,
|
|
749
|
+
contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'])[2],
|
|
750
|
+
] },
|
|
741
751
|
];
|
|
742
752
|
|
|
743
753
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
744
754
|
export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
745
755
|
...CONTENT_VERSION_COLUMNS,
|
|
746
756
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
747
|
-
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'],
|
|
748
|
-
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
|
|
757
|
+
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
|
|
758
|
+
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
|
|
749
759
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
750
760
|
};
|
|
751
761
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { LiveConfig } from '../live/types';
|
|
|
16
16
|
import { ApiError } from '../api';
|
|
17
17
|
import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
|
|
18
18
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
19
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
19
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, SeriesSegment, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
20
20
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
21
21
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
22
22
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -164,6 +164,13 @@ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
|
|
|
164
164
|
// (push_control.quiet shares the write's transaction)
|
|
165
165
|
const quietly = (write: SqlStatement, quiet?: boolean): SqlStatement[] =>
|
|
166
166
|
quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write];
|
|
167
|
+
// a corrupt segments list, or one bad segment, reads as "not changed from a date"
|
|
168
|
+
function parseSegments(raw: unknown): CalEvent['segments'] {
|
|
169
|
+
let value: unknown;
|
|
170
|
+
try { value = JSON.parse(String(raw ?? '[]')); } catch { return []; }
|
|
171
|
+
if (!Array.isArray(value)) return [];
|
|
172
|
+
return value.flatMap((entry) => { const s = SeriesSegment.safeParse(entry); return s.success ? [s.data] : []; }).sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
173
|
+
}
|
|
167
174
|
function rowToEvent(r: Row): CalEvent {
|
|
168
175
|
return CalEvent.parse({
|
|
169
176
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -172,6 +179,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
172
179
|
featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
|
|
173
180
|
start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
|
|
174
181
|
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
|
|
182
|
+
segments: parseSegments(r.segments),
|
|
175
183
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
176
184
|
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
177
185
|
hostIds: json(r.host_ids, []), request: parseRequest(r.request),
|
|
@@ -181,6 +189,11 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
181
189
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
182
190
|
});
|
|
183
191
|
}
|
|
192
|
+
// a room's names in other languages; a bad entry (or a broken column) reads as untranslated
|
|
193
|
+
function roomTranslations(raw: unknown): Resource['translations'] {
|
|
194
|
+
const parsed = Resource.shape.translations.safeParse(json(raw, {}));
|
|
195
|
+
return parsed.success ? parsed.data : {};
|
|
196
|
+
}
|
|
184
197
|
function rowToResource(r: Row): Resource {
|
|
185
198
|
return Resource.parse({
|
|
186
199
|
id: r.id, slug: r.slug, name: r.name, kind: r.kind ?? 'Room', description: r.description ?? '', photo: r.photo ?? '',
|
|
@@ -188,7 +201,7 @@ function rowToResource(r: Row): Resource {
|
|
|
188
201
|
amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
|
|
189
202
|
busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
|
|
190
203
|
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
|
|
191
|
-
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable),
|
|
204
|
+
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable), translations: roomTranslations(r.translations),
|
|
192
205
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
193
206
|
});
|
|
194
207
|
}
|
|
@@ -870,8 +883,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
870
883
|
await this.driver.run(
|
|
871
884
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
872
885
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
873
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
|
|
874
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
886
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates, segments)
|
|
887
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
875
888
|
[
|
|
876
889
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
877
890
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -881,7 +894,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
881
894
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
882
895
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
883
896
|
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
|
|
884
|
-
JSON.stringify(data.movedDates ?? {}),
|
|
897
|
+
JSON.stringify(data.movedDates ?? {}), JSON.stringify(data.segments ?? []),
|
|
885
898
|
]
|
|
886
899
|
);
|
|
887
900
|
await this.syncEventIndex(id);
|
|
@@ -907,7 +920,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
907
920
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
908
921
|
}
|
|
909
922
|
const jsonCols: Record<string, string> = {
|
|
910
|
-
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
923
|
+
tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', segments: 'segments', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
911
924
|
};
|
|
912
925
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
913
926
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -986,6 +999,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
986
999
|
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
987
1000
|
await this.driver.batch(quietly(write, opts.quiet));
|
|
988
1001
|
}
|
|
1002
|
+
async setEventDates(id: string, dates: Partial<Pick<CalEvent, 'segments' | 'movedDates' | 'cancelledDates' | 'exdates'>>, opts: { quiet?: boolean } = {}) {
|
|
1003
|
+
await this.ready;
|
|
1004
|
+
const cols: [keyof typeof dates, string][] = [['segments', 'segments'], ['movedDates', 'moved_dates'], ['cancelledDates', 'cancelled_dates'], ['exdates', 'exdates']];
|
|
1005
|
+
const set = cols.filter(([k]) => dates[k] !== undefined);
|
|
1006
|
+
if (!set.length) return;
|
|
1007
|
+
const write = { sql: `UPDATE events SET ${set.map(([, c]) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, args: [...set.map(([k]) => JSON.stringify(dates[k])), Date.now(), id] };
|
|
1008
|
+
await this.driver.batch(quietly(write, opts.quiet));
|
|
1009
|
+
}
|
|
989
1010
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
990
1011
|
await this.ready;
|
|
991
1012
|
// the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
|
|
@@ -1144,10 +1165,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1144
1165
|
private resourceInsert(r: Resource): SqlStatement {
|
|
1145
1166
|
return {
|
|
1146
1167
|
sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
|
|
1147
|
-
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1168
|
+
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable, translations) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1148
1169
|
args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1149
1170
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1150
|
-
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0],
|
|
1171
|
+
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, JSON.stringify(r.translations)],
|
|
1151
1172
|
};
|
|
1152
1173
|
}
|
|
1153
1174
|
private linkInsert(l: BookingLink): SqlStatement {
|
|
@@ -1199,10 +1220,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1199
1220
|
await this.driver.run(
|
|
1200
1221
|
`UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
|
|
1201
1222
|
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
|
|
1202
|
-
color = ?, show_on_events = ?, requestable = ?, updated_at = ? WHERE id = ?`,
|
|
1223
|
+
color = ?, show_on_events = ?, requestable = ?, translations = ?, updated_at = ? WHERE id = ?`,
|
|
1203
1224
|
[r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1204
1225
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1205
|
-
r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, r.updatedAt, id]
|
|
1226
|
+
r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, JSON.stringify(r.translations), r.updatedAt, id]
|
|
1206
1227
|
);
|
|
1207
1228
|
}
|
|
1208
1229
|
async reorderResources(ids: string[]) {
|
|
@@ -1939,15 +1960,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1939
1960
|
...(content.events ?? []).map((e) => ({
|
|
1940
1961
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1941
1962
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1942
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
|
|
1943
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1963
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates, segments)
|
|
1964
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1944
1965
|
args: [
|
|
1945
1966
|
e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
|
|
1946
1967
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1947
1968
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1948
1969
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1949
1970
|
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
|
|
1950
|
-
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
|
|
1971
|
+
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}), JSON.stringify(e.segments ?? []),
|
|
1951
1972
|
],
|
|
1952
1973
|
})),
|
|
1953
1974
|
// announcements (a backup made before them leaves the current ones). Restored
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -51,7 +51,7 @@ export type EventUpdate = Partial<
|
|
|
51
51
|
CalEvent,
|
|
52
52
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
53
53
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
54
|
-
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
|
|
54
|
+
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates' | 'segments'
|
|
55
55
|
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
|
|
56
56
|
| 'publishedAt' | 'scheduledAt'
|
|
57
57
|
>
|
|
@@ -179,6 +179,8 @@ export interface ContentRepository {
|
|
|
179
179
|
setEventCancelledDates(id: string, dates: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
180
180
|
/** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
|
|
181
181
|
setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
182
|
+
/** replace an event's rule segments, moved, cancelled or skipped dates together in one write (lib/calendar/segments.ts), keeping any staged draft; quiet as above */
|
|
183
|
+
setEventDates(id: string, dates: Partial<Pick<CalEvent, 'segments' | 'movedDates' | 'cancelledDates' | 'exdates'>>, opts?: { quiet?: boolean }): Promise<void>;
|
|
182
184
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
|
183
185
|
cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
|
|
184
186
|
/**
|
package/src/lib/schema.ts
CHANGED
|
@@ -375,6 +375,28 @@ export const MovedDate = z.object({
|
|
|
375
375
|
export type MovedDate = z.infer<typeof MovedDate>;
|
|
376
376
|
export const MovedDates = z.record(z.string(), MovedDate).default({});
|
|
377
377
|
|
|
378
|
+
// A repeating event changed from one date on (CalEvent.segments, C4): the
|
|
379
|
+
// rule before it stops at `from` (an occurrence key of that rule: its dates
|
|
380
|
+
// from there on are gone) and this one takes over with its own first date
|
|
381
|
+
// (`start`/`end`, wall-clock like the event's, and possibly before `from`),
|
|
382
|
+
// rule, rooms and place. It stays one event: same page, hosts and RSVPs.
|
|
383
|
+
// `cancelled`: every date from `from` on is cancelled (struck through,
|
|
384
|
+
// restorable). note is public; by/at as for MovedDate. lib/calendar/segments.ts
|
|
385
|
+
// writes these; they're sorted by `from`.
|
|
386
|
+
export const SeriesSegment = z.object({
|
|
387
|
+
from: z.string(),
|
|
388
|
+
start: z.string(),
|
|
389
|
+
end: z.string(),
|
|
390
|
+
rrule: z.string().default(''),
|
|
391
|
+
roomIds: z.array(z.string()).optional(),
|
|
392
|
+
location: EventLocation.optional(),
|
|
393
|
+
cancelled: z.boolean().default(false),
|
|
394
|
+
note: z.string().max(500).default(''),
|
|
395
|
+
by: z.string().default(''),
|
|
396
|
+
at: z.number().default(0),
|
|
397
|
+
});
|
|
398
|
+
export type SeriesSegment = z.infer<typeof SeriesSegment>;
|
|
399
|
+
|
|
378
400
|
// A member's event request (lib/calendar/requests.ts): a new event waiting
|
|
379
401
|
// for approval (kind 'new', the event stays a draft), or changes to a live
|
|
380
402
|
// event staged in its `draft` (kind 'changes'). Staff approve or decline;
|
|
@@ -477,6 +499,8 @@ export const CalEvent = z.object({
|
|
|
477
499
|
// single dates at another time (MovedDate above); a date is moved or
|
|
478
500
|
// cancelled, never both. Keys that no longer match an occurrence are ignored.
|
|
479
501
|
movedDates: MovedDates,
|
|
502
|
+
// the series changed from a date on (SeriesSegment above); only used while it repeats
|
|
503
|
+
segments: z.array(SeriesSegment).default([]),
|
|
480
504
|
location: EventLocation.default({}),
|
|
481
505
|
cta: EventCta.default({}),
|
|
482
506
|
calendarIds: z.array(z.string()).default([]),
|
|
@@ -587,6 +611,9 @@ export const Resource = z.object({
|
|
|
587
611
|
// anyone may ask whether it's free at a time (GET /api/v1/rooms/free: free or
|
|
588
612
|
// busy, never what or who)
|
|
589
613
|
requestable: z.boolean().default(false),
|
|
614
|
+
// its name in the site's other languages ({ es: { name: 'Salón de jóvenes' } });
|
|
615
|
+
// roomName() picks one, else `name`. The slug stays the same in every language.
|
|
616
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().max(120).default('') })).default({}),
|
|
590
617
|
// secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
|
|
591
618
|
feedToken: z.string().default(''),
|
|
592
619
|
// the saved place this room is in ('' = none)
|
|
@@ -598,6 +625,13 @@ export const Resource = z.object({
|
|
|
598
625
|
export type Resource = z.infer<typeof Resource>;
|
|
599
626
|
/** A room's colour as #rrggbb, or '' when it has none (anything else stored reads as none). */
|
|
600
627
|
export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
|
|
628
|
+
/** A room's name in `locale` ('es', 'es-MX' → its 'es' name), else its main name. */
|
|
629
|
+
export function roomName(r: Pick<Resource, 'name'> & { translations?: Resource['translations'] }, locale?: string | null): string {
|
|
630
|
+
if (!locale || !r.translations) return r.name;
|
|
631
|
+
const want = locale.toLowerCase();
|
|
632
|
+
const t = r.translations[want] ?? r.translations[want.split('-')[0]];
|
|
633
|
+
return t?.name.trim() || r.name;
|
|
634
|
+
}
|
|
601
635
|
|
|
602
636
|
// A saved place (HQ, the park pavilion…): picked in an event's Where field,
|
|
603
637
|
// it copies its details into the event. Rooms can belong to one (resource.placeId).
|
package/src/lib/sections.ts
CHANGED
|
@@ -128,14 +128,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
128
128
|
}
|
|
129
129
|
const tz = settings.timezone || 'UTC';
|
|
130
130
|
const locale = siteLocale(settings);
|
|
131
|
-
|
|
131
|
+
// in the page's language: the dates' rooms come named in it
|
|
132
|
+
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag, locale: lang || undefined })).map((o): EventItem => {
|
|
132
133
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
133
134
|
const f = formatOccurrence(o, tz, locale);
|
|
134
135
|
const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
|
|
135
136
|
const ctaHref = safeUrl(e.cta.href);
|
|
136
137
|
return {
|
|
137
138
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
138
|
-
location: locationLine(o.
|
|
139
|
+
location: locationLine(o.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
139
140
|
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
|
|
140
141
|
rooms: (o.rooms ?? []).map((r) => ({ slug: r.slug, name: r.name, color: r.color || null })), when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
|
|
141
142
|
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -8,7 +8,7 @@ import ContentList, { type ContentItem } from '../../../../components/ContentLis
|
|
|
8
8
|
import { getRepo } from '../../../../lib/repo';
|
|
9
9
|
import { slugify, type Status } from '../../../../lib/schema';
|
|
10
10
|
import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
11
|
-
import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
11
|
+
import { describeSeries, featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
12
12
|
import { defaultPlaceLocation } from '../../../../lib/maps/places';
|
|
13
13
|
import { describeRepeat } from '../../../../lib/calendar/rrule';
|
|
14
14
|
import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
|
|
@@ -97,10 +97,11 @@ if (Astro.request.method === 'POST') {
|
|
|
97
97
|
try {
|
|
98
98
|
// a host's date move (host-requests.ts): `id` is the request's
|
|
99
99
|
if (op === 'approve-date') {
|
|
100
|
-
const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
101
|
-
const
|
|
100
|
+
const { moved, changed } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
101
|
+
const done = (moved ?? changed)!;
|
|
102
|
+
const n = done.clashes.length;
|
|
102
103
|
const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
|
|
103
|
-
return toRequests('Approved. “' +
|
|
104
|
+
return toRequests('Approved. “' + done.event.title + '” ' + (moved ? 'has the date moved' : 'is changed from that date on') + shared + '.');
|
|
104
105
|
}
|
|
105
106
|
if (op === 'decline-date') {
|
|
106
107
|
await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
|
|
@@ -233,7 +234,7 @@ const selected = rows
|
|
|
233
234
|
const items: ContentItem[] = selected.map(({ e, o }) => {
|
|
234
235
|
const f = o ? formatOccurrence(o, tz, locale) : null;
|
|
235
236
|
const cals = e.calendarIds.map((id) => calById.get(id)?.name).filter(Boolean);
|
|
236
|
-
const repeats =
|
|
237
|
+
const repeats = describeSeries(e, settings, now);
|
|
237
238
|
return {
|
|
238
239
|
id: e.id,
|
|
239
240
|
href: `/admin/calendar/events/${e.id}`,
|
|
@@ -307,8 +308,8 @@ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
|
|
|
307
308
|
const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
|
|
308
309
|
return {
|
|
309
310
|
id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
|
|
310
|
-
...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
|
|
311
|
-
line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
|
|
311
|
+
...(await dateRequestTimes(repo, r)), back: Boolean(d.back), label: r.kind === 'from-date' ? 'From this date on' : d.back ? 'Move back' : 'Date move',
|
|
312
|
+
line: `${r.kind === 'from-date' ? 'Change from this date on' : d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
|
|
312
313
|
};
|
|
313
314
|
}));
|
|
314
315
|
|
|
@@ -511,7 +512,7 @@ const subBits = [
|
|
|
511
512
|
{r.publicNote && <small>Note on the date: {r.publicNote}</small>}
|
|
512
513
|
{r.clash && <small class="ev-req-clash">{r.clash}</small>}
|
|
513
514
|
</a>
|
|
514
|
-
<span class="bk-st is-pending">{r.
|
|
515
|
+
<span class="bk-st is-pending">{r.label}</span>
|
|
515
516
|
<div class="bk-acts">
|
|
516
517
|
<a class="btn sm ghost" href={r.href}>Open</a>
|
|
517
518
|
{canApprove && (
|
|
@@ -139,7 +139,7 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
|
|
|
139
139
|
{tab === 'pending' && canAnswer && (
|
|
140
140
|
<div class="rq-acts">
|
|
141
141
|
{(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
|
|
142
|
-
{r.kind === 'move' && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
|
|
142
|
+
{(r.kind === 'move' || r.kind === 'from-date') && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
|
|
143
143
|
{(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
|
|
144
144
|
{r.kind === 'member-new' || r.kind === 'member-changes' ? (
|
|
145
145
|
<>
|
|
@@ -147,7 +147,7 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
|
|
|
147
147
|
<form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
|
|
148
148
|
</>
|
|
149
149
|
) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
|
|
150
|
-
|
|
150
|
+
<button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>
|
|
151
151
|
</div>
|
|
152
152
|
)}
|
|
153
153
|
{tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
|
|
@@ -189,12 +189,12 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
|
|
|
189
189
|
</form>
|
|
190
190
|
</Modal>
|
|
191
191
|
)}
|
|
192
|
-
{tab === 'pending' &&
|
|
192
|
+
{tab === 'pending' && (
|
|
193
193
|
<Modal id={'decline-' + r.dom} title="Decline this request">
|
|
194
194
|
<form method="post">
|
|
195
195
|
<input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
196
|
-
<p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move'
|
|
197
|
-
? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : '.')
|
|
196
|
+
<p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move' || r.kind === 'from-date'
|
|
197
|
+
? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : r.kind === 'from-date' ? '; the schedule stays as it is.' : '.')
|
|
198
198
|
: 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
|
|
199
199
|
<label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
|
|
200
200
|
{(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
|
|
@@ -13,6 +13,7 @@ import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
|
|
|
13
13
|
import { eventHolds } from '../../../../lib/booking/holds';
|
|
14
14
|
import { fmt } from '../../../../lib/booking/view';
|
|
15
15
|
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
16
|
+
import { extraLocales, localeName } from '../../../../lib/i18n';
|
|
16
17
|
|
|
17
18
|
// One resource: details, weekly hours, date overrides, calendars that block
|
|
18
19
|
// time, who gets notified, the staff feed, and its booking links.
|
|
@@ -36,6 +37,12 @@ if (Astro.request.method === 'POST') {
|
|
|
36
37
|
const op = String(form.get('op'));
|
|
37
38
|
if (op === 'save') {
|
|
38
39
|
const name = String(form.get('name') || '').trim().slice(0, 120) || resource.name;
|
|
40
|
+
// its name in each of the site's other languages ('' = the main name); languages no longer configured keep theirs
|
|
41
|
+
const translations = { ...resource.translations };
|
|
42
|
+
for (const l of extraLocales()) {
|
|
43
|
+
const n = String(form.get('name_' + l) || '').trim().slice(0, 120);
|
|
44
|
+
if (n) translations[l] = { name: n }; else delete translations[l];
|
|
45
|
+
}
|
|
39
46
|
const base = slugify(String(form.get('slug') || name));
|
|
40
47
|
let slug = base;
|
|
41
48
|
for (let n = 2; ; n++) { const hit = await repo.getResourceBySlug(slug); if (!hit || hit.id === id) break; slug = `${base}-${n}`; }
|
|
@@ -53,6 +60,7 @@ if (Astro.request.method === 'POST') {
|
|
|
53
60
|
timezone: tz && isValidTimeZone(tz) ? tz : '',
|
|
54
61
|
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
55
62
|
showOnCalendar: Boolean(form.get('showOnCalendar')),
|
|
63
|
+
translations,
|
|
56
64
|
color: form.get('noColor') ? '' : roomColor({ color: String(form.get('color') || '') }),
|
|
57
65
|
showOnEvents: Boolean(form.get('showOnEvents')),
|
|
58
66
|
requestable: Boolean(form.get('requestable')),
|
|
@@ -160,6 +168,9 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
160
168
|
<section class="card card-pad">
|
|
161
169
|
<div class="card-head"><b>Details</b></div>
|
|
162
170
|
<label class="field"><span>Name</span><input type="text" name="name" value={resource.name} required /></label>
|
|
171
|
+
{extraLocales().map((l) => (
|
|
172
|
+
<label class="field"><span>Name in {localeName(l)} <em class="hint">optional; shown on {localeName(l)} pages and in the app</em></span><input type="text" name={'name_' + l} lang={l} maxlength="120" value={resource.translations[l]?.name ?? ''} placeholder={resource.name} /></label>
|
|
173
|
+
))}
|
|
163
174
|
<div class="field-row">
|
|
164
175
|
<label class="field"><span>Kind</span><input type="text" name="kind" list="res-kinds" value={resource.kind} /></label>
|
|
165
176
|
<label class="field"><span>Capacity</span><input type="number" name="capacity" min="1" value={resource.capacity ?? ''} /></label>
|