@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +76 -6
  4. package/docs/calendar.md +49 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +68 -1
  7. package/integration.mjs +15 -5
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/api.ts +3 -0
  11. package/src/lib/backup/content.ts +4 -2
  12. package/src/lib/calendar/google/client.ts +167 -0
  13. package/src/lib/calendar/google/connect.ts +161 -0
  14. package/src/lib/calendar/google/context.ts +50 -0
  15. package/src/lib/calendar/google/inbound.ts +493 -0
  16. package/src/lib/calendar/google/links.ts +139 -0
  17. package/src/lib/calendar/google/map.ts +543 -0
  18. package/src/lib/calendar/google/match.ts +103 -0
  19. package/src/lib/calendar/google/migration.ts +71 -0
  20. package/src/lib/calendar/google/outbound.ts +272 -0
  21. package/src/lib/calendar/google/service.ts +193 -0
  22. package/src/lib/calendar/google/store.ts +241 -0
  23. package/src/lib/calendar/google/types.ts +188 -0
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/calendar/schedule.ts +8 -8
  33. package/src/lib/display/board.ts +3 -1
  34. package/src/lib/email/service.ts +22 -2
  35. package/src/lib/email/templates.ts +20 -2
  36. package/src/lib/legal/admin.ts +1 -1
  37. package/src/lib/legal/facts.ts +3 -0
  38. package/src/lib/legal/text-en.ts +4 -0
  39. package/src/lib/legal/text-es.ts +4 -0
  40. package/src/lib/live/crypto.ts +7 -28
  41. package/src/lib/repo/migrations.ts +8 -0
  42. package/src/lib/repo/sql.ts +25 -9
  43. package/src/lib/repo/types.ts +12 -3
  44. package/src/lib/scheduler.ts +3 -0
  45. package/src/lib/schema.ts +47 -0
  46. package/src/lib/sealed.ts +46 -0
  47. package/src/pages/admin/calendar/events/[id].astro +42 -1
  48. package/src/pages/admin/calendar/events/index.astro +82 -6
  49. package/src/pages/admin/calendar/google/match.astro +160 -0
  50. package/src/pages/admin/calendar/google.astro +107 -0
  51. package/src/pages/admin/calendar/settings.astro +273 -4
  52. package/src/pages/admin/index.astro +6 -2
  53. package/src/pages/admin/integrations.astro +6 -0
  54. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  55. package/src/pages/members/_EventForm.astro +33 -27
  56. package/src/pages/members/_Layout.astro +25 -5
  57. package/src/pages/members/_http.ts +29 -6
  58. package/src/pages/members/events/[id].astro +21 -20
  59. package/src/pages/members/events/index.astro +295 -101
  60. package/src/pages/members/events/new.astro +11 -10
  61. package/src/server/index.ts +102 -0
  62. package/src/virtual.d.ts +1 -1
@@ -205,6 +205,55 @@ export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SY
205
205
  return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
206
206
  }
207
207
 
208
+ // ---- Google Calendar sync ----------------------------------------------------
209
+ // The two-way sync is set up in the admin (Calendar settings → Synced with
210
+ // Google) and runs from the scheduler. These read what it did, for a site's
211
+ // own pages (a "Synced with Google" badge, a staff report). They don't check
212
+ // permissions and their results name Google calendars: show them to staff.
213
+
214
+ /** The internal tag on appointments booked through a Google booking page (they come in titled "Booked"). Never shown publicly. */
215
+ export const GOOGLE_BOOKING_TAG = '#google-booking';
216
+
217
+ export interface GoogleCopy {
218
+ /** the Google calendar's name and id */
219
+ googleCalendar: string;
220
+ googleCalendarId: string;
221
+ /** the Google event's own page */
222
+ htmlLink: string;
223
+ /** when the pair last synced (ms) */
224
+ syncedAt: number;
225
+ /** created in Google and its description never edited on the site: Google's description is used */
226
+ descriptionFromGoogle: boolean;
227
+ /** a booked appointment from a Google booking page */
228
+ booking: boolean;
229
+ /** the latest field both sides changed (the site kept its value), until someone picks one */
230
+ clash: { field: string; site: string; google: string; at: number } | null;
231
+ }
232
+
233
+ /** The Google copies of one event (an event in two linked calendars has two). Empty when it isn't synced. */
234
+ export async function googleCopies(repo: Repo, eventId: string): Promise<GoogleCopy[]> {
235
+ const r = internal(repo);
236
+ const items = (await r.gcal.itemsForEvent(eventId)).filter((i) => i.kind !== 'segment' && !i.gone);
237
+ if (!items.length) return [];
238
+ const links = new Map((await r.gcal.listLinks()).map((l) => [l.id, l]));
239
+ return items.map((i) => ({
240
+ googleCalendar: links.get(i.linkId)?.googleName ?? '', googleCalendarId: links.get(i.linkId)?.googleId ?? '', htmlLink: i.htmlLink, syncedAt: i.syncedAt,
241
+ descriptionFromGoogle: i.descGoogle, booking: i.booking, clash: i.conflict && { field: i.conflict.field, site: i.conflict.site, google: i.conflict.google, at: i.conflict.at },
242
+ }));
243
+ }
244
+
245
+ /** Recent changes the sync made (newest first, kept 90 days): direction in/out/clash/info, the event, and what changed in words. */
246
+ export async function googleSyncHistory(repo: Repo, opts: { eventId?: string; limit?: number } = {}) {
247
+ return (await internal(repo).gcal.listLog({ eventId: opts.eventId, limit: opts.limit ?? 50 }))
248
+ .map(({ id, at, eventId, direction, action, title, summary, resolved }) => ({ id, at, eventId, direction, action, title, summary, resolved }));
249
+ }
250
+
251
+ /** Runs the sync once now, as the scheduler does every minute (changes out; each link reads Google every 2 minutes). Returns how many changes it handled. */
252
+ export async function syncGoogleCalendar(repo: Repo, context: Pick<WriteContext, 'locals'>): Promise<number> {
253
+ const { syncGoogle } = await import('../lib/calendar/google/service');
254
+ return syncGoogle(internal(repo), context.locals);
255
+ }
256
+
208
257
  // ---- rooms -----------------------------------------------------------------
