@acms-subir/core 0.3.29 → 0.3.31

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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.31
4
+
5
+ ### Patch Changes
6
+
7
+ - 3eaa9a3: Cancelling one date of a repeating event now also cancels that date's RSVPs, the same way cancelling a whole event cancels all of them. Each person gets the "cancelled" email, and no 24-hour reminder goes out for that date. Before, those RSVPs stayed confirmed, nobody was told, and the reminder still went out. RSVPs for the event's other dates aren't touched. Restoring the date doesn't bring the RSVPs back, so people sign up again. RSVPs for a date cancelled before this release get cancelled, with the email, when their reminder would have been due.
8
+
9
+ The "Starting soon" push no longer goes out for a cancelled date of a repeating event. Only events cancelled as a whole were skipped before.
10
+
11
+ New repository method: `cancelOccurrenceRsvps(eventId, start)`.
12
+
13
+ - 5d98d6a: The standard privacy notice and terms (`/privacy`, `/terms`) now describe what each site actually does, in English and Spanish, and the owner can complete them in a new **Settings → Legal** card. That's what Apple's 5.1.1, Google Play's User Data policy and Mexico's LFPDPPP expect, and the app stores need it before submission.
14
+
15
+ - **Built from the features that are on.** Always covered: site visits and rate limiting, sign-in (built-in or Clerk), forms (missing before), embedded content and Google Fonts, connected tools and backups, cookies with their lifetimes, service providers, retention, rights, children, Do Not Track, and how changes are announced. Covered when turned on: bookings and RSVPs (shown whenever the calendar is on, so RSVP-only sites are covered now), email sign-in in the app, the app and push notifications (token, platform, language and member-chosen topics, Expo → APNs/FCM, removal after the push retention period, how to turn them off), the email and maps providers by name, live video, Umami, and Meta/TikTok pixels. With a pixel on, the "no advertising" sentences are dropped and the Do Not Track answer changes.
16
+ - **Spanish.** `/es/privacy` and `/es/terms` are in Spanish (the site's language setting decides on single-language sites). Other languages show English, marked `lang="en"`.
17
+ - **Settings → Legal** (General settings permission):
18
+ - the legal name, contact email, address, country and who answers privacy requests. Country `MX` adds the ARCO wording, the 20-business-day answer and the data protection authority; any country makes the terms name its law;
19
+ - the owner's own sections for either page, up to 20, translatable into each site language, for retention, other services, sensitive data and consent;
20
+ - a summary of what the standard text covers, warnings for missing details, and **App store answers**: how to fill in App Store Connect → App Privacy and Google Play → Data safety for the enabled features, including the in-app deletion expected for email sign-in.
21
+ - `acms({ legal })` values (now with `address` and `country`) are the defaults for empty fields.
22
+ - **"Last updated" is automatic.** The date changes only when the wording changes; `legal.updatedAt` is the earliest date shown.
23
+ - A published `privacy` or `terms` page still replaces the standard text, and now uses its translations on `/es/…`.
24
+ - `/api/v1/site` adds `legal: { privacy, terms }` (absolute URLs, or null) so apps can link to them.
25
+
26
+ Migration 57 adds `settings.legal` (it clears the page cache when saved) and `settings.legal_stamps`. The text describes the framework's behavior; it isn't legal advice, and the pages stay marked as a standard notice, and noindex, until a name and contact email are set.
27
+
28
+ ## 0.3.30
29
+
30
+ ### Patch Changes
31
+
32
+ - aeaed2c: YouTube **Authorize** (Integrations → Live video) now finishes on sites that use a Clerk development instance. Before, Google's redirect back to `/api/live/oauth/youtube/callback` was always signed out there: development instances set `__client_uat` as `SameSite=Strict`, so the browser drops it from the cross-site return, Clerk answers with a handshake, and the page showed "YouTube authorization didn't finish". The same thing could happen on production when the owner spent more than about a minute on Google's screens and the session token lapsed.
33
+
34
+ The callback no longer checks the sign-in. It attaches Google's code, encrypted, to its one-use pending state for up to two minutes. Only the first return counts. It then sends the owner to `/admin/live?live=finish`, where a lapsed sign-in renews like on any admin page. That page finishes with a same-origin request (the new `finishAuthorize` action on `/api/live/settings`, gated like every other owner action), and only the session that started authorization can finish it. The code never appears in a URL again, and nothing changes in Google's setup: the redirect URI is the same. No migration.
35
+
3
36
  ## 0.3.29
4
37
 
5
38
  ### Patch Changes
@@ -1065,11 +1065,19 @@ var MIGRATIONS = [
1065
1065
  { version: 56, statements: [
1066
1066
  `ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
1067
1067
  `ALTER TABLE events ADD COLUMN request TEXT`
1068
+ ] },
1069
+ // Settings → Legal (Settings.legal): shown on /privacy and /terms, so it
1070
+ // bumps the content version; the automatic "last updated" stamps don't
1071
+ { version: 57, statements: [
1072
+ `ALTER TABLE settings ADD COLUMN legal TEXT NOT NULL DEFAULT '{}'`,
1073
+ `ALTER TABLE settings ADD COLUMN legal_stamps TEXT NOT NULL DEFAULT '{}'`,
1074
+ `DROP TRIGGER content_version_settings_update`,
1075
+ ...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"])
1068
1076
  ] }
1069
1077
  ];
1070
1078
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1071
1079
  ...CONTENT_VERSION_COLUMNS,
1072
- settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"],
1080
+ settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1073
1081
  events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"]
1074
1082
  };
1075
1083
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -28,7 +28,7 @@ ACMS obtains an app token with the client-credentials flow and checks Get Stream
28
28
  4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
29
29
  5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
30
30
 
31
- The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
31
+ The authorization-code flow uses session-bound, one-use state and PKCE. Google's redirect back is a cross-site request that may arrive without a usable sign-in (a Clerk development instance withholds its `SameSite=Strict` cookie, and a session token can lapse while the owner is on Google's screens). It doesn't need one: it only attaches the code, encrypted, to its pending state for up to two minutes, then sends the owner to `/admin/live?live=finish`. That page finishes with a same-origin request from the signed-in session that began authorization; no other session can finish it, and the code never appears in a URL again. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
32
32
 
33
33
  External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
34
34
 
package/docs/mobile.md CHANGED
@@ -307,10 +307,14 @@ a pre-ticked choice doesn't count. So:
307
307
  fine once the person has allowed notifications from your explained prompt.
308
308
  List the topics they'll get on that screen.
309
309
  - Keep a Notifications screen with a switch per topic (`usePushTopics()`) and
310
- an "Off" (`unregister()`). The app must work fully with notifications off.
311
- - Describe push notifications in the app's privacy policy (aviso de privacidad
312
- in Mexico) and App Privacy answers. The site stores only the Expo token,
313
- platform, language and topics, with no name or email.
310
+ an "Off" (`unregister()`). Off stays off across restarts (`turnedOff`); don't
311
+ invite people who turned it off. The app must work fully with notifications off.
312
+ - The site's privacy notice (`/privacy`, `/es/privacy`) already describes push,
313
+ forms, bookings and email sign-in for the features that are on. The owner
314
+ completes it in **Settings → Legal**, which also lists the App Privacy and
315
+ Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
316
+ (and `.terms`). The site stores only the Expo token, platform, language and
317
+ topics, with no name or email.
314
318
 
315
319
  ## Editor apps (staff sign-in)
316
320
 
@@ -551,7 +555,8 @@ npx eas-cli submit --platform ios # and android
551
555
  Store checklist: real `ios.bundleIdentifier` / `android.package` (can't change
552
556
  after release; doctor warns on `com.example.*`); app name, `icon` (1024×1024)
553
557
  and splash in `app.json`; `version` and build numbers bumped; `.env` points at
554
- the **https** production site; privacy policy URL; screenshots for phone and
558
+ the **https** production site; privacy policy URL (`site.legal.privacy`, also
559
+ linked inside the app) and the store privacy answers from Settings → Legal; screenshots for phone and
555
560
  tablet; deep-link config deployed on the site; a production build tested on
556
561
  real devices; `acms mobile doctor` clean.
557
562
 
package/integration.mjs CHANGED
@@ -73,6 +73,7 @@ const ROUTES = [
73
73
  ['/api/ops/status', 'api/ops/status.ts'],
74
74
  ['/api/health', 'api/health.ts'],
75
75
  ['/api/monitoring', 'api/monitoring.ts'],
76
+ ['/api/legal', 'api/legal.ts'],
76
77
  ['/admin/ai', 'admin/ai.astro'],
77
78
  ['/admin/import', 'admin/import.astro'],
78
79
  ['/admin/tags', 'admin/tags.astro'],
@@ -623,7 +624,7 @@ function acmsVitePlugin(userConfig) {
623
624
  * the environment. Without a provider, MAPS_URL picks photon and
624
625
  * MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
625
626
  * pasted map links still work.
626
- * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
627
+ * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, address?: string, country?: string, updatedAt?: string}} [options.legal]
627
628
  * Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
628
629
  * @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
629
630
  * @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
@@ -676,8 +677,10 @@ export default function acms(options = {}) {
676
677
  const roles = resolveRoles(options.roles, members);
677
678
  const permissions = resolvePermissions(options.permissions, roles);
678
679
  const maps = resolveMaps(options.maps);
679
- const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
680
- if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
680
+ const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
681
+ if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
682
+ legal.country = legal.country.trim().toUpperCase();
683
+ if (legal.country && !/^[A-Z]{2}$/.test(legal.country)) throw new Error('acms: legal.country must be a two-letter country code such as MX or US');
681
684
  if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
682
685
  if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
683
686
  const auth = { provider: options.auth?.provider === undefined ? 'builtin' : options.auth.provider };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -2,26 +2,34 @@
2
2
  import Site from '../layouts/Site.astro';
3
3
  import WidgetRenderer from './WidgetRenderer.astro';
4
4
  import { getRepo } from '../lib/repo';
5
- import { defaultPolicy, LEGAL } from '../lib/legal';
5
+ import { LEGAL, LEGAL_LANGUAGES, legalPolicy, policyLocale } from '../lib/legal';
6
6
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
+ import { requestLocale } from '../lib/i18n';
8
+ import { localizeDoc } from '../lib/translations';
7
9
  interface Props { policy: 'privacy' | 'terms' }
8
10
  const { policy } = Astro.props;
9
11
  const repo = await getRepo(Astro.locals);
10
12
  const [settings, page] = await Promise.all([repo.getSettings(), repo.getPageBySlug(policy)]);
11
- const custom = page?.status === 'published' ? page : null;
12
- const standard = defaultPolicy(policy, settings);
13
+ const locale = policyLocale(requestLocale(Astro.url, Astro.locals), settings);
14
+ // a published page with this slug replaces the standard text, in each language it's translated into
15
+ const custom = page?.status === 'published' ? localizeDoc(page, requestLocale(Astro.url, Astro.locals)) : null;
16
+ const standard = custom ? null : await legalPolicy(repo, policy, { locale, locals: Astro.locals });
13
17
  const html = custom?.bodyHtml ? await expandDynamicBlocks(custom.bodyHtml, repo) : '';
18
+ const t = standard ? LEGAL_LANGUAGES[standard.lang] : null;
19
+ const updated = standard?.updated ? new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(`${standard.updated}T12:00:00Z`)) : '';
20
+ const contactEmail = settings.legal.contactEmail || LEGAL.contactEmail;
21
+ const mailto = contactEmail ? `mailto:${contactEmail}` : '';
14
22
  ---
15
- <Site title={custom?.title || standard.title} noindex={!custom && !standard.complete}>
23
+ <Site title={custom?.title || standard!.title} noindex={!custom && !standard!.complete}>
16
24
  {custom ? (
17
25
  html ? <article class="page-body" set:html={html} /> : <WidgetRenderer pageId={custom.id} blocks={custom.blocks} />
18
26
  ) : (
19
- <article class="page-body">
20
- <h1>{standard.title}</h1>
21
- {LEGAL.updatedAt && <p>Last updated: <time datetime={LEGAL.updatedAt}>{LEGAL.updatedAt}</time></p>}
22
- {!standard.complete && <p><strong>Standard notice:</strong> The site operator has not yet completed its identity and contact details. This page describes the standard site features and needs the operator’s site-specific information.</p>}
23
- {standard.sections.map(section => <section><h2>{section.title}</h2><p>{section.text}</p></section>)}
24
- {LEGAL.contactEmail && <p><a href={`mailto:${LEGAL.contactEmail}`}>Contact the site operator</a></p>}
27
+ <article class="page-body" lang={standard!.lang}>
28
+ <h1>{standard!.title}</h1>
29
+ {updated && <p>{t!.updated}: <time datetime={standard!.updated}>{updated}</time></p>}
30
+ {!standard!.complete && <p><strong>{t!.incomplete}</strong></p>}
31
+ {standard!.sections.map(section => <section><h2>{section.title}</h2>{section.text.map(p => <p>{p}</p>)}</section>)}
32
+ {mailto && <p><a href={mailto}>{t!.contact}</a></p>}
25
33
  </article>
26
34
  )}
27
35
  </Site>
@@ -0,0 +1,152 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { LegalAdminState } from '../lib/legal/admin';
3
+ import type { LegalSection, LegalSettings as Legal } from '../lib/legal/types';
4
+ import '../styles/legal-settings.css';
5
+
6
+ // Settings → Legal: who operates the site and the owner's own sections of the
7
+ // privacy notice and terms (translatable), what the standard text already
8
+ // covers, and the answers for the App Store and Google Play privacy forms.
9
+
10
+ const ENDPOINT = '/api/legal';
11
+ async function api(body?: Legal): Promise<LegalAdminState> {
12
+ const res = await fetch(ENDPOINT, {
13
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
14
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
15
+ body: body ? JSON.stringify(body) : undefined,
16
+ });
17
+ const data = await res.json().catch(() => null);
18
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
19
+ return data as LegalAdminState;
20
+ }
21
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
22
+ const newId = () => Math.random().toString(36).slice(2, 10);
23
+ const longDate = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { dateStyle: 'long', timeZone: 'UTC' });
24
+
25
+ function covered(c: LegalAdminState['covers']): string[] {
26
+ return [
27
+ 'visits and rate limiting', c.auth === 'clerk' ? 'sign-in with Clerk' : 'staff sign-in', 'forms',
28
+ ...(c.calendar ? [c.bookings ? 'bookings and RSVPs' : 'event RSVPs'] : []),
29
+ ...(c.guestSignIn ? ['email-code sign-in in the app'] : []),
30
+ ...(c.push ? ['the app and push notifications'] : []),
31
+ ...(c.email ? [`email (${c.email})`] : []), ...(c.maps ? [`maps (${c.maps})`] : []),
32
+ ...(c.live ? ['live video'] : []),
33
+ ...(c.analytics.umami ? ['Umami statistics'] : []), ...(c.analytics.meta ? ['Meta Pixel'] : []), ...(c.analytics.tiktok ? ['TikTok Pixel'] : []),
34
+ ...(c.ai ? ['AI writing tools'] : []),
35
+ 'embedded content and Google Fonts', 'cookies', 'service providers', 'retention', 'rights (including ARCO)', 'children',
36
+ ];
37
+ }
38
+
39
+ export default function LegalSettings() {
40
+ const [state, setState] = useState<LegalAdminState | null>(null);
41
+ const [form, setForm] = useState<Legal | null>(null);
42
+ const [error, setError] = useState('');
43
+ const [note, setNote] = useState('');
44
+ const [busy, setBusy] = useState(false);
45
+ useEffect(() => { api().then((s) => { setState(s); setForm(s.legal); }, (e) => setError(errText(e))); }, []);
46
+ if (!state || !form) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
47
+
48
+ const dirty = JSON.stringify(form) !== JSON.stringify(state.legal);
49
+ const set = (patch: Partial<Legal>) => setForm({ ...form, ...patch });
50
+ const setSection = (i: number, patch: Partial<LegalSection>) => set({ sections: form.sections.map((s, j) => (j === i ? { ...s, ...patch } : s)) });
51
+ const setTranslation = (i: number, code: string, patch: { title?: string; body?: string }) => {
52
+ const s = form.sections[i];
53
+ const t = { title: s.translations[code]?.title ?? '', body: s.translations[code]?.body ?? '', ...patch };
54
+ setSection(i, { translations: { ...s.translations, [code]: t } });
55
+ };
56
+ const move = (i: number, by: number) => {
57
+ const next = [...form.sections];
58
+ const [s] = next.splice(i, 1);
59
+ next.splice(i + by, 0, s);
60
+ set({ sections: next });
61
+ };
62
+ const save = async () => {
63
+ setBusy(true); setError(''); setNote('');
64
+ try { const s = await api(form); setState(s); setForm(s.legal); setNote('Saved. The pages show it now.'); }
65
+ catch (e) { setError(errText(e)); } finally { setBusy(false); }
66
+ };
67
+
68
+ return (
69
+ <div className="lg">
70
+ {!state.enabled && <p className="flash">The standard pages are off (<code>legal: false</code>). Your site serves its own /privacy and /terms.</p>}
71
+ {state.warnings.length > 0 && <ul className="lg-warn">{state.warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
72
+
73
+ <div className="lg-block">
74
+ <h3>Who operates this site</h3>
75
+ <div className="lg-grid">
76
+ <label className="field"><span>Legal name</span>
77
+ <input value={form.operatorName} maxLength={200} placeholder={state.defaults.operatorName} onChange={(e) => set({ operatorName: e.target.value })} />
78
+ <small className="hint">As it appears in your app’s store listing.</small></label>
79
+ <label className="field"><span>Contact email for privacy</span>
80
+ <input type="email" value={form.contactEmail} maxLength={200} placeholder={state.defaults.contactEmail || 'privacy@example.org'} onChange={(e) => set({ contactEmail: e.target.value })} /></label>
81
+ <label className="field lg-wide"><span>Address</span>
82
+ <textarea rows={2} value={form.address} maxLength={500} placeholder={state.defaults.address || 'Street, city, state, postal code, country'} onChange={(e) => set({ address: e.target.value })} /></label>
83
+ <label className="field"><span>Country</span>
84
+ <input value={form.country} maxLength={2} placeholder={state.defaults.country || 'MX, US…'} list="lg-countries" onChange={(e) => set({ country: e.target.value.toUpperCase() })} />
85
+ <datalist id="lg-countries">{['MX', 'US', 'CA', 'ES', 'CO', 'GT', 'SV', 'HN', 'PR', 'DO', 'AR', 'CL', 'PE'].map((c) => <option key={c} value={c} />)}</datalist>
86
+ <small className="hint">Two letters. MX adds the ARCO and authority wording.</small></label>
87
+ <label className="field"><span>Who answers privacy requests</span>
88
+ <input value={form.privacyContact} maxLength={200} placeholder="e.g. The church office" onChange={(e) => set({ privacyContact: e.target.value })} /></label>
89
+ </div>
90
+ </div>
91
+
92
+ <div className="lg-block">
93
+ <h3>Already covered</h3>
94
+ <p className="hint">The standard text describes what this site does, in English and Spanish, and updates itself when you turn features on or off: {covered(state.covers).join(', ')}.</p>
95
+ <p className="lg-links">
96
+ {state.links.privacy && <a href={state.links.privacy} target="_blank" rel="noopener">Privacy notice ↗</a>}
97
+ {state.links.terms && <a href={state.links.terms} target="_blank" rel="noopener">Terms ↗</a>}
98
+ {state.updated && <span className="hint">Last updated {longDate(state.updated.privacy)} (privacy) · {longDate(state.updated.terms)} (terms)</span>}
99
+ </p>
100
+ </div>
101
+
102
+ <div className="lg-block">
103
+ <h3>Your own sections</h3>
104
+ <p className="hint">For what the site can’t know: how long you keep records, how to reach you for privacy requests, other services you use, sensitive information you collect and how you ask for consent. Separate paragraphs with a blank line.</p>
105
+ {form.sections.map((s, i) => (
106
+ <fieldset key={s.id} className="lg-section">
107
+ <div className="lg-row">
108
+ <select value={s.policy} aria-label="Page" onChange={(e) => setSection(i, { policy: e.target.value as LegalSection['policy'] })}>
109
+ <option value="privacy">Privacy notice</option><option value="terms">Terms</option>
110
+ </select>
111
+ <input className="lg-title" value={s.title} maxLength={120} placeholder="Section title" aria-label="Section title" onChange={(e) => setSection(i, { title: e.target.value })} />
112
+ <button type="button" className="btn ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
113
+ <button type="button" className="btn ghost" disabled={i === form.sections.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
114
+ <button type="button" className="btn ghost" onClick={() => set({ sections: form.sections.filter((_, j) => j !== i) })}>Remove</button>
115
+ </div>
116
+ <textarea rows={4} value={s.body} maxLength={6000} aria-label="Section text" onChange={(e) => setSection(i, { body: e.target.value })} />
117
+ {state.languages.extra.map((l) => (
118
+ <details key={l.code} className="lg-tr" open={!!(s.translations[l.code]?.title || s.translations[l.code]?.body)}>
119
+ <summary>{l.name}{!s.translations[l.code]?.body && s.body ? ' (missing: shows the main text)' : ''}</summary>
120
+ <input value={s.translations[l.code]?.title ?? ''} maxLength={120} placeholder={s.title} aria-label={`Title in ${l.name}`} onChange={(e) => setTranslation(i, l.code, { title: e.target.value })} />
121
+ <textarea rows={4} value={s.translations[l.code]?.body ?? ''} maxLength={6000} aria-label={`Text in ${l.name}`} onChange={(e) => setTranslation(i, l.code, { body: e.target.value })} />
122
+ </details>
123
+ ))}
124
+ </fieldset>
125
+ ))}
126
+ <button type="button" className="btn" disabled={form.sections.length >= 20} onClick={() => set({ sections: [...form.sections, { id: newId(), policy: 'privacy', title: '', body: '', translations: {} }] })}>Add a section</button>
127
+ </div>
128
+
129
+ {error && <p className="flash error" role="alert">{error}</p>}
130
+ <div className="form-actions">
131
+ <button type="button" className="btn primary" disabled={!dirty || busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
132
+ {dirty ? <button type="button" className="btn ghost" onClick={() => { setForm(state.legal); setError(''); }}>Discard</button> : note && <span className="hint" role="status">{note}</span>}
133
+ </div>
134
+
135
+ <div className="lg-block">
136
+ <h3>App store answers</h3>
137
+ <p className="hint">What an app built on this site collects, for App Store Connect → App Privacy and Google Play → Data safety. Review them, and add anything your app’s own code collects.</p>
138
+ <div className="lg-table" role="table" aria-label="App store answers">
139
+ <div role="row" className="lg-th"><span role="columnheader">Data</span><span role="columnheader">Apple</span><span role="columnheader">Google Play</span></div>
140
+ {state.answers.rows.map((r) => (
141
+ <div role="row" key={r.data}>
142
+ <span role="cell"><b>{r.data}</b><small className="hint">{r.when}</small></span>
143
+ <span role="cell">{r.apple}</span><span role="cell">{r.google}</span>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ <p><b>Tracking:</b> {state.answers.tracking}</p>
148
+ <ul className="lg-notes">{state.answers.notes.map((n) => <li key={n}>{n}</li>)}</ul>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
@@ -167,11 +167,23 @@ export default function LiveManager() {
167
167
  }
168
168
  }
169
169
 
170
+ // Google sent the owner back (?live=finish): the callback parked the code; finish it with this
171
+ // signed-in session (a same-origin POST, so a lapsed sign-in renews on this page first)
172
+ async function finishAuthorize() {
173
+ try {
174
+ adopt(await api<LiveAdminState>({ action: 'finishAuthorize', provider: 'youtube' }));
175
+ setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
176
+ } catch (e) {
177
+ setPageMsg({ ok: '', err: `YouTube authorization didn’t finish: ${errText(e)} Try Authorize again.` });
178
+ await load();
179
+ }
180
+ }
181
+
170
182
  useEffect(() => {
171
183
  const q = new URLSearchParams(location.search);
172
184
  const r = q.get('live');
173
- if (r === 'connected') {
174
- setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
185
+ if (r === 'finish') {
186
+ setPageMsg({ ok: 'Finishing YouTube authorization…', err: '' });
175
187
  } else if (r === 'error') {
176
188
  setPageMsg({ ok: '', err: 'YouTube authorization didn’t finish. Check that the redirect URI in Google matches exactly, then try Authorize again.' });
177
189
  }
@@ -182,7 +194,7 @@ export default function LiveManager() {
182
194
  const qs = q.toString();
183
195
  history.replaceState(history.state, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
184
196
  }
185
- void load();
197
+ void (r === 'finish' ? finishAuthorize() : load());
186
198
  const tick = setInterval(() => setNow(Date.now()), 30_000);
187
199
  return () => clearInterval(tick);
188
200
  }, []);
@@ -335,12 +335,27 @@ export async function rescheduleBooking(repo: ContentRepository, b: Booking, sta
335
335
  export async function sendDueReminders(repo: ContentRepository, opts: FlowOptions): Promise<number> {
336
336
  const now = opts.now ?? Date.now();
337
337
  const due = await repo.listDueReminders(now + 60 * 60_000, now + 24 * 3600_000);
338
+ const events = new Map<string, CalEvent | null>();
339
+ let settings: Settings | undefined;
340
+ let queued = 0;
338
341
  for (const b of due) {
342
+ // an RSVP for a date cancelled before its RSVPs were cancelled with it
343
+ // (older versions): cancel it now (the person gets that email) instead of reminding
344
+ if (b.kind === 'rsvp' && b.eventId) {
345
+ if (!events.has(b.eventId)) events.set(b.eventId, await repo.getEvent(b.eventId));
346
+ const e = events.get(b.eventId);
347
+ settings ??= await repo.getSettings();
348
+ if (e?.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, b.start, settings))) {
349
+ await repo.cancelOccurrenceRsvps(e.id, b.start);
350
+ continue;
351
+ }
352
+ }
353
+ queued++;
339
354
  await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:reminder:${b.email}`,kind:'booking',recipient:b.email,
340
355
  payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'reminder'}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.start});
341
356
  }
