@acms-subir/core 0.3.55 → 0.3.57
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 +29 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -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/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/api.ts +23 -2
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +9 -2
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +212 -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 +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- 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 +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { ApiError } from '../../lib/api';
|
|
5
|
+
import { readForm } from '../../lib/forms/http';
|
|
6
|
+
import { I18N, localePath, requestLocale as pageLocale } from '../../lib/i18n';
|
|
7
|
+
import { siteLang, siteLocale } from '../../lib/dates';
|
|
8
|
+
import { LEGAL } from '../../lib/legal/config';
|
|
9
|
+
import { fill } from '../../lib/live/words';
|
|
10
|
+
import { REQUESTS } from '../../lib/calendar/request-config';
|
|
11
|
+
import { requestOptions, submitRequest, requestLocale, type RequestRefusal } from '../../lib/calendar/request-service';
|
|
12
|
+
import { requestStrings } from '../../lib/calendar/request-words';
|
|
13
|
+
import { formRequest, sentSummary } from '../../lib/calendar/request-page';
|
|
14
|
+
|
|
15
|
+
// Request an event (acms({ requests })): the same request as the app's
|
|
16
|
+
// screen, for anyone, without an account, at /request and under each
|
|
17
|
+
// language's prefix (/es/request). The page posts to itself; the rules,
|
|
18
|
+
// limits and emails are lib/calendar/request-service.ts. Themed through the
|
|
19
|
+
// site's tokens (the booking form's pc-bk-* fields); a site file at this path
|
|
20
|
+
// replaces it, and acms({ requests: { page: false } }) drops it.
|
|
21
|
+
if (!REQUESTS.enabled) return new Response(null, { status: 404 });
|
|
22
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
23
|
+
|
|
24
|
+
const repo = await getRepo(Astro.locals);
|
|
25
|
+
const settings = await repo.getSettings();
|
|
26
|
+
const locale = I18N.enabled ? requestLocale(pageLocale(Astro.url, Astro.locals)) : '';
|
|
27
|
+
const lang = locale || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
28
|
+
const dateLocale = lang.split('-')[0] === siteLang(settings) ? siteLocale(settings) : lang;
|
|
29
|
+
const w = requestStrings(lang);
|
|
30
|
+
const options = await requestOptions(repo, locale);
|
|
31
|
+
|
|
32
|
+
let error = '';
|
|
33
|
+
let field = '';
|
|
34
|
+
let values: Record<string, string | string[]> = { kind: 'new', repeat: 'once' };
|
|
35
|
+
let key: string = crypto.randomUUID();
|
|
36
|
+
let sent: { email: string; title: string; line: string } | null = null;
|
|
37
|
+
if (Astro.request.method === 'POST') {
|
|
38
|
+
try {
|
|
39
|
+
const data = await readForm(Astro.request);
|
|
40
|
+
values = Object.fromEntries([...new Set(data.keys())].map((k) => [k, k === 'rooms' ? data.getAll(k).map(String) : String(data.get(k) ?? '')]));
|
|
41
|
+
key = String(data.get('requestId') ?? '');
|
|
42
|
+
const { request } = await submitRequest(repo, formRequest(data, locale), { source: 'web', key, address: Astro.clientAddress, locals: Astro.locals });
|
|
43
|
+
sent = { email: request.requester?.email ?? '', ...await sentSummary(repo, request, settings, lang, dateLocale) };
|
|
44
|
+
} catch (e) {
|
|
45
|
+
const code = (e as { code?: RequestRefusal }).code;
|
|
46
|
+
field = code ?? '';
|
|
47
|
+
error = code ? w.errors[code] : e instanceof ApiError && e.status !== 500 ? e.message : w.errors.failed;
|
|
48
|
+
Astro.response.status = e instanceof ApiError ? e.status : 500;
|
|
49
|
+
// a refused request can be fixed and sent again with the same key; one that went through can't
|
|
50
|
+
if (code === 'changed') key = crypto.randomUUID();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const v = (k: string) => { const x = values[k]; return Array.isArray(x) ? x[0] ?? '' : x ?? ''; };
|
|
54
|
+
const chosenRooms = Array.isArray(values.rooms) ? values.rooms : values.rooms ? [values.rooms] : [];
|
|
55
|
+
const today = new Intl.DateTimeFormat('en-CA', { timeZone: settings.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(Date.now());
|
|
56
|
+
const privacy = LEGAL.enabled ? localePath('/privacy', locale) : '';
|
|
57
|
+
const self = localePath(REQUESTS.path, locale);
|
|
58
|
+
const tel = (p: string) => 'tel:' + p.replace(/[^\d+]/g, '');
|
|
59
|
+
const invalid = (...codes: string[]) => (codes.includes(field) ? 'true' : undefined);
|
|
60
|
+
const fieldWords = { free: w.free, taken: w.taken };
|
|
61
|
+
---
|
|
62
|
+
<Site title={w.heading}>
|
|
63
|
+
<section class="pc-simple-form pc-rq">
|
|
64
|
+
<h1>{sent ? w.sent : w.heading}</h1>
|
|
65
|
+
{sent ? (
|
|
66
|
+
<div class="pc-rq-sent" role="status">
|
|
67
|
+
<p>{options.replyTime ? fill(w.answerWithin, { time: options.replyTime, email: sent.email }) : fill(w.answerBy, { email: sent.email })}</p>
|
|
68
|
+
<div class="pc-rq-card"><small>{w.yours}</small><b>{sent.title}</b><span>{sent.line}</span></div>
|
|
69
|
+
<p class="pc-bk-hint">{w.notYet}{options.phone && <> <a href={tel(options.phone)}>{fill(w.sooner, { phone: options.phone })}</a></>}</p>
|
|
70
|
+
<p><a href={self}>{w.another}</a></p>
|
|
71
|
+
</div>
|
|
72
|
+
) : !options.open ? (
|
|
73
|
+
<div class="pc-rq-closed" role="status">
|
|
74
|
+
<p>{w.closed}</p>
|
|
75
|
+
{options.phone && <p><a href={tel(options.phone)}>{fill(w.call, { phone: options.phone })}</a></p>}
|
|
76
|
+
</div>
|
|
77
|
+
) : (
|
|
78
|
+
<form method="post" action={self} class="pc-rq-form" data-tz={options.timezone} data-words={JSON.stringify(fieldWords)}>
|
|
79
|
+
<p class="pc-rq-notice">{options.notice || w.notice}</p>
|
|
80
|
+
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
81
|
+
<input type="hidden" name="requestId" value={key} />
|
|
82
|
+
<div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off" /></label></div>
|
|
83
|
+
<fieldset class="pc-rq-kind">
|
|
84
|
+
<legend class="pc-rq-sr">{w.heading}</legend>
|
|
85
|
+
<label><input type="radio" name="kind" value="new" checked={v('kind') !== 'change'} /><span>{w.somethingNew}</span></label>
|
|
86
|
+
<label><input type="radio" name="kind" value="change" checked={v('kind') === 'change'} /><span>{w.change}</span></label>
|
|
87
|
+
</fieldset>
|
|
88
|
+
|
|
89
|
+
<div class="pc-rq-new">
|
|
90
|
+
<label class="pc-bk-field"><span>{w.what}</span><input name="title" maxlength="200" value={v('title')} aria-invalid={invalid('title')} /></label>
|
|
91
|
+
{options.types.length > 0 && (
|
|
92
|
+
<fieldset class="pc-bk-field pc-bk-choices"><legend>{w.type}</legend>
|
|
93
|
+
<div class="pc-rq-chips">{options.types.map((t) => <label class="pc-bk-option"><input type="radio" name="type" value={t.id} checked={v('type') === t.id} /><span>{t.label}</span></label>)}</div>
|
|
94
|
+
</fieldset>
|
|
95
|
+
)}
|
|
96
|
+
<div class="pc-rq-when">
|
|
97
|
+
<label class="pc-bk-field"><span>{w.date}</span><input type="date" name="date" min={today} value={v('date')} aria-invalid={invalid('start', 'past', 'far')} /></label>
|
|
98
|
+
<label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="start" step="300" value={v('start')} aria-invalid={invalid('start', 'past')} /></label>
|
|
99
|
+
<label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="end" step="300" value={v('end')} aria-invalid={invalid('end')} /></label>
|
|
100
|
+
</div>
|
|
101
|
+
<fieldset class="pc-bk-field pc-bk-choices"><legend>{w.repeats}</legend>
|
|
102
|
+
<div class="pc-rq-chips">
|
|
103
|
+
<label class="pc-bk-option"><input type="radio" name="repeat" value="once" checked={v('repeat') !== 'weekly' && v('repeat') !== 'other'} /><span>{w.once}</span></label>
|
|
104
|
+
<label class="pc-bk-option"><input type="radio" name="repeat" value="weekly" checked={v('repeat') === 'weekly'} /><span>{w.weekly}</span></label>
|
|
105
|
+
<label class="pc-bk-option"><input type="radio" name="repeat" value="other" checked={v('repeat') === 'other'} /><span>{w.other}</span></label>
|
|
106
|
+
</div>
|
|
107
|
+
<label class="pc-bk-field pc-rq-other"><span>{w.otherHint}</span><input name="repeatText" maxlength="200" value={v('repeatText')} aria-invalid={invalid('repeat')} /></label>
|
|
108
|
+
</fieldset>
|
|
109
|
+
{options.rooms.length > 0 && (
|
|
110
|
+
<fieldset class="pc-bk-field pc-bk-choices"><legend>{w.room}</legend>
|
|
111
|
+
<div class="pc-rq-chips">
|
|
112
|
+
{options.rooms.map((r) => <label class="pc-bk-option"><input type="checkbox" name="rooms" value={r.slug} data-name={r.name} checked={chosenRooms.includes(r.slug)} /><span>{r.name}</span></label>)}
|
|
113
|
+
<label class="pc-bk-option"><input type="checkbox" name="roomsUnsure" value="1" checked={v('roomsUnsure') === '1'} /><span>{w.notSure}</span></label>
|
|
114
|
+
</div>
|
|
115
|
+
<p class="pc-rq-free" aria-live="polite"></p>
|
|
116
|
+
</fieldset>
|
|
117
|
+
)}
|
|
118
|
+
<label class="pc-bk-field"><span>{w.people} <em>{w.optional}</em></span><input type="number" name="people" min="1" max="100000" inputmode="numeric" value={v('people')} aria-invalid={invalid('people')} /></label>
|
|
119
|
+
<label class="pc-bk-field"><span>{w.message} <em>{w.optional}</em></span><textarea name="message" rows="3" maxlength="2000">{v('message')}</textarea></label>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="pc-rq-change">
|
|
123
|
+
<label class="pc-bk-field"><span>{w.event}</span>
|
|
124
|
+
<select name="event" aria-invalid={invalid('event')}>
|
|
125
|
+
<option value="">{w.pickEvent}</option>
|
|
126
|
+
{options.events.map((e) => <option value={e.slug} selected={v('event') === e.slug}>{e.title}</option>)}
|
|
127
|
+
</select>
|
|
128
|
+
</label>
|
|
129
|
+
<label class="pc-bk-field"><span>{w.onDate} <em>{w.optional}</em></span><input type="date" name="onDate" min={today} value={v('onDate')} aria-invalid={invalid('date')} /><small>{w.onDateHint}</small></label>
|
|
130
|
+
<label class="pc-bk-field"><span>{w.whatChange}</span><textarea name="whatChange" rows="3" maxlength="2000" aria-invalid={invalid('what')}>{v('whatChange')}</textarea></label>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<fieldset class="pc-rq-contact">
|
|
134
|
+
<legend>{w.contact}</legend>
|
|
135
|
+
<label class="pc-bk-field"><span>{w.name}</span><input name="name" required maxlength="120" autocomplete="name" value={v('name')} aria-invalid={invalid('name')} /></label>
|
|
136
|
+
<div class="pc-form-row">
|
|
137
|
+
<label class="pc-bk-field"><span>{w.email}</span><input type="email" name="email" required maxlength="254" autocomplete="email" value={v('email')} aria-invalid={invalid('email')} /></label>
|
|
138
|
+
<label class="pc-bk-field"><span>{w.phone} <em>{w.optional}</em></span><input type="tel" name="phone" maxlength="40" autocomplete="tel" value={v('phone')} aria-invalid={invalid('phone')} /></label>
|
|
139
|
+
</div>
|
|
140
|
+
<p class="pc-bk-hint">{w.contactWhy}{privacy && <> <a href={privacy}>{w.privacy}</a></>}</p>
|
|
141
|
+
</fieldset>
|
|
142
|
+
<button class="pc-bk-submit" type="submit">{w.send}</button>
|
|
143
|
+
</form>
|
|
144
|
+
)}
|
|
145
|
+
</section>
|
|
146
|
+
</Site>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
// "Sanctuary looks free then": free or busy for the chosen rooms
|
|
150
|
+
// (GET /api/v1/rooms/free, no names), in the site's zone
|
|
151
|
+
const form = document.querySelector<HTMLFormElement>('.pc-rq-form');
|
|
152
|
+
const out = form?.querySelector<HTMLElement>('.pc-rq-free');
|
|
153
|
+
if (form && out) {
|
|
154
|
+
const tz = form.dataset.tz || 'UTC';
|
|
155
|
+
const words = JSON.parse(form.dataset.words || '{}') as { free: string; taken: string };
|
|
156
|
+
const wall = (iso: string) => {
|
|
157
|
+
const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
158
|
+
.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
|
|
159
|
+
return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
|
|
160
|
+
};
|
|
161
|
+
const next = (day: string) => { const d = new Date(day + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
|
|
162
|
+
let run = 0;
|
|
163
|
+
const check = async () => {
|
|
164
|
+
const mine = ++run;
|
|
165
|
+
const get = (n: string) => (form.elements.namedItem(n) as HTMLInputElement | null)?.value || '';
|
|
166
|
+
const day = get('date'), start = get('start'), end = get('end');
|
|
167
|
+
const rooms = [...form.querySelectorAll<HTMLInputElement>('input[name=rooms]:checked')];
|
|
168
|
+
if (!day || !start || !end || end <= start || !rooms.length) { out.textContent = ''; return; }
|
|
169
|
+
const from = day + 'T' + start, to = day + 'T' + end;
|
|
170
|
+
const lines = await Promise.all(rooms.map(async (r) => {
|
|
171
|
+
const res = await fetch('/api/v1/rooms/free?room=' + encodeURIComponent(r.value) + '&start=' + day + '&end=' + next(day)).catch(() => null);
|
|
172
|
+
if (!res?.ok) return '';
|
|
173
|
+
const { data } = await res.json() as { data: { busy: { start: string; end: string }[] } };
|
|
174
|
+
const taken = data.busy.some((b) => wall(b.start) < to && wall(b.end) > from);
|
|
175
|
+
return (taken ? words.taken : words.free).replace('{room}', r.dataset.name || r.value);
|
|
176
|
+
}));
|
|
177
|
+
if (mine === run) out.textContent = lines.filter(Boolean).join(' ');
|
|
178
|
+
};
|
|
179
|
+
form.addEventListener('change', (e) => { if ((e.target as HTMLInputElement).name !== 'roomsUnsure') void check(); });
|
|
180
|
+
void check();
|
|
181
|
+
}
|
|
182
|
+
</script>
|
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) */
|
|
@@ -254,6 +256,39 @@ export async function syncGoogleCalendar(repo: Repo, context: Pick<WriteContext,
|
|
|
254
256
|
return syncGoogle(internal(repo), context.locals);
|
|
255
257
|
}
|
|
256
258
|
|
|
259
|
+
// ---- from this date on -------------------------------------------------------
|
|
260
|
+
// A repeating event changed from one of its dates on (new times, weekday,
|
|
261
|
+
// rule, rooms or place), the dates before it kept as they were. It stays one
|
|
262
|
+
// event; the change is a segment on its row (CalEvent.segments). RSVPs,
|
|
263
|
+
// cancelled and moved dates follow to the new date nearest them within half
|
|
264
|
+
// a week (`npx acms docs calendar`). Staff and GOOGLE_SYNC only (room clashes
|
|
265
|
+
// warn); hosts send requestFromDate. All are idempotent (`changed: false`);
|
|
266
|
+
// `quiet` skips the member push, and RSVP emails still go.
|
|
267
|
+
|
|
268
|
+
const segments = () => import('../lib/calendar/segments');
|
|
269
|
+
export type { ChangeFromInput, ChangeFromResult } from '../lib/calendar/segments';
|
|
270
|
+
export type { SeriesSegment } from '../lib/schema';
|
|
271
|
+
|
|
272
|
+
/** Change the series from `fromKey` (an occurrence key) on: `input.start` is the new first date (wall-clock in the event's zone), `rrule` omitted = the rule in effect on the new weekday. */
|
|
273
|
+
export async function changeFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, input: import('../lib/calendar/segments').ChangeFromInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
|
|
274
|
+
return (await segments()).changeFromDate(internal(repo), who, eventId, fromKey, input, { url: context.url, locals: context.locals }, opts);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Cancel every date from `fromKey` on: listed, struck through, holding no room; their RSVPs are cancelled (emailed). */
|
|
278
|
+
export async function cancelFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
|
|
279
|
+
return (await segments()).cancelFromDate(internal(repo), who, eventId, fromKey, { url: context.url, locals: context.locals }, opts);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Undo cancelFromDate (from any date it covers). The dates are back; their RSVPs stay cancelled. */
|
|
283
|
+
export async function restoreFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
|
|
284
|
+
return (await segments()).restoreFromDate(internal(repo), who, eventId, fromKey, { url: context.url, locals: context.locals }, opts);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Remove the change that took over at `from`: the rule before it carries on, RSVPs and dates following back. */
|
|
288
|
+
export async function revertFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, from: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
|
|
289
|
+
return (await segments()).revertFromDate(internal(repo), who, eventId, from, { url: context.url, locals: context.locals }, opts);
|
|
290
|
+
}
|
|
291
|
+
|
|
257
292
|
// ---- rooms -----------------------------------------------------------------
|
|
258
293
|
// Events hold rooms (CalEvent.roomHold) while they happen. These find where
|
|
259
294
|
// two things want the same room. They don't check permissions, and their
|
|
@@ -359,8 +394,7 @@ export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string,
|
|
|
359
394
|
// that staff who can publish approve or decline; both sides are emailed.
|
|
360
395
|
|
|
361
396
|
const hostRequests = () => import('../lib/calendar/host-requests');
|
|
362
|
-
export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
|
|
363
|
-
export type { RequestRecord } from '../lib/schema';
|
|
397
|
+
export type { HostMoveResult, MoveRequestData, FromDateRequestData } from '../lib/calendar/host-requests';
|
|
364
398
|
|
|
365
399
|
/** 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
400
|
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> {
|
|
@@ -382,6 +416,11 @@ export async function requestDateMove(repo: Repo, who: Who, eventId: string, occ
|
|
|
382
416
|
return (await hostRequests()).requestDateMove(internal(repo), who, eventId, occurrenceKey, input, message, context);
|
|
383
417
|
}
|
|
384
418
|
|
|
419
|
+
/** A host asks staff to change their event from one date on (checked like changeFromDate; rooms among the event's and the requestable ones). Staff are emailed. */
|
|
420
|
+
export async function requestFromDate(repo: Repo, who: Who, eventId: string, fromKey: string, input: Omit<import('../lib/calendar/segments').ChangeFromInput, 'location'>, message: string, context: WriteContext): Promise<import('../lib/schema').RequestRecord> {
|
|
421
|
+
return (await hostRequests()).requestFromDate(internal(repo), who, eventId, fromKey, input, message, context);
|
|
422
|
+
}
|
|
423
|
+
|
|
385
424
|
/** A host's own date requests (waiting, approved, declined and withdrawn), newest first. */
|
|
386
425
|
export async function listMyDateRequests(repo: Repo, who: Who, eventId?: string): Promise<import('../lib/schema').RequestRecord[]> {
|
|
387
426
|
if (!who) return [];
|
|
@@ -393,8 +432,13 @@ export async function withdrawDateRequest(repo: Repo, who: Who, requestId: strin
|
|
|
393
432
|
return (await hostRequests()).withdrawDateRequest(internal(repo), who, requestId);
|
|
394
433
|
}
|
|
395
434
|
|
|
396
|
-
/**
|
|
397
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Staff ("Publish content"): approve a host's date request. A move runs as
|
|
437
|
+
* them (room clashes come back as warnings) and is recorded as the host's
|
|
438
|
+
* (`moved`); a change from a date on runs changeFromDate (`changed`). Pass
|
|
439
|
+
* `seen`, the request's `updatedAt` you showed.
|
|
440
|
+
*/
|
|
441
|
+
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; changed?: import('../lib/calendar/segments').ChangeFromResult }> {
|
|
398
442
|
return (await hostRequests()).approveDateRequest(internal(repo), who, requestId, context, seen);
|
|
399
443
|
}
|
|
400
444
|
|
|
@@ -402,3 +446,57 @@ export async function approveDateRequest(repo: Repo, who: Who, requestId: string
|
|
|
402
446
|
export async function declineDateRequest(repo: Repo, who: Who, requestId: string, reply: string, context: WriteContext, seen?: number): Promise<import('../lib/schema').RequestRecord> {
|
|
403
447
|
return (await hostRequests()).declineDateRequest(internal(repo), who, requestId, reply, context, seen);
|
|
404
448
|
}
|
|
449
|
+
|
|
450
|
+
// ---- event requests without an account ---------------------------------------
|
|
451
|
+
// The rules behind the built-in /request form, POST /api/v1/requests and
|
|
452
|
+
// Calendar → Requests (acms({ requests })), for a site that builds its own
|
|
453
|
+
// form or list. Asking needs no account; reading the list needs "Create and
|
|
454
|
+
// edit content", answering "Publish content". Refusals carry a `status` and a
|
|
455
|
+
// `code` (RequestRefusal: which field, closed, busy…) with an English message;
|
|
456
|
+
// the built-in form shows each code in the visitor's language.
|
|
457
|
+
|
|
458
|
+
const asks = () => import('../lib/calendar/request-service');
|
|
459
|
+
|
|
460
|
+
/** What the request form offers: open or closed, the types and requestable rooms, and the site's words in this language. */
|
|
461
|
+
export async function requestOptions(repo: Repo, locale = '') {
|
|
462
|
+
return (await asks()).requestOptions(internal(repo), locale);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Send a request from the site's own form. `key` is a random UUID v4 the page
|
|
467
|
+
* kept for this form (a retry with it never makes a second request); `address`
|
|
468
|
+
* is the client address (Astro.clientAddress) for the rate limit. The person
|
|
469
|
+
* is emailed that it arrived, and staff get an alert.
|
|
470
|
+
*/
|
|
471
|
+
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 }> {
|
|
472
|
+
const { id, created } = await (await asks()).submitRequest(internal(repo), input, { source: 'web', key: opts.key, address: opts.address, locals: opts.context.locals });
|
|
473
|
+
return { id, created };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Requests for staff ("Create and edit content"): filter by state, kind, event or member; pending ones oldest first, the rest newest first. */
|
|
477
|
+
export async function listRequests(repo: Repo, who: Who, q: import('../lib/calendar/request-store').RequestQuery = {}): Promise<import('../lib/schema').RequestRecord[]> {
|
|
478
|
+
if (!who) throw Object.assign(new Error('Sign in to continue.'), { status: 401 });
|
|
479
|
+
if (!who.can('content.edit')) throw Object.assign(new Error('Your role doesn’t include “Create and edit content”.'), { status: 403 });
|
|
480
|
+
const pending = q.states?.length === 1 && q.states[0] === 'pending';
|
|
481
|
+
return internal(repo).requests.list({ order: pending ? 'oldest' : 'newest', ...q });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Staff approve: a new event becomes a filled-in draft; a change points at its event. `next` is where to go in the admin. */
|
|
485
|
+
export async function approveRequest(repo: Repo, who: Who, id: string, context: WriteContext, seen?: number, opts: { message?: string; email?: boolean } = {}) {
|
|
486
|
+
return (await asks()).approveRequest(internal(repo), who, id, context, seen, opts);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Staff decline, with an optional reason emailed to the person in their language (`email: false` sends nothing). */
|
|
490
|
+
export async function declineRequest(repo: Repo, who: Who, id: string, reason: string, context: WriteContext, seen?: number, opts: { email?: boolean } = {}) {
|
|
491
|
+
return (await asks()).declineRequest(internal(repo), who, id, reason, context, seen, opts);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Staff delete a request from the app or the website as spam, without emailing anyone. */
|
|
495
|
+
export async function deleteRequest(repo: Repo, who: Who, id: string): Promise<void> {
|
|
496
|
+
return (await asks()).deleteRequest(internal(repo), who, id);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Staff email the person about their request (it stays as it is). */
|
|
500
|
+
export async function replyToRequest(repo: Repo, who: Who, id: string, message: string, context: WriteContext) {
|
|
501
|
+
return (await asks()).replyToRequest(internal(repo), who, id, message, context);
|
|
502
|
+
}
|
package/src/styles/editor.css
CHANGED
|
@@ -1346,4 +1346,6 @@ html.pe-evr-lock { overflow: hidden; }
|
|
|
1346
1346
|
.pe-evd-rooms legend { font-size: 12px; color: var(--text-2); margin-bottom: 4px; padding: 0; }
|
|
1347
1347
|
.pe-evd-room { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
1348
1348
|
.pe-evd-formacts { display: flex; gap: 8px; }
|
|
1349
|
+
.pe-evd-segs { margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--line); }
|
|
1350
|
+
.pe-evd-hint { margin: 0; font-size: 12.5px; color: var(--text-2); }
|
|
1349
1351
|
.pe-ev-note.is-error { color: var(--red); }
|
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)
|