@acms-subir/core 0.3.30 → 0.3.32

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,42 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.32
4
+
5
+ ### Patch Changes
6
+
7
+ - f1c7635: Sites can add their own push topics, and topic names come in the app's language.
8
+
9
+ - **Site topics.** `acms({ mobile: { push: { topics: [{ id: 'help', label: { en: 'Help needed', es: 'Se necesita ayuda' }, description?: … }] } } })`. A single string is used for every language. They're listed after the built-in topics with `kind: 'site'` (and their `description`), devices may choose them, and staff send to them from Admin → Mobile app. Ids are lowercase letters, numbers, `-` and `_`. `news`, `events` and `live` are reserved, and there can be up to 20. Like every topic, they never reach devices that didn't choose them.
10
+ - **Names in the app's language.** `GET /api/v1/push/topics?locale=es` names the built-in topics in that language (News/Noticias, Events/Eventos, Live video/Video en vivo; also French, German and Portuguese) and uses the site's words for its own. Missing words fall back to the main language, and the response says which `locale` it used.
11
+ - **Legal links in the app's language.** `GET /api/v1/site?locale=es` returns `legal.privacy` / `legal.terms` for the Spanish pages (`/es/privacy`), so apps no longer add `/es` themselves.
12
+
13
+ Unknown locales return 400 on both routes, as on other localized routes.
14
+
15
+ ## 0.3.31
16
+
17
+ ### Patch Changes
18
+
19
+ - 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.
20
+
21
+ 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.
22
+
23
+ New repository method: `cancelOccurrenceRsvps(eventId, start)`.
24
+
25
+ - 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.
26
+
27
+ - **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.
28
+ - **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"`.
29
+ - **Settings → Legal** (General settings permission):
30
+ - 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;
31
+ - the owner's own sections for either page, up to 20, translatable into each site language, for retention, other services, sensitive data and consent;
32
+ - 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.
33
+ - `acms({ legal })` values (now with `address` and `country`) are the defaults for empty fields.
34
+ - **"Last updated" is automatic.** The date changes only when the wording changes; `legal.updatedAt` is the earliest date shown.
35
+ - A published `privacy` or `terms` page still replaces the standard text, and now uses its translations on `/es/…`.
36
+ - `/api/v1/site` adds `legal: { privacy, terms }` (absolute URLs, or null) so apps can link to them.
37
+
38
+ 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.
39
+
3
40
  ## 0.3.30
4
41
 
5
42
  ### 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;
package/docs/mobile.md CHANGED
@@ -125,7 +125,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
125
125
  | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
126
126
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
127
127
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
128
- | `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
128
+ | `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
129
129
  | `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
130
130
 
131
131
  Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
@@ -301,16 +301,22 @@ a pre-ticked choice doesn't count. So:
301
301
  - A new topic (for example `live`) never reaches existing devices by itself.
302
302
  To offer it to people who installed the app before it existed, show a
303
303
  one-time in-app prompt ("Get notified when we're live?") and call
304
- `setTopics([...topics, 'live'])` only on yes. Remember that you asked
305
- (AsyncStorage), and don't ask again.
304
+ `setTopics([...topics, 'live'])` only on yes. `useNewPushTopics({ baseline })`
305
+ lists the topics added since the person last saw the list (pass `baseline`:
306
+ the topic ids your older app version offered) and `markSeen()` remembers
307
+ the answer on the device, so you ask once.
306
308
  - Starting topics for new installs (`usePushRegistration({ topics })`) are
307
309
  fine once the person has allowed notifications from your explained prompt.
308
310
  List the topics they'll get on that screen.
309
311
  - 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.
312
+ an "Off" (`unregister()`). Off stays off across restarts (`turnedOff`); don't
313
+ invite people who turned it off. The app must work fully with notifications off.
314
+ - The site's privacy notice (`/privacy`, `/es/privacy`) already describes push,
315
+ forms, bookings and email sign-in for the features that are on. The owner
316
+ completes it in **Settings → Legal**, which also lists the App Privacy and
317
+ Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
318
+ (and `.terms`). The site stores only the Expo token, platform, language and
319
+ topics, with no name or email.
314
320
 
315
321
  ## Editor apps (staff sign-in)
316
322
 
@@ -551,7 +557,8 @@ npx eas-cli submit --platform ios # and android
551
557
  Store checklist: real `ios.bundleIdentifier` / `android.package` (can't change
552
558
  after release; doctor warns on `com.example.*`); app name, `icon` (1024×1024)
553
559
  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
560
+ the **https** production site; privacy policy URL (`site.legal.privacy`, also
561
+ linked inside the app) and the store privacy answers from Settings → Legal; screenshots for phone and
555
562
  tablet; deep-link config deployed on the site; a production build tested on
556
563
  real devices; `acms mobile doctor` clean.
557
564
 
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'],
@@ -594,10 +595,13 @@ function acmsVitePlugin(userConfig) {
594
595
  * Home page content per language is authored in /admin as a normal page
595
596
  * marked "home" with that language selected. Omit for monolingual sites —
596
597
  * nothing changes.
597
- * @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
598
+ * @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number, topics?: Array<{id: string, label: string | Record<string, string>, description?: string | Record<string, string>}>}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
598
599
  * Optional iOS Universal Links and Android App Links associations, and push
599
600
  * notifications for the site's own Expo apps (`push: true`; off by default):
600
- * /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `editor:
601
+ * /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `topics` adds
602
+ * the site's own topics (e.g. `{ id: 'help', label: { en: 'Help needed', es:
603
+ * 'Se necesita ayuda' } }`): apps offer them, staff send to them; they never
604
+ * reach devices that didn't choose them. `editor:
601
605
  * { redirectUris, name? }` turns on editor apps: staff sign in through the
602
606
  * browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
603
607
  * receive codes) and edit through the bearer-token /api/v1/admin/* API.
@@ -623,7 +627,7 @@ function acmsVitePlugin(userConfig) {
623
627
  * the environment. Without a provider, MAPS_URL picks photon and
624
628
  * MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
625
629
  * pasted map links still work.
626
- * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
630
+ * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, address?: string, country?: string, updatedAt?: string}} [options.legal]
627
631
  * Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
628
632
  * @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
629
633
  * @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
@@ -676,8 +680,10 @@ export default function acms(options = {}) {
676
680
  const roles = resolveRoles(options.roles, members);
677
681
  const permissions = resolvePermissions(options.permissions, roles);
678
682
  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');
683
+ const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
684
+ if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
685
+ legal.country = legal.country.trim().toUpperCase();
686
+ 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
687
  if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
682
688
  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
689
  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.30",
3
+ "version": "0.3.32",
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
+ }
@@ -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 };