@acms-subir/core 0.3.54 → 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 +20 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +29 -0
- package/docs/google-calendar.md +302 -0
- package/docs/server.md +7 -0
- package/integration.mjs +4 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- 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/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +11 -1
- package/src/lib/email/templates.ts +8 -0
- 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 +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- 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/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/server/index.ts +49 -0
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);
|
|
@@ -12,6 +12,7 @@ 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
14
|
import { REQUEST_TABLE } from '../calendar/request-migration';
|
|
15
|
+
import { GCAL_MIGRATION } from '../calendar/google/migration';
|
|
15
16
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
16
17
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
17
18
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -734,6 +735,9 @@ export const MIGRATIONS: Migration[] = [
|
|
|
734
735
|
// calendar requests on their own rows (RequestRecord): new events and
|
|
735
736
|
// changes asked for without an account, and hosts' date moves
|
|
736
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,
|
|
737
741
|
];
|
|
738
742
|
|
|
739
743
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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';
|
|
@@ -159,6 +160,10 @@ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
|
|
|
159
160
|
for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
|
|
160
161
|
return out;
|
|
161
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];
|
|
162
167
|
function rowToEvent(r: Row): CalEvent {
|
|
163
168
|
return CalEvent.parse({
|
|
164
169
|
id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
@@ -293,6 +298,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
293
298
|
readonly forms: FormStore;
|
|
294
299
|
readonly guests: GuestStore;
|
|
295
300
|
readonly live: LiveStore;
|
|
301
|
+
readonly gcal: GcalStore;
|
|
296
302
|
readonly push: PushStore;
|
|
297
303
|
readonly announcements: AnnouncementStore;
|
|
298
304
|
readonly requests: RequestStore;
|
|
@@ -307,6 +313,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
307
313
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
308
314
|
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
309
315
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
316
|
+
this.gcal = new GcalStore(async () => { await this.ready; return this.driver; });
|
|
310
317
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
311
318
|
this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
|
|
312
319
|
this.requests = new RequestStore(async () => { await this.ready; return this.driver; });
|
|
@@ -880,13 +887,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
880
887
|
await this.syncEventIndex(id);
|
|
881
888
|
return (await this.getEvent(id))!;
|
|
882
889
|
}
|
|
883
|
-
async updateEvent(id: string, data: EventUpdate) {
|
|
890
|
+
async updateEvent(id: string, data: EventUpdate, opts: { keepDraft?: boolean; quiet?: boolean } = {}) {
|
|
884
891
|
await this.ready;
|
|
885
892
|
const current = await this.getEvent(id);
|
|
886
893
|
if (!current) return;
|
|
887
894
|
// a direct live write supersedes any staged edits (applyEventDraft routes
|
|
888
|
-
// through here too, so applying clears the snapshot in the same UPDATE)
|
|
889
|
-
|
|
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'];
|
|
890
898
|
const args: unknown[] = [Date.now()];
|
|
891
899
|
const plain: Record<string, string> = {
|
|
892
900
|
title: 'title', slug: 'slug', bodyJson: 'body_json', bodyHtml: 'body_html', excerpt: 'excerpt',
|
|
@@ -946,8 +954,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
946
954
|
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
947
955
|
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
948
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]},
|
|
949
|
-
...notifications]);
|
|
950
|
-
} 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));
|
|
951
959
|
await this.syncEventIndex(id);
|
|
952
960
|
}
|
|
953
961
|
async saveEventDraft(id: string, data: Parameters<ContentRepository['saveEventDraft']>[1]) {
|
|
@@ -967,17 +975,16 @@ export class SqlRepository implements ContentRepository {
|
|
|
967
975
|
await this.updateEvent(id, pickEventDraft(draft));
|
|
968
976
|
}
|
|
969
977
|
// per-date cancellations apply at once and never touch a staged draft
|
|
970
|
-
async setEventCancelledDates(id: string, dates: string[]) {
|
|
978
|
+
async setEventCancelledDates(id: string, dates: string[], opts: { quiet?: boolean } = {}) {
|
|
971
979
|
await this.ready;
|
|
972
|
-
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));
|
|
973
981
|
}
|
|
974
982
|
async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
|
|
975
983
|
await this.ready;
|
|
976
984
|
const write = cancelledDates
|
|
977
985
|
? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
|
|
978
986
|
: { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
|
|
979
|
-
|
|
980
|
-
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));
|
|
981
988
|
}
|
|
982
989
|
async cancelOccurrenceRsvps(id: string, start: number) {
|
|
983
990
|
await this.ready;
|
|
@@ -2008,6 +2015,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
2008
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] });
|
|
2009
2016
|
const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
|
|
2010
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());
|
|
2011
2020
|
if (settingsWrite) stmts.push(settingsWrite);
|
|
2012
2021
|
stmts.push(
|
|
2013
2022
|
{ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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';
|
|
@@ -81,6 +82,8 @@ export interface ContentRepository {
|
|
|
81
82
|
readonly backups: BackupStore;
|
|
82
83
|
readonly guests: GuestStore;
|
|
83
84
|
readonly live: LiveStore;
|
|
85
|
+
/** two-way Google Calendar sync (lib/calendar/google/) */
|
|
86
|
+
readonly gcal: GcalStore;
|
|
84
87
|
readonly push: PushStore;
|
|
85
88
|
readonly announcements: AnnouncementStore;
|
|
86
89
|
// calendar requests on their own rows (lib/calendar/request-store.ts)
|
|
@@ -163,7 +166,8 @@ export interface ContentRepository {
|
|
|
163
166
|
getEvent(id: string): Promise<CalEvent | null>;
|
|
164
167
|
getEventBySlug(slug: string): Promise<CalEvent | null>;
|
|
165
168
|
createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
|
|
166
|
-
|
|
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>;
|
|
167
171
|
saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
|
|
168
172
|
/** the Team members who host an event (lib/calendar/requests.ts) */
|
|
169
173
|
setEventHosts(id: string, hostIds: string[]): Promise<void>;
|
|
@@ -172,7 +176,7 @@ export interface ContentRepository {
|
|
|
172
176
|
applyEventDraft(id: string): Promise<void>;
|
|
173
177
|
discardEventDraft(id: string): Promise<void>;
|
|
174
178
|
/** replace an event's cancelled occurrence keys at once, keeping any staged draft */
|
|
175
|
-
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
179
|
+
setEventCancelledDates(id: string, dates: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
176
180
|
/** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
|
|
177
181
|
setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
|
|
178
182
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
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)
|
|
@@ -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; }
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../../../lib/repo';
|
|
4
|
+
import { ApiError } from '../../../../lib/api';
|
|
5
|
+
import { CALENDAR } from '../../../../lib/calendar/config';
|
|
6
|
+
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
7
|
+
import { reviewMatch } from '../../../../lib/calendar/google/service';
|
|
8
|
+
import type { CalEvent } from '../../../../lib/schema';
|
|
9
|
+
import type { MatchGoogle, MatchPair } from '../../../../lib/calendar/google/types';
|
|
10
|
+
|
|
11
|
+
// Step 2 of linking a Google calendar: what's on both sides, before anything
|
|
12
|
+
// is written. Decisions save at once ("Save and finish later" just leaves);
|
|
13
|
+
// "Start syncing" pairs, copies the rest each way and sends no notifications.
|
|
14
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
15
|
+
const repo = await getRepo(Astro.locals);
|
|
16
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
|
|
17
|
+
if (denied) return denied;
|
|
18
|
+
const linkId = Astro.url.searchParams.get('link') ?? '';
|
|
19
|
+
let link = await repo.gcal.getLink(linkId);
|
|
20
|
+
if (!link) return Astro.redirect('/admin/calendar/settings#google');
|
|
21
|
+
if (link.state !== 'matching') return Astro.redirect(`/admin/calendar/settings?notice=${encodeURIComponent('That link is already past its first match.')}#google`);
|
|
22
|
+
let error = Astro.url.searchParams.get('error') ?? '';
|
|
23
|
+
if (!link.match && !error) {
|
|
24
|
+
try { await reviewMatch(repo, link.id, Astro.locals); link = (await repo.gcal.getLink(link.id))!; }
|
|
25
|
+
catch (e) { error = e instanceof ApiError ? e.message : 'Couldn’t read the Google calendar. Try again.'; }
|
|
26
|
+
}
|
|
27
|
+
const [settings, calendar, events] = await Promise.all([repo.getSettings(), repo.getCalendar(link.calendarId), repo.listEvents()]);
|
|
28
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
29
|
+
const m = link.match;
|
|
30
|
+
const tz = settings.timezone || 'UTC';
|
|
31
|
+
const fmt = (ms: number, allDay: boolean) => new Intl.DateTimeFormat(settings.locale || 'en', allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(ms);
|
|
32
|
+
const siteWhen = (e: CalEvent) => `${fmt(e.startAt, e.allDay)}${e.rrule ? ', repeats' : ''}`;
|
|
33
|
+
const googleWhen = (g: MatchGoogle) => `${fmt(g.start, g.allDay)}${g.repeats ? ', repeats' : ''}`;
|
|
34
|
+
const pairs = (m?.pairs ?? []).filter((p) => byId.has(p.eventId) && m?.google[p.googleId]);
|
|
35
|
+
const waiting = pairs.filter((p) => p.state === 'suggested').length;
|
|
36
|
+
const apart = pairs.filter((p) => p.state === 'apart');
|
|
37
|
+
const toGoogle = (m?.siteOnly.length ?? 0) + apart.length;
|
|
38
|
+
const fromGoogle = (m?.googleOnly.length ?? 0) + apart.length;
|
|
39
|
+
const STATE: Record<MatchPair['state'], string> = { linked: 'Linked', suggested: 'Same?', apart: 'Not the same' };
|
|
40
|
+
const WHY: Record<MatchPair['reason'], string> = {
|
|
41
|
+
'hidden-id': 'Linked before (Google’s copy carries the site’s hidden link)', uid: 'Same Google ID (copied from Google earlier)',
|
|
42
|
+
'title-time': 'Same title and time', 'time-repeat': 'Same time and repeat, different title',
|
|
43
|
+
};
|
|
44
|
+
const out = link.direction !== 'in', inn = link.direction !== 'out';
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<Admin title="Match events" section="calendar:schedule">
|
|
48
|
+
<div class="page-head">
|
|
49
|
+
<div>
|
|
50
|
+
<p class="sub"><a href="/admin/calendar/settings#google">Calendar settings</a> › Synced with Google</p>
|
|
51
|
+
<h1>Match events: {calendar?.name ?? 'Calendar'} {link.direction === 'both' ? '⇄' : link.direction === 'out' ? '→' : '←'} {link.googleName}</h1>
|
|
52
|
+
<p class="sub">Step 2 of 2. Nothing has been written to either side yet.</p>
|
|
53
|
+
</div>
|
|
54
|
+
<form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="match-build" /><input type="hidden" name="link" value={link.id} /><button class="btn" type="submit">Read both sides again</button></form>
|
|
55
|
+
</div>
|
|
56
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
57
|
+
{m && (
|
|
58
|
+
<>
|
|
59
|
+
<nav class="gm-tabs" aria-label="Groups">
|
|
60
|
+
<a href="#both">On both · {pairs.length}</a>
|
|
61
|
+
<a href="#site">Only on the site · {m.siteOnly.length}</a>
|
|
62
|
+
<a href="#google">Only in Google · {m.googleOnly.length}</a>
|
|
63
|
+
</nav>
|
|
64
|
+
|
|
65
|
+
<section class="card gm-sect" id="both">
|
|
66
|
+
<table class="gm-table">
|
|
67
|
+
<thead><tr><th>On the site</th><th>In Google</th><th>Why they match</th><th><span class="gm-sr">Decision</span></th></tr></thead>
|
|
68
|
+
<tbody>
|
|
69
|
+
{pairs.length === 0 && <tr><td colspan="4" class="hint">No events look like they’re on both sides.</td></tr>}
|
|
70
|
+
{pairs.map((p) => { const e = byId.get(p.eventId)!; const g = m.google[p.googleId]; return (
|
|
71
|
+
<tr id={p.googleId} class={`is-${p.state}`}>
|
|
72
|
+
<td><b>{e.title}</b><small>{siteWhen(e)}</small></td>
|
|
73
|
+
<td><b>{g.title || '(no title)'}</b><small>{googleWhen(g)}</small></td>
|
|
74
|
+
<td><small>{WHY[p.reason]}</small></td>
|
|
75
|
+
<td>
|
|
76
|
+
<form method="post" action="/admin/calendar/google" class="gm-acts">
|
|
77
|
+
<input type="hidden" name="op" value="match-decide" /><input type="hidden" name="link" value={link.id} /><input type="hidden" name="googleId" value={p.googleId} />
|
|
78
|
+
{p.state === 'suggested' ? (<>
|
|
79
|
+
<button class="btn sm primary" type="submit" name="decision" value="link">Link</button>
|
|
80
|
+
<button class="btn sm ghost" type="submit" name="decision" value="apart">Not the same</button>
|
|
81
|
+
</>) : (<>
|
|
82
|
+
<span class={`gm-state is-${p.state}`}>{STATE[p.state]}</span>
|
|
83
|
+
<button class="btn sm ghost" type="submit" name="decision" value={p.state === 'linked' ? 'apart' : 'link'}>{p.state === 'linked' ? 'Undo' : 'Link'}</button>
|
|
84
|
+
</>)}
|
|
85
|
+
</form>
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
); })}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section class="card gm-sect" id="site">
|
|
94
|
+
<h2>Only on the site · {m.siteOnly.length}</h2>
|
|
95
|
+
{m.siteOnly.length === 0 ? <p class="hint">None.</p> : (
|
|
96
|
+
<ul class="gm-list">{m.siteOnly.map((id) => byId.get(id)).filter(Boolean).slice(0, 200).map((e) => <li><b>{e!.title}</b> <small>{siteWhen(e!)}</small></li>)}</ul>
|
|
97
|
+
)}
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<section class="card gm-sect" id="google">
|
|
101
|
+
<h2>Only in Google · {m.googleOnly.length}</h2>
|
|
102
|
+
{m.googleOnly.length === 0 ? <p class="hint">None.</p> : (
|
|
103
|
+
<ul class="gm-list">{m.googleOnly.map((id) => m.google[id]).filter(Boolean).slice(0, 200).map((g) => <li><b>{g.booking ? 'Booked' : g.title || '(no title)'}</b> <small>{googleWhen(g)}{g.booking ? ' · a booked appointment' : ''}</small></li>)}</ul>
|
|
104
|
+
)}
|
|
105
|
+
</section>
|
|
106
|
+
|
|
107
|
+
<div class="card gm-foot">
|
|
108
|
+
<p>
|
|
109
|
+
When you start:
|
|
110
|
+
{out ? ` the ${toGoogle} event${toGoogle === 1 ? '' : 's'} only on the site ${toGoogle === 1 ? 'is' : 'are'} copied to Google.` : ' events only on the site stay on the site.'}
|
|
111
|
+
{inn ? ` The ${fromGoogle} only in Google come to the site as published events in ${calendar?.name ?? 'this calendar'}${link.roomIds.length ? ', holding its room' : ''}${link.rules.length ? `, and titles starting with ${link.rules.map((r) => `“${r.prefix}”`).join(' or ')} get their tag` : ''}.` : ' Events only in Google stay in Google.'}
|
|
112
|
+
{' '}Linked pairs keep both titles as they are until someone edits one. Pairs you mark “Not the same” are treated as two events. No member notifications are sent for any of this.
|
|
113
|
+
</p>
|
|
114
|
+
<div class="gm-buttons">
|
|
115
|
+
<a class="btn ghost" href="/admin/calendar/settings#google">Back</a>
|
|
116
|
+
<a class="btn" href="/admin/calendar/settings#google" title="Your decisions are saved as you make them">Save and finish later</a>
|
|
117
|
+
<form method="post" action="/admin/calendar/google">
|
|
118
|
+
<input type="hidden" name="op" value="match-start" /><input type="hidden" name="link" value={link.id} />
|
|
119
|
+
<button class="btn primary" type="submit" disabled={waiting > 0} title={waiting ? `Decide the ${waiting} suggested pair${waiting === 1 ? '' : 's'} first` : undefined}>Start syncing</button>
|
|
120
|
+
</form>
|
|
121
|
+
</div>
|
|
122
|
+
{waiting > 0 && <p class="hint">Decide the {waiting} suggested pair{waiting === 1 ? '' : 's'} first: Link, or Not the same.</p>}
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</Admin>
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
.gm-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; font-size: 13px; }
|
|
130
|
+
.gm-tabs a { padding: 6px 12px; border: 1px solid var(--line); border-radius: 999px; color: var(--text-2); background: var(--panel); }
|
|
131
|
+
.gm-sect { margin-bottom: 14px; padding: 0; overflow: hidden; scroll-margin-top: 64px; }
|
|
132
|
+
.gm-sect h2 { font-size: 14px; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
133
|
+
.gm-sect .hint { padding: 12px 16px; margin: 0; }
|
|
134
|
+
.gm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
135
|
+
.gm-table th { text-align: left; font-weight: 600; color: var(--muted); padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
|
136
|
+
.gm-table td { padding: 10px 16px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
137
|
+
.gm-table tr:last-child td { border-bottom: 0; }
|
|
138
|
+
.gm-table td b { display: block; font-weight: 600; }
|
|
139
|
+
.gm-table td small { color: var(--muted); font-size: 12px; }
|
|
140
|
+
.gm-table tr.is-apart td:not(:last-child) { opacity: 0.6; }
|
|
141
|
+
.gm-acts { display: flex; gap: 6px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
|
|
142
|
+
.gm-state { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
|
143
|
+
.gm-state.is-linked { color: var(--green); }
|
|
144
|
+
.gm-list { list-style: none; margin: 0; padding: 0; }
|
|
145
|
+
.gm-list li { padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
|
146
|
+
.gm-list li:last-child { border-bottom: 0; }
|
|
147
|
+
.gm-list small { color: var(--muted); }
|
|
148
|
+
.gm-foot { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--text-2); }
|
|
149
|
+
.gm-foot p { margin: 0; }
|
|
150
|
+
.gm-buttons { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
|
151
|
+
.gm-buttons .btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
152
|
+
.gm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
|
153
|
+
@media (max-width: 760px) {
|
|
154
|
+
.gm-table thead { display: none; }
|
|
155
|
+
.gm-table tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--line); }
|
|
156
|
+
.gm-table td { border: 0; }
|
|
157
|
+
.gm-table td:nth-child(3), .gm-table td:nth-child(4) { grid-column: span 2; padding-top: 0; }
|
|
158
|
+
.gm-acts { justify-content: flex-start; }
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { ApiError } from '../../../lib/api';
|
|
4
|
+
import { CALENDAR } from '../../../lib/calendar/config';
|
|
5
|
+
import { accessFor } from '../../../lib/permissions/access';
|
|
6
|
+
import { deniedMessage, type Permission } from '../../../lib/permissions/catalog';
|
|
7
|
+
import { requestSessionBinding } from '../../../lib/auth/request';
|
|
8
|
+
import { beginGoogleConnect, disconnectGoogle, finishGoogleConnect, refreshGoogleCalendars, saveGoogleClient } from '../../../lib/calendar/google/connect';
|
|
9
|
+
import { createLink, pauseLink, resumeLink, unlink, updateLink } from '../../../lib/calendar/google/links';
|
|
10
|
+
import { decideMatch } from '../../../lib/calendar/google/match';
|
|
11
|
+
import { reviewMatch, syncLinkNow, useGoogleVersion } from '../../../lib/calendar/google/service';
|
|
12
|
+
|
|
13
|
+
// The forms of Calendar settings → Synced with Google, the first-match page
|
|
14
|
+
// and the event editor's sync note post here, then go back with a notice.
|
|
15
|
+
// Connecting and disconnecting Google need "Connected services"; linking,
|
|
16
|
+
// pausing and reviewing need "Manage rooms and calendars"; using Google's
|
|
17
|
+
// version of a field changes the live event, so "Publish content" does too.
|
|
18
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
|
+
if (Astro.request.method !== 'POST') return Astro.redirect('/admin/calendar/settings#google');
|
|
20
|
+
|
|
21
|
+
const repo = await getRepo(Astro.locals);
|
|
22
|
+
const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
23
|
+
const form = await Astro.request.formData();
|
|
24
|
+
const op = String(form.get('op') ?? '');
|
|
25
|
+
const linkId = String(form.get('link') ?? '');
|
|
26
|
+
const NEEDS: Record<string, Permission> = { client: 'services.manage', connect: 'services.manage', finish: 'services.manage', disconnect: 'services.manage' };
|
|
27
|
+
const need = NEEDS[op] ?? (op === 'use' ? (access?.can('content.publish') ? 'content.publish' : 'schedule.manage') : 'schedule.manage');
|
|
28
|
+
if (!access?.can(need)) return new Response(deniedMessage(need), { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
29
|
+
|
|
30
|
+
// only back to the admin (an event page or the match page)
|
|
31
|
+
const ret = String(form.get('return') ?? '');
|
|
32
|
+
const home = ret.startsWith('/admin/') && !ret.startsWith('//') ? ret : '/admin/calendar/settings';
|
|
33
|
+
const back = (key: 'notice' | 'error', text: string, hash = home === '/admin/calendar/settings' ? '#google' : '') => {
|
|
34
|
+
const [path, query = ''] = home.split('?');
|
|
35
|
+
const params = new URLSearchParams(query);
|
|
36
|
+
params.set(key, text);
|
|
37
|
+
return Astro.redirect(`${path}?${params}${hash}`);
|
|
38
|
+
};
|
|
39
|
+
const list = (name: string) => form.getAll(name).map(String).filter(Boolean);
|
|
40
|
+
// rules come as rows of prefix + tag fields; booking titles one per line
|
|
41
|
+
const linkFields = () => ({
|
|
42
|
+
direction: String(form.get('direction') || 'both'),
|
|
43
|
+
roomIds: list('roomIds'),
|
|
44
|
+
noRoom: form.has('noRoom'),
|
|
45
|
+
rules: form.getAll('rulePrefix').map((p, i) => ({ prefix: String(p).trim(), tag: String(form.getAll('ruleTag')[i] ?? '').trim() })).filter((r) => r.prefix && r.tag),
|
|
46
|
+
bookingTitles: String(form.get('bookingTitles') ?? '').split(/\n|,/).map((t) => t.trim()).filter(Boolean),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
switch (op) {
|
|
51
|
+
case 'client':
|
|
52
|
+
await saveGoogleClient(repo, { clientId: String(form.get('clientId') ?? ''), clientSecret: String(form.get('clientSecret') ?? '') }, Astro.locals);
|
|
53
|
+
return back('notice', 'Saved. Next, connect Google.');
|
|
54
|
+
case 'connect':
|
|
55
|
+
return Astro.redirect(await beginGoogleConnect(repo, await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals), Astro.url, Astro.locals));
|
|
56
|
+
case 'finish': {
|
|
57
|
+
const account = await finishGoogleConnect(repo, await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals), Astro.url, access.userId, Astro.locals);
|
|
58
|
+
return back('notice', `Connected ${account.email || 'Google'}. Link a calendar next.`);
|
|
59
|
+
}
|
|
60
|
+
case 'disconnect':
|
|
61
|
+
await disconnectGoogle(repo, Astro.locals);
|
|
62
|
+
return back('notice', 'Google is disconnected. Links are paused, and every event stays in both places.');
|
|
63
|
+
case 'refresh':
|
|
64
|
+
await refreshGoogleCalendars(repo, Astro.locals);
|
|
65
|
+
return back('notice', 'Google calendars listed again.');
|
|
66
|
+
case 'link-new': {
|
|
67
|
+
const link = await createLink(repo, { ...linkFields(), calendarId: String(form.get('calendarId') ?? ''), googleId: String(form.get('googleId') ?? ''), startDate: String(form.get('startDate') || '') || undefined });
|
|
68
|
+
return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(link.id)}`);
|
|
69
|
+
}
|
|
70
|
+
case 'link-edit':
|
|
71
|
+
await updateLink(repo, linkId, linkFields());
|
|
72
|
+
return back('notice', 'Link saved. Events that now belong to it go out at the next sync, and ones that don’t are removed from Google.');
|
|
73
|
+
case 'pause':
|
|
74
|
+
await pauseLink(repo, linkId);
|
|
75
|
+
return back('notice', 'Paused. Changes on either side wait until you resume.');
|
|
76
|
+
case 'resume': {
|
|
77
|
+
const link = await resumeLink(repo, linkId);
|
|
78
|
+
if (link.state === 'matching') return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(link.id)}`);
|
|
79
|
+
return back('notice', 'Resumed. It reads Google again from the start date, quietly, and catches up.');
|
|
80
|
+
}
|
|
81
|
+
case 'unlink':
|
|
82
|
+
await unlink(repo, linkId);
|
|
83
|
+
return back('notice', 'Unlinked. Every event stays in both places; linking again pairs them up.');
|
|
84
|
+
case 'sync':
|
|
85
|
+
await syncLinkNow(repo, linkId, Astro.locals);
|
|
86
|
+
return back('notice', 'Synced.');
|
|
87
|
+
case 'match-build':
|
|
88
|
+
await reviewMatch(repo, linkId, Astro.locals);
|
|
89
|
+
return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}`);
|
|
90
|
+
case 'match-decide':
|
|
91
|
+
await decideMatch({ repo }, linkId, String(form.get('googleId') ?? ''), form.get('decision') === 'link' ? 'link' : 'apart');
|
|
92
|
+
return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}#${encodeURIComponent(String(form.get('googleId') ?? ''))}`);
|
|
93
|
+
case 'match-start':
|
|
94
|
+
await syncLinkNow(repo, linkId, Astro.locals, { start: true });
|
|
95
|
+
return Astro.redirect(`/admin/calendar/settings?notice=${encodeURIComponent('Syncing. Recent changes lists what was copied each way.')}#google`);
|
|
96
|
+
case 'use':
|
|
97
|
+
await useGoogleVersion(repo, Number(form.get('log')), Astro.url, Astro.locals);
|
|
98
|
+
return back('notice', 'Using Google’s version. The other copies follow at the next sync.');
|
|
99
|
+
}
|
|
100
|
+
return back('error', 'Unknown action.');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
const message = e instanceof ApiError ? e.message : e instanceof Error && e.name === 'ZodError' ? 'Check the link’s settings and try again.' : 'That didn’t work. Try again.';
|
|
103
|
+
if (!(e instanceof ApiError)) console.error('acms google sync admin:', e);
|
|
104
|
+
if (op.startsWith('match-')) return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}&error=${encodeURIComponent(message)}`);
|
|
105
|
+
return back('error', message);
|
|
106
|
+
}
|
|
107
|
+
---
|