@acms-subir/core 0.3.53 → 0.3.55
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 +32 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +76 -6
- package/docs/calendar.md +49 -2
- package/docs/google-calendar.md +302 -0
- package/docs/server.md +68 -1
- package/integration.mjs +15 -5
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- 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/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +22 -2
- package/src/lib/email/templates.ts +20 -2
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/facts.ts +3 -0
- package/src/lib/legal/text-en.ts +4 -0
- package/src/lib/legal/text-es.ts +4 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +25 -9
- package/src/lib/repo/types.ts +12 -3
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +47 -0
- 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 +82 -6
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +102 -0
- package/src/virtual.d.ts +1 -1
package/src/lib/legal/admin.ts
CHANGED
|
@@ -40,7 +40,7 @@ export async function legalAdminState(repo: ContentRepository, url: URL, locals?
|
|
|
40
40
|
updated: LEGAL.enabled ? { privacy: await policyUpdated(repo, 'privacy', facts, settings), terms: await policyUpdated(repo, 'terms', facts, settings) } : null,
|
|
41
41
|
covers: {
|
|
42
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,
|
|
43
|
+
email: facts.email, maps: facts.maps, live: facts.live, googleCalendar: facts.googleCalendar, analytics: facts.analytics, ai: facts.ai,
|
|
44
44
|
},
|
|
45
45
|
warnings,
|
|
46
46
|
answers: appPrivacyAnswers(facts, link('privacy') ?? `${origin}/privacy`),
|
package/src/lib/legal/facts.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface LegalFacts {
|
|
|
40
40
|
/** maps provider name, or '' */
|
|
41
41
|
maps: string;
|
|
42
42
|
live: boolean;
|
|
43
|
+
/** a Google account is connected for the two-way Google Calendar sync (lib/calendar/google/) */
|
|
44
|
+
googleCalendar: boolean;
|
|
43
45
|
/** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
|
|
44
46
|
announcements: boolean;
|
|
45
47
|
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
@@ -82,6 +84,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
|
|
|
82
84
|
email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
|
|
83
85
|
maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
|
|
84
86
|
live: settings.live.mode !== 'off',
|
|
87
|
+
googleCalendar: CALENDAR.enabled && await repo.gcal.account().then((a) => !!a && a.status !== 'setup').catch(() => false),
|
|
85
88
|
announcements: ANNOUNCEMENTS.enabled,
|
|
86
89
|
analytics: {
|
|
87
90
|
umami: a.umami.enabled && !!a.umami.websiteId,
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple and Google (app notifications)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (sign-in)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (maps)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (visit statistics)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI or another AI service we choose (writing tools used by staff)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['our backup storage provider'] : []),
|
|
@@ -61,6 +62,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
61
62
|
if (f.maps) s.push({ id: 'maps', title: 'Maps', text: [
|
|
62
63
|
`When staff enter an event’s address, we look it up with ${f.maps}. Your browser doesn’t contact the map service: map images are stored on this site. Directions links open Google Maps only if you choose them.`,
|
|
63
64
|
] });
|
|
65
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
66
|
+
'We keep a copy of our calendar in our own Google Calendar account, and changes made there come back to this site. That copy holds what our calendar shows: event titles, times, places and descriptions. It never includes your RSVPs, your name or what you tell us when you sign up for an event, and Google sends no invitations. Appointments booked through our Google booking pages show on this site only as “Booked”, without names. Google applies its own privacy policy to its copy.',
|
|
67
|
+
] });
|
|
64
68
|
if (f.live) s.push({ id: 'live', title: 'Live video', text: [
|
|
65
69
|
'When we stream, the site shows our broadcast from YouTube or Twitch. Until you press play, your browser only contacts this site: preview images come through it. Pressing play, or opening the broadcast on YouTube or Twitch, loads that platform, which applies its own privacy policy.',
|
|
66
70
|
] });
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple y Google (notificaciones de la app)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (inicio de sesión)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (mapas)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (estadísticas de visitas)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI u otro servicio de inteligencia artificial que elijamos (herramientas de redacción que usa el personal)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['nuestro proveedor de almacenamiento de respaldos'] : []),
|
|
@@ -62,6 +63,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
62
63
|
if (f.maps) s.push({ id: 'maps', title: 'Mapas', text: [
|
|
63
64
|
`Cuando el personal escribe la dirección de un evento, la buscamos con ${f.maps}. Su navegador no se conecta con el servicio de mapas: las imágenes de los mapas se guardan en este sitio. Los enlaces para llegar abren Google Maps solo si usted los elige.`,
|
|
64
65
|
] });
|
|
66
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
67
|
+
'Guardamos una copia de nuestro calendario en nuestra propia cuenta de Google Calendar, y los cambios que se hacen allí regresan a este sitio. Esa copia contiene lo que muestra nuestro calendario: títulos, horarios, lugares y descripciones de los eventos. Nunca incluye sus confirmaciones de asistencia, su nombre ni lo que nos dice al inscribirse en un evento, y Google no envía invitaciones. Las citas reservadas en nuestras páginas de reserva de Google aparecen en este sitio solo como “Reservado”, sin nombres. Google aplica su propia política de privacidad a su copia.',
|
|
68
|
+
] });
|
|
65
69
|
if (f.live) s.push({ id: 'live', title: 'Video en vivo', text: [
|
|
66
70
|
'Cuando transmitimos, el sitio muestra nuestra transmisión de YouTube o Twitch. Hasta que usted presiona reproducir, su navegador solo se conecta con este sitio: las imágenes de vista previa pasan por él. Al presionar reproducir, o al abrir la transmisión en YouTube o Twitch, se carga esa plataforma, que aplica su propia política de privacidad.',
|
|
67
71
|
] });
|
package/src/lib/live/crypto.ts
CHANGED
|
@@ -1,28 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return createHash('sha256').update(`acms:live:v1:${secret}`).digest();
|
|
9
|
-
}
|
|
10
|
-
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
11
|
-
export const randomToken = () => randomBytes(32).toString('base64url');
|
|
12
|
-
export function encryptionReady(locals?: unknown) { try { key(locals); return true; } catch { return false; } }
|
|
13
|
-
export function encrypt(value: unknown, context: string, locals?: unknown) {
|
|
14
|
-
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key(locals), iv);
|
|
15
|
-
cipher.setAAD(Buffer.from(`acms:live:${context}`));
|
|
16
|
-
const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
17
|
-
return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
|
|
18
|
-
}
|
|
19
|
-
export function decrypt<T>(encrypted: string, context: string, locals?: unknown): T {
|
|
20
|
-
try {
|
|
21
|
-
const parts = encrypted.split('.');
|
|
22
|
-
if (parts.length !== 3) throw new Error();
|
|
23
|
-
const [iv, tag, data] = parts.map(v => Buffer.from(v, 'base64url'));
|
|
24
|
-
const cipher = createDecipheriv('aes-256-gcm', key(locals), iv);
|
|
25
|
-
cipher.setAAD(Buffer.from(`acms:live:${context}`)); cipher.setAuthTag(tag);
|
|
26
|
-
return JSON.parse(Buffer.concat([cipher.update(data), cipher.final()]).toString('utf8'));
|
|
27
|
-
} catch { throw new ApiError(503, 'Saved live credentials cannot be decrypted. Re-enter the credentials and reconnect.'); }
|
|
28
|
-
}
|
|
1
|
+
import { sealer } from '../sealed';
|
|
2
|
+
// Live video credentials (lib/sealed.ts): the same key and wire format as before it was shared.
|
|
3
|
+
const live = sealer('live', 'live credentials');
|
|
4
|
+
export { hash, randomToken } from '../sealed';
|
|
5
|
+
export const encryptionReady = (locals?: unknown) => live.ready(locals);
|
|
6
|
+
export const encrypt = (value: unknown, context: string, locals?: unknown) => live.encrypt(value, context, locals);
|
|
7
|
+
export const decrypt = <T>(encrypted: string, context: string, locals?: unknown): T => live.decrypt<T>(encrypted, context, locals);
|
|
@@ -11,6 +11,8 @@ import { ROLES_MIGRATION } from '../permissions/migration';
|
|
|
11
11
|
import { SYNC_MIGRATION } from '../sync/migration';
|
|
12
12
|
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
13
13
|
import { SCREENS_MIGRATION } from '../display/store';
|
|
14
|
+
import { REQUEST_TABLE } from '../calendar/request-migration';
|
|
15
|
+
import { GCAL_MIGRATION } from '../calendar/google/migration';
|
|
14
16
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
15
17
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
16
18
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -730,6 +732,12 @@ export const MIGRATIONS: Migration[] = [
|
|
|
730
732
|
MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
|
|
731
733
|
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
732
734
|
SCREENS_MIGRATION,
|
|
735
|
+
// calendar requests on their own rows (RequestRecord): new events and
|
|
736
|
+
// changes asked for without an account, and hosts' date moves
|
|
737
|
+
{ version: 68, statements: REQUEST_TABLE },
|
|
738
|
+
// two-way Google Calendar sync: the account, links, pairs, outgoing queue
|
|
739
|
+
// (triggers on events) and Recent changes. Not public, so no cache trigger.
|
|
740
|
+
GCAL_MIGRATION,
|
|
733
741
|
];
|
|
734
742
|
|
|
735
743
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { AuthStore } from '../auth/store';
|
|
|
4
4
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
5
5
|
import { GuestStore } from '../mobile/guest-store';
|
|
6
6
|
import { LiveStore } from '../live/store';
|
|
7
|
+
import { GcalStore } from '../calendar/google/store';
|
|
7
8
|
import { PushStore } from '../push/store';
|
|
8
9
|
import { AnnouncementStore, announcementInsert } from '../announcements/store';
|
|
9
10
|
import { ScreenStore, screenInsert, screenKey } from '../display/store';
|
|
11
|
+
import { RequestStore, requestInsert } from '../calendar/request-store';
|
|
10
12
|
import { EditorStore } from '../editor/store';
|
|
11
13
|
import { PermissionStore } from '../permissions/store';
|
|
12
14
|
import { BackupStore } from '../backup/store';
|
|
@@ -158,6 +160,10 @@ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
|
|
|
158
160
|
for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
|
|
159
161
|
return out;
|
|
160
162
|
}
|
|
163
|
+
// quiet: the event change it records sends its webhook but no member push
|
|
164
|
+
// (push_control.quiet shares the write's transaction)
|
|
165
|
+
const quietly = (write: SqlStatement, quiet?: boolean): SqlStatement[] =>
|
|
166
|
+
quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write];
|
|
161
167
|
function rowToEvent(r: Row): CalEvent {
|
|
162
168
|
return CalEvent.parse({
|
|
163
169
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -292,8 +298,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
292
298
|
readonly forms: FormStore;
|
|
293
299
|
readonly guests: GuestStore;
|
|
294
300
|
readonly live: LiveStore;
|
|
301
|
+
readonly gcal: GcalStore;
|
|
295
302
|
readonly push: PushStore;
|
|
296
303
|
readonly announcements: AnnouncementStore;
|
|
304
|
+
readonly requests: RequestStore;
|
|
297
305
|
readonly editor: EditorStore;
|
|
298
306
|
readonly backups: BackupStore;
|
|
299
307
|
readonly permissions: PermissionStore;
|
|
@@ -305,8 +313,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
305
313
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
306
314
|
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
307
315
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
316
|
+
this.gcal = new GcalStore(async () => { await this.ready; return this.driver; });
|
|
308
317
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
309
318
|
this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
|
|
319
|
+
this.requests = new RequestStore(async () => { await this.ready; return this.driver; });
|
|
310
320
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
311
321
|
this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
|
|
312
322
|
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
@@ -877,13 +887,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
877
887
|
await this.syncEventIndex(id);
|
|
878
888
|
return (await this.getEvent(id))!;
|
|
879
889
|
}
|
|
880
|
-
async updateEvent(id: string, data: EventUpdate) {
|
|
890
|
+
async updateEvent(id: string, data: EventUpdate, opts: { keepDraft?: boolean; quiet?: boolean } = {}) {
|
|
881
891
|
await this.ready;
|
|
882
892
|
const current = await this.getEvent(id);
|
|
883
893
|
if (!current) return;
|
|
884
894
|
// a direct live write supersedes any staged edits (applyEventDraft routes
|
|
885
|
-
// through here too, so applying clears the snapshot in the same UPDATE)
|
|
886
|
-
|
|
895
|
+
// through here too, so applying clears the snapshot in the same UPDATE);
|
|
896
|
+
// keepDraft: a change made elsewhere (the Google sync) leaves them staged
|
|
897
|
+
const sets: string[] = opts.keepDraft ? ['updated_at = ?'] : ['updated_at = ?', 'draft = NULL'];
|
|
887
898
|
const args: unknown[] = [Date.now()];
|
|
888
899
|
const plain: Record<string, string> = {
|
|
889
900
|
title: 'title', slug: 'slug', bodyJson: 'body_json', bodyHtml: 'body_html', excerpt: 'excerpt',
|
|
@@ -943,8 +954,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
943
954
|
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
944
955
|
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
945
956
|
WHERE event_id=? AND kind='rsvp' AND status IN ('waitlisted','offered')`,args:[cancelled?1:0,id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
946
|
-
...notifications]);
|
|
947
|
-
} else await this.driver.batch(
|
|
957
|
+
...notifications].flatMap((st) => st === eventWrite ? quietly(eventWrite, opts.quiet) : [st]));
|
|
958
|
+
} else await this.driver.batch(quietly(eventWrite, opts.quiet));
|
|
948
959
|
await this.syncEventIndex(id);
|
|
949
960
|
}
|
|
950
961
|
async saveEventDraft(id: string, data: Parameters<ContentRepository['saveEventDraft']>[1]) {
|
|
@@ -964,17 +975,16 @@ export class SqlRepository implements ContentRepository {
|
|
|
964
975
|
await this.updateEvent(id, pickEventDraft(draft));
|
|
965
976
|
}
|
|
966
977
|
// per-date cancellations apply at once and never touch a staged draft
|
|
967
|
-
async setEventCancelledDates(id: string, dates: string[]) {
|
|
978
|
+
async setEventCancelledDates(id: string, dates: string[], opts: { quiet?: boolean } = {}) {
|
|
968
979
|
await this.ready;
|
|
969
|
-
await this.driver.
|
|
980
|
+
await this.driver.batch(quietly({ sql: 'UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(dates), Date.now(), id] }, opts.quiet));
|
|
970
981
|
}
|
|
971
982
|
async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
|
|
972
983
|
await this.ready;
|
|
973
984
|
const write = cancelledDates
|
|
974
985
|
? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
|
|
975
986
|
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
976
|
-
|
|
977
|
-
await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
|
|
987
|
+
await this.driver.batch(quietly(write, opts.quiet));
|
|
978
988
|
}
|
|
979
989
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
980
990
|
await this.ready;
|
|
@@ -1023,6 +1033,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1023
1033
|
...notifications,
|
|
1024
1034
|
{ sql: 'DELETE FROM events WHERE id = ?', args: [id] },
|
|
1025
1035
|
{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
|
|
1036
|
+
// requests about it can't be answered any more: withdrawn (so they're pruned like answered ones)
|
|
1037
|
+
{ sql: "UPDATE event_requests SET state = 'withdrawn', reviewed_at = ?, updated_at = MAX(?, updated_at + 1) WHERE event_id = ? AND state = 'pending'", args: [Date.now(), Date.now(), id] },
|
|
1026
1038
|
]);
|
|
1027
1039
|
}
|
|
1028
1040
|
|
|
@@ -1943,6 +1955,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1943
1955
|
...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
|
|
1944
1956
|
// screens come back with new keys (backups never hold them): each TV needs its new link
|
|
1945
1957
|
...(content.screens ? [{ sql: 'DELETE FROM display_screens' }, ...content.screens.map((s) => screenInsert({ ...s, key: screenKey(), lastSeenAt: null }))] : []),
|
|
1958
|
+
// calendar requests (a backup made before them leaves the current ones)
|
|
1959
|
+
...(content.eventRequests ? [{ sql: 'DELETE FROM event_requests' }, ...content.eventRequests.map(requestInsert)] : []),
|
|
1946
1960
|
...(content.forms ?? []).map(formInsert),
|
|
1947
1961
|
...(content.formSubmissions ?? []).map(submissionInsert),
|
|
1948
1962
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
|
@@ -2001,6 +2015,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
2001
2015
|
if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
|
|
2002
2016
|
const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
|
|
2003
2017
|
stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
|
|
2018
|
+
// Google Calendar links pause until someone checks them (the restored events may be older than Google's)
|
|
2019
|
+
stmts.push(...GcalStore.restoreStatements());
|
|
2004
2020
|
if (settingsWrite) stmts.push(settingsWrite);
|
|
2005
2021
|
stmts.push(
|
|
2006
2022
|
{ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -5,11 +5,13 @@ import type { BackupStore } from '../backup/store';
|
|
|
5
5
|
import type { GuestStore } from '../mobile/guest-store';
|
|
6
6
|
import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
7
7
|
import type { LiveStore } from '../live/store';
|
|
8
|
+
import type { GcalStore } from '../calendar/google/store';
|
|
8
9
|
import type { BookingRequest } from '../backup/content';
|
|
9
10
|
import type { EmailStore } from '../email/store';
|
|
10
11
|
import type { RsvpStaffNotice } from '../email/types';
|
|
11
12
|
import type { PushStore } from '../push/store';
|
|
12
13
|
import type { AnnouncementStore } from '../announcements/store';
|
|
14
|
+
import type { RequestStore } from '../calendar/request-store';
|
|
13
15
|
import type { Announcement } from '../announcements/types';
|
|
14
16
|
import type { EditorStore } from '../editor/store';
|
|
15
17
|
import type { PermissionStore } from '../permissions/store';
|
|
@@ -19,7 +21,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
|
|
|
19
21
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
20
22
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
21
23
|
|
|
22
|
-
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
|
|
24
|
+
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest, RequestRecord } from '../schema';
|
|
23
25
|
|
|
24
26
|
export interface Stats {
|
|
25
27
|
publishedPages: number;
|
|
@@ -80,8 +82,12 @@ export interface ContentRepository {
|
|
|
80
82
|
readonly backups: BackupStore;
|
|
81
83
|
readonly guests: GuestStore;
|
|
82
84
|
readonly live: LiveStore;
|
|
85
|
+
/** two-way Google Calendar sync (lib/calendar/google/) */
|
|
86
|
+
readonly gcal: GcalStore;
|
|
83
87
|
readonly push: PushStore;
|
|
84
88
|
readonly announcements: AnnouncementStore;
|
|
89
|
+
// calendar requests on their own rows (lib/calendar/request-store.ts)
|
|
90
|
+
readonly requests: RequestStore;
|
|
85
91
|
readonly editor: EditorStore;
|
|
86
92
|
readonly permissions: PermissionStore;
|
|
87
93
|
readonly screens: ScreenStore;
|
|
@@ -160,7 +166,8 @@ export interface ContentRepository {
|
|
|
160
166
|
getEvent(id: string): Promise<CalEvent | null>;
|
|
161
167
|
getEventBySlug(slug: string): Promise<CalEvent | null>;
|
|
162
168
|
createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
|
|
163
|
-
|
|
169
|
+
/** keepDraft: leave staged edits staged (a change made elsewhere); quiet: no member push for it */
|
|
170
|
+
updateEvent(id: string, data: EventUpdate, opts?: { keepDraft?: boolean; quiet?: boolean }): Promise<void>;
|
|
164
171
|
saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
|
|
165
172
|
/** the Team members who host an event (lib/calendar/requests.ts) */
|
|
166
173
|
setEventHosts(id: string, hostIds: string[]): Promise<void>;
|
|
@@ -169,7 +176,7 @@ export interface ContentRepository {
|
|
|
169
176
|
applyEventDraft(id: string): Promise<void>;
|
|
170
177
|
discardEventDraft(id: string): Promise<void>;
|
|
171
178
|
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
|
172
|
-
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
179
|
+
setEventCancelledDates(id: string, dates: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
173
180
|
/** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
|
|
174
181
|
setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
175
182
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
|
@@ -372,6 +379,8 @@ export interface ContentRepository {
|
|
|
372
379
|
// saved TV screens, without their keys (each gets a new one); a backup
|
|
373
380
|
// without them leaves the current ones
|
|
374
381
|
screens?: Screen[];
|
|
382
|
+
// added with calendar requests; a backup without them leaves the current ones
|
|
383
|
+
eventRequests?: RequestRecord[];
|
|
375
384
|
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
376
385
|
|
|
377
386
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
|
10
10
|
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
11
11
|
import { syncCachePurge } from './cache/purge';
|
|
12
12
|
import { runOffsiteBackup } from './backup/offsite';
|
|
13
|
+
import { syncGoogle } from './calendar/google/service';
|
|
13
14
|
|
|
14
15
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
15
16
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -20,6 +21,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
20
21
|
publish: () => repo.publishDuePosts(startedAt),
|
|
21
22
|
// changes to published events (scheduled publishing above included): webhooks, change pushes, redirects
|
|
22
23
|
events: () => flushEventChanges(repo),
|
|
24
|
+
// two-way Google Calendar sync: site changes go out every run, each link reads Google every 2 minutes
|
|
25
|
+
gcal: () => syncGoogle(repo, locals, { now: startedAt }),
|
|
23
26
|
webhooks: () => processDueDeliveries(repo),
|
|
24
27
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
25
28
|
// pending bookings past their approval deadline (Calendar settings → Bookings)
|
package/src/lib/schema.ts
CHANGED
|
@@ -395,6 +395,53 @@ export const EventRequest = z.object({
|
|
|
395
395
|
});
|
|
396
396
|
export type EventRequest = z.infer<typeof EventRequest>;
|
|
397
397
|
|
|
398
|
+
// A request about the calendar kept on its own row (table event_requests,
|
|
399
|
+
// lib/calendar/request-store.ts), unlike the member requests above that
|
|
400
|
+
// stage an event draft:
|
|
401
|
+
// - 'new': a new event from anyone, without an account (source 'app' or 'web').
|
|
402
|
+
// Approving makes a draft event and stores its id in `eventId`.
|
|
403
|
+
// - 'change': anyone asks for a change to an event (`eventId`, one date in
|
|
404
|
+
// `key` or '' for the whole event); `note` says what should change.
|
|
405
|
+
// - 'move' / 'from-date': a host's move of one date, or a change from a date
|
|
406
|
+
// on (C3/C4); `data` holds the move.
|
|
407
|
+
// `userId` is the member who asked; `requester` is the person without an
|
|
408
|
+
// account (never both). `note` is their own words to staff, `reply` staff's
|
|
409
|
+
// last answer. Decided rows (and withdrawn ones, which set reviewedAt too)
|
|
410
|
+
// are deleted some days after `reviewedAt` (CalendarSettings.requests).
|
|
411
|
+
export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
|
|
412
|
+
export type RequestKind = z.infer<typeof RequestKind>;
|
|
413
|
+
export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
|
|
414
|
+
export type RequestState = z.infer<typeof RequestState>;
|
|
415
|
+
export const RequestSource = z.enum(['member', 'web', 'app']);
|
|
416
|
+
export type RequestSource = z.infer<typeof RequestSource>;
|
|
417
|
+
export const Requester = z.object({
|
|
418
|
+
name: z.string(),
|
|
419
|
+
email: z.string(),
|
|
420
|
+
phone: z.string().default(''),
|
|
421
|
+
// the language they asked in, for replies ('' = the main language)
|
|
422
|
+
locale: z.string().default(''),
|
|
423
|
+
});
|
|
424
|
+
export type Requester = z.infer<typeof Requester>;
|
|
425
|
+
export const RequestRecord = z.object({
|
|
426
|
+
id: z.string(),
|
|
427
|
+
kind: RequestKind,
|
|
428
|
+
state: RequestState.default('pending'),
|
|
429
|
+
source: RequestSource,
|
|
430
|
+
userId: z.string().default(''),
|
|
431
|
+
requester: Requester.nullable().default(null),
|
|
432
|
+
eventId: z.string().default(''),
|
|
433
|
+
// an occurrence key (same format as exdates) when it's about one date
|
|
434
|
+
key: z.string().default(''),
|
|
435
|
+
data: z.record(z.any()).default({}),
|
|
436
|
+
note: z.string().default(''),
|
|
437
|
+
reply: z.string().default(''),
|
|
438
|
+
reviewedBy: z.string().default(''),
|
|
439
|
+
reviewedAt: z.number().default(0),
|
|
440
|
+
createdAt: z.number(),
|
|
441
|
+
updatedAt: z.number(),
|
|
442
|
+
});
|
|
443
|
+
export type RequestRecord = z.infer<typeof RequestRecord>;
|
|
444
|
+
|
|
398
445
|
export const CalEvent = z.object({
|
|
399
446
|
id: z.string(),
|
|
400
447
|
slug: z.string(),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { env } from './env';
|
|
3
|
+
import { ApiError } from './api';
|
|
4
|
+
|
|
5
|
+
// Credentials for connected services (YouTube/Twitch live video, Google
|
|
6
|
+
// Calendar) are sealed with AES-256-GCM under a key derived from
|
|
7
|
+
// LIVE_ENCRYPTION_KEY, else SESSION_SECRET. Each service has its own domain:
|
|
8
|
+
// a different derived key and associated data, so one service's ciphertext
|
|
9
|
+
// never opens as another's. `context` narrows it further (a provider, a
|
|
10
|
+
// pending OAuth state).
|
|
11
|
+
export interface Sealer {
|
|
12
|
+
ready(locals?: unknown): boolean;
|
|
13
|
+
encrypt(value: unknown, context: string, locals?: unknown): string;
|
|
14
|
+
decrypt<T>(encrypted: string, context: string, locals?: unknown): T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `what` names the credentials in the errors people see ("live credentials"). */
|
|
18
|
+
export function sealer(domain: string, what: string): Sealer {
|
|
19
|
+
function key(locals?: unknown) {
|
|
20
|
+
const runtime = (locals as { runtime?: { env?: Record<string, unknown> } } | undefined)?.runtime?.env;
|
|
21
|
+
const secret = String(runtime?.LIVE_ENCRYPTION_KEY || env('LIVE_ENCRYPTION_KEY') || runtime?.SESSION_SECRET || env('SESSION_SECRET'));
|
|
22
|
+
if (secret.length < 32) throw new ApiError(503, `Set LIVE_ENCRYPTION_KEY or SESSION_SECRET to at least 32 random characters on your host before storing ${what}.`);
|
|
23
|
+
return createHash('sha256').update(`acms:${domain}:v1:${secret}`).digest();
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ready(locals) { try { key(locals); return true; } catch { return false; } },
|
|
27
|
+
encrypt(value, context, locals) {
|
|
28
|
+
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key(locals), iv);
|
|
29
|
+
cipher.setAAD(Buffer.from(`acms:${domain}:${context}`));
|
|
30
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
|
31
|
+
return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
|
|
32
|
+
},
|
|
33
|
+
decrypt<T>(encrypted: string, context: string, locals?: unknown): T {
|
|
34
|
+
try {
|
|
35
|
+
const parts = encrypted.split('.');
|
|
36
|
+
if (parts.length !== 3) throw new Error();
|
|
37
|
+
const [iv, tag, data] = parts.map(v => Buffer.from(v, 'base64url'));
|
|
38
|
+
const cipher = createDecipheriv('aes-256-gcm', key(locals), iv);
|
|
39
|
+
cipher.setAAD(Buffer.from(`acms:${domain}:${context}`)); cipher.setAuthTag(tag);
|
|
40
|
+
return JSON.parse(Buffer.concat([cipher.update(data), cipher.final()]).toString('utf8'));
|
|
41
|
+
} catch { throw new ApiError(503, `Saved ${what} cannot be decrypted. Re-enter the credentials and reconnect.`); }
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
|
46
|
+
export const randomToken = () => randomBytes(32).toString('base64url');
|
|
@@ -14,6 +14,8 @@ import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
|
14
14
|
import type { Permission } from '../../../../lib/permissions/catalog';
|
|
15
15
|
import { ApiError } from '../../../../lib/api';
|
|
16
16
|
import { roleLabel } from '../../../../lib/roles';
|
|
17
|
+
import { eventClashes } from '../../../../lib/booking/holds';
|
|
18
|
+
import { fieldWord } from '../../../../lib/calendar/google/map';
|
|
17
19
|
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
18
20
|
import ClerkSession from '../../../../components/auth/ClerkSession.astro';
|
|
19
21
|
import Modal from '../../../../components/Modal.astro';
|
|
@@ -92,7 +94,20 @@ const hosts = event.hostIds.map((id) => ({ id, name: nameOf(id) })).filter((h) =
|
|
|
92
94
|
const people = [...users].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email));
|
|
93
95
|
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
94
96
|
const error = Astro.url.searchParams.get('error') ?? '';
|
|
95
|
-
|
|
97
|
+
// Synced with Google (lib/calendar/google/): which Google calendars have a copy, the latest
|
|
98
|
+
// field both sides changed (the site kept its value; "Use Google's version" takes Google's),
|
|
99
|
+
// whether the description still comes from Google (Q8), and room clashes a change from Google made
|
|
100
|
+
const gItems = (await repo.gcal.itemsForEvent(event.id)).filter((i) => i.kind !== 'segment' && !i.gone);
|
|
101
|
+
const gLinks = gItems.length ? new Map((await repo.gcal.listLinks()).map((l) => [l.id, l])) : new Map();
|
|
102
|
+
const synced = gItems.map((i) => ({ ...i, googleName: gLinks.get(i.linkId)?.googleName ?? 'Google' }));
|
|
103
|
+
const gConflict = synced.map((i) => i.conflict).filter((c): c is NonNullable<typeof c> => !!c).sort((a, b) => b.at - a.at)[0] ?? null;
|
|
104
|
+
const gDescFromGoogle = synced.some((i) => i.descGoogle);
|
|
105
|
+
const gTz = settings.timezone || 'UTC';
|
|
106
|
+
const gTime = (ts: number) => new Intl.DateTimeFormat(settings.locale || 'en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: gTz }).format(ts);
|
|
107
|
+
const gClashes = synced.length && event.status === 'published' && !event.cancelled && event.roomHold.resourceIds.length
|
|
108
|
+
? (await eventClashes(repo, settings, event, { horizonDays: 120 }).catch(() => ({ clashes: [] }))).clashes : [];
|
|
109
|
+
const gRooms = gClashes.length ? new Map((await repo.listResources()).map((r) => [r.id, r.name])) : new Map<string, string>();
|
|
110
|
+
const showBar = Boolean(req) || canEdit || hosts.length > 0 || Boolean(notice || error) || synced.length > 0;
|
|
96
111
|
const ago = (ts: number) => {
|
|
97
112
|
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
98
113
|
if (s < 60) return 'just now';
|
|
@@ -162,6 +177,27 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
162
177
|
{declined.kind === 'new' && !isLive && ` It stays a draft until ${requester} sends it again.`}
|
|
163
178
|
</p>
|
|
164
179
|
)}
|
|
180
|
+
{synced.length > 0 && (
|
|
181
|
+
<div class="evreq-gcal">
|
|
182
|
+
<p class="evreq-line">
|
|
183
|
+
<span class="evreq-label">Synced with Google</span>
|
|
184
|
+
{synced.map((i) => <span class="evreq-chip">{i.googleName}{i.syncedAt ? ` · ${gTime(i.syncedAt)}` : ''}{i.htmlLink && <> · <a href={i.htmlLink} target="_blank" rel="noopener">Open in Google ↗</a></>}</span>)}
|
|
185
|
+
</p>
|
|
186
|
+
{gConflict && (
|
|
187
|
+
<div class="evreq-gcal-clash">
|
|
188
|
+
<span>Changed in both places at {gTime(gConflict.at)}. The site kept its {fieldWord(gConflict.field).toLowerCase()}, “{gConflict.site}”. Google had “{gConflict.google}”.</span>
|
|
189
|
+
{(canPublish || access?.can('schedule.manage')) && (
|
|
190
|
+
<form method="post" action="/admin/calendar/google" data-evreq>
|
|
191
|
+
<input type="hidden" name="op" value="use" /><input type="hidden" name="log" value={gConflict.logId} /><input type="hidden" name="return" value={self} />
|
|
192
|
+
<button class="btn sm" type="submit">Use Google’s version</button>
|
|
193
|
+
</form>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
{gDescFromGoogle && <p class="evreq-muted">Created in Google. Its description comes from Google until someone edits it here.</p>}
|
|
198
|
+
{gClashes.length > 0 && <p class="evreq-gcal-room">Room clash: {gRooms.get(gClashes[0].hold.resourceId) ?? 'a room'} is also held by “{gClashes[0].other.title}” on {gTime(gClashes[0].hold.eventStart)}{gClashes.length > 1 ? ` (and ${gClashes.length - 1} more)` : ''}. Changes from Google keep their rooms: check Bookings.</p>}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
165
201
|
<div class="evreq-hosts">
|
|
166
202
|
<span class="evreq-label">Hosts</span>
|
|
167
203
|
{hosts.length ? hosts.map((h) => <span class="evreq-chip">{h.name}</span>) : <span class="evreq-muted">None</span>}
|
|
@@ -306,6 +342,11 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
|
|
|
306
342
|
.evreq-hosts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: auto; }
|
|
307
343
|
.evreq-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-right: 2px; }
|
|
308
344
|
.evreq-chip { font-size: 12px; font-weight: 500; color: var(--text-2); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-full); padding: 1px 9px; white-space: nowrap; }
|
|
345
|
+
.evreq-gcal { flex: 1 1 480px; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
346
|
+
.evreq-gcal .evreq-chip a { color: var(--accent-deep); }
|
|
347
|
+
.evreq-gcal-clash { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; color: var(--text); }
|
|
348
|
+
.evreq-gcal-clash form { margin: 0; }
|
|
349
|
+
.evreq-gcal-room { color: var(--red); }
|
|
309
350
|
.evreq-modal-hint { margin: 0 0 12px; }
|
|
310
351
|
.evreq-find { width: 100%; margin-bottom: 10px; }
|
|
311
352
|
.evreq-people { max-height: min(320px, 50vh); overflow-y: auto; margin: 0 -4px; padding: 2px 4px; }
|