@acms-subir/core 0.3.54 → 0.3.56
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 +36 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +104 -2
- package/docs/google-calendar.md +302 -0
- package/docs/mobile.md +3 -1
- package/docs/server.md +58 -0
- package/integration.mjs +38 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -0
- package/src/lib/calendar/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +17 -2
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +7 -0
- package/src/lib/legal/text-en.ts +7 -0
- package/src/lib/legal/text-es.ts +7 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/schema.ts +43 -2
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +105 -1
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ requests }) config (integration.mjs resolves it): off unless a site
|
|
4
|
+
// turns it on, and only with the calendar. `path` is the public form ('/request'
|
|
5
|
+
// by default, '/es/request' in Spanish); `page: false` keeps the app API and
|
|
6
|
+
// Calendar → Requests but drops that form, so a site can build its own.
|
|
7
|
+
export const REQUESTS: { enabled: boolean; path: string; page: boolean } =
|
|
8
|
+
{ enabled: false, path: '/request', page: true, ...userConfig.requests };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import type { Mail } from '../email/types';
|
|
4
|
+
import { siteLang, siteLocale } from '../dates';
|
|
5
|
+
import { I18N, localeName } from '../i18n';
|
|
6
|
+
import { fill } from '../live/words';
|
|
7
|
+
import { parseWall } from './events';
|
|
8
|
+
import { zonedToUtc } from './ics';
|
|
9
|
+
import { requestSettings, type NewEventDetails, type RequestMailPayload } from './request-service';
|
|
10
|
+
import { requestStaffWords, requestStrings } from './request-words';
|
|
11
|
+
|
|
12
|
+
// The emails about event requests (queued by lib/calendar/request-service.ts
|
|
13
|
+
// as kind 'calendar-request') and the staff push. To the person: in the
|
|
14
|
+
// language they asked in, and never with words they typed (their title or
|
|
15
|
+
// message), so the form can't be used to email someone else a message; only
|
|
16
|
+
// the date, time, kind, rooms and number of people, or the event's own title.
|
|
17
|
+
// Staff get everything, in the site's main language, with Reply-To set to
|
|
18
|
+
// the person. Plain HTML like the other emails, plus a text part.
|
|
19
|
+
|
|
20
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
21
|
+
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
22
|
+
|
|
23
|
+
/** the site's main language, and the locale for its dates (keeping the site's region when it's the same language) */
|
|
24
|
+
function language(settings: Settings, lang = '') {
|
|
25
|
+
const l = lang || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
26
|
+
const base = l.toLowerCase().split('-')[0];
|
|
27
|
+
return { lang: l, locale: base === siteLang(settings) ? siteLocale(settings) : l };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "Sat, Nov 7 · 2:00 – 5:00 PM" for site-local wall times, in a language */
|
|
31
|
+
export function requestWhen(start: string, end: string, settings: Settings, locale: string): string {
|
|
32
|
+
const tz = settings.timezone || 'UTC';
|
|
33
|
+
const s = parseWall(start), e = parseWall(end);
|
|
34
|
+
if (!s || s.dateOnly) return start;
|
|
35
|
+
const at = (w: NonNullable<typeof s>) => zonedToUtc(w.y, w.m, w.d, w.hh, w.mm, 0, tz);
|
|
36
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
37
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
|
|
38
|
+
catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
|
|
39
|
+
};
|
|
40
|
+
const date = f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at(s));
|
|
41
|
+
const time = f({ hour: 'numeric', minute: '2-digit' });
|
|
42
|
+
return `${date} · ${e && !e.dateOnly ? time.formatRange(at(s), at(e)) : time.format(at(s))}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a date (YYYY-MM-DD…) as "Sat, Nov 7" */
|
|
46
|
+
function dayText(key: string, settings: Settings, locale: string) {
|
|
47
|
+
const w = parseWall(key.slice(0, 10));
|
|
48
|
+
if (!w) return key;
|
|
49
|
+
try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(Date.UTC(w.y, w.m - 1, w.d)); }
|
|
50
|
+
catch { return key.slice(0, 10); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Summary { what: string; lines: string[] }
|
|
54
|
+
/** the request as the person may see it again: never their own words */
|
|
55
|
+
async function personSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<Summary> {
|
|
56
|
+
const w = requestStrings(lang);
|
|
57
|
+
if (q.kind === 'change') {
|
|
58
|
+
const e = q.eventId ? await repo.getEvent(q.eventId) : null;
|
|
59
|
+
const title = e?.title ?? String(q.data.title ?? '');
|
|
60
|
+
return { what: fill(w.changeTo, { title }), lines: [q.key ? dayText(q.key, settings, locale) : w.everyDate] };
|
|
61
|
+
}
|
|
62
|
+
if (q.kind !== 'new') return { what: '', lines: [] };
|
|
63
|
+
const d = q.data as unknown as NewEventDetails;
|
|
64
|
+
const rooms = await repo.listResources();
|
|
65
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
66
|
+
const parts = [
|
|
67
|
+
requestWhen(d.start, d.end, settings, locale),
|
|
68
|
+
...(d.repeat === 'weekly' ? [w.weekly] : []),
|
|
69
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
|
|
70
|
+
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
71
|
+
type ? (lang && type.translations[lang]) || type.label : '',
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
return { what: '', lines: [parts.join(' · ')] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function page(site: string, heading: string, paras: string[], quote: string, button?: { label: string; url: string }, foot = '') {
|
|
77
|
+
const box = quote ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px;white-space:pre-line">${esc(quote)}</p>` : '';
|
|
78
|
+
const btn = button ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${esc(button.url)}">${esc(button.label)}</a></p>` : '';
|
|
79
|
+
return `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${esc(site)}</p><h1 style="font-size:22px">${esc(heading)}</h1>`
|
|
80
|
+
+ paras.map((p) => `<p>${esc(p)}</p>`).join('') + box + btn + (foot ? `<p style="color:#7d776e;font-size:14px">${esc(foot)}</p>` : '') + '</div></body></html>';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One queued request email, or null when it no longer applies. */
|
|
84
|
+
export async function requestMail(repo: ContentRepository, recipient: string, p: RequestMailPayload, origin: string): Promise<Mail | null> {
|
|
85
|
+
const q = await repo.requests.get(p.requestId);
|
|
86
|
+
if (!q) return null;
|
|
87
|
+
const settings = await repo.getSettings();
|
|
88
|
+
const site = settings.siteTitle;
|
|
89
|
+
if (p.kind === 'staff-new') {
|
|
90
|
+
// staff hear only about requests still waiting
|
|
91
|
+
if (q.state !== 'pending') return null;
|
|
92
|
+
return staffMail(repo, q, recipient, settings, origin);
|
|
93
|
+
}
|
|
94
|
+
const { lang, locale } = language(settings, q.requester?.locale);
|
|
95
|
+
const w = requestStrings(lang).mail;
|
|
96
|
+
const s = await personSummary(repo, q, settings, lang, locale);
|
|
97
|
+
const rq = requestSettings(settings);
|
|
98
|
+
const replyTime = (q.requester?.locale && rq.translations[q.requester.locale]?.replyTime) || rq.replyTime;
|
|
99
|
+
const change = q.kind === 'change';
|
|
100
|
+
const [subject, intro, quote] = p.kind === 'received' ? [w.receivedSubject, [w.received, ...(replyTime ? [fill(w.within, { time: replyTime })] : [])], '']
|
|
101
|
+
: p.kind === 'reply' ? [w.replySubject, [w.reply], p.message ?? '']
|
|
102
|
+
: p.kind === 'approved' ? [w.approvedSubject, [change ? w.approvedChange : w.approved], p.message ? `${w.fromTeam} ${p.message}` : '']
|
|
103
|
+
: [w.declinedSubject, [change ? w.declinedChange : w.declined], p.message ? `${w.fromTeam} ${p.message}` : ''];
|
|
104
|
+
const details = [s.what, ...s.lines].filter(Boolean);
|
|
105
|
+
const foot = p.kind === 'received' ? w.notYou : '';
|
|
106
|
+
return {
|
|
107
|
+
to: recipient, subject: `${subject} · ${site}`,
|
|
108
|
+
text: [subject, '', ...intro, '', ...details, ...(quote ? ['', quote] : []), ...(foot ? ['', foot] : [])].join('\n'),
|
|
109
|
+
html: page(site, subject, [...intro, details.join(' — ')], quote, undefined, foot),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function staffMail(repo: ContentRepository, q: RequestRecord, to: string, settings: Settings, origin: string): Promise<Mail> {
|
|
114
|
+
const { lang, locale } = language(settings);
|
|
115
|
+
const w = requestStaffWords(lang);
|
|
116
|
+
const site = settings.siteTitle;
|
|
117
|
+
const user = q.userId ? await repo.getUser(q.userId) : null;
|
|
118
|
+
const who = q.requester ?? { name: user?.name ?? '', email: user?.email ?? '', phone: '', locale: '' };
|
|
119
|
+
const rows: [string, string][] = [];
|
|
120
|
+
let subject: string;
|
|
121
|
+
if (q.kind === 'new') {
|
|
122
|
+
const d = q.data as unknown as NewEventDetails;
|
|
123
|
+
const rooms = await repo.listResources();
|
|
124
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
125
|
+
subject = fill(w.subjectNew, { title: oneLine(d.title) });
|
|
126
|
+
rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
|
|
127
|
+
if (d.repeat !== 'once') rows.push([w.repeats, d.repeat === 'weekly' ? requestStrings(lang).weekly : d.repeatText]);
|
|
128
|
+
rows.push([w.rooms, d.roomsUnsure ? w.roomsUnsure : d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name ?? '').filter(Boolean).join(', ') || '—']);
|
|
129
|
+
if (d.people) rows.push([w.people, String(d.people)]);
|
|
130
|
+
if (type) rows.push([w.type, type.label]);
|
|
131
|
+
} else {
|
|
132
|
+
const e = q.eventId ? await repo.getEvent(q.eventId) : null;
|
|
133
|
+
const title = e?.title ?? String(q.data.title ?? '');
|
|
134
|
+
subject = fill(q.kind === 'change' ? w.subjectChange : w.subjectMove, { title: oneLine(title) });
|
|
135
|
+
rows.push([w.date, q.key ? dayText(q.key, settings, locale) : w.everyDate]);
|
|
136
|
+
const d = q.data as { start?: string; end?: string };
|
|
137
|
+
if (q.kind !== 'change' && d.start && d.end) rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
|
|
138
|
+
}
|
|
139
|
+
rows.push([w.from, [who.name, who.email, who.phone].filter(Boolean).join(' · ')]);
|
|
140
|
+
if (who.locale) rows.push([w.language, localeName(who.locale, true)]);
|
|
141
|
+
const quote = q.note ? `${q.kind === 'change' ? w.whatChange : w.message}: “${q.note}”` : '';
|
|
142
|
+
const url = `${origin}/admin/calendar/requests#r-${q.id}`;
|
|
143
|
+
const intro = w.intro[q.source];
|
|
144
|
+
return {
|
|
145
|
+
to, subject: `${subject} · ${site}`, ...(who.email ? { replyTo: who.email } : {}),
|
|
146
|
+
text: [subject, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(quote ? ['', quote] : []), '', `${w.open}: ${url}`, '', w.why].join('\n'),
|
|
147
|
+
html: page(site, subject, [intro, ...rows.map(([k, v]) => `${k}: ${v}`)], quote, { label: w.open, url }, w.why),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The staff push: that a request arrived and for when, nothing the person typed. */
|
|
152
|
+
export async function requestPush(_repo: ContentRepository, q: RequestRecord, settings: Settings): Promise<{ title: string; body: string }> {
|
|
153
|
+
const { lang, locale } = language(settings);
|
|
154
|
+
const w = requestStaffWords(lang);
|
|
155
|
+
const d = q.data as { start?: string; end?: string };
|
|
156
|
+
const when = d.start && d.end ? requestWhen(d.start, d.end, settings, locale) : q.key ? dayText(q.key, settings, locale) : '';
|
|
157
|
+
return { title: w.pushTitle, body: when ? fill(w.pushBody, { when }) : '' };
|
|
158
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import { requestWhen } from './request-mail';
|
|
4
|
+
import { requestSettings, type NewEventDetails, type RequestInput } from './request-service';
|
|
5
|
+
import { requestStrings } from './request-words';
|
|
6
|
+
import { fill } from '../live/words';
|
|
7
|
+
|
|
8
|
+
// The built-in request form (pages/request/index.astro): what the posted
|
|
9
|
+
// form means as a request, and the "Request sent" summary. A site's own form
|
|
10
|
+
// can use the server API instead.
|
|
11
|
+
|
|
12
|
+
const str = (data: FormData, name: string) => String(data.get(name) ?? '');
|
|
13
|
+
|
|
14
|
+
/** The posted form as RequestInput (the date and times come as separate fields). */
|
|
15
|
+
export function formRequest(data: FormData, locale: string): RequestInput {
|
|
16
|
+
const kind = str(data, 'kind') === 'change' ? 'change' : 'new';
|
|
17
|
+
const common = { name: str(data, 'name'), email: str(data, 'email'), phone: str(data, 'phone'), locale, website: str(data, 'website') };
|
|
18
|
+
if (kind === 'change') return { kind, event: str(data, 'event'), date: str(data, 'onDate'), message: str(data, 'whatChange'), ...common };
|
|
19
|
+
const date = str(data, 'date');
|
|
20
|
+
const repeat = str(data, 'repeat');
|
|
21
|
+
const people = Number.parseInt(str(data, 'people'), 10);
|
|
22
|
+
return {
|
|
23
|
+
kind, type: str(data, 'type'), title: str(data, 'title'),
|
|
24
|
+
start: date && str(data, 'start') ? `${date}T${str(data, 'start')}` : date, end: str(data, 'end'),
|
|
25
|
+
repeat: repeat === 'weekly' || repeat === 'other' ? repeat : 'once', repeatText: str(data, 'repeatText'),
|
|
26
|
+
rooms: data.getAll('rooms').map(String).slice(0, 10), roomsUnsure: data.get('roomsUnsure') === '1',
|
|
27
|
+
people: Number.isFinite(people) && people > 0 ? Math.min(people, 1_000_000) : null, message: str(data, 'message'), ...common,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "Request sent": the request as the person sent it, in their language. */
|
|
32
|
+
export async function sentSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<{ title: string; line: string }> {
|
|
33
|
+
const w = requestStrings(lang);
|
|
34
|
+
if (q.kind === 'change') {
|
|
35
|
+
const e = await repo.getEvent(q.eventId);
|
|
36
|
+
const day = q.key ? new Date(`${q.key.slice(0, 10)}T12:00:00Z`) : null;
|
|
37
|
+
let date = w.everyDate;
|
|
38
|
+
if (day) { try { date = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(day); } catch { date = q.key.slice(0, 10); } }
|
|
39
|
+
return { title: fill(w.changeTo, { title: e?.title ?? String(q.data.title ?? '') }), line: date };
|
|
40
|
+
}
|
|
41
|
+
const d = q.data as unknown as NewEventDetails;
|
|
42
|
+
const rooms = await repo.listResources();
|
|
43
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
44
|
+
const line = [
|
|
45
|
+
requestWhen(d.start, d.end, settings, locale),
|
|
46
|
+
d.repeat === 'weekly' ? w.weekly : d.repeat === 'other' ? d.repeatText : '',
|
|
47
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
|
|
48
|
+
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
49
|
+
type ? (lang && type.translations[lang]) || type.label : '',
|
|
50
|
+
].filter(Boolean).join(' · ');
|
|
51
|
+
return { title: d.title, line };
|
|
52
|
+
}
|