@acms-subir/core 0.3.54 → 0.3.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +104 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/mobile.md +3 -1
  7. package/docs/server.md +58 -0
  8. package/integration.mjs +38 -0
  9. package/mobile-template/client/AGENTS.md +1 -0
  10. package/mobile-template/client/README.md +1 -1
  11. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  12. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  13. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  14. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  15. package/mobile-template/recipes/event-request/recipe.json +21 -0
  16. package/package.json +1 -1
  17. package/src/components/LegalSettings.tsx +1 -1
  18. package/src/components/MobileApp.tsx +2 -2
  19. package/src/components/editor/event/SingleDates.tsx +12 -2
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/google/client.ts +167 -0
  22. package/src/lib/calendar/google/connect.ts +161 -0
  23. package/src/lib/calendar/google/context.ts +50 -0
  24. package/src/lib/calendar/google/inbound.ts +493 -0
  25. package/src/lib/calendar/google/links.ts +139 -0
  26. package/src/lib/calendar/google/map.ts +543 -0
  27. package/src/lib/calendar/google/match.ts +103 -0
  28. package/src/lib/calendar/google/migration.ts +71 -0
  29. package/src/lib/calendar/google/outbound.ts +272 -0
  30. package/src/lib/calendar/google/service.ts +193 -0
  31. package/src/lib/calendar/google/store.ts +241 -0
  32. package/src/lib/calendar/google/types.ts +188 -0
  33. package/src/lib/calendar/request-admin.ts +211 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +8 -8
  41. package/src/lib/display/board.ts +3 -1
  42. package/src/lib/email/service.ts +17 -2
  43. package/src/lib/email/templates.ts +8 -0
  44. package/src/lib/legal/admin.ts +1 -1
  45. package/src/lib/legal/app-privacy.ts +2 -2
  46. package/src/lib/legal/facts.ts +7 -0
  47. package/src/lib/legal/text-en.ts +7 -0
  48. package/src/lib/legal/text-es.ts +7 -0
  49. package/src/lib/live/crypto.ts +7 -28
  50. package/src/lib/mobile/http.ts +2 -1
  51. package/src/lib/mobile/openapi.ts +8 -0
  52. package/src/lib/mobile/requests.ts +27 -0
  53. package/src/lib/mobile/schemas.ts +14 -3
  54. package/src/lib/mobile/site.ts +3 -0
  55. package/src/lib/push/types.ts +1 -1
  56. package/src/lib/repo/migrations.ts +4 -0
  57. package/src/lib/repo/sql.ts +18 -9
  58. package/src/lib/repo/types.ts +6 -2
  59. package/src/lib/scheduler.ts +6 -0
  60. package/src/lib/schema.ts +43 -2
  61. package/src/lib/sealed.ts +46 -0
  62. package/src/pages/admin/calendar/events/[id].astro +42 -1
  63. package/src/pages/admin/calendar/events/index.astro +2 -0
  64. package/src/pages/admin/calendar/google/match.astro +160 -0
  65. package/src/pages/admin/calendar/google.astro +107 -0
  66. package/src/pages/admin/calendar/requests.astro +298 -0
  67. package/src/pages/admin/calendar/settings.astro +273 -4
  68. package/src/pages/admin/integrations.astro +6 -0
  69. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  70. package/src/pages/api/v1/requests/index.ts +6 -0
  71. package/src/pages/api/v1/requests/options.ts +7 -0
  72. package/src/pages/request/index.astro +182 -0
  73. package/src/server/index.ts +105 -1
  74. package/src/styles/site.css +29 -0
  75. package/src/virtual.d.ts +1 -0
@@ -38,6 +38,8 @@ export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
39
  export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
40
40
  export type { MoveInput, MoveResult } from '../lib/calendar/moves';
41
+ export type { RequestRecord, RequestKind, RequestState, RequestSource, Requester } from '../lib/schema';
42
+ export type { RequestInput, RequestRefusal, NewEventDetails, Answered, RequestClash } from '../lib/calendar/request-service';
41
43
 
