@acms-subir/core 0.3.30 → 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,30 @@
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
+
3
28
  ## 0.3.30
4
29
 
5
30
  ### 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
@@ -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.30",
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
+ }
@@ -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 };
@@ -0,0 +1,91 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Settings } from '../schema';
4
+ import { CALENDAR, bookingsEnabled } from '../calendar/config';
5
+ import { PUSH } from '../push/config';
6
+ import { AUTH } from '../auth/config';
7
+ import { EDITOR } from '../editor/config';
8
+ import { SITE_ROLES } from '../roles';
9
+ import { mapsStatus } from '../maps/config';
10
+ import { resolveConfig } from '../email/config';
11
+ import { guestSignInReady } from '../mobile/guest';
12
+ import { offsiteConfig } from '../backup/offsite';
13
+ import { LEGAL } from './config';
14
+
15
+ // What this site actually does with personal information, read from its
16
+ // configuration and settings, so the generated notice only describes enabled
17
+ // features (and names the providers in use).
18
+
19
+ export interface LegalOperator {
20
+ name: string; address: string; country: string; email: string; contact: string;
21
+ }
22
+ export interface LegalFacts {
23
+ operator: LegalOperator;
24
+ /** the operator supplied a name and a contact email (otherwise the page is marked incomplete and noindex) */
25
+ complete: boolean;
26
+ calendar: boolean;
27
+ /** resource bookings (RSVPs come with the calendar) */
28
+ bookings: boolean;
29
+ guestSignIn: boolean;
30
+ /** the app API takes form responses (Admin → Mobile app → App access) */
31
+ appForms: boolean;
32
+ push: { enabled: boolean; retentionDays: number };
33
+ auth: 'builtin' | 'clerk';
34
+ /** people other than staff can sign in (site roles or members pages) */
35
+ members: boolean;
36
+ editorApps: boolean;
37
+ /** email provider name, or '' when email isn't set up */
38
+ email: string;
39
+ /** maps provider name, or '' */
40
+ maps: string;
41
+ live: boolean;
42
+ analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
43
+ ai: boolean;
44
+ offsiteBackups: boolean;
45
+ }
46
+
47
+ const EMAIL_NAMES: Record<string, string> = { resend: 'Resend', mailgun: 'Mailgun', ses: 'Amazon SES', cloudflare: 'Cloudflare Email' };
48
+
49
+ /** Operator details: Settings → Legal first, then acms({ legal }) config, then the site title. */
50
+ export function legalOperator(settings: Settings): LegalOperator {
51
+ const l = settings.legal;
52
+ return {
53
+ name: l.operatorName || LEGAL.operatorName || settings.siteTitle,
54
+ address: l.address || LEGAL.address,
55
+ country: l.country || LEGAL.country,
56
+ email: l.contactEmail || LEGAL.contactEmail,
57
+ contact: l.privacyContact,
58
+ };
59
+ }
60
+
61
+ export async function legalFacts(repo: ContentRepository, settings: Settings, locals?: unknown): Promise<LegalFacts> {
62
+ const operator = legalOperator(settings);
63
+ const email = await repo.email.config().then((record) => resolveConfig(record, locals)).catch(() => null);
64
+ let offsite = false;
65
+ try { offsite = !!offsiteConfig(locals); } catch { offsite = true; /* configured, if misconfigured */ }
66
+ const a = settings.analytics;
67
+ const members = SITE_ROLES.length > 0 || (userConfig as { members?: { enabled?: boolean } }).members?.enabled === true;
68
+ return {
69
+ operator,
70
+ complete: Boolean((settings.legal.operatorName || LEGAL.operatorName) && operator.email),
71
+ calendar: CALENDAR.enabled,
72
+ bookings: bookingsEnabled(),
73
+ guestSignIn: await guestSignInReady(repo, locals).catch(() => false),
74
+ appForms: settings.mobileAccess.forms,
75
+ push: { enabled: PUSH.enabled, retentionDays: PUSH.retentionDays },
76
+ auth: AUTH.provider === 'clerk' ? 'clerk' : 'builtin',
77
+ members,
78
+ editorApps: EDITOR.enabled,
79
+ email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
80
+ maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
81
+ live: settings.live.mode !== 'off',
82
+ analytics: {
83
+ umami: a.umami.enabled && !!a.umami.websiteId,
84
+ replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
85
+ meta: a.metaPixel.enabled && !!a.metaPixel.pixelId,
86
+ tiktok: a.tiktokPixel.enabled && !!a.tiktokPixel.pixelId,
87
+ },
88
+ ai: settings.ai.enabled,
89
+ offsiteBackups: offsite,
90
+ };
91
+ }