@acms-subir/core 0.3.53 → 0.3.54
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 +12 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/package.json +1 -1
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/virtual.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.54
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 031e463: Hosts move single dates of their events from My events, and My events speaks the visitor's language.
|
|
8
|
+
|
|
9
|
+
- **Move… on each date.** Each upcoming date of a repeating event a host runs now has **Move…**, next to Cancel and Restore. It takes the date, the start and end, the date's rooms and a note for people who come. Hosts pick from the event's own rooms and the ones marked **Anyone can check if it’s free**. A date the host moved also gets **Move back**. People who signed up are emailed the new time, as with staff moves.
|
|
10
|
+
- **`acms({ members: { hostMoves } })`.** With `'direct'` (the default), a move applies at once while the rooms are free, and staff get the "Date moved" email. If a room is taken, the host sees which one and can **Send as a request** instead. With `'approve'`, every host move is a request.
|
|
11
|
+
- **Requests.** Host requests wait in Schedule → List → **Requests** under "Date changes from hosts", with what else holds the room at the new time. **Approve** makes the move (a clash only warns) and records it as the host's. **Decline…** keeps the date and sends the host your note. Staff get a "Date move to review" email, and the host hears the answer. The requests are stored on their own rows, in a new `event_requests` table (migration 68). Requests from people without an account will share that table.
|
|
12
|
+
- **Server API.** `@acms-subir/core/server` adds `moveHostedDate`, `moveHostedDateBack`, `requestDateMove`, `listMyDateRequests`, `withdrawDateRequest`, `approveDateRequest` and `declineDateRequest`. Member errors now carry a `code`.
|
|
13
|
+
- **My events under `/es`.** The pages, buttons, notices, the request form and their errors follow the address, so `/es/members/events` is in Spanish, including the event's translated title and the dates. The words exist in English, Spanish, French, German and Portuguese.
|
|
14
|
+
|
|
3
15
|
## 0.3.53
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -447,6 +447,20 @@ var SCREENS_MIGRATION = {
|
|
|
447
447
|
]
|
|
448
448
|
};
|
|
449
449
|
|
|
450
|
+
// src/lib/calendar/request-migration.ts
|
|
451
|
+
var REQUEST_TABLE = [
|
|
452
|
+
`CREATE TABLE event_requests (
|
|
453
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, state TEXT NOT NULL, source TEXT NOT NULL,
|
|
454
|
+
user_id TEXT NOT NULL DEFAULT '', requester TEXT,
|
|
455
|
+
event_id TEXT NOT NULL DEFAULT '', occurrence_key TEXT NOT NULL DEFAULT '',
|
|
456
|
+
data TEXT NOT NULL DEFAULT '{}', note TEXT NOT NULL DEFAULT '', reply TEXT NOT NULL DEFAULT '',
|
|
457
|
+
reviewed_by TEXT NOT NULL DEFAULT '', reviewed_at INTEGER NOT NULL DEFAULT 0,
|
|
458
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
459
|
+
)`,
|
|
460
|
+
`CREATE INDEX event_requests_state ON event_requests (state, created_at)`,
|
|
461
|
+
`CREATE INDEX event_requests_event ON event_requests (event_id)`
|
|
462
|
+
];
|
|
463
|
+
|
|
450
464
|
// src/lib/repo/migrations.ts
|
|
451
465
|
var CONTENT_VERSION_COLUMNS = {
|
|
452
466
|
settings: [
|
|
@@ -1281,7 +1295,10 @@ var MIGRATIONS = [
|
|
|
1281
1295
|
// row, so the triggers on events (content version, event_changes) see a move
|
|
1282
1296
|
MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2]),
|
|
1283
1297
|
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
1284
|
-
SCREENS_MIGRATION
|
|
1298
|
+
SCREENS_MIGRATION,
|
|
1299
|
+
// calendar requests on their own rows (RequestRecord): new events and
|
|
1300
|
+
// changes asked for without an account, and hosts' date moves
|
|
1301
|
+
{ version: 68, statements: REQUEST_TABLE }
|
|
1285
1302
|
];
|
|
1286
1303
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1287
1304
|
...CONTENT_VERSION_COLUMNS,
|
package/docs/calendar.md
CHANGED
|
@@ -99,8 +99,9 @@ and `moveOccurrenceBack(repo, who, eventId, key, ctx, { quiet? })` check who is
|
|
|
99
99
|
- **`GOOGLE_SYNC`** is a calendar sync acting with staff rights. A clash only warns,
|
|
100
100
|
since the change already happened in Google.
|
|
101
101
|
- **A host of the event** may move its dates while the rooms are free; a taken room
|
|
102
|
-
refuses the move (409).
|
|
103
|
-
|
|
102
|
+
refuses the move (409). They pick rooms among the event's own and the rooms marked
|
|
103
|
+
**Anyone can check if it’s free** (403 `room-not-allowed` otherwise). Staff
|
|
104
|
+
(`members.notify`, else the owners) are emailed about each move a host makes.
|
|
104
105
|
- Anyone else gets 404, and nobody signed in gets 401.
|
|
105
106
|
|
|
106
107
|
`end` defaults to the date's own length. `roomIds`, `location` and `note` replace the
|
|
@@ -114,6 +115,22 @@ write, webhook, push or email. A sync can retry, or re-apply what it already app
|
|
|
114
115
|
safely. Cancelling a date that's already cancelled, or restoring one that isn't, is a
|
|
115
116
|
no-op too, even after the date has passed.
|
|
116
117
|
|
|
118
|
+
### Hosts on My events
|
|
119
|
+
|
|
120
|
+
Hosts move dates from **My events** (`/members/events`): each upcoming date has
|
|
121
|
+
**Move…** next to **Cancel**, and a date they moved has **Move back**.
|
|
122
|
+
`acms({ members: { hostMoves } })` decides how a host's move applies:
|
|
123
|
+
|
|
124
|
+
- `'direct'` (the default): at once while the date's rooms are free, as above. When a
|
|
125
|
+
room is taken the host sees which one and can **Send as a request** instead.
|
|
126
|
+
- `'approve'`: every host move, and every move back, is a request.
|
|
127
|
+
|
|
128
|
+
Requests wait in Schedule → List → **Requests** under "Date changes from hosts", with
|
|
129
|
+
what else holds the rooms at the new time. **Approve** makes the move as you (a clash
|
|
130
|
+
only warns) and records it as the host's. **Decline…** keeps the date and sends the host
|
|
131
|
+
your note. Both answers email the host. The server API is `moveHostedDate`,
|
|
132
|
+
`requestDateMove` and `approveDateRequest` in `npx acms docs server`.
|
|
133
|
+
|
|
117
134
|
Errors carry a `status`, a message safe to show people, and a `code`:
|
|
118
135
|
|
|
119
136
|
| status | code | when |
|
|
@@ -127,6 +144,7 @@ Errors carry a `status`, a message safe to show people, and a `code`:
|
|
|
127
144
|
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
128
145
|
| 409 | `cancelled-date` | the date is cancelled (restore it first) |
|
|
129
146
|
| 409 | `room-taken` | a host moving into a taken room |
|
|
147
|
+
| 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
|
|
130
148
|
| 401, 404 | | who is acting |
|
|
131
149
|
|
|
132
150
|
## Rooms
|
package/docs/server.md
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
eventRoomClashes, roomBusy,
|
|
17
17
|
listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
|
|
18
18
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
19
|
+
moveHostedDate, moveHostedDateBack, requestDateMove, listMyDateRequests, withdrawDateRequest,
|
|
20
|
+
approveDateRequest, declineDateRequest,
|
|
19
21
|
} from '@acms-subir/core/server';
|
|
20
22
|
```
|
|
21
23
|
|
|
@@ -49,11 +51,23 @@ approveEventRequest(repo: Repo, who: ServerUser | null, eventId: string, ctx: Wr
|
|
|
49
51
|
declineEventRequest(repo: Repo, who: ServerUser | null, eventId: string, reply: string, ctx: WriteContext, seen?: number): Promise<CalEvent>
|
|
50
52
|
setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, cancelled: boolean, ctx: WriteContext): Promise<CalEvent>
|
|
51
53
|
|
|
54
|
+
// hosts moving dates (acms({ members: { hostMoves } }))
|
|
55
|
+
moveHostedDate(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext): Promise<HostMoveResult>
|
|
56
|
+
moveHostedDateBack(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<HostMoveResult>
|
|
57
|
+
requestDateMove(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput | null, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
58
|
+
listMyDateRequests(repo: Repo, who: ServerUser | null, eventId?: string): Promise<RequestRecord[]>
|
|
59
|
+
withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Promise<void>
|
|
60
|
+
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }>
|
|
61
|
+
declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
|
|
62
|
+
|
|
52
63
|
type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
|
|
53
64
|
interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
|
|
54
65
|
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 } }
|
|
55
66
|
interface MoveInput { start: string; end?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
|
|
56
67
|
interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
|
|
68
|
+
type HostMoveResult = { moved: MoveResult } | { request: RequestRecord }
|
|
69
|
+
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 }
|
|
70
|
+
interface MoveRequestData { start: string; end: string; roomIds?: string[]; note: string; back?: boolean }
|
|
57
71
|
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
58
72
|
interface EventClash { hold: Hold; other: Hold }
|
|
59
73
|
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
@@ -145,6 +159,9 @@ error.
|
|
|
145
159
|
emailed. `opts.quiet` skips the app notice and that staff email.
|
|
146
160
|
- **`moveOccurrenceBack(...)`**: puts it back where the series has it. Idempotent.
|
|
147
161
|
|
|
162
|
+
A host picks rooms only among the ones the event already uses and the rooms marked
|
|
163
|
+
**Anyone can check if it’s free** (403, code `room-not-allowed`, otherwise).
|
|
164
|
+
|
|
148
165
|
Moving a date to where it already is returns `changed: false` without writing or
|
|
149
166
|
sending anything, so retries are safe. Errors carry `status` and `code` (`one-off`,
|
|
150
167
|
`not-a-date`, `started`, `past`, `bad-time`, `cancelled-date`, `room-taken`…; the
|
|
@@ -300,6 +317,42 @@ The functions take the person acting (`currentUser(ctx)`) and check them on ever
|
|
|
300
317
|
(`true`) or restores (`false`) one date of an event they host, at once. No permission
|
|
301
318
|
needed beyond being in `hostIds`. Same rules and errors as `cancelOccurrence`.
|
|
302
319
|
|
|
320
|
+
### Hosts moving dates
|
|
321
|
+
|
|
322
|
+
A host moves one date of an event they host from My events, or from your own page with
|
|
323
|
+
these. Being in `hostIds` is enough; no permission is needed. Staff who host an event
|
|
324
|
+
always move at once.
|
|
325
|
+
|
|
326
|
+
- **`moveHostedDate(repo, who, eventId, key, input, ctx)`**: with
|
|
327
|
+
`acms({ members: { hostMoves: 'direct' } })` (the default) the move applies at once
|
|
328
|
+
while the date's rooms are free: `{ moved }`, and staff get the "Date moved" email. A
|
|
329
|
+
taken room answers 409 with code `room-taken`, and the same move can then go to staff
|
|
330
|
+
with `requestDateMove`. With `hostMoves: 'approve'` every host move becomes a request:
|
|
331
|
+
`{ request }`.
|
|
332
|
+
- **`moveHostedDateBack(repo, who, eventId, key, ctx)`**: puts a moved date back, at once
|
|
333
|
+
or as a request, the same way.
|
|
334
|
+
- **`requestDateMove(repo, who, eventId, key, input, message, ctx)`**: asks staff to move
|
|
335
|
+
the date (`input`), or to move it back (`null`). It's checked like the move itself, but
|
|
336
|
+
a taken room is allowed, since staff decide. `message` is for staff only. Asking again
|
|
337
|
+
about the same date replaces the waiting request. Staff (`members.notify`, else the
|
|
338
|
+
owners) get a "Date move to review" email.
|
|
339
|
+
- **`listMyDateRequests(repo, who, eventId?)`**, **`withdrawDateRequest(repo, who, requestId)`**:
|
|
340
|
+
the host's own requests, newest first, and taking one back.
|
|
341
|
+
- **`approveDateRequest(repo, who, requestId, ctx, seen?)`**: needs **Publish content**.
|
|
342
|
+
The move runs as the approver, so room clashes are warnings in `moved.clashes`. It is
|
|
343
|
+
recorded as the host's move (`movedDates[key].by`), so they can move it back later. The
|
|
344
|
+
host is emailed. Pass `seen`, the request's `updatedAt` you showed, to get 409 if the
|
|
345
|
+
host changed it since.
|
|
346
|
+
- **`declineDateRequest(repo, who, requestId, reply, ctx, seen?)`**: the date stays as it
|
|
347
|
+
is, and the host is emailed `reply`.
|
|
348
|
+
|
|
349
|
+
These requests are `RequestRecord`s with `kind: 'move'` and `data: MoveRequestData`,
|
|
350
|
+
stored on their own rows (the same table as requests from people without an account).
|
|
351
|
+
Approved, declined and withdrawn ones stay listed until they're deleted after the
|
|
352
|
+
retention period. Errors carry a `code` as well as a `status`, so your page can word
|
|
353
|
+
them in its own language. The built-in pages use the codes in
|
|
354
|
+
`lib/calendar/member-words.ts` (English, Spanish, French, German and Portuguese).
|
|
355
|
+
|
|
303
356
|
`EventRequestInput` is what a member may fill in. Staff can change anything else in the
|
|
304
357
|
admin.
|
|
305
358
|
|
|
@@ -340,7 +393,14 @@ With the calendar on, `acms({ members })` adds "My events" in the site's theme:
|
|
|
340
393
|
| `/members/events/new` | the request form |
|
|
341
394
|
| `/members/events/[id]` | one of their events and its request |
|
|
342
395
|
|
|
343
|
-
`/members` itself redirects to `/members/events`.
|
|
396
|
+
`/members` itself redirects to `/members/events`. With more than one site language the
|
|
397
|
+
pages follow the address, like the rest of the site: `/es/members/events` is My events in
|
|
398
|
+
Spanish (words, dates and the event's translated title). Its buttons and errors exist in
|
|
399
|
+
English, Spanish, French, German and Portuguese; other languages show English.
|
|
400
|
+
|
|
401
|
+
Each upcoming date of a repeating event a host runs has **Cancel** (or **Restore**),
|
|
402
|
+
**Move…** (date, times, rooms and a note for people who come) and, for a date they
|
|
403
|
+
moved, **Move back**. A taken room shows the clash with **Send as a request**.
|
|
344
404
|
|
|
345
405
|
`members: { base: '/club' }` moves them. A file at the same path in your `src/pages/`
|
|
346
406
|
replaces that one page. `members: false` removes them all; the functions above keep
|
package/integration.mjs
CHANGED
|
@@ -428,10 +428,13 @@ const unknownKeys = (label, value, known) => {
|
|
|
428
428
|
const isSitePath = (p) => typeof p === 'string' && p.startsWith('/') && !p.startsWith('//') && !/[\\\s\u0000-\u001f]/.test(p);
|
|
429
429
|
const isAdminPath = (p) => /^\/(admin|api|_actions)(\/|$|\?|#)/.test(p);
|
|
430
430
|
function resolveMembers(members, calendar) {
|
|
431
|
-
if (members === false) return { enabled: false, base: '/members', notify: [] };
|
|
431
|
+
if (members === false) return { enabled: false, base: '/members', notify: [], hostMoves: 'direct' };
|
|
432
432
|
if (members === undefined || members === true) members = {};
|
|
433
433
|
if (!members || typeof members !== 'object' || Array.isArray(members)) throw new Error('acms: members must be an object (or false to turn off the built-in member pages)');
|
|
434
|
-
unknownKeys('members', members, ['base', 'notify']);
|
|
434
|
+
unknownKeys('members', members, ['base', 'notify', 'hostMoves']);
|
|
435
|
+
// a host moving one date: at once while its rooms are free, or always as a request for staff
|
|
436
|
+
const hostMoves = members.hostMoves ?? 'direct';
|
|
437
|
+
if (hostMoves !== 'direct' && hostMoves !== 'approve') throw new Error(`acms: members.hostMoves must be 'direct' or 'approve' — got ${JSON.stringify(hostMoves)}`);
|
|
435
438
|
// who hears about new event requests (default: the owners)
|
|
436
439
|
const notify = members.notify ?? [];
|
|
437
440
|
if (!Array.isArray(notify) || notify.length > 20 || notify.some((e) => typeof e !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) {
|
|
@@ -443,7 +446,7 @@ function resolveMembers(members, calendar) {
|
|
|
443
446
|
}
|
|
444
447
|
if (RESERVED_BASES.has(base) || base === '/account') throw new Error(`acms: members.base "${base}" collides with a built-in route — pick another path`);
|
|
445
448
|
// the built-in pages are "My events": they need the calendar
|
|
446
|
-
return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()) };
|
|
449
|
+
return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()), hostMoves };
|
|
447
450
|
}
|
|
448
451
|
function resolveAnnouncements(announcements, reserved) {
|
|
449
452
|
if (announcements === undefined || announcements === false) return { enabled: false, base: '/announcements', pages: true, topics: {} };
|
|
@@ -689,13 +692,16 @@ function acmsVitePlugin(userConfig) {
|
|
|
689
692
|
* 'Cancel any class', defaults: ['administrator'] }`. They show on Team →
|
|
690
693
|
* Roles under "This site" and answer `currentUser().can(key)`. Owners hold
|
|
691
694
|
* every permission; `defaults` lists the other roles that start with it.
|
|
692
|
-
* @param {false | {base?: string, notify?: string[]}} [options.members]
|
|
695
|
+
* @param {false | {base?: string, notify?: string[], hostMoves?: 'direct' | 'approve'}} [options.members]
|
|
693
696
|
* Built-in "My events" pages for signed-in members (default base
|
|
694
697
|
* '/members', needs the calendar): the events they host, their event
|
|
695
698
|
* requests and a request form. `false` turns the pages off (the server API
|
|
696
699
|
* in @acms-subir/core/server still works, so a site can build its own); a
|
|
697
700
|
* site file at the same path replaces any one page. `notify` lists who is
|
|
698
|
-
* emailed about new event requests (default: the owners).
|
|
701
|
+
* emailed about new event requests (default: the owners). `hostMoves`:
|
|
702
|
+
* 'direct' (default) lets a host move one date at once while its rooms are
|
|
703
|
+
* free (a taken room can be sent as a request instead); 'approve' sends
|
|
704
|
+
* every host move to staff as a request.
|
|
699
705
|
* @param {boolean | {base?: string, pages?: boolean, topics?: {event?: string, registration?: string, help?: string, info?: string}}} [options.announcements]
|
|
700
706
|
* Announcements (off by default): Admin → Announcements stores messages for
|
|
701
707
|
* members (event, registration, help needed, good to know, urgent) in each
|
package/package.json
CHANGED
package/src/lib/api.ts
CHANGED
|
@@ -8,6 +8,9 @@ export class ApiError extends Error {
|
|
|
8
8
|
/** retryAfter: seconds a 429 should wait (mobile API Retry-After; default 60) */
|
|
9
9
|
constructor(public status: number, message: string, public retryAfter?: number) { super(message); }
|
|
10
10
|
}
|
|
11
|
+
/** An ApiError with a `code` saying why, for pages that word errors themselves (the member pages' words look them up). */
|
|
12
|
+
export const coded = (status: number, code: string, message: string, retryAfter?: number): ApiError & { code: string } =>
|
|
13
|
+
Object.assign(new ApiError(status, message, retryAfter), { code });
|
|
11
14
|
export const json = (body: unknown, status = 200) => Response.json(body, {
|
|
12
15
|
status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
|
|
13
16
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
3
|
import { AnnouncementRecord } from '../announcements/types';
|
|
4
|
-
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
|
|
4
|
+
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place, RequestRecord } from '../schema';
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
|
|
7
7
|
// backups from before booking approval modes: a link that didn't confirm on
|
|
@@ -29,6 +29,7 @@ export const BackupContent = z.object({
|
|
|
29
29
|
// optional (no version bump): older cores restore such a backup and skip them
|
|
30
30
|
announcements: z.array(AnnouncementRecord).optional(),
|
|
31
31
|
screens: z.array(ScreenRecord).optional(),
|
|
32
|
+
eventRequests: z.array(RequestRecord).optional(),
|
|
32
33
|
}).superRefine((data, ctx) => {
|
|
33
34
|
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
34
35
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
@@ -48,12 +49,13 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
48
49
|
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
49
50
|
]);
|
|
50
51
|
const [announcements, screens] = await Promise.all([repo.announcements.list(-1), repo.screens.list()]);
|
|
52
|
+
const eventRequests = await repo.requests.list({ order: 'oldest', limit: -1 });
|
|
51
53
|
return {
|
|
52
54
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
53
55
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
54
56
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
55
57
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
56
|
-
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
|
|
58
|
+
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements, eventRequests,
|
|
57
59
|
screens: screens.map(s => ({ ...s, key: '', lastSeenAt: null })),
|
|
58
60
|
};
|
|
59
61
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Hosts moving single dates of the events they host (C3), on top of
|
|
2
|
+
// lib/calendar/moves.ts. acms({ members: { hostMoves } }) decides how:
|
|
3
|
+
// - 'direct' (default): the move applies at once while the date's rooms are
|
|
4
|
+
// free, and staff get the "Date moved" email. A taken room refuses it
|
|
5
|
+
// (409 room-taken); the host can send that same move as a request instead;
|
|
6
|
+
// - 'approve': every host move, and every move back, is a request.
|
|
7
|
+
// Requests are rows in event_requests (repo.requests, RequestRecord kind
|
|
8
|
+
// 'move', data: MoveRequestData) that staff who can publish approve (the move
|
|
9
|
+
// runs as them, recorded as the host's) or decline with a note.
|
|
10
|
+
//
|
|
11
|
+
// Like requests.ts, everything checks who is acting, so the built-in My
|
|
12
|
+
// events page and a site's own pages share the rules. Hosts need no
|
|
13
|
+
// permission beyond being in hostIds; staff ("Create and edit content") who
|
|
14
|
+
// host an event always move at once.
|
|
15
|
+
import type { ContentRepository } from '../repo/types';
|
|
16
|
+
import type { CalEvent, RequestRecord } from '../schema';
|
|
17
|
+
import { ApiError, coded } from '../api';
|
|
18
|
+
import type { SaveHooks } from '../content-service';
|
|
19
|
+
import { GOOGLE_SYNC, checkMove, moveOccurrence, moveOccurrenceBack, type MoveInput, type MoveResult } from './moves';
|
|
20
|
+
import { eventTimeZone, findOccurrence, type Occurrence } from './events';
|
|
21
|
+
import { MEMBERS } from './members-config';
|
|
22
|
+
import { siteLocale } from '../dates';
|
|
23
|
+
import { kickEmail } from '../email/service';
|
|
24
|
+
import { waitUntilFrom } from '../webhooks';
|
|
25
|
+
import type { BusySlot } from '../booking/holds';
|
|
26
|
+
|
|
27
|
+
/** who is acting: currentUser() from @acms-subir/core/server fits */
|
|
28
|
+
export interface HostActor { id: string; can(permission: string): boolean }
|
|
29
|
+
|
|
30
|
+
/** RequestRecord.data of a 'move' request: the move to make (MoveInput), or `back` to put the date back */
|
|
31
|
+
export interface MoveRequestData {
|
|
32
|
+
start: string;
|
|
33
|
+
end: string;
|
|
34
|
+
roomIds?: string[];
|
|
35
|
+
/** the public note shown with the moved date */
|
|
36
|
+
note: string;
|
|
37
|
+
/** put a moved date back where the series has it (start and end are '') */
|
|
38
|
+
back?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a host's move did: moved at once (`moved`), or became a request (`request`). */
|
|
42
|
+
export type HostMoveResult = { moved: MoveResult; request?: undefined } | { request: RequestRecord; moved?: undefined };
|
|
43
|
+
|
|
44
|
+
const DAY = 86_400_000;
|
|
45
|
+
const LIMITS = { note: 1000, pending: 20 };
|
|
46
|
+
const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim().slice(0, max);
|
|
47
|
+
|
|
48
|
+
const staff = (who: HostActor) => who.can('content.edit');
|
|
49
|
+
|
|
50
|
+
async function hosted(repo: ContentRepository, who: HostActor | null, id: string): Promise<CalEvent> {
|
|
51
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
52
|
+
const e = await repo.getEvent(id);
|
|
53
|
+
if (!e || e.status === 'archived' || (!e.hostIds.includes(who.id) && !staff(who))) throw coded(404, 'not-found', 'Event not found.');
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A host moves one date of an event they host. With hostMoves 'direct' (or
|
|
59
|
+
* when they're staff) it applies at once and refuses a taken room (409,
|
|
60
|
+
* code room-taken); with 'approve' it's sent to staff as a request.
|
|
61
|
+
*/
|
|
62
|
+
export async function moveHostedDate(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks): Promise<HostMoveResult> {
|
|
63
|
+
await hosted(repo, who, id);
|
|
64
|
+
if (MEMBERS.hostMoves === 'approve' && !staff(who!)) return { request: await requestDateMove(repo, who, id, key, input, '', hooks) };
|
|
65
|
+
const moved = await moveOccurrence(repo, who, id, key, input, hooks);
|
|
66
|
+
await settleWaiting(repo, who!, id, key);
|
|
67
|
+
return { moved };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// a move the host made at once replaces any move of that date they asked staff for
|
|
71
|
+
async function settleWaiting(repo: ContentRepository, who: HostActor, eventId: string, key: string) {
|
|
72
|
+
for (const r of await repo.requests.list({ eventId, userId: who.id, kinds: ['move'], states: ['pending'], limit: -1 })) {
|
|
73
|
+
if (r.key === key) await repo.requests.update(r.id, { state: 'withdrawn', reviewedAt: Date.now() }, { state: 'pending' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A host puts back a date they moved: at once ('direct') or as a request ('approve'). */
|
|
78
|
+
export async function moveHostedDateBack(repo: ContentRepository, who: HostActor | null, id: string, key: string, hooks: SaveHooks): Promise<HostMoveResult> {
|
|
79
|
+
await hosted(repo, who, id);
|
|
80
|
+
if (MEMBERS.hostMoves === 'approve' && !staff(who!)) return { request: await requestDateMove(repo, who, id, key, null, '', hooks) };
|
|
81
|
+
const moved = await moveOccurrenceBack(repo, who, id, key, hooks);
|
|
82
|
+
await settleWaiting(repo, who!, id, key);
|
|
83
|
+
return { moved };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A host asks staff to move one date (`input`), or to put it back (null):
|
|
88
|
+
* checked like the move itself, then saved as a pending request. A taken
|
|
89
|
+
* room is fine here: staff decide. Asking again about the same date replaces
|
|
90
|
+
* the waiting request. `message` is for staff only.
|
|
91
|
+
*/
|
|
92
|
+
export async function requestDateMove(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: MoveInput | null, message: string, hooks: SaveHooks): Promise<RequestRecord> {
|
|
93
|
+
const e = await hosted(repo, who, id);
|
|
94
|
+
if (!e.hostIds.includes(who!.id)) throw coded(404, 'not-found', 'Event not found.');
|
|
95
|
+
// as the host: the same refusals a move gets, except the taken room (a request carries no place of its own)
|
|
96
|
+
const check = await checkMove(repo, who, id, key, input ? { ...input, location: undefined } : null);
|
|
97
|
+
if (!check.changed) throw coded(400, 'unchanged', input ? 'That date is already there.' : 'That date isn’t moved.');
|
|
98
|
+
if (!await repo.email.rateLimit(`event-request:${who!.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of requests in an hour. Try again later.', 3600);
|
|
99
|
+
// moving it to where the series has it is asking to move it back
|
|
100
|
+
const data: MoveRequestData = input && !check.back
|
|
101
|
+
? { start: check.start!, end: check.end!, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: check.note ?? '' }
|
|
102
|
+
: { start: '', end: '', note: '', back: true };
|
|
103
|
+
const note = text(message, LIMITS.note);
|
|
104
|
+
const waiting = (await repo.requests.list({ eventId: id, userId: who!.id, kinds: ['move'], states: ['pending'], limit: -1 })).find((r) => r.key === key);
|
|
105
|
+
let saved: RequestRecord | null;
|
|
106
|
+
if (waiting) saved = await repo.requests.update(waiting.id, { data: { ...data }, note }, { state: 'pending' });
|
|
107
|
+
else {
|
|
108
|
+
if (await repo.requests.count({ userId: who!.id, states: ['pending'] }) >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
saved = await repo.requests.create({ id: crypto.randomUUID(), kind: 'move', state: 'pending', source: 'member', userId: who!.id, requester: null,
|
|
111
|
+
eventId: id, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
|
|
112
|
+
}
|
|
113
|
+
if (!saved) throw coded(409, 'answered', 'That request was just answered. Look at My events again.');
|
|
114
|
+
await alertStaff(repo, saved, hooks);
|
|
115
|
+
return saved;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A host's own move requests (all states, newest first): `eventId` narrows to one event. */
|
|
119
|
+
export async function myDateRequests(repo: ContentRepository, userId: string, eventId?: string): Promise<RequestRecord[]> {
|
|
120
|
+
return repo.requests.list({ userId, kinds: ['move', 'from-date'], ...(eventId ? { eventId } : {}), limit: 200 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A host takes back a waiting request of theirs. */
|
|
124
|
+
export async function withdrawDateRequest(repo: ContentRepository, who: HostActor | null, requestId: string): Promise<void> {
|
|
125
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
126
|
+
const r = await repo.requests.get(requestId);
|
|
127
|
+
if (!r || r.userId !== who.id || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
|
|
128
|
+
if (r.state !== 'pending') return;
|
|
129
|
+
await repo.requests.update(requestId, { state: 'withdrawn', reviewedAt: Date.now() }, { state: 'pending' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A host clears an answered request from My events (it's deleted with the others later anyway). */
|
|
133
|
+
export async function dismissDateRequest(repo: ContentRepository, who: HostActor | null, requestId: string): Promise<void> {
|
|
134
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
135
|
+
const r = await repo.requests.get(requestId);
|
|
136
|
+
if (!r || r.userId !== who.id || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
|
|
137
|
+
if (r.state === 'declined' || r.state === 'approved') await repo.requests.delete(requestId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What else takes the rooms of a waiting move request at its new time (names included: staff only). */
|
|
141
|
+
export async function dateRequestClashes(repo: ContentRepository, r: RequestRecord): Promise<BusySlot[]> {
|
|
142
|
+
if (r.kind !== 'move' || r.state !== 'pending') return [];
|
|
143
|
+
const d = r.data as MoveRequestData;
|
|
144
|
+
try {
|
|
145
|
+
return (await checkMove(repo, GOOGLE_SYNC, r.eventId, r.key, d.back ? null : { start: d.start, end: d.end, roomIds: d.roomIds, note: d.note })).clashes;
|
|
146
|
+
} catch { return []; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- answering (staff who can publish) ---------------------------------------
|
|
150
|
+
|
|
151
|
+
function requireApprover(who: HostActor | null): asserts who is HostActor {
|
|
152
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
153
|
+
if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function answerable(repo: ContentRepository, id: string, seen?: number): Promise<RequestRecord> {
|
|
157
|
+
const r = await repo.requests.get(id);
|
|
158
|
+
if (!r || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
|
|
159
|
+
if (r.state !== 'pending') throw coded(409, 'answered', 'This request was already answered or withdrawn.');
|
|
160
|
+
if (seen !== undefined && r.updatedAt !== seen) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
|
|
161
|
+
return r;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Staff approve a host's move request: the move runs as them (room clashes warn), recorded as the host's, and the host is emailed. Pass `seen` (the request's updatedAt they reviewed). */
|
|
165
|
+
export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }> {
|
|
166
|
+
requireApprover(who);
|
|
167
|
+
const r = await answerable(repo, id, seen);
|
|
168
|
+
if (r.kind !== 'move') throw coded(400, 'unavailable', 'Open the event to apply this change.');
|
|
169
|
+
const d = r.data as MoveRequestData;
|
|
170
|
+
// worded before it moves: "where it was → where it goes"
|
|
171
|
+
const when = await dateRequestWhen(repo, r);
|
|
172
|
+
const request = await claim(repo, r, who);
|
|
173
|
+
let moved: MoveResult;
|
|
174
|
+
try {
|
|
175
|
+
moved = d.back
|
|
176
|
+
? await moveOccurrenceBack(repo, who, r.eventId, r.key, hooks)
|
|
177
|
+
: await moveOccurrence(repo, who, r.eventId, r.key, { start: d.start, end: d.end, roomIds: d.roomIds, note: d.note }, hooks, { by: r.userId });
|
|
178
|
+
} catch (err) { await release(repo, id); throw err; }
|
|
179
|
+
await tellHost(repo, request, 'move-approved', '', when, hooks);
|
|
180
|
+
return { request, moved };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Approving claims the request first (only as staff saw it, and only once),
|
|
184
|
+
// so two approvals at once, or a host's edit meanwhile, can't both go
|
|
185
|
+
// through; a change that then fails puts it back to waiting.
|
|
186
|
+
async function claim(repo: ContentRepository, r: RequestRecord, who: HostActor): Promise<RequestRecord> {
|
|
187
|
+
const claimed = await repo.requests.update(r.id, { state: 'approved', reviewedBy: who.id, reviewedAt: Date.now() }, { state: 'pending', updatedAt: r.updatedAt });
|
|
188
|
+
if (!claimed) throw coded(409, 'changed', 'This request was just answered or changed. Look at it again, then answer.');
|
|
189
|
+
return claimed;
|
|
190
|
+
}
|
|
191
|
+
const release = (repo: ContentRepository, id: string) => repo.requests.update(id, { state: 'pending', reviewedBy: '', reviewedAt: 0 }, { state: 'approved' });
|
|
192
|
+
|
|
193
|
+
/** Staff decline a host's request, with an optional note the host reads. */
|
|
194
|
+
export async function declineDateRequest(repo: ContentRepository, who: HostActor | null, id: string, reply: string, hooks: SaveHooks, seen?: number): Promise<RequestRecord> {
|
|
195
|
+
requireApprover(who);
|
|
196
|
+
const r = await answerable(repo, id, seen);
|
|
197
|
+
const answer = text(reply, LIMITS.note);
|
|
198
|
+
const request = await repo.requests.update(id, { state: 'declined', reply: answer, reviewedBy: who.id, reviewedAt: Date.now() }, { state: 'pending', updatedAt: r.updatedAt });
|
|
199
|
+
if (!request) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
|
|
200
|
+
await tellHost(repo, request, r.kind === 'move' ? 'move-declined' : 'from-date-declined', answer, await dateRequestWhen(repo, r), hooks);
|
|
201
|
+
return request;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- email ------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Where a move request's date is now and where it would go ("Thu, Jan 10, 6:00 PM", "Wed, Jan 9, 5:00 PM"), in the site's language; '' when it isn't a date any more. */
|
|
207
|
+
export async function dateRequestTimes(repo: ContentRepository, r: RequestRecord): Promise<{ from: string; to: string }> {
|
|
208
|
+
const e = await repo.getEvent(r.eventId);
|
|
209
|
+
if (!e) return { from: '', to: '' };
|
|
210
|
+
const settings = await repo.getSettings();
|
|
211
|
+
const d = r.data as MoveRequestData;
|
|
212
|
+
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
213
|
+
const fmt = (o: Occurrence | null) => {
|
|
214
|
+
if (!o) return '';
|
|
215
|
+
try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
|
|
216
|
+
catch { return new Date(o.start).toISOString(); }
|
|
217
|
+
};
|
|
218
|
+
const to = d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
|
|
219
|
+
: findOccurrence({ ...e, movedDates: { ...e.movedDates, [r.key]: { start: d.start, end: d.end, note: d.note, by: '', at: 0, ...(d.roomIds ? { roomIds: d.roomIds } : {}) } } }, r.key, settings);
|
|
220
|
+
return { from: fmt(findOccurrence(e, r.key, settings)), to: fmt(to) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** "Thu, Jan 10, 6:00 PM → Wed, Jan 9, 5:00 PM" for a move request (dateRequestTimes) */
|
|
224
|
+
export async function dateRequestWhen(repo: ContentRepository, r: RequestRecord): Promise<string> {
|
|
225
|
+
const t = await dateRequestTimes(repo, r);
|
|
226
|
+
return t.from && t.to ? `${t.from} → ${t.to}` : t.from || t.to;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// staff: "Date move to review" (members.notify, else the owners)
|
|
230
|
+
async function alertStaff(repo: ContentRepository, r: RequestRecord, hooks: SaveHooks) {
|
|
231
|
+
const to = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
|
|
232
|
+
await queue(repo, r, to, r.kind === 'move' ? 'staff-move-request' : 'staff-from-date-request', '', await dateRequestWhen(repo, r), hooks);
|
|
233
|
+
}
|
|
234
|
+
async function tellHost(repo: ContentRepository, r: RequestRecord, kind: string, reply: string, when: string, hooks: SaveHooks) {
|
|
235
|
+
const host = r.userId ? await repo.getUser(r.userId) : null;
|
|
236
|
+
if (host?.email) await queue(repo, r, [host.email], kind, reply, when, hooks);
|
|
237
|
+
}
|
|
238
|
+
// `when` is worded now: by the time it's sent the date may have moved
|
|
239
|
+
async function queue(repo: ContentRepository, r: RequestRecord, to: string[], kind: string, reply: string, when: string, hooks: SaveHooks) {
|
|
240
|
+
if (!to.length) return;
|
|
241
|
+
const version = (await repo.email.config().catch(() => null))?.version ?? 0;
|
|
242
|
+
for (const recipient of to) {
|
|
243
|
+
await repo.email.enqueue({ dedupe: `date-request:${r.id}:${r.updatedAt}:${kind}:${recipient}`, kind: 'event-request', recipient,
|
|
244
|
+
payload: JSON.stringify({ eventId: r.eventId, requestId: r.id, kind, at: r.updatedAt, reply, when }), configVersion: version, expiresAt: Date.now() + 7 * DAY });
|
|
245
|
+
}
|
|
246
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
|
|
247
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { CalEvent, EventRequest } from '../schema';
|
|
1
|
+
import type { CalEvent, EventRequest, RequestRecord } from '../schema';
|
|
2
2
|
import type { EventRequestInput } from './requests';
|
|
3
3
|
import { CALENDAR, type EventFieldConfig } from './config';
|
|
4
4
|
import { describeRepeat } from './rrule';
|
|
5
|
+
import { memberStrings, type MemberStrings } from './member-words';
|
|
5
6
|
|
|
6
7
|
// The event request form on the built-in "My events" pages, as pure
|
|
7
8
|
// functions: the HTML form's fields → EventRequestInput (requests.ts checks
|
|
@@ -30,7 +31,7 @@ export interface MemberEventForm {
|
|
|
30
31
|
note: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/** the request form's "Repeats" choices */
|
|
34
|
+
/** the request form's "Repeats" choices (labels in English; repeatChoices words them in the page's language) */
|
|
34
35
|
export const REPEAT_CHOICES: readonly { value: string; label: string }[] = [
|
|
35
36
|
{ value: '', label: 'Does not repeat' },
|
|
36
37
|
{ value: 'FREQ=DAILY', label: 'Every day' },
|
|
@@ -38,6 +39,7 @@ export const REPEAT_CHOICES: readonly { value: string; label: string }[] = [
|
|
|
38
39
|
{ value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Every 2 weeks' },
|
|
39
40
|
{ value: 'FREQ=MONTHLY', label: 'Every month' },
|
|
40
41
|
];
|
|
42
|
+
const CHOICE_WORD = ['none', 'daily', 'weekly', 'biweekly', 'monthly'] as const;
|
|
41
43
|
|
|
42
44
|
/** the patterns requests.ts accepts from members (kept in step with its REPEAT) */
|
|
43
45
|
const MEMBER_REPEAT = /^FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=[1-9]\d?)?(;COUNT=[1-9]\d{0,2})?$/;
|
|
@@ -134,19 +136,23 @@ export function eventFormValues(e: FormEvent, opts: { description: string; calen
|
|
|
134
136
|
/**
|
|
135
137
|
* The "Repeats" select for a value: the standard choices, plus the event's
|
|
136
138
|
* own pattern (e.g. every 3 weeks, or one only the admin can set, like weekly
|
|
137
|
-
* on two days), selected, so sending the form unchanged keeps it.
|
|
139
|
+
* on two days), selected, so sending the form unchanged keeps it. `lang`
|
|
140
|
+
* words them (memberStrings; English by default).
|
|
138
141
|
*/
|
|
139
|
-
export function repeatChoices(current: string, start: string): { options: { value: string; label: string }[]; value: string; note: string } {
|
|
142
|
+
export function repeatChoices(current: string, start: string, lang?: string): { options: { value: string; label: string }[]; value: string; note: string } {
|
|
143
|
+
const w = memberStrings(lang).form;
|
|
140
144
|
const rule = current.trim().replace(/^RRULE:/i, '').toUpperCase();
|
|
141
|
-
const options = [
|
|
145
|
+
const options = REPEAT_CHOICES.map((o, i) => ({ value: o.value, label: w.repeat[CHOICE_WORD[i]] }));
|
|
142
146
|
if (!rule || options.some((o) => o.value === rule)) return { options, value: rule, note: '' };
|
|
143
|
-
if (MEMBER_REPEAT.test(rule)) return { options: [...options, { value: rule, label: describeRepeat(rule, start) }], value: rule, note: '' };
|
|
147
|
+
if (MEMBER_REPEAT.test(rule)) return { options: [...options, { value: rule, label: describeRepeat(rule, start, undefined, lang) }], value: rule, note: '' };
|
|
144
148
|
// set up by the team: members can keep it (requests.ts accepts an unchanged rule) or pick another
|
|
145
|
-
return { options: [...options, { value: rule, label:
|
|
149
|
+
return { options: [...options, { value: rule, label: w.asSetByTeam.replace('{repeat}', describeRepeat(rule, start, undefined, lang)) }], value: rule, note: '' };
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
/** how a request reads on "My events" */
|
|
149
|
-
export function requestLabel(r: Pick<EventRequest, 'kind' | 'state'>): string {
|
|
150
|
-
if (r.kind === 'changes') return r.state === 'pending' ?
|
|
151
|
-
return r.state === 'pending' ?
|
|
152
|
+
/** how a request reads on "My events": a member's request on its event (EventRequest), or a host's date request (RequestRecord 'move' / 'from-date') */
|
|
153
|
+
export function requestLabel(r: Pick<EventRequest, 'kind' | 'state'> | Pick<RequestRecord, 'kind' | 'state'>, w: MemberStrings = memberStrings('en')): string {
|
|
154
|
+
if (r.kind === 'changes') return r.state === 'pending' ? w.changesWaiting : w.changesDeclined;
|
|
155
|
+
if (r.kind === 'move') return r.state === 'pending' ? w.moveWaiting : r.state === 'approved' ? w.moveApproved : w.moveDeclined;
|
|
156
|
+
if (r.kind === 'from-date') return r.state === 'pending' ? w.fromDateWaiting : r.state === 'approved' ? w.fromDateApproved : w.fromDateDeclined;
|
|
157
|
+
return r.state === 'pending' ? w.waiting : w.declined;
|
|
152
158
|
}
|