42
44
  export interface ListEventsOptions {
43
45
  /** only published events (default: every event except archived ones) */
@@ -205,6 +207,55 @@ export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SY
205
207
  return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
206
208
  }
207
209
 
210
+ // ---- Google Calendar sync ----------------------------------------------------
211
+ // The two-way sync is set up in the admin (Calendar settings → Synced with
212
+ // Google) and runs from the scheduler. These read what it did, for a site's
213
+ // own pages (a "Synced with Google" badge, a staff report). They don't check
214
+ // permissions and their results name Google calendars: show them to staff.
215
+
216
+ /** The internal tag on appointments booked through a Google booking page (they come in titled "Booked"). Never shown publicly. */
217
+ export const GOOGLE_BOOKING_TAG = '#google-booking';
218
+
219
+ export interface GoogleCopy {
220
+ /** the Google calendar's name and id */
221
+ googleCalendar: string;
222
+ googleCalendarId: string;
223
+ /** the Google event's own page */
224
+ htmlLink: string;
225
+ /** when the pair last synced (ms) */
226
+ syncedAt: number;
227
+ /** created in Google and its description never edited on the site: Google's description is used */
228
+ descriptionFromGoogle: boolean;
229
+ /** a booked appointment from a Google booking page */
230
+ booking: boolean;
231
+ /** the latest field both sides changed (the site kept its value), until someone picks one */
232
+ clash: { field: string; site: string; google: string; at: number } | null;
233
+ }
234
+
235
+ /** The Google copies of one event (an event in two linked calendars has two). Empty when it isn't synced. */
236
+ export async function googleCopies(repo: Repo, eventId: string): Promise<GoogleCopy[]> {
237
+ const r = internal(repo);
238
+ const items = (await r.gcal.itemsForEvent(eventId)).filter((i) => i.kind !== 'segment' && !i.gone);
239
+ if (!items.length) return [];
240
+ const links = new Map((await r.gcal.listLinks()).map((l) => [l.id, l]));
241
+ return items.map((i) => ({
242
+ googleCalendar: links.get(i.linkId)?.googleName ?? '', googleCalendarId: links.get(i.linkId)?.googleId ?? '', htmlLink: i.htmlLink, syncedAt: i.syncedAt,
243
+ 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 },
244
+ }));
245
+ }
246
+
247
+ /** Recent changes the sync made (newest first, kept 90 days): direction in/out/clash/info, the event, and what changed in words. */
248
+ export async function googleSyncHistory(repo: Repo, opts: { eventId?: string; limit?: number } = {}) {
249
+ return (await internal(repo).gcal.listLog({ eventId: opts.eventId, limit: opts.limit ?? 50 }))
250
+ .map(({ id, at, eventId, direction, action, title, summary, resolved }) => ({ id, at, eventId, direction, action, title, summary, resolved }));
251
+ }
252
+
253
+ /** 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. */
254
+ export async function syncGoogleCalendar(repo: Repo, context: Pick<WriteContext, 'locals'>): Promise<number> {
255
+ const { syncGoogle } = await import('../lib/calendar/google/service');
256
+ return syncGoogle(internal(repo), context.locals);
257
+ }
258
+
208
259
  // ---- rooms -----------------------------------------------------------------
209
260
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
210
261
  // two things want the same room. They don't check permissions, and their
@@ -311,7 +362,6 @@ export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string,
311
362
 
312
363
  const hostRequests = () => import('../lib/calendar/host-requests');
313
364
  export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
314
- export type { RequestRecord } from '../lib/schema';
315
365
 
316
366
  /** 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). */
