@acms-subir/core 0.3.55 → 0.3.57
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 +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/api.ts +23 -2
- 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 +9 -2
- 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 +212 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -113,6 +113,7 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>) {
|
|
|
113
113
|
return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
|
|
114
114
|
moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
|
|
115
115
|
location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
|
|
116
|
+
location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
|
|
116
117
|
rooms: roomsToJson(o.roomIds, resources) };
|
|
117
118
|
}
|
|
118
119
|
// e is already localized; publicCalendars limits which calendars are named
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -33,7 +33,8 @@ export function failure(error: unknown) {
|
|
|
33
33
|
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
34
34
|
const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
|
|
35
35
|
return Response.json({ version: 1, error: {
|
|
36
|
-
code
|
|
36
|
+
// a refusal's own code (BookingError, or an ApiError with one: requests, moves)
|
|
37
|
+
code: ((error instanceof BookingError || error instanceof ApiError) && typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code) || (codes[status] ?? 'internal_error'),
|
|
37
38
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
38
39
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
39
40
|
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
|
@@ -9,6 +9,8 @@ import { pagesQuery } from './pages';
|
|
|
9
9
|
import { searchQuery } from './search';
|
|
10
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
11
11
|
import { formResponseInput } from './forms';
|
|
12
|
+
import { requestOptionsQuery } from './requests';
|
|
13
|
+
import { RequestInput } from '../calendar/request-service';
|
|
12
14
|
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
13
15
|
import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
|
|
14
16
|
import { announcementsQuery } from '../announcements/api';
|
|
@@ -116,6 +118,12 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
116
118
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
117
119
|
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
|
|
118
120
|
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
121
|
+
const requestNote = 'Requires acms({ requests }) (site.features.requests); 404 when off. No account.';
|
|
122
|
+
paths['/api/v1/requests/options'] = { get: priv('What the event request form offers: open or closed, the types, requestable rooms and changeable events (with their dates), the site’s reply time and notice (in the requested language) and its phone', ref('RequestOptions'), { query: requestOptionsQuery,
|
|
123
|
+
description: `${requestNote} Never cached. When open is false, say so and show phone; keep the screen itself reachable. Check whether a room is free with GET /rooms/free.` }), ...cors };
|
|
124
|
+
paths['/api/v1/requests'] = { post: { ...priv('Ask for a new event or a change to one, without an account', ref('RequestReceipt'), { body: RequestInput, write: true, status: 201,
|
|
125
|
+
description: `${requestNote} kind new: type (from options; required when the site lists types), title, start (site-local YYYY-MM-DDTHH:mm, in the future), end (the same, or HH:mm: the start's day, or the next when it's before the start; within a day), repeat (once, weekly, or other with repeatText), rooms (slugs from options) or roomsUnsure, people, message. kind change: event (a public event's slug), date (YYYY-MM-DD, optional: one of its dates; none = every date), message (what should change, required). name and email are required, phone optional; locale is the language for replies. Idempotency-Key (UUID v4) is the request's id: a retry answers the same, other details with the same key answer 409 changed. Refusals carry a code (see Error). 10 requests a minute and 20 a day per client address, 5 a day per email address, and the site's daily cap (429). A retry is answered before any limit. Leave website empty (spam trap). The person gets an email that it arrived, without the words they typed.` }),
|
|
126
|
+
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
119
127
|
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
120
128
|
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
121
129
|
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiError, readJson } from '../api';
|
|
3
|
+
import { REQUESTS } from '../calendar/request-config';
|
|
4
|
+
import { requestOptions, submitRequest } from '../calendar/request-service';
|
|
5
|
+
import { queryParams } from './http';
|
|
6
|
+
import type { PublicContext } from './route';
|
|
7
|
+
|
|
8
|
+
// Event requests from apps (acms({ requests })): the options the form needs,
|
|
9
|
+
// and sending one. No account; an Idempotency-Key per request (retries never
|
|
10
|
+
// duplicate); 10 a minute per address and the site's daily cap
|
|
11
|
+
// (lib/calendar/request-service.ts holds the rules). Never cached: "closed"
|
|
12
|
+
// and the rooms must show at once.
|
|
13
|
+
|
|
14
|
+
export const requestOptionsQuery = z.object({ locale: z.string().max(35).optional() }).strict();
|
|
15
|
+
|
|
16
|
+
export async function getRequestOptions(c: PublicContext) {
|
|
17
|
+
if (!REQUESTS.enabled) throw new ApiError(404, 'Not found.');
|
|
18
|
+
const q = requestOptionsQuery.parse(queryParams(c.url));
|
|
19
|
+
return requestOptions(c.repo, q.locale);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function postRequest(c: PublicContext) {
|
|
23
|
+
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
24
|
+
const body = await readJson(c.request, 20_000);
|
|
25
|
+
const { id } = await submitRequest(c.repo, body, { source: 'app', key, address: c.address, locals: c.locals });
|
|
26
|
+
return { id };
|
|
27
|
+
}
|
|
@@ -9,7 +9,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
|
|
|
9
9
|
export const Site = z.object({
|
|
10
10
|
name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
|
|
11
11
|
languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
|
|
12
|
-
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
|
|
12
|
+
features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool, requests: bool }),
|
|
13
13
|
legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
|
|
14
14
|
});
|
|
15
15
|
export const Theme = z.object({
|
|
@@ -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();
|
|
@@ -159,8 +166,19 @@ export const LiveStatus = z.object({
|
|
|
159
166
|
snapshot: LiveSnapshot.nullable(), serverNow: ms,
|
|
160
167
|
display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
|
|
161
168
|
});
|
|
169
|
+
// GET /requests/options: types and rooms to offer, the site's words (in the asked language), the phone for when it's closed
|
|
170
|
+
export const RequestOptions = z.object({
|
|
171
|
+
open: bool, types: z.array(z.object({ id: str, label: str })), rooms: z.array(z.object({ slug: str, name: str, color: str })),
|
|
172
|
+
replyTime: str, notice: str, phone: str, timezone: str,
|
|
173
|
+
// what a change can be about: public events by title, each with its dates (site-local days) in the next 120 days
|
|
174
|
+
events: z.array(z.object({ slug: str, title: str, dates: z.array(str) })),
|
|
175
|
+
});
|
|
176
|
+
export const RequestReceipt = z.object({ id: str });
|
|
162
177
|
export const Error = z.object({ version: z.literal(1), error: z.object({
|
|
163
|
-
// duplicate (409): that email already has an RSVP for the date
|
|
164
|
-
|
|
178
|
+
// duplicate (409): that email already has an RSVP for the date. POST /requests refusals say
|
|
179
|
+
// which field (title, type, start, end, past, far, repeat, room, people, event, date, what,
|
|
180
|
+
// name, email, phone; 400), closed or changed (409), busy or full (429), trap (400)
|
|
181
|
+
code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error',
|
|
182
|
+
'closed', 'trap', 'busy', 'full', 'changed', 'title', 'type', 'start', 'end', 'past', 'far', 'repeat', 'room', 'people', 'event', 'date', 'what', 'name', 'email', 'phone']),
|
|
165
183
|
message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
|
|
166
184
|
}) });
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { CONTENT, legacyBases } from '../content';
|
|
|
4
4
|
import { I18N, localeName } from '../i18n';
|
|
5
5
|
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
6
6
|
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
7
|
+
import { REQUESTS } from '../calendar/request-config';
|
|
7
8
|
import { SEARCH } from '../search-config';
|
|
8
9
|
import { siteOrigin } from '../seo';
|
|
9
10
|
import { resolveDesign } from '../theme';
|
|
@@ -51,6 +52,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
51
52
|
live: settings.live.mode !== 'off',
|
|
52
53
|
// acms({ announcements }): GET /api/v1/announcements
|
|
53
54
|
announcements: ANNOUNCEMENTS.enabled,
|
|
55
|
+
// acms({ requests }): GET /api/v1/requests/options, POST /api/v1/requests (on even while requests are closed)
|
|
56
|
+
requests: REQUESTS.enabled,
|
|
54
57
|
},
|
|
55
58
|
// in the requested language (?locale=es → /es/privacy)
|
|
56
59
|
legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
|
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
|
|
|
@@ -57,7 +59,7 @@ export interface PushDevice {
|
|
|
57
59
|
* new form response, to the linked phones of staff on its alert list. Neither
|
|
58
60
|
* counts in the weekly pace.
|
|
59
61
|
*/
|
|
60
|
-
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form';
|
|
62
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form' | 'request';
|
|
61
63
|
export interface PushNotification {
|
|
62
64
|
id: string; title: string; body: string; url: string;
|
|
63
65
|
/** ['*'] = everyone */
|
|
@@ -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,13 +738,16 @@ 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]),
|
|
741
744
|
];
|
|
742
745
|
|
|
743
746
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
744
747
|
export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
745
748
|
...CONTENT_VERSION_COLUMNS,
|
|
746
749
|
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'],
|
|
750
|
+
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
|
|
748
751
|
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
|
|
749
752
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
750
753
|
};
|
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),
|
|
@@ -870,8 +878,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
870
878
|
await this.driver.run(
|
|
871
879
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
872
880
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
881
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates, segments)
|
|
882
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
875
883
|
[
|
|
876
884
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
877
885
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -881,7 +889,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
881
889
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
882
890
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
883
891
|
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
|
|
884
|
-
JSON.stringify(data.movedDates ?? {}),
|
|
892
|
+
JSON.stringify(data.movedDates ?? {}), JSON.stringify(data.segments ?? []),
|
|
885
893
|
]
|
|
886
894
|
);
|
|
887
895
|
await this.syncEventIndex(id);
|
|
@@ -907,7 +915,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
907
915
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
908
916
|
}
|
|
909
917
|
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',
|
|
918
|
+
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
919
|
};
|
|
912
920
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
913
921
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -986,6 +994,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
986
994
|
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
987
995
|
await this.driver.batch(quietly(write, opts.quiet));
|
|
988
996
|
}
|
|
997
|
+
async setEventDates(id: string, dates: Partial<Pick<CalEvent, 'segments' | 'movedDates' | 'cancelledDates' | 'exdates'>>, opts: { quiet?: boolean } = {}) {
|
|
998
|
+
await this.ready;
|
|
999
|
+
const cols: [keyof typeof dates, string][] = [['segments', 'segments'], ['movedDates', 'moved_dates'], ['cancelledDates', 'cancelled_dates'], ['exdates', 'exdates']];
|
|
1000
|
+
const set = cols.filter(([k]) => dates[k] !== undefined);
|
|
1001
|
+
if (!set.length) return;
|
|
1002
|
+
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] };
|
|
1003
|
+
await this.driver.batch(quietly(write, opts.quiet));
|
|
1004
|
+
}
|
|
989
1005
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
990
1006
|
await this.ready;
|
|
991
1007
|
// the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
|
|
@@ -1939,15 +1955,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1939
1955
|
...(content.events ?? []).map((e) => ({
|
|
1940
1956
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1941
1957
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1958
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates, segments)
|
|
1959
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1944
1960
|
args: [
|
|
1945
1961
|
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
1962
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1947
1963
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1948
1964
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1949
1965
|
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 ?? {}),
|
|
1966
|
+
JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}), JSON.stringify(e.segments ?? []),
|
|
1951
1967
|
],
|
|
1952
1968
|
})),
|
|
1953
1969
|
// 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/scheduler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { dispatchEmail } from './email/service';
|
|
|
6
6
|
import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
|
|
7
7
|
import { runPushJobs } from './push/service';
|
|
8
8
|
import { runAnnouncementJobs } from './announcements/service';
|
|
9
|
+
import { pruneRequests } from './calendar/request-service';
|
|
9
10
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
10
11
|
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
11
12
|
import { syncCachePurge } from './cache/purge';
|
|
@@ -35,6 +36,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
35
36
|
announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
|
|
36
37
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
37
38
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
39
|
+
// event requests past Calendar → Requests' retention (after the answer, or after arriving if nobody answered)
|
|
40
|
+
requests: () => pruneRequests(repo, startedAt),
|
|
38
41
|
// expired guest sign-in codes and app sessions unused for 90 days
|
|
39
42
|
guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
|
|
40
43
|
// clear shared caches after scheduled publishes, and retry a purge that
|
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;
|
|
@@ -406,8 +428,10 @@ export type EventRequest = z.infer<typeof EventRequest>;
|
|
|
406
428
|
// on (C3/C4); `data` holds the move.
|
|
407
429
|
// `userId` is the member who asked; `requester` is the person without an
|
|
408
430
|
// account (never both). `note` is their own words to staff, `reply` staff's
|
|
409
|
-
// last answer.
|
|
410
|
-
// are deleted some days after
|
|
431
|
+
// last answer. `data.sent` on new and change requests is a hash of what was
|
|
432
|
+
// sent (a retry must match it). Rows are deleted some days after
|
|
433
|
+
// `reviewedAt` (withdrawing sets it too), or after `createdAt` while nobody
|
|
434
|
+
// answered (CalendarSettings.requests).
|
|
411
435
|
export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
|
|
412
436
|
export type RequestKind = z.infer<typeof RequestKind>;
|
|
413
437
|
export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
|
|
@@ -475,6 +499,8 @@ export const CalEvent = z.object({
|
|
|
475
499
|
// single dates at another time (MovedDate above); a date is moved or
|
|
476
500
|
// cancelled, never both. Keys that no longer match an occurrence are ignored.
|
|
477
501
|
movedDates: MovedDates,
|
|
502
|
+
// the series changed from a date on (SeriesSegment above); only used while it repeats
|
|
503
|
+
segments: z.array(SeriesSegment).default([]),
|
|
478
504
|
location: EventLocation.default({}),
|
|
479
505
|
cta: EventCta.default({}),
|
|
480
506
|
calendarIds: z.array(z.string()).default([]),
|
|
@@ -651,6 +677,44 @@ export type ApprovalRules = z.infer<typeof ApprovalRules>;
|
|
|
651
677
|
export const EventImageUrl = z.string().trim().max(2000).refine((value) => !value || Boolean(safeEventImageUrl(value)), 'Choose an image or use an http(s) URL or site-relative image path.');
|
|
652
678
|
|
|
653
679
|
// Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
|
|
680
|
+
// Event requests from people without an account (acms({ requests }),
|
|
681
|
+
// lib/calendar/request-service.ts), set on Calendar → Requests. A type is
|
|
682
|
+
// what people pick under "Kind" (Class, Meeting…): approving a request makes
|
|
683
|
+
// a draft with its tag, in its calendar.
|
|
684
|
+
export const RequestType = z.object({
|
|
685
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/),
|
|
686
|
+
label: z.string().min(1).max(60),
|
|
687
|
+
// the label in each other site language
|
|
688
|
+
translations: z.record(z.string().max(60)).default({}),
|
|
689
|
+
// what an approved draft gets: this tag ('' = none), this calendar ('' = the default calendar)
|
|
690
|
+
tag: z.string().max(60).default(''),
|
|
691
|
+
calendarId: z.string().default(''),
|
|
692
|
+
});
|
|
693
|
+
export type RequestType = z.infer<typeof RequestType>;
|
|
694
|
+
export const RequestWords = z.object({ replyTime: z.string().max(60).default(''), notice: z.string().max(400).default('') });
|
|
695
|
+
export const RequestSettings = z.object({
|
|
696
|
+
// closed: the form says so (with the phone number) and new requests are refused
|
|
697
|
+
open: z.boolean().default(true),
|
|
698
|
+
types: z.array(RequestType).max(12).default([]),
|
|
699
|
+
// "We usually answer within {replyTime}" ("3 days"); '' leaves the sentence out
|
|
700
|
+
replyTime: z.string().max(60).default(''),
|
|
701
|
+
// the form's opening line ('' = the built-in "It isn't on the calendar until…")
|
|
702
|
+
notice: z.string().max(400).default(''),
|
|
703
|
+
// replyTime and notice in each other site language
|
|
704
|
+
translations: z.record(RequestWords).default({}),
|
|
705
|
+
// the office phone, shown when requests are closed and on "Request sent"
|
|
706
|
+
phone: z.string().max(40).default(''),
|
|
707
|
+
// who hears about new requests; [] = acms({ members.notify }), else the owners
|
|
708
|
+
notify: z.array(z.string().max(254)).max(20).default([]),
|
|
709
|
+
// staff on that list who linked a phone also get a push (with no details)
|
|
710
|
+
phones: z.boolean().default(false),
|
|
711
|
+
// new requests a day from the app and the website together; 0 = no cap
|
|
712
|
+
perDay: z.number().int().min(0).max(10000).default(100),
|
|
713
|
+
// requests are deleted this many days after the answer (or after they arrived, if nobody answered)
|
|
714
|
+
keepDays: z.number().int().min(7).max(1095).default(180),
|
|
715
|
+
});
|
|
716
|
+
export type RequestSettings = z.infer<typeof RequestSettings>;
|
|
717
|
+
|
|
654
718
|
export const CalendarSettings = z.object({
|
|
655
719
|
// Display-only fallback: event covers remain empty until an image is explicitly chosen.
|
|
656
720
|
eventFallbackImage: EventImageUrl.default(''),
|
|
@@ -663,6 +727,7 @@ export const CalendarSettings = z.object({
|
|
|
663
727
|
approvalRules: ApprovalRules.default({}),
|
|
664
728
|
// show bookings on the Schedule grid
|
|
665
729
|
showBookings: z.boolean().default(true),
|
|
730
|
+
requests: RequestSettings.default({}),
|
|
666
731
|
});
|
|
667
732
|
export type CalendarSettings = z.infer<typeof CalendarSettings>;
|
|
668
733
|
|
package/src/lib/sections.ts
CHANGED
|
@@ -135,7 +135,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
135
135
|
const ctaHref = safeUrl(e.cta.href);
|
|
136
136
|
return {
|
|
137
137
|
title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
|
|
138
|
-
location: locationLine(o.
|
|
138
|
+
location: locationLine(o.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
|
|
139
139
|
allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
|
|
140
140
|
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
141
|
cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
|