@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.57
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b9eed5e: Change a repeating event from one of its dates on, and keep the dates before it as they were.
|
|
8
|
+
|
|
9
|
+
- **One event.** When a class moves for good, from Thursdays 6 PM to Wednesdays 5 PM, the dates before the change stay exactly as they were, and it stays one event: the same page, slug, hosts, RSVPs and My events entry. Each change is a segment on the events row (`CalEvent.segments`, migration 70), with its own first date, repeat, rooms and place.
|
|
10
|
+
- **Where.** Change it from the event editor's Single dates (**From here on…**, with **Undo**), or drag a date on the Schedule and choose **This and following dates**. Hosts can ask with **Change from this date on…** on My events, and staff apply it from Requests.
|
|
11
|
+
- **What follows.** Sign-ups move to the new date nearest them within half a week, and each person is emailed the new time with your note. Cancelled, skipped and moved dates follow the same way. Sign-ups with no new date near them are cancelled, with the email. `npx acms docs calendar` has the rule.
|
|
12
|
+
- **Every output.** `/calendar`, the event page ("Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday"), cards, the TV display, room holds and the app API show the new dates. `calendar.ics` writes one VEVENT per part, as Google does for "this and following". The "When an event changes" rule sends one notice: "From Wed, Jan 9: Weekly on Wednesday, 5:00 PM".
|
|
13
|
+
- **API.** Events carry `segments` (`from`, `start`, `end`, `rrule`, `repeats`, `cancelled`, `note`, `location`, `rooms`). `repeats` is the rule in effect now, and each occurrence has its own `location`. Push data gains `change: 'fromDate' | 'cancelledFrom'`.
|
|
14
|
+
- **Server API.** `@acms-subir/core/server` adds `changeFromDate`, `cancelFromDate`, `restoreFromDate`, `revertFromDate` (staff and `GOOGLE_SYNC`, all idempotent, `quiet` for a sync) and `requestFromDate` for hosts.
|
|
15
|
+
|
|
16
|
+
## 0.3.56
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 191830f: Anyone can now ask for an event, or for a change to one, without an account. Turn it on with `acms({ requests: true })`; it needs the calendar.
|
|
21
|
+
|
|
22
|
+
- **On the website**, a form at `/request` (and `/es/request` and so on), in the visitor's language and themed through the site's tokens. It asks what they're planning, the kind, date and times, how it repeats, rooms (or "Not sure"), how many people, and anything else. "Change to an event" asks which event, optionally which date, and what should change. A live line says whether the chosen rooms look free then (free or busy only). `requests: { path: '/ask' }` moves it; `requests: { page: false }` drops it but keeps the rest, and a site file at the path replaces it.
|
|
23
|
+
- **In apps**, `GET /api/v1/requests/options` (open or closed, the kinds and requestable rooms, the reply time, notice and office phone, in the asked language) and `POST /api/v1/requests` (Idempotency-Key; returns `{ id }`; refusals carry a code such as `title`, `past`, `room`, `closed` or `busy`). `site.features.requests` says the site has it. 10 requests a minute and 20 a day per address, 5 a day per email address, plus the site's daily cap.
|
|
24
|
+
- **Calendar → Requests** lists requests from the app, the website and hosts on My events (new events, suggested changes and date moves, each answered there) in one place, with Pending, Approved and Declined tabs and, on each new request, what takes its rooms then (12 weeks for weekly ones). **Approve** makes a draft that's already filled in: title, times, weekly when asked, the kind's tag and calendar, and the rooms held while it's a draft. The person's details stay on the request, never on the event. Approving a change opens the event, or that date's Move… form. **Reply** and **Decline** (with an optional reason) email the person in the language they asked in. **Delete as spam** removes one without an email. The list shows 50 at a time. Reading the list needs "Create and edit content"; answering needs "Publish content".
|
|
25
|
+
- **Settings** on that page (needs "Manage rooms and calendars"): open or closed (closed forms and apps say so and show the office phone, and never disappear), the kinds with their label in each language, tag and calendar, the reply time and opening notice in each language, the office phone, who hears about new requests (else `members.notify`, else the owners), an optional push to their linked phones, the daily cap (100) and how long requests are kept (180 days after the answer, or after they arrive if nobody answers). Rooms marked **Anyone can check if it's free** are the ones people can choose.
|
|
26
|
+
- **Emails:** the person gets "We got your request" in their language. It never repeats what they typed, so the form can't be used to send someone else a message, and each address gets at most 3 a day. Staff get every detail, with Reply-To set to the person, and only while the request still waits.
|
|
27
|
+
- **Privacy:** the generated privacy notice gets an "Event requests" section (what's kept, why, and when it's deleted), and the app store answers list event requests under contact info and user content. Requests are in backups.
|
|
28
|
+
- **Server API** (`@acms-subir/core/server`): `requestOptions`, `submitRequest` (for a site's own form), `listRequests`, `approveRequest`, `declineRequest` and `replyToRequest`. See `npx acms docs calendar` and `npx acms docs server`.
|
|
29
|
+
|
|
30
|
+
Requests use the `event_requests` table that came with hosts' date moves (migration 68); this release adds no migration. After updating, a site that wants requests adds `requests: true`, marks the rooms people may ask for as **Anyone can check if it's free**, and sets the kinds, reply time and office phone in Calendar → Requests → Settings. Update the privacy notice and the app's store answers, which now include requests.
|
|
31
|
+
|
|
3
32
|
## 0.3.55
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
|
@@ -283,8 +283,8 @@ var V65_COLUMNS = [
|
|
|
283
283
|
"start_at",
|
|
284
284
|
"end_at"
|
|
285
285
|
];
|
|
286
|
-
var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates"];
|
|
287
|
-
var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates"];
|
|
286
|
+
var EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, "moved_dates", "segments"];
|
|
287
|
+
var EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, "moved_dates", "segments"];
|
|
288
288
|
var snapshotOf = (cols, row) => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(",")})`;
|
|
289
289
|
var live = "(SELECT suppressed FROM webhook_control WHERE id=1)=0";
|
|
290
290
|
var quiet = "COALESCE((SELECT quiet FROM push_control WHERE id=1),0)";
|
|
@@ -331,6 +331,15 @@ var MOVED_DATES_MIGRATION = (contentVersionUpdate) => ({ version: 66, statements
|
|
|
331
331
|
`DROP TRIGGER event_changes_delete`,
|
|
332
332
|
...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates"], [...V65_COLUMNS, "moved_dates"])
|
|
333
333
|
] });
|
|
334
|
+
var SEGMENTS_MIGRATION = (contentVersionUpdate) => ({ version: 70, statements: [
|
|
335
|
+
`ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
|
|
336
|
+
`DROP TRIGGER content_version_events_update`,
|
|
337
|
+
contentVersionUpdate,
|
|
338
|
+
`DROP TRIGGER event_changes_insert`,
|
|
339
|
+
`DROP TRIGGER event_changes_update`,
|
|
340
|
+
`DROP TRIGGER event_changes_delete`,
|
|
341
|
+
...eventChangeTriggers([...V65_SNAPSHOT, "moved_dates", "segments"], [...V65_COLUMNS, "moved_dates", "segments"])
|
|
342
|
+
] });
|
|
334
343
|
|
|
335
344
|
// src/lib/maps/migration.ts
|
|
336
345
|
var PLACES_MIGRATION = { version: 43, statements: [
|
|
@@ -1351,12 +1360,15 @@ var MIGRATIONS = [
|
|
|
1351
1360
|
{ version: 68, statements: REQUEST_TABLE },
|
|
1352
1361
|
// two-way Google Calendar sync: the account, links, pairs, outgoing queue
|
|
1353
1362
|
// (triggers on events) and Recent changes. Not public, so no cache trigger.
|
|
1354
|
-
GCAL_MIGRATION
|
|
1363
|
+
GCAL_MIGRATION,
|
|
1364
|
+
// a series changed from one date on (CalEvent.segments): a column on the
|
|
1365
|
+
// row, so the triggers on events see it, like moved_dates
|
|
1366
|
+
SEGMENTS_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"])[2])
|
|
1355
1367
|
];
|
|
1356
1368
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1357
1369
|
...CONTENT_VERSION_COLUMNS,
|
|
1358
1370
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1359
|
-
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"],
|
|
1371
|
+
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
|
|
1360
1372
|
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
|
|
1361
1373
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1362
1374
|
};
|
package/docs/calendar.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
# Calendar: repeats, single dates, rooms and
|
|
1
|
+
# Calendar: repeats, single dates, rooms, clashes and requests
|
|
2
2
|
|
|
3
|
-
How events repeat, how one date of a series is changed,
|
|
3
|
+
How events repeat, how one date of a series is changed, how a series changes from a
|
|
4
|
+
date on, how events hold rooms, and how people ask for events.
|
|
4
5
|
Read it with `npx acms docs calendar`. The functions named here come from
|
|
5
6
|
`@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
|
|
6
7
|
API is in `docs/mobile-api-v1.md` in the framework repository.
|
|
@@ -147,6 +148,120 @@ Errors carry a `status`, a message safe to show people, and a `code`:
|
|
|
147
148
|
| 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
|
|
148
149
|
| 401, 404 | | who is acting |
|
|
149
150
|
|
|
151
|
+
## From this date on
|
|
152
|
+
|
|
153
|
+
When a class moves for good, say from Thursdays 6 PM to Wednesdays 5 PM, change the
|
|
154
|
+
series **from one of its dates on**. The dates before it stay exactly as they were. It
|
|
155
|
+
stays one event: the same page, slug, hosts, RSVPs and My events entry.
|
|
156
|
+
|
|
157
|
+
Ways to do it:
|
|
158
|
+
|
|
159
|
+
- the event editor's **Single dates** list: **From here on…** on a date (the new first
|
|
160
|
+
date, its times, how it repeats, its rooms and a note);
|
|
161
|
+
- dragging a date of a repeating event on the Schedule, then **This and following
|
|
162
|
+
dates**. **Undo** puts it back;
|
|
163
|
+
- a host's **Change from this date on…** on My events, which is a request staff apply
|
|
164
|
+
from Schedule → List → Requests;
|
|
165
|
+
- `changeFromDate` from site code or a calendar sync.
|
|
166
|
+
|
|
167
|
+
The new first date may come before the date you change from (from Thursday Jan 10 to
|
|
168
|
+
Wednesday Jan 9), but it has to come after the date before it. Leave the repeat as it
|
|
169
|
+
is and it carries on from the new day: "weekly on Thursday" moved to a Wednesday becomes
|
|
170
|
+
"weekly on Wednesday", a COUNT becomes what's left of it, and an end date stays.
|
|
171
|
+
|
|
172
|
+
### What follows the change
|
|
173
|
+
|
|
174
|
+
Each date from the change point on is matched to the new date nearest it, within half
|
|
175
|
+
a week (3½ days). Each new date takes at most one old date, closest pairs first. Then:
|
|
176
|
+
|
|
177
|
+
- its RSVPs move to that date, and each person gets the "new time" email in their own
|
|
178
|
+
language, with your note. Places in line move quietly;
|
|
179
|
+
- a cancelled date stays cancelled on its match, and a skipped one stays skipped;
|
|
180
|
+
- a moved date keeps its new time, as its match's date;
|
|
181
|
+
- a date with no match has its RSVPs cancelled (with the "cancelled" email). Its
|
|
182
|
+
cancellation, skip or move is dropped, and `changeFromDate` lists it in `dropped`.
|
|
183
|
+
|
|
184
|
+
So Thursday → Wednesday moves each week's sign-ups to that week's Wednesday, and twice
|
|
185
|
+
a week → once a week keeps the day that matches and cancels the other.
|
|
186
|
+
|
|
187
|
+
Changing again from a later date replaces every change after it, the way Google's
|
|
188
|
+
"this and following" works. Changing from the first date of an earlier change replaces
|
|
189
|
+
that change. A change that gives exactly the dates the rule before it gives is no
|
|
190
|
+
change at all: it's removed. **Undo** in the editor (`revertFromDate`) removes a change
|
|
191
|
+
the same way, with RSVPs following back.
|
|
192
|
+
|
|
193
|
+
### How it shows
|
|
194
|
+
|
|
195
|
+
- `/calendar`, the event page, cards, the TV display, the app API and room holds show
|
|
196
|
+
the new dates like any other date of the series. Its rooms and place hold from then.
|
|
197
|
+
- The event page's repeat line says the rule in effect and the change still to come:
|
|
198
|
+
"Weekly on Thursday · From Wed, Jan 9: Weekly on Wednesday". Once it has taken over,
|
|
199
|
+
just "Weekly on Wednesday".
|
|
200
|
+
- `calendar.ics` has one VEVENT per part, the way Google writes "this and following":
|
|
201
|
+
the event's own rule keeps its UID and stops with an UNTIL at the end of the day
|
|
202
|
+
before the change,
|
|
203
|
+
and each change is a recurring event of its own with UID `<id>-<from>@<host>` (`from`
|
|
204
|
+
as `YYYYMMDDTHHmm`), with its own cancelled and moved dates. Calendar apps that
|
|
205
|
+
subscribed show both parts.
|
|
206
|
+
- The "When an event changes" rule sends one notice: "Music class · From Wed, Jan 9:
|
|
207
|
+
Weekly on Wednesday, 5:00 PM" (`data.change: 'fromDate'`), or "Cancelled: every date
|
|
208
|
+
from …" (`'cancelledFrom'`).
|
|
209
|
+
- `/api/v1/events` and `/api/calendar/events` carry `segments: [{ from, start, end,
|
|
210
|
+
rrule, repeats, cancelled, note, location, rooms }]` on each event, and `repeats` is
|
|
211
|
+
the rule in effect now. Each occurrence has `location`: its own place (a moved
|
|
212
|
+
date's, or its segment's), `null` for the event's.
|
|
213
|
+
|
|
214
|
+
### Stored on the event row
|
|
215
|
+
|
|
216
|
+
Changes are `CalEvent.segments`, sorted by `from`, on the events row (so its triggers,
|
|
217
|
+
the event_changes outbox and the content version see them):
|
|
218
|
+
|
|
219
|
+
`{ from, start, end, rrule, roomIds?, location?, cancelled, note, by, at }`
|
|
220
|
+
|
|
221
|
+
- `from` is an occurrence key of the rule before it: that rule's dates from that day on
|
|
222
|
+
are gone (so retiming the earlier rule never leaves a second date that day);
|
|
223
|
+
- `start`/`end` are the new part's first date, wall-clock like the event's own;
|
|
224
|
+
- `rrule` is its rule (`''` = just that date);
|
|
225
|
+
- `roomIds`/`location` are its rooms and place (unset = the event's);
|
|
226
|
+
- `cancelled: true` cancels every date of it.
|
|
227
|
+
|
|
228
|
+
Its dates have their own keys, so `cancelledDates`, `movedDates` and `exdates` work on
|
|
229
|
+
them, and so do `moveOccurrence` and `cancelOccurrence`. `seriesParts(event)` splits a
|
|
230
|
+
series into its parts. Dragging the whole series on the Schedule moves every part.
|
|
231
|
+
|
|
232
|
+
Why one event with segments, and not a second event for the later part: hosts, RSVPs,
|
|
233
|
+
the page and slug, My events and the Classes card all stay with the one event, every
|
|
234
|
+
event write path already goes through the row's triggers, and calendar feeds can still
|
|
235
|
+
show the parts as separate series.
|
|
236
|
+
|
|
237
|
+
### Server API
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
changeFromDate(repo, who, eventId, fromKey, { start, end?, rrule?, roomIds?, location?, note? }, ctx, { quiet? })
|
|
241
|
+
→ { event, clashes, changed, rsvps: { moved, cancelled }, dropped }
|
|
242
|
+
cancelFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // every date from it on, struck through
|
|
243
|
+
restoreFromDate(repo, who, eventId, fromKey, ctx, { quiet? }) // undo that; RSVPs stay cancelled
|
|
244
|
+
revertFromDate(repo, who, eventId, from, ctx, { quiet? }) // remove a change: the rule before it carries on
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`who` is staff ("Create and edit content") or `GOOGLE_SYNC`. Room clashes at the new
|
|
248
|
+
times (180 days ahead) come back in `clashes` as warnings. Hosts get 403
|
|
249
|
+
`host-request`: they send `requestFromDate` instead (`npx acms docs server`). All are
|
|
250
|
+
idempotent (`changed: false`). `quiet` skips the member push; the RSVP emails still go.
|
|
251
|
+
A `fromKey` that a change took over from (no longer a date itself) means that change,
|
|
252
|
+
so a sync can retry safely: the same change again is `changed: false`, whoever sends it
|
|
253
|
+
and whenever. A change whose first date has started can't be replaced or undone
|
|
254
|
+
(`started`), and neither can a cancelled part be restored once its dates have started.
|
|
255
|
+
|
|
256
|
+
| status | code | when |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| 400 | `one-off`, `not-a-date`, `started`, `past`, `bad-time` | as for moves |
|
|
259
|
+
| 400 | `first-date` | changing from the series' very first date (edit the event instead) |
|
|
260
|
+
| 400 | `previous-date` | the new first date isn't after the date before the change |
|
|
261
|
+
| 400 | `bad-rule` | a rule the calendar can't read, or a first date that isn't one of its dates |
|
|
262
|
+
| 403 | `host-request` | a host: send it as a request |
|
|
263
|
+
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
264
|
+
|
|
150
265
|
## Rooms
|
|
151
266
|
|
|
152
267
|
Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
|
|
@@ -315,3 +430,75 @@ calendars in step, both ways, within a few minutes. The full guide is
|
|
|
315
430
|
until "from this date on" exists.
|
|
316
431
|
- Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
|
|
317
432
|
Recent changes with `googleSyncHistory(repo)`.
|
|
433
|
+
|
|
434
|
+
## Requests without an account
|
|
435
|
+
|
|
436
|
+
`acms({ requests: true })` lets anyone ask for a new event, or for a change to one,
|
|
437
|
+
without an account:
|
|
438
|
+
|
|
439
|
+
- from the website's form at `/request` (and `/es/request` and so on);
|
|
440
|
+
- from an app (`GET /api/v1/requests/options`, `POST /api/v1/requests`);
|
|
441
|
+
- from the site's own form, through `submitRequest`.
|
|
442
|
+
|
|
443
|
+
Staff answer them on **Calendar → Requests**. That one list also shows hosts'
|
|
444
|
+
requests from My events. `requests: { path: '/ask' }` moves the form, and
|
|
445
|
+
`requests: { page: false }` drops it but keeps the API and the admin. A site file at
|
|
446
|
+
the form's path replaces it.
|
|
447
|
+
|
|
448
|
+
Each request is a row of its own (`RequestRecord`), not an event:
|
|
449
|
+
|
|
450
|
+
- `kind`:
|
|
451
|
+
- `'new'`: `data` has `type`, `title`, `start`, `end`, `repeat` (`once`, `weekly`
|
|
452
|
+
or `other` with `repeatText`), `roomIds`, `roomsUnsure` and `people`;
|
|
453
|
+
- `'change'`: `eventId`, plus `key` for one date (`''` = every date).
|
|
454
|
+
- `state`: `pending`, `approved`, `declined` or `withdrawn`.
|
|
455
|
+
- `source`: `app`, `web` or `member`.
|
|
456
|
+
- `requester`: `{ name, email, phone, locale }`, for a person without an account.
|
|
457
|
+
- `note`: their own words to staff.
|
|
458
|
+
- `reply`: staff's last answer.
|
|
459
|
+
|
|
460
|
+
Hosts' date moves (C3/C4) use the same table with kinds `move` and `from-date`.
|
|
461
|
+
|
|
462
|
+
**Settings** (Calendar → Requests → Settings, in `settings.calendar.requests`):
|
|
463
|
+
|
|
464
|
+
- **Open or closed.** Closed forms and apps say so and show the office phone; they never
|
|
465
|
+
disappear.
|
|
466
|
+
- **Kinds.** Each has a label per language, plus the tag and calendar an approved
|
|
467
|
+
draft gets.
|
|
468
|
+
- **Reply time and notice**, per language.
|
|
469
|
+
- **Office phone.**
|
|
470
|
+
- **Who hears about new requests.** Their email, else `members.notify`, else the owners.
|
|
471
|
+
Optionally a push to their linked phones, without names.
|
|
472
|
+
- **A daily cap** on new requests (100), on top of 10 a minute and 20 a day per
|
|
473
|
+
address and 5 a day per email address.
|
|
474
|
+
- **How long requests are kept** (180 days) after their answer, or after they
|
|
475
|
+
arrived if nobody answered. The scheduler deletes them then.
|
|
476
|
+
|
|
477
|
+
**Rooms** people can choose are the ones marked **Anyone can check if it’s free**
|
|
478
|
+
(`requestable`). The form's live "free then" line uses `GET /api/v1/rooms/free`, which
|
|
479
|
+
says free or busy, never what or who.
|
|
480
|
+
|
|
481
|
+
**Answering** (needs **Publish content**; reading the list needs **Create and edit
|
|
482
|
+
content**):
|
|
483
|
+
|
|
484
|
+
- **Approve** a new request: makes a draft that's already filled in. It has the
|
|
485
|
+
title and times, `FREQ=WEEKLY` when asked, the kind's tag and calendar (else the
|
|
486
|
+
default calendar), and the rooms held while it's a draft. The draft opens in the
|
|
487
|
+
editor. The person's details stay on the request, never on the event.
|
|
488
|
+
- **Approve** a change: opens the event, or that date's Move… form
|
|
489
|
+
(`/admin/calendar/events/<id>?move=<key>`).
|
|
490
|
+
- Both email the person, with an optional message, unless you untick it.
|
|
491
|
+
- **Decline**, with an optional reason, and **Reply** email the person in the
|
|
492
|
+
language they asked in. A reply leaves the request as it is. **Delete as spam**
|
|
493
|
+
removes a request from the app or the website without emailing anyone.
|
|
494
|
+
- Each row shows what takes its rooms then. Weekly requests are checked for 12
|
|
495
|
+
weeks.
|
|
496
|
+
|
|
497
|
+
**Emails.** The person gets "We got your request" in their language. It never
|
|
498
|
+
repeats what they typed (the title or message), so the form can't be used to send
|
|
499
|
+
someone else a message, and each address gets at most 3 a day. Staff get every
|
|
500
|
+
detail in the site's main language, with Reply-To set to the person.
|
|
501
|
+
|
|
502
|
+
**Privacy.** The generated privacy notice gets an "Event requests" section, and
|
|
503
|
+
the store answers list event requests under contact info and user content.
|
|
504
|
+
Requests are in backups.
|
package/docs/mobile.md
CHANGED
|
@@ -114,7 +114,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
114
114
|
|
|
115
115
|
| Hook | For |
|
|
116
116
|
| --- | --- |
|
|
117
|
-
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
|
|
117
|
+
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`, `announcements`, `requests`) |
|
|
118
118
|
| `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
|
|
119
119
|
| `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
|
|
120
120
|
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
@@ -128,6 +128,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
128
128
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
129
129
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
130
130
|
| `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
|
|
131
|
+
| `useRequestOptions()` / `useSubmitRequest()` | event requests without an account (`features.requests`): the form's kinds, rooms, words and office phone (`open: false` = say so and show the phone, never hide the screen), and sending one with its own Idempotency-Key; `problem` and `field` say what to fix |
|
|
131
132
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
132
133
|
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
|
|
133
134
|
| `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
|
|
@@ -150,6 +151,7 @@ npx acms mobile recipe add news # posts list, article
|
|
|
150
151
|
npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
|
|
151
152
|
npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
|
|
152
153
|
npx acms mobile recipe add announcements # list with kind tabs and unread dots, detail, home banners (site needs announcements on)
|
|
154
|
+
npx acms mobile recipe add event-request # "Request an event" screen, a More row and an events-list link, no account (site needs requests on; expo-crypto, expo-secure-store)
|
|
153
155
|
```
|
|
154
156
|
|
|
155
157
|
Each prints the `npx expo install …` line for its packages: run it (doctor
|
package/docs/server.md
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
19
19
|
moveHostedDate, moveHostedDateBack, requestDateMove, listMyDateRequests, withdrawDateRequest,
|
|
20
20
|
approveDateRequest, declineDateRequest,
|
|
21
|
+
changeFromDate, cancelFromDate, restoreFromDate, revertFromDate, requestFromDate,
|
|
21
22
|
} from '@acms-subir/core/server';
|
|
22
23
|
```
|
|
23
24
|
|
|
@@ -38,6 +39,10 @@ cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteC
|
|
|
38
39
|
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
39
40
|
moveOccurrence(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
40
41
|
moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<MoveResult>
|
|
42
|
+
changeFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, input: ChangeFromInput, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
43
|
+
cancelFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
44
|
+
restoreFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, fromKey: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
45
|
+
revertFromDate(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, eventId: string, from: string, ctx: WriteContext, opts?: { quiet?: boolean }): Promise<ChangeFromResult>
|
|
41
46
|
eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
|
|
42
47
|
roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
|
|
43
48
|
|
|
@@ -61,11 +66,22 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
|
|
|
61
66
|
moveHostedDate(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext): Promise<HostMoveResult>
|
|
62
67
|
moveHostedDateBack(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<HostMoveResult>
|
|
63
68
|
requestDateMove(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput | null, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
69
|
+
requestFromDate(repo: Repo, who: ServerUser | null, eventId: string, fromKey: string, input: Omit<ChangeFromInput, 'location'>, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
64
70
|
listMyDateRequests(repo: Repo, who: ServerUser | null, eventId?: string): Promise<RequestRecord[]>
|
|
65
71
|
withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Promise<void>
|
|
66
|
-
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved
|
|
72
|
+
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved?: MoveResult; changed?: ChangeFromResult }>
|
|
67
73
|
declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
|
|
68
74
|
|
|
75
|
+
// requests without an account (acms({ requests })): asking needs no account; listing needs
|
|
76
|
+
// content.edit, answering content.publish
|
|
77
|
+
requestOptions(repo: Repo, locale?: string): Promise<{ open: boolean; types: { id: string; label: string }[]; rooms: { slug: string; name: string; color: string }[]; replyTime: string; notice: string; phone: string; timezone: string; events: { slug: string; title: string; dates: string[] }[] }>
|
|
78
|
+
submitRequest(repo: Repo, input: RequestInput, opts: { key: string; address: string; context: WriteContext }): Promise<{ id: string; created: boolean }>
|
|
79
|
+
listRequests(repo: Repo, who: ServerUser | null, q?: { states?: RequestState[]; kinds?: RequestKind[]; sources?: RequestSource[]; eventId?: string; userId?: string; order?: 'oldest' | 'newest' | 'decided'; limit?: number; offset?: number }): Promise<RequestRecord[]>
|
|
80
|
+
approveRequest(repo: Repo, who: ServerUser | null, id: string, ctx: WriteContext, seen?: number, opts?: { message?: string; email?: boolean }): Promise<{ request: RequestRecord; event: CalEvent | null; next: string }>
|
|
81
|
+
declineRequest(repo: Repo, who: ServerUser | null, id: string, reason: string, ctx: WriteContext, seen?: number, opts?: { email?: boolean }): Promise<RequestRecord>
|
|
82
|
+
deleteRequest(repo: Repo, who: ServerUser | null, id: string): Promise<void>
|
|
83
|
+
replyToRequest(repo: Repo, who: ServerUser | null, id: string, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
84
|
+
|
|
69
85
|
type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
|
|
70
86
|
interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
|
|
71
87
|
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[]; moved?: { from: { start: number; end: number }; note: string; location?: EventLocation } }
|
|
@@ -74,6 +90,9 @@ interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
|
|
|
74
90
|
type HostMoveResult = { moved: MoveResult } | { request: RequestRecord }
|
|
75
91
|
interface RequestRecord { id: string; kind: 'new' | 'change' | 'move' | 'from-date'; state: 'pending' | 'approved' | 'declined' | 'withdrawn'; source: 'member' | 'web' | 'app'; userId: string; requester: { name: string; email: string; phone: string; locale: string } | null; eventId: string; key: string; data: Record<string, unknown>; note: string; reply: string; reviewedBy: string; reviewedAt: number; createdAt: number; updatedAt: number }
|
|
76
92
|
interface MoveRequestData { start: string; end: string; roomIds?: string[]; note: string; back?: boolean }
|
|
93
|
+
interface FromDateRequestData { start: string; end: string; rrule: string; roomIds?: string[]; note: string }
|
|
94
|
+
interface ChangeFromInput { start: string; end?: string; rrule?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
|
|
95
|
+
interface ChangeFromResult { event: CalEvent; clashes: BusySlot[]; changed: boolean; rsvps: { moved: number; cancelled: number }; dropped: string[] }
|
|
77
96
|
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
78
97
|
interface EventClash { hold: Hold; other: Hold }
|
|
79
98
|
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
@@ -177,6 +196,29 @@ full table is in `npx acms docs calendar`).
|
|
|
177
196
|
`npx acms docs calendar` has the details: what follows a moved date, how it shows,
|
|
178
197
|
and the error statuses.
|
|
179
198
|
|
|
199
|
+
## From this date on
|
|
200
|
+
|
|
201
|
+
- **`changeFromDate(repo, who, eventId, fromKey, input, ctx, opts?)`**: changes a repeating
|
|
202
|
+
event from one of its dates (`fromKey`, an occurrence key) on. `input.start` is the new
|
|
203
|
+
first date, wall-clock in the event's timezone. It may come before `fromKey` but has to
|
|
204
|
+
come after the date before it. `end` defaults to the dates' length, `rrule` to the rule
|
|
205
|
+
in effect on the new weekday (`''` = just that date), and `roomIds` / `location` to
|
|
206
|
+
what's in effect there. Dates before it stay exactly as they were. RSVPs, cancelled,
|
|
207
|
+
skipped and moved dates follow to the new date nearest them within half a week. RSVPs
|
|
208
|
+
with no new date are cancelled, and `dropped` lists the other dates that had none.
|
|
209
|
+
`clashes` lists what else holds the new rooms over the next 180 days.
|
|
210
|
+
- **`cancelFromDate(...)`** / **`restoreFromDate(...)`**: cancel every date from one on
|
|
211
|
+
(they stay listed, struck through) and undo that.
|
|
212
|
+
- **`revertFromDate(repo, who, eventId, from, ctx)`**: removes the change that took over
|
|
213
|
+
at `from`.
|
|
214
|
+
|
|
215
|
+
All four check `who`: staff ("Create and edit content") or `GOOGLE_SYNC`. A host gets 403
|
|
216
|
+
with code `host-request` and sends **`requestFromDate`** instead (rooms among the event's
|
|
217
|
+
own and the requestable ones). Staff answer it with `approveDateRequest` /
|
|
218
|
+
`declineDateRequest`. All of these are idempotent (`changed: false`), and a `fromKey` that a
|
|
219
|
+
change took over from means that change. The changes are stored on the event as
|
|
220
|
+
`segments`. `npx acms docs calendar` has the rules and the error codes.
|
|
221
|
+
|
|
180
222
|
## Rooms
|
|
181
223
|
|
|
182
224
|
Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
|
|
@@ -460,3 +502,44 @@ if (Astro.request.method === 'POST') {
|
|
|
460
502
|
|
|
461
503
|
`/classes/mine` would list `listMyEvents(repo, me)` and show `e.request?.state`
|
|
462
504
|
(`'pending'`, `'declined'` with `e.request.reply`, or no request once approved).
|
|
505
|
+
|
|
506
|
+
## Requests without an account
|
|
507
|
+
|
|
508
|
+
With `acms({ requests })`, anyone can ask for an event or a change to one (see `npx
|
|
509
|
+
acms docs calendar`). The built-in form at `/request` and `POST /api/v1/requests` use
|
|
510
|
+
these functions, and so can a site's own form:
|
|
511
|
+
|
|
512
|
+
- **`requestOptions(repo, locale)`**: whether requests are open, the kinds, requestable
|
|
513
|
+
rooms and the events a change can be about (with their next dates), and the site's
|
|
514
|
+
reply time, notice and phone, in that language.
|
|
515
|
+
- **`submitRequest(repo, input, { key, address, context })`**: `input` is the same as
|
|
516
|
+
the API body (`kind: 'new'` or `'change'`, `name`, `email`…). `key` is a random UUID v4
|
|
517
|
+
your page keeps for this form, so a double submit stores one request. `address` is
|
|
518
|
+
`Astro.clientAddress`, for the 10-a-minute limit. Refusals throw with a `status` and a
|
|
519
|
+
`code` (`RequestRefusal`: `title`, `start`, `past`, `room`, `email`, `closed`,
|
|
520
|
+
`busy`…). Show your own words for each code.
|
|
521
|
+
- **`listRequests(repo, who, q)`**: for staff (`content.edit`). `states: ['pending']`
|
|
522
|
+
comes oldest first.
|
|
523
|
+
- **`approveRequest`**, **`declineRequest`** and **`replyToRequest`** answer one (`content.publish`) and
|
|
524
|
+
email the person in their language (approve and decline take `email: false` to stay quiet).
|
|
525
|
+
**`deleteRequest`** removes spam from the app or the website without an email. Pass `seen` (the `updatedAt` you showed) and an
|
|
526
|
+
edited request answers 409 instead.
|
|
527
|
+
|
|
528
|
+
```astro
|
|
529
|
+
---
|
|
530
|
+
// src/pages/ask.astro: the site's own request form
|
|
531
|
+
import { getRepo, submitRequest } from '@acms-subir/core/server';
|
|
532
|
+
const repo = await getRepo(Astro.locals);
|
|
533
|
+
let done = false, problem = '';
|
|
534
|
+
if (Astro.request.method === 'POST') {
|
|
535
|
+
const f = await Astro.request.formData();
|
|
536
|
+
try {
|
|
537
|
+
await submitRequest(repo, {
|
|
538
|
+
kind: 'new', title: String(f.get('title')), start: `${f.get('date')}T${f.get('from')}`, end: String(f.get('to')),
|
|
539
|
+
name: String(f.get('name')), email: String(f.get('email')),
|
|
540
|
+
}, { key: String(f.get('key')), address: Astro.clientAddress, context: { url: Astro.url, locals: Astro.locals } });
|
|
541
|
+
done = true;
|
|
542
|
+
} catch (e) { problem = (e as { code?: string }).code ?? 'failed'; }
|
|
543
|
+
}
|
|
544
|
+
---
|
|
545
|
+
```
|
package/integration.mjs
CHANGED
|
@@ -448,6 +448,20 @@ function resolveMembers(members, calendar) {
|
|
|
448
448
|
// the built-in pages are "My events": they need the calendar
|
|
449
449
|
return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()), hostMoves };
|
|
450
450
|
}
|
|
451
|
+
function resolveRequests(requests, calendar, reserved) {
|
|
452
|
+
if (requests === undefined || requests === false) return { enabled: false, path: '/request', page: true };
|
|
453
|
+
if (requests === true) requests = {};
|
|
454
|
+
if (!requests || typeof requests !== 'object' || Array.isArray(requests)) throw new Error('acms: requests must be true, false or an object');
|
|
455
|
+
unknownKeys('requests', requests, ['path', 'page']);
|
|
456
|
+
const path = requests.path ?? '/request';
|
|
457
|
+
if (typeof path !== 'string' || !/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(path)) {
|
|
458
|
+
throw new Error(`acms: requests.path must start with "/" and use lowercase letters, digits, and hyphens (no trailing slash) — got "${path}"`);
|
|
459
|
+
}
|
|
460
|
+
if (RESERVED_BASES.has(path) || reserved.includes(path)) throw new Error(`acms: requests.path "${path}" collides with another route — pick another path`);
|
|
461
|
+
if (requests.page !== undefined && typeof requests.page !== 'boolean') throw new Error('acms: requests.page must be true or false');
|
|
462
|
+
// requests become calendar events: they need the calendar
|
|
463
|
+
return { enabled: calendar.enabled, path, page: requests.page ?? true };
|
|
464
|
+
}
|
|
451
465
|
function resolveAnnouncements(announcements, reserved) {
|
|
452
466
|
if (announcements === undefined || announcements === false) return { enabled: false, base: '/announcements', pages: true, topics: {} };
|
|
453
467
|
if (announcements === true) announcements = {};
|
|
@@ -713,6 +727,14 @@ function acmsVitePlugin(userConfig) {
|
|
|
713
727
|
* `topics` picks the push topic each kind starts with in the composer, e.g.
|
|
714
728
|
* `{ registration: 'registrations', help: 'help' }` (otherwise a topic named
|
|
715
729
|
* after the kind, such as `events` or `help`, else everyone).
|
|
730
|
+
* @param {boolean | {path?: string, page?: boolean}} [options.requests]
|
|
731
|
+
* Event requests without an account (off by default; needs the calendar):
|
|
732
|
+
* anyone asks for a new event or a change to one from the app
|
|
733
|
+
* (GET /api/v1/requests/options, POST /api/v1/requests) or the website's
|
|
734
|
+
* form (default path '/request', also under each language's prefix). Staff
|
|
735
|
+
* answer them on Calendar → Requests, where approving makes a filled-in
|
|
736
|
+
* draft event. `page: false` keeps the API and the admin but drops the
|
|
737
|
+
* built-in form, so a site can build its own on the same server API.
|
|
716
738
|
* @param {string[]} [options.signedRoutes]
|
|
717
739
|
* The site's own routes that receive posts from another server and check
|
|
718
740
|
* its signature themselves (an SMS or payment provider's webhook), as route
|
|
@@ -741,6 +763,7 @@ export default function acms(options = {}) {
|
|
|
741
763
|
if (clash) throw new Error(`acms: members.base "${members.base}" collides with another configured path — pick another`);
|
|
742
764
|
}
|
|
743
765
|
const announcements = resolveAnnouncements(options.announcements, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : [])]);
|
|
766
|
+
const requests = resolveRequests(options.requests, calendar, [...contentBases.keys(), ...(calendar.enabled ? [calendar.base, calendar.eventsBase, calendar.bookBase] : []), ...(members.enabled ? [members.base] : []), ...(announcements.enabled ? [announcements.base] : [])]);
|
|
744
767
|
const roles = resolveRoles(options.roles, members);
|
|
745
768
|
const permissions = resolvePermissions(options.permissions, roles);
|
|
746
769
|
const maps = resolveMaps(options.maps);
|
|
@@ -771,6 +794,7 @@ export default function acms(options = {}) {
|
|
|
771
794
|
permissions,
|
|
772
795
|
members,
|
|
773
796
|
announcements,
|
|
797
|
+
requests,
|
|
774
798
|
};
|
|
775
799
|
const routes = [...ROUTES,
|
|
776
800
|
['/api/v1/site', 'api/v1/site.ts'],
|
|
@@ -942,6 +966,16 @@ export default function acms(options = {}) {
|
|
|
942
966
|
);
|
|
943
967
|
if (announcements.pages) routes.push([announcements.base, 'announcements/index.astro'], [`${announcements.base}/[id]`, 'announcements/[id].astro']);
|
|
944
968
|
}
|
|
969
|
+
// event requests from people without an account (acms({ requests })): the
|
|
970
|
+
// app API, Calendar → Requests and, unless page: false, the public form
|
|
971
|
+
if (requests.enabled) {
|
|
972
|
+
routes.push(
|
|
973
|
+
['/admin/calendar/requests', 'admin/calendar/requests.astro'],
|
|
974
|
+
['/api/v1/requests', 'api/v1/requests/index.ts'],
|
|
975
|
+
['/api/v1/requests/options', 'api/v1/requests/options.ts'],
|
|
976
|
+
);
|
|
977
|
+
if (requests.page) routes.push([requests.path, 'request/index.astro']);
|
|
978
|
+
}
|
|
945
979
|
// "My events" for signed-in members (acms({ members })): thin pages over
|
|
946
980
|
// lib/calendar/requests.ts. A site file at any of these paths replaces that
|
|
947
981
|
// page; `members: false` drops them all (members.enabled needs the calendar).
|
|
@@ -78,6 +78,7 @@ npx acms mobile recipe add news # posts list and article
|
|
|
78
78
|
npx acms mobile recipe add booking # resources → slots → form → confirmation
|
|
79
79
|
npx acms mobile recipe add push # notifications: turn on, topics, taps (site needs push on)
|
|
80
80
|
npx acms mobile recipe add announcements # list, detail, home banners (site needs announcements on)
|
|
81
|
+
npx acms mobile recipe add event-request # Request an event: screen, More row, events-list link (site needs requests on)
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
Each recipe prints the extra Expo packages to install (`npx expo install ...`).
|
|
@@ -28,7 +28,7 @@ site) and `.env.local` (written by `acms mobile dev`, not committed).
|
|
|
28
28
|
## Add screens
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements
|
|
31
|
+
npx acms mobile recipe add events # also: news, booking, push, rsvp, announcements, event-request
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Build your own with the hooks from `@acms-subir/native`. `AGENTS.md` has the
|