317
367
  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> {
@@ -353,3 +403,57 @@ export async function approveDateRequest(repo: Repo, who: Who, requestId: string
353
403
  export async function declineDateRequest(repo: Repo, who: Who, requestId: string, reply: string, context: WriteContext, seen?: number): Promise<import('../lib/schema').RequestRecord> {
354
404
  return (await hostRequests()).declineDateRequest(internal(repo), who, requestId, reply, context, seen);
355
405
  }
406
+
407
+ // ---- event requests without an account ---------------------------------------
408
+ // The rules behind the built-in /request form, POST /api/v1/requests and
409
+ // Calendar → Requests (acms({ requests })), for a site that builds its own
410
+ // form or list. Asking needs no account; reading the list needs "Create and
411
+ // edit content", answering "Publish content". Refusals carry a `status` and a
412
+ // `code` (RequestRefusal: which field, closed, busy…) with an English message;
413
+ // the built-in form shows each code in the visitor's language.
414
+
415
+ const asks = () => import('../lib/calendar/request-service');
416
+
417
+ /** What the request form offers: open or closed, the types and requestable rooms, and the site's words in this language. */
418
+ export async function requestOptions(repo: Repo, locale = '') {
419
+ return (await asks()).requestOptions(internal(repo), locale);
420
+ }
421
+
422
+ /**
423
+ * Send a request from the site's own form. `key` is a random UUID v4 the page
424
+ * kept for this form (a retry with it never makes a second request); `address`
425
+ * is the client address (Astro.clientAddress) for the rate limit. The person
426
+ * is emailed that it arrived, and staff get an alert.
427
+ */
428
+ export async function submitRequest(repo: Repo, input: import('../lib/calendar/request-service').RequestInput, opts: { key: string; address: string; context: WriteContext }): Promise<{ id: string; created: boolean }> {
429
+ const { id, created } = await (await asks()).submitRequest(internal(repo), input, { source: 'web', key: opts.key, address: opts.address, locals: opts.context.locals });
430
+ return { id, created };
431
+ }
432
+
433
+ /** Requests for staff ("Create and edit content"): filter by state, kind, event or member; pending ones oldest first, the rest newest first. */
434
+ export async function listRequests(repo: Repo, who: Who, q: import('../lib/calendar/request-store').RequestQuery = {}): Promise<import('../lib/schema').RequestRecord[]> {
435
+ if (!who) throw Object.assign(new Error('Sign in to continue.'), { status: 401 });
436
+ if (!who.can('content.edit')) throw Object.assign(new Error('Your role doesn’t include “Create and edit content”.'), { status: 403 });
437
+ const pending = q.states?.length === 1 && q.states[0] === 'pending';
438
+ return internal(repo).requests.list({ order: pending ? 'oldest' : 'newest', ...q });
439
+ }
440
+
441
+ /** Staff approve: a new event becomes a filled-in draft; a change points at its event. `next` is where to go in the admin. */
442
+ export async function approveRequest(repo: Repo, who: Who, id: string, context: WriteContext, seen?: number, opts: { message?: string; email?: boolean } = {}) {
443
+ return (await asks()).approveRequest(internal(repo), who, id, context, seen, opts);
444
+ }
445
+
446
+ /** Staff decline, with an optional reason emailed to the person in their language (`email: false` sends nothing). */
447
+ export async function declineRequest(repo: Repo, who: Who, id: string, reason: string, context: WriteContext, seen?: number, opts: { email?: boolean } = {}) {
448
+ return (await asks()).declineRequest(internal(repo), who, id, reason, context, seen, opts);
449
+ }
450
+
451
+ /** Staff delete a request from the app or the website as spam, without emailing anyone. */
452
+ export async function deleteRequest(repo: Repo, who: Who, id: string): Promise<void> {
453
+ return (await asks()).deleteRequest(internal(repo), who, id);
454
+ }
455
+
456
+ /** Staff email the person about their request (it stays as it is). */
457
+ export async function replyToRequest(repo: Repo, who: Who, id: string, message: string, context: WriteContext) {
458
+ return (await asks()).replyToRequest(internal(repo), who, id, message, context);
459
+ }
@@ -1226,6 +1226,35 @@ li::marker { color: var(--muted); }
1226
1226
  .pc-simple-form form { display: grid; gap: 16px; }
1227
1227
  .pc-simple-form .pc-bk-field { margin: 0; }
1228
1228
  .pc-form-trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
1229
+ /* Request an event (pages/request/index.astro). The kind toggle shows one set
1230
+ of fields; without :has() both show and the server reads the chosen kind's. */
1231
+ .pc-rq .pc-rq-notice { color: var(--muted); margin: 0; }
1232
+ .pc-rq-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1233
+ .pc-rq-kind { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; border: 1px solid var(--line-2, var(--line)); border-radius: 12px; padding: 4px; margin: 0; background: var(--soft); }
1234
+ .pc-rq-kind label { display: block; position: relative; }
1235
+ .pc-rq-kind input { position: absolute; opacity: 0; pointer-events: none; }
1236
+ .pc-rq-kind span { display: block; text-align: center; padding: 8px 10px; border-radius: 9px; font-size: 14.5px; font-weight: 550; cursor: pointer; color: var(--muted); }
1237
+ .pc-rq-kind input:checked + span { background: var(--panel); color: var(--ink); box-shadow: var(--sh-1); }
1238
+ .pc-rq-kind input:focus-visible + span { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
1239
+ .pc-rq-new, .pc-rq-change { display: grid; gap: 16px; }
1240
+ .pc-rq-form:has(input[name=kind][value=change]:checked) .pc-rq-new,
1241
+ .pc-rq-form:has(input[name=kind][value=new]:checked) .pc-rq-change { display: none; }
1242
+ .pc-rq-when { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 12px; }
1243
+ .pc-rq-when .pc-bk-field { margin: 0; }
1244
+ @media (max-width: 520px) { .pc-rq-when { grid-template-columns: 1fr 1fr; } .pc-rq-when > :first-child { grid-column: 1 / -1; } }
1245
+ .pc-rq-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1246
+ .pc-rq-chips .pc-bk-option { min-height: 38px; padding: 6px 12px; }
1247
+ .pc-rq .pc-rq-other { margin: 8px 0 0; }
1248
+ .pc-rq-form:not(:has(input[name=repeat][value=other]:checked)) .pc-rq-other { display: none; }
1249
+ .pc-rq-free { margin: 8px 0 0; font-size: 14px; color: var(--muted); }
1250
+ .pc-rq-free:empty { display: none; }
1251
+ .pc-rq-contact { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
1252
+ .pc-rq-contact legend { font-weight: 650; font-size: 17px; padding: 0; margin-bottom: 8px; }
1253
+ .pc-rq-contact .pc-bk-hint { margin: 0; }
1254
+ .pc-rq-card { display: grid; gap: 2px; border: 1px solid var(--line); border-radius: var(--r); background: var(--panel); padding: 14px 16px; margin: 16px 0; }
1255
+ .pc-rq-card small { color: var(--muted); font-size: 12.5px; }
1256
+ .pc-rq-card span { color: var(--muted); font-size: 14.5px; }
1257
+ .pc-rq [aria-invalid=true] { border-color: var(--danger); }
1229
1258
  .pc-bk-check { display: flex; gap: 8px; align-items: start; margin-block: 12px; }
1230
1259
  .pc-bk-check input { width: auto; margin-top: 5px; }
1231
1260
  .pc-bk-check small, .pc-bk-field small { display: block; font-size: .85em; opacity: .8; }
package/src/virtual.d.ts CHANGED
@@ -24,6 +24,7 @@ declare module 'virtual:acms-config' {
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
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
+ requests?: { enabled: boolean; path: string; page: boolean };
27
28
  // set in astro:config:setup (lib/origin-check.ts): off only when the site turned security.checkOrigin off
28
29
  originCheck?: { enabled: boolean; signedRoutes: string[] };
29
30
  // resolved by integration.mjs (lib/calendar/config.ts documents the shape)