@acms-subir/core 0.3.54 → 0.3.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +104 -2
- package/docs/google-calendar.md +302 -0
- package/docs/mobile.md +3 -1
- package/docs/server.md +58 -0
- package/integration.mjs +38 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -0
- package/src/lib/calendar/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +17 -2
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +7 -0
- package/src/lib/legal/text-en.ts +7 -0
- package/src/lib/legal/text-es.ts +7 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/schema.ts +43 -2
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +105 -1
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/site.css
CHANGED
|
@@ -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)
|