209
258
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
210
259
  // two things want the same room. They don't check permissions, and their
@@ -300,3 +349,56 @@ export async function declineEventRequest(repo: Repo, who: Who, eventId: string,
300
349
  export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string, occurrenceKey: string, cancelled: boolean, context: WriteContext): Promise<CalEvent> {
301
350
  return (await requests()).setHostedOccurrence(internal(repo), who, eventId, occurrenceKey, cancelled, context);
302
351
  }
352
+
353
+ // ---- hosts moving dates ------------------------------------------------------
354
+ // A host moves one date of an event they host (no permission beyond being in
355
+ // hostIds). acms({ members: { hostMoves } }) decides how: 'direct' (default)
356
+ // moves it at once while its rooms are free (409 room-taken otherwise, and
357
+ // the same move can then be sent with requestDateMove); 'approve' turns every
358
+ // host move into a request. Requests are RequestRecord rows (kind 'move')
359
+ // that staff who can publish approve or decline; both sides are emailed.
360
+
361
+ const hostRequests = () => import('../lib/calendar/host-requests');
362
+ export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
363
+ export type { RequestRecord } from '../lib/schema';
364
+
365
+ /** A host moves one date: `{ moved }` at once, or `{ request }` when moves need approval. Errors as moveOccurrence's, plus `code` room-not-allowed (a room the event doesn't use and nobody may request). */
366
+ export async function moveHostedDate(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
367
+ return (await hostRequests()).moveHostedDate(internal(repo), who, eventId, occurrenceKey, input, context);
368
+ }
369
+
370
+ /** A host puts a date they moved back where the series has it: at once, or as a request. */
371
+ export async function moveHostedDateBack(repo: Repo, who: Who, eventId: string, occurrenceKey: string, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
372
+ return (await hostRequests()).moveHostedDateBack(internal(repo), who, eventId, occurrenceKey, context);
373
+ }
374
+
375
+ /**
376
+ * A host asks staff to move one date (`input`), or to move it back (null),
377
+ * with an optional `message` for staff. Checked like the move, except that a
378
+ * taken room is allowed: staff decide. Asking again about the same date
379
+ * replaces the waiting request. Staff are emailed.
380
+ */
381
+ export async function requestDateMove(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput | null, message: string, context: WriteContext): Promise<import('../lib/schema').RequestRecord> {
382
+ return (await hostRequests()).requestDateMove(internal(repo), who, eventId, occurrenceKey, input, message, context);
383
+ }
384
+
385
+ /** A host's own date requests (waiting, approved, declined and withdrawn), newest first. */
386
+ export async function listMyDateRequests(repo: Repo, who: Who, eventId?: string): Promise<import('../lib/schema').RequestRecord[]> {
387
+ if (!who) return [];
388
+ return (await hostRequests()).myDateRequests(internal(repo), who.id, eventId);
389
+ }
390
+
391
+ /** A host withdraws a waiting date request of theirs. */
392
+ export async function withdrawDateRequest(repo: Repo, who: Who, requestId: string): Promise<void> {
393
+ return (await hostRequests()).withdrawDateRequest(internal(repo), who, requestId);
394
+ }
395
+
396
+ /** Staff ("Publish content"): approve a host's date move. It runs as them (room clashes come back as warnings) and is recorded as the host's. Pass `seen`, the request's `updatedAt` you showed. */
397
+ export async function approveDateRequest(repo: Repo, who: Who, requestId: string, context: WriteContext, seen?: number): Promise<{ request: import('../lib/schema').RequestRecord; moved: import('../lib/calendar/moves').MoveResult }> {
398
+ return (await hostRequests()).approveDateRequest(internal(repo), who, requestId, context, seen);
399
+ }
400
+
401
+ /** Staff: decline a host's date request, with a note the host reads. */
402
+ export async function declineDateRequest(repo: Repo, who: Who, requestId: string, reply: string, context: WriteContext, seen?: number): Promise<import('../lib/schema').RequestRecord> {
403
+ return (await hostRequests()).declineDateRequest(internal(repo), who, requestId, reply, context, seen);
404
+ }
package/src/virtual.d.ts CHANGED
@@ -22,7 +22,7 @@ declare module 'virtual:acms-config' {
22
22
  // resolved by integration.mjs (lib/roles.ts and lib/permissions/catalog.ts read them)
23
23
  roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
- members?: { enabled: boolean; base: string; notify: string[] };
25
+ members?: { enabled: boolean; base: string; notify: string[]; hostMoves?: 'direct' | 'approve' };
26
26
  announcements?: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> };
27
27
  // set in astro:config:setup (lib/origin-check.ts): off only when the site turned security.checkOrigin off
28
28
  originCheck?: { enabled: boolean; signedRoutes: string[] };