342
357
  await kickEmail(repo,opts.locals,opts.waitUntil);
343
- return due.length;
358
+ return queued;
344
359
  }
345
360
 
346
361
  // cron: pending bookings nobody answered within the link's deadline get the
@@ -15,6 +15,7 @@ import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
15
15
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
16
16
  import { eventDeleted } from './hooks';
17
17
  import { waitUntilFrom } from '../webhooks';
18
+ import { kickEmail } from '../email/service';
18
19
 
19
20
  export async function uniqueSlug(repo: ContentRepository, title: string) {
20
21
  const base = slugify(title) || 'event';
@@ -167,7 +168,9 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
167
168
 
168
169
  // ---- cancel one date ----------------------------------------------------------
169
170
  // The date stays on the calendar struck through ("Cancelled"), its room holds
170
- // are released, and feeds mark it STATUS:CANCELLED. These do no permission
171
+ // are released, feeds mark it STATUS:CANCELLED, and its RSVPs are cancelled
172
+ // (each person gets the "cancelled" email; no reminder). Restoring the date
173
+ // leaves those RSVPs cancelled: people sign up again. These do no permission
171
174
  // checks: callers (admin actions, site code through @acms-subir/core/server)
172
175
  // decide who may do this.
173
176
 
@@ -182,6 +185,7 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
182
185
  const has = e.cancelledDates.includes(key);
183
186
  if (has === cancel) return e; // already so: nothing to change
184
187
  await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
188
+ if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
185
189
  const after = (await repo.getEvent(id))!;
186
190
  await eventSideEffects(repo, hooks, e, after);
187
191
  return after;
@@ -0,0 +1,60 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import { I18N, localeName } from '../i18n';
3
+ import { siteOrigin } from '../seo';
4
+ import { LEGAL } from './config';
5
+ import { legalFacts } from './facts';
6
+ import { LEGAL_LANGUAGES, legalLanguage } from './text';
7
+ import { policyUpdated } from './index';
8
+ import { appPrivacyAnswers } from './app-privacy';
9
+ import { LegalSettings } from './types';
10
+
11
+ // Settings → Legal: the owner's details and sections for /privacy and /terms,
12
+ // what the standard text covers, and the app store answers.
13
+
14
+ export async function legalAdminState(repo: ContentRepository, url: URL, locals?: unknown) {
15
+ const [settings, pages] = await Promise.all([repo.getSettings(), repo.listPages()]);
16
+ const facts = await legalFacts(repo, settings, locals);
17
+ const origin = siteOrigin(settings, url);
18
+ const custom = { privacy: pages.some((p) => p.status === 'published' && p.slug === 'privacy'), terms: pages.some((p) => p.status === 'published' && p.slug === 'terms') };
19
+ const extra = I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : [];
20
+ const siteLanguages = I18N.enabled ? I18N.locales : [settings.locale || 'en'];
21
+ const pixels = facts.analytics.meta || facts.analytics.tiktok;
22
+ const warnings = [
23
+ ...(!facts.complete ? ['Add your legal name and a contact email. Until then the pages say they’re a standard notice and ask search engines not to list them.'] : []),
24
+ ...(!facts.operator.address ? ['Add your address. Mexico’s data protection law asks for the operator’s domicile, and app stores show the operator’s name.'] : []),
25
+ ...(facts.operator.country === 'MX' && !facts.operator.contact ? ['Name the person or department that answers privacy (ARCO) requests.'] : []),
26
+ ...(pixels ? ['Meta or TikTok ad pixels are on (Settings → Analytics). The notice says so, but you may also need visitors’ consent before they load, depending on where your visitors are.'] : []),
27
+ ...(siteLanguages.filter((l) => !LEGAL_LANGUAGES[legalLanguage(l)] || legalLanguage(l) !== l.split('-')[0].toLowerCase()).map((l) => `The standard text has no ${localeName(l)} version yet; ${localeName(l)} pages show it in English.`)),
28
+ ...(custom.privacy ? ['A published page at /privacy replaces the standard privacy notice. Unpublish it to use the text below.'] : []),
29
+ ...(custom.terms ? ['A published page at /terms replaces the standard terms. Unpublish it to use the text below.'] : []),
30
+ ];
31
+ const link = (slug: 'privacy' | 'terms') => (LEGAL.enabled || custom[slug] ? `${origin}/${slug}` : null);
32
+ return {
33
+ enabled: LEGAL.enabled,
34
+ legal: settings.legal,
35
+ // what each empty field falls back to (acms({ legal }) config, then the site title)
36
+ defaults: { operatorName: LEGAL.operatorName || settings.siteTitle, contactEmail: LEGAL.contactEmail, address: LEGAL.address, country: LEGAL.country },
37
+ languages: { main: I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en'), extra: extra.map((code) => ({ code, name: localeName(code) })) },
38
+ links: { privacy: link('privacy'), terms: link('terms') },
39
+ custom,
40
+ updated: LEGAL.enabled ? { privacy: await policyUpdated(repo, 'privacy', facts, settings), terms: await policyUpdated(repo, 'terms', facts, settings) } : null,
41
+ covers: {
42
+ calendar: facts.calendar, bookings: facts.bookings, guestSignIn: facts.guestSignIn, push: facts.push.enabled, auth: facts.auth,
43
+ email: facts.email, maps: facts.maps, live: facts.live, analytics: facts.analytics, ai: facts.ai,
44
+ },
45
+ warnings,
46
+ answers: appPrivacyAnswers(facts, link('privacy') ?? `${origin}/privacy`),
47
+ };
48
+ }
49
+ export type LegalAdminState = Awaited<ReturnType<typeof legalAdminState>>;
50
+
51
+ /** Saves Settings → Legal. Section ids must be unique; translations only for the site's other languages. */
52
+ export async function saveLegal(repo: ContentRepository, raw: unknown) {
53
+ const input = LegalSettings.parse(raw);
54
+ const extra = new Set(I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : []);
55
+ const seen = new Set<string>();
56
+ const sections = input.sections.filter((s) => !seen.has(s.id) && !!seen.add(s.id)).map((s) => ({
57
+ ...s, translations: Object.fromEntries(Object.entries(s.translations).filter(([code, t]) => extra.has(code) && (t.title || t.body))),
58
+ }));
59
+ await repo.updateSettings({ legal: { ...input, sections } });
60
+ }
@@ -0,0 +1,49 @@
1
+ import type { LegalFacts } from './facts';
2
+
3
+ // Settings → Legal → App store answers: what an app built on this site's
4
+ // public API collects, in the words of Apple's App Privacy form (App Store
5
+ // Connect) and Google Play's Data safety form. Only the site's features; the
6
+ // app's own SDKs (crash reporting, analytics) must be added by its developer.
7
+
8
+ export interface StoreRow { data: string; when: string; apple: string; google: string }
9
+ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes: string[] }
10
+
11
+ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
12
+ const rows: StoreRow[] = [];
13
+ const contact = [...(f.bookings ? ['bookings'] : []), ...(f.appForms ? ['forms'] : [])];
14
+ if (contact.length) {
15
+ rows.push({
16
+ data: 'Name, email address, phone number', when: `When someone sends ${contact.join(' or ')} from the app`,
17
+ apple: 'Contact Info → Name, Email Address, Phone Number. Purpose: App Functionality. Linked to the user. Not used for tracking.',
18
+ google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
19
+ });
20
+ rows.push({
21
+ data: 'Answers typed into a form or booking question', when: `When someone sends ${contact.join(' or ')} from the app`,
22
+ apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
23
+ google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
24
+ });
25
+ }
26
+ if (f.guestSignIn) rows.push({
27
+ data: 'Email address (sign-in to see bookings)', when: 'When someone signs in with an emailed code',
28
+ apple: 'Contact Info → Email Address. Purpose: App Functionality. Linked to the user. Not used for tracking.',
29
+ google: 'Personal info → Email address. Collected, not shared. Purpose: App functionality, Account management. Optional.',
30
+ });
31
+ if (f.push.enabled) rows.push({
32
+ data: 'Push token, platform, language and chosen topics', when: 'When someone turns on notifications',
33
+ apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
34
+ google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
35
+ });
36
+ rows.push({
37
+ data: 'IP address', when: 'Every request (rate limiting only)',
38
+ apple: 'Usually not declared: only a scrambled copy is kept, in counters that expire after about a minute. To be cautious, declare Other Data → Other Data Types, App Functionality, not linked.',
39
+ google: 'Usually not declared for the same reason. To be cautious: Device or other IDs, processed ephemerally, App functionality and Fraud prevention, security.',
40
+ });
41
+ const notes = [
42
+ 'The site encrypts data in transit (HTTPS): answer “Yes” to “Is all of the user data collected by your app encrypted in transit?” in Google Play, once the site runs on https.',
43
+ `People can ask for their data to be deleted by writing to ${f.operator.email || 'your contact address (set it above)'}${f.push.enabled ? '; turning notifications off removes their device at once' : ''}. Use ${privacyUrl} as the privacy policy URL in both stores, and link to it inside the app too.`,
44
+ 'Not used for tracking: the app doesn’t load the website’s analytics or ad pixels. If you add an SDK that tracks (ads, attribution), these answers change.',
45
+ 'Add whatever the app itself collects: crash reporting, analytics or update SDKs (for example Sentry or Expo Updates) are not included here.',
46
+ ...(f.guestSignIn ? ['Signing in with an emailed code creates an account in the stores’ eyes. Apple (guideline 5.1.1(v)) and Google Play expect a way to delete it inside the app, plus a web link for Google. Offer “Sign out and delete my sign-in” (useGuest().signOut()) and say how to ask for bookings to be deleted.'] : []),
47
+ ];
48
+ return { rows, tracking: 'No: neither the site’s app API nor the packaged app components track people across other companies’ apps or websites.', notes };
49
+ }
@@ -0,0 +1,4 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ // acms({ legal }) config: defaults for Settings → Legal. `legal: false` removes /privacy and /terms.
4
+ export const LEGAL = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', address: '', country: '', ...userConfig.legal };