@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
|
@@ -2,36 +2,72 @@
|
|
|
2
2
|
import Layout from '../_Layout.astro';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { currentUser } from '../../../server';
|
|
5
|
-
import {
|
|
5
|
+
import { coded } from '../../../lib/api';
|
|
6
6
|
import { readForm } from '../../../lib/forms/http';
|
|
7
7
|
import { MEMBERS, memberEventsPath, hostedEvents, withdrawEventRequest, dismissEventRequest, setHostedOccurrence } from '../../../lib/calendar/requests';
|
|
8
|
+
import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, withdrawDateRequest, type MoveRequestData } from '../../../lib/calendar/host-requests';
|
|
8
9
|
import { eventUrl } from '../../../lib/calendar/config';
|
|
9
|
-
import { eventIsPublic, eventTimeZone, expandEvents, featuredOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
|
|
10
|
+
import { eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
|
|
11
|
+
import { tzParts } from '../../../lib/calendar/ics';
|
|
10
12
|
import { describeRepeat } from '../../../lib/calendar/rrule';
|
|
11
13
|
import { requestLabel } from '../../../lib/calendar/member-form';
|
|
12
14
|
import { mergeEventDraft } from '../../../lib/drafts';
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
15
|
+
import { fill } from '../../../lib/live/words';
|
|
16
|
+
import { localizeDoc } from '../../../lib/translations';
|
|
17
|
+
import type { CalEvent, RequestRecord } from '../../../lib/schema';
|
|
18
|
+
import type { MoveInput } from '../../../lib/calendar/moves';
|
|
19
|
+
import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
|
|
16
20
|
|
|
17
21
|
// "My events": the person's requests, the events they host (each upcoming
|
|
18
|
-
// date with
|
|
19
|
-
// live in lib/calendar/requests.ts; this page
|
|
20
|
-
//
|
|
22
|
+
// date with Cancel / Restore, Move… and Move back) and their past events.
|
|
23
|
+
// The rules live in lib/calendar/requests.ts and host-requests.ts; this page
|
|
24
|
+
// only shows them and posts. It follows the page's language (/es/…). A site
|
|
25
|
+
// replaces it with its own file at the same path.
|
|
21
26
|
if (!MEMBERS.enabled) return notFound();
|
|
22
27
|
const user = await currentUser(Astro);
|
|
23
|
-
if (!user) return signInRedirect(Astro.url);
|
|
28
|
+
if (!user) return signInRedirect(Astro.url, Astro.locals);
|
|
24
29
|
keepPrivate(Astro.response.headers);
|
|
25
30
|
const repo = await getRepo(Astro.locals);
|
|
26
|
-
const
|
|
31
|
+
const settings = await repo.getSettings();
|
|
32
|
+
const { w, lang, locale: pageLocale, here, dateLocale } = memberLanguage(Astro, settings);
|
|
33
|
+
const home = here(memberEventsPath());
|
|
27
34
|
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
35
|
+
// a host's moves wait for staff with members.hostMoves 'approve' (staff who host move at once)
|
|
36
|
+
const movesNeedApproval = MEMBERS.hostMoves === 'approve' && !user.can('content.edit');
|
|
37
|
+
|
|
38
|
+
/** the move form's fields → MoveInput (times are wall-clock in the event's zone) */
|
|
39
|
+
function moveInput(form: FormData, e: CalEvent | null): MoveInput {
|
|
40
|
+
const s = (name: string) => { const v = form.get(name); return typeof v === 'string' ? v.trim() : ''; };
|
|
41
|
+
const date = s('date');
|
|
42
|
+
// an end at or before the start runs past midnight
|
|
43
|
+
const endDay = s('endTime') && s('endTime') <= s('startTime') ? nextDay(date) : date;
|
|
44
|
+
const input: MoveInput = e?.allDay
|
|
45
|
+
? { start: date }
|
|
46
|
+
: { start: `${date}T${s('startTime')}`, ...(s('endTime') ? { end: `${endDay}T${s('endTime')}` } : {}) };
|
|
47
|
+
// the rooms fieldset was on the form: what's ticked is the date's rooms
|
|
48
|
+
if (form.get('rooms') === '1') input.roomIds = form.getAll('roomIds').map(String).filter(Boolean);
|
|
49
|
+
if (form.get('note') !== null) input.note = s('note');
|
|
50
|
+
return input;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nextDay(day: string): string {
|
|
54
|
+
const d = new Date(`${day}T00:00:00Z`);
|
|
55
|
+
if (Number.isNaN(+d)) return day;
|
|
56
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
57
|
+
return d.toISOString().slice(0, 10);
|
|
58
|
+
}
|
|
28
59
|
|
|
29
60
|
let error = '';
|
|
61
|
+
// a move refused because a room is taken: shown under its date, with "Send as a request"
|
|
62
|
+
let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
|
|
63
|
+
// the date whose move form was posted (re-opened with what was typed when it failed)
|
|
64
|
+
let posted: { id: string; key: string; form: FormData } | null = null;
|
|
30
65
|
if (Astro.request.method === 'POST') {
|
|
66
|
+
const form = await readForm(Astro.request);
|
|
67
|
+
const op = String(form.get('op') ?? '');
|
|
68
|
+
const id = String(form.get('id') ?? '');
|
|
69
|
+
const key = String(form.get('key') ?? '');
|
|
31
70
|
try {
|
|
32
|
-
const form = await readForm(Astro.request);
|
|
33
|
-
const op = String(form.get('op') ?? '');
|
|
34
|
-
const id = String(form.get('id') ?? '');
|
|
35
71
|
if (op === 'withdraw') {
|
|
36
72
|
await withdrawEventRequest(repo, user, id, hooks);
|
|
37
73
|
return redirectTo(`${home}?withdrawn=1`);
|
|
@@ -41,59 +77,118 @@ if (Astro.request.method === 'POST') {
|
|
|
41
77
|
return redirectTo(home);
|
|
42
78
|
}
|
|
43
79
|
if (op === 'cancel-date' || op === 'restore-date') {
|
|
44
|
-
await setHostedOccurrence(repo, user, id,
|
|
80
|
+
await setHostedOccurrence(repo, user, id, key, op === 'cancel-date', hooks);
|
|
45
81
|
return redirectTo(`${home}?${op === 'cancel-date' ? 'cancelled' : 'restored'}=1`);
|
|
46
82
|
}
|
|
47
|
-
|
|
83
|
+
if (op === 'move-date' || op === 'request-move') {
|
|
84
|
+
posted = { id, key, form };
|
|
85
|
+
const input = moveInput(form, await repo.getEvent(id));
|
|
86
|
+
if (op === 'request-move') {
|
|
87
|
+
await requestDateMove(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
|
|
88
|
+
return redirectTo(`${home}?moveSent=1`);
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const r = await moveHostedDate(repo, user, id, key, input, hooks);
|
|
92
|
+
return redirectTo(`${home}?${r.request ? 'moveSent' : 'moved'}=1`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if ((err as { code?: string }).code !== 'room-taken') throw err;
|
|
95
|
+
clash = { id, key, back: false, message: failure(err, w, lang).message, input };
|
|
96
|
+
Astro.response.status = 409;
|
|
97
|
+
}
|
|
98
|
+
} else if (op === 'move-back' || op === 'request-back') {
|
|
99
|
+
if (op === 'request-back') {
|
|
100
|
+
await requestDateMove(repo, user, id, key, null, String(form.get('message') ?? ''), hooks);
|
|
101
|
+
return redirectTo(`${home}?moveSent=1`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const r = await moveHostedDateBack(repo, user, id, key, hooks);
|
|
105
|
+
return redirectTo(`${home}?${r.request ? 'moveSent' : 'movedBack'}=1`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if ((err as { code?: string }).code !== 'room-taken') throw err;
|
|
108
|
+
clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
|
|
109
|
+
Astro.response.status = 409;
|
|
110
|
+
}
|
|
111
|
+
} else if (op === 'withdraw-request') {
|
|
112
|
+
await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
|
|
113
|
+
return redirectTo(`${home}?withdrawn=1`);
|
|
114
|
+
} else if (op === 'dismiss-request') {
|
|
115
|
+
await dismissDateRequest(repo, user, String(form.get('request') ?? ''));
|
|
116
|
+
return redirectTo(home);
|
|
117
|
+
} else throw coded(400, 'unavailable', 'That action isn’t available here.');
|
|
48
118
|
} catch (err) {
|
|
49
|
-
const f = failure(err);
|
|
119
|
+
const f = failure(err, w, lang);
|
|
50
120
|
error = f.message;
|
|
51
121
|
Astro.response.status = f.status;
|
|
52
122
|
}
|
|
53
123
|
}
|
|
54
124
|
|
|
55
|
-
const FLASH: [
|
|
56
|
-
|
|
57
|
-
['updated', 'Your request is updated and waiting for approval.'],
|
|
58
|
-
['changes', 'Your changes are with the team. The event stays as it is until they approve them.'],
|
|
59
|
-
['withdrawn', 'Request withdrawn.'],
|
|
60
|
-
['cancelled', 'That date is cancelled. The calendar shows it as cancelled.'],
|
|
61
|
-
['restored', 'That date is back on.'],
|
|
62
|
-
];
|
|
63
|
-
const flash = error ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
|
|
125
|
+
const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent'] as const).map((k) => [k, w.flash[k]]);
|
|
126
|
+
const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
|
|
64
127
|
|
|
65
128
|
const DAY = 86_400_000;
|
|
66
129
|
const now = Date.now();
|
|
67
|
-
const [
|
|
130
|
+
const [calendars, mine, dateRequests, resources] = await Promise.all([repo.listCalendars(), hostedEvents(repo, user.id), myDateRequests(repo, user.id), repo.listResources()]);
|
|
68
131
|
const calById = new Map(calendars.map((c) => [c.id, c]));
|
|
69
132
|
const siteTz = settings.timezone || 'UTC';
|
|
70
|
-
const locale = siteLocale(settings);
|
|
71
133
|
const canRequest = user.can('events.request');
|
|
72
134
|
const isLive = (e: CalEvent) => e.status === 'published' || e.status === 'scheduled';
|
|
135
|
+
// titles and places in the page's language when the event has them
|
|
136
|
+
const shown = (e: CalEvent) => localizeDoc(e, pageLocale || undefined);
|
|
73
137
|
|
|
74
138
|
const format = (opts: Intl.DateTimeFormatOptions, tz: string) => {
|
|
75
|
-
try { return new Intl.DateTimeFormat(
|
|
139
|
+
try { return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone: tz }); }
|
|
76
140
|
catch { return new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }); }
|
|
77
141
|
};
|
|
78
142
|
/** "Tue, Oct 21 · 7:00 PM – 9:00 PM", or "Tue, Oct 21 · All day" */
|
|
79
|
-
const when = (o: Occurrence) => {
|
|
143
|
+
const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>) => {
|
|
80
144
|
const tz = o.allDay ? siteTz : eventTimeZone(o.event, settings);
|
|
81
|
-
const f = formatOccurrence(o, tz,
|
|
145
|
+
const f = formatOccurrence(o, tz, dateLocale);
|
|
82
146
|
return o.allDay ? f.range : `${f.range} – ${format({ hour: 'numeric', minute: '2-digit' }, tz).format(o.end)}`;
|
|
83
147
|
};
|
|
84
148
|
const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
|
|
85
149
|
const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
|
|
150
|
+
const repeats = (e: CalEvent) => describeRepeat(e.rrule, e.start, undefined, lang);
|
|
151
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
152
|
+
/** an instant as the move form's date and time, in the event's zone */
|
|
153
|
+
const wallParts = (e: CalEvent, ms: number) => {
|
|
154
|
+
const p = tzParts(ms, e.allDay ? siteTz : eventTimeZone(e, settings));
|
|
155
|
+
return { date: `${p.y}-${pad(p.m)}-${pad(p.d)}`, time: `${pad(p.hh)}:${pad(p.mm)}` };
|
|
156
|
+
};
|
|
157
|
+
const roomName = new Map(resources.map((r) => [r.id, r.name]));
|
|
158
|
+
const requestable = resources.filter((r) => r.requestable).map((r) => r.id);
|
|
159
|
+
/** the rooms a host may pick for a date: the event's own (and its dates'), and the requestable ones */
|
|
160
|
+
const roomChoices = (e: CalEvent) => [...new Set([...eventRoomIds(e), ...requestable])].filter((id) => roomName.has(id)).map((id) => ({ id, name: roomName.get(id)! }));
|
|
161
|
+
|
|
162
|
+
// a host's date request: where the date would go
|
|
163
|
+
const byId = new Map(mine.map((e) => [e.id, e]));
|
|
164
|
+
const requestTarget = (e: CalEvent, r: RequestRecord) => {
|
|
165
|
+
const d = r.data as MoveRequestData;
|
|
166
|
+
return d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
|
|
167
|
+
: 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);
|
|
168
|
+
};
|
|
169
|
+
// …worded for its card: "Thu, Jan 10 · 6:00 – 7:00 PM → Wed, Jan 9 · 5:00 – 6:00 PM" while it waits, else the date as it is
|
|
170
|
+
const requestWhen = (r: RequestRecord) => {
|
|
171
|
+
const e = byId.get(r.eventId);
|
|
172
|
+
const at = e && findOccurrence(e, r.key, settings);
|
|
173
|
+
if (!e || !at) return '';
|
|
174
|
+
const to = r.state === 'pending' ? requestTarget(e, r) : null;
|
|
175
|
+
return to ? `${when(at)} → ${when(to)}` : when(at);
|
|
176
|
+
};
|
|
177
|
+
const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
|
|
86
178
|
|
|
87
179
|
// requests: waiting ones first, newest first; shown as they'd be once approved
|
|
88
|
-
const requests =
|
|
89
|
-
.filter((e) => e.request)
|
|
90
|
-
.sort((a, b) => Number(b.request!.state === 'pending') - Number(a.request!.state === 'pending') || b.request!.at - a.request!.at)
|
|
91
|
-
.map((e) => {
|
|
180
|
+
const requests = [
|
|
181
|
+
...mine.filter((e) => e.request).map((e) => {
|
|
92
182
|
// the host's own waiting changes, never staff's unpublished edits
|
|
93
|
-
const
|
|
94
|
-
const o = featuredOccurrence(
|
|
95
|
-
return { e,
|
|
96
|
-
})
|
|
183
|
+
const s = shown(e.request!.kind === 'changes' && e.request!.state === 'pending' ? mergeEventDraft(e) : e);
|
|
184
|
+
const o = featuredOccurrence(s, settings, now);
|
|
185
|
+
return { kind: 'event' as const, e, state: e.request!.state, at: e.request!.at, label: requestLabel(e.request!, w), title: s.title, line: meta(o ? when(o) : '', describeRepeat(s.rrule, s.start, undefined, lang), s.location.name), reply: e.request!.reply, r: e.request! };
|
|
186
|
+
}),
|
|
187
|
+
// a host's date moves: waiting, and answered ones until dismissed (withdrawn ones drop off)
|
|
188
|
+
...dateRequests.filter((r) => r.state !== 'withdrawn' && byId.has(r.eventId)).map((r) => ({
|
|
189
|
+
kind: 'date' as const, e: byId.get(r.eventId)!, state: r.state, at: r.createdAt, label: requestLabel(r, w), title: shown(byId.get(r.eventId)!).title, line: requestWhen(r), reply: r.reply, req: r,
|
|
190
|
+
})),
|
|
191
|
+
].sort((a, b) => Number(b.state === 'pending') - Number(a.state === 'pending') || b.at - a.at);
|
|
97
192
|
|
|
98
193
|
// live events they host: the next 8 dates, or (all dates over) the last one
|
|
99
194
|
const win = { displayTz: siteTz, now, windowStartMs: now, windowEndMs: now + 366 * DAY };
|
|
@@ -112,43 +207,66 @@ for (const e of mine) {
|
|
|
112
207
|
upcoming.sort((a, b) => a.dates[0].start - b.dates[0].start);
|
|
113
208
|
past.sort((a, b) => (b.last?.start ?? b.e.startAt) - (a.last?.start ?? a.e.startAt));
|
|
114
209
|
const pastShown = past.slice(0, 10);
|
|
210
|
+
const confirm = (text: string) => 'return confirm(' + JSON.stringify(text) + ')';
|
|
211
|
+
|
|
212
|
+
/** the move form's starting values: what was posted (a failed try), else the date as it is now */
|
|
213
|
+
function moveValues(e: CalEvent, o: Occurrence) {
|
|
214
|
+
const typed = posted && posted.id === e.id && posted.key === o.key ? posted.form : null;
|
|
215
|
+
const start = wallParts(e, o.start), end = wallParts(e, e.allDay ? o.end - DAY : o.end);
|
|
216
|
+
const get = (name: string, fallback: string) => { const v = typed?.get(name); return typeof v === 'string' ? v : fallback; };
|
|
217
|
+
return {
|
|
218
|
+
date: get('date', start.date), startTime: get('startTime', start.time), endTime: get('endTime', end.time),
|
|
219
|
+
roomIds: typed && typed.get('rooms') === '1' ? typed.getAll('roomIds').map(String) : o.roomIds,
|
|
220
|
+
note: get('note', o.moved?.note ?? ''), message: get('message', ''),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
115
223
|
---
|
|
116
224
|
|
|
117
|
-
<Layout title=
|
|
225
|
+
<Layout title={w.myEvents} user={user} w={w}>
|
|
118
226
|
<div class="pc-mem-head">
|
|
119
|
-
<h1>
|
|
120
|
-
{canRequest && <a class="pc-mem-btn is-primary" href={`${home}/new`}>
|
|
227
|
+
<h1>{w.myEvents}</h1>
|
|
228
|
+
{canRequest && <a class="pc-mem-btn is-primary" href={`${home}/new`}>{w.requestEvent}</a>}
|
|
121
229
|
</div>
|
|
122
230
|
{flash && <p class="pc-mem-flash" role="status">{flash}</p>}
|
|
123
231
|
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
124
232
|
|
|
125
233
|
{requests.length > 0 && (
|
|
126
234
|
<section aria-labelledby="pc-mem-requests">
|
|
127
|
-
<h2 id="pc-mem-requests">
|
|
235
|
+
<h2 id="pc-mem-requests">{w.requests}</h2>
|
|
128
236
|
<ul class="pc-mem-list">
|
|
129
|
-
{requests.map((
|
|
237
|
+
{requests.map((q) => (
|
|
130
238
|
<li class="pc-mem-card">
|
|
131
|
-
<p class={`pc-mem-state is-${
|
|
132
|
-
<h3>{title}</h3>
|
|
133
|
-
{line && <p class="pc-mem-meta">{line}</p>}
|
|
134
|
-
<p class="pc-mem-meta">
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
239
|
+
<p class={`pc-mem-state is-${q.state === 'approved' ? 'approved' : q.state}`}>{q.label}</p>
|
|
240
|
+
<h3>{q.title}</h3>
|
|
241
|
+
{q.line && <p class="pc-mem-meta">{q.line}</p>}
|
|
242
|
+
<p class="pc-mem-meta">{fill(w.sentOn, { date: day(q.at) })}</p>
|
|
243
|
+
{q.state === 'declined' && q.reply && <p class="pc-mem-reply"><strong>{w.teamSaid}</strong> {q.reply}</p>}
|
|
244
|
+
{q.kind === 'event' ? (
|
|
245
|
+
<div class="pc-mem-actions">
|
|
246
|
+
{canRequest && <a class="pc-mem-btn is-small" href={`${home}/${q.e.id}`}>{q.r.kind === 'changes' && q.r.state === 'declined' ? w.suggestAgain : w.edit}</a>}
|
|
247
|
+
{q.r.kind === 'changes' && q.r.state === 'declined' ? (
|
|
248
|
+
<form method="post">
|
|
249
|
+
<input type="hidden" name="op" value="dismiss" />
|
|
250
|
+
<input type="hidden" name="id" value={q.e.id} />
|
|
251
|
+
<button class="pc-mem-btn is-small" type="submit">{w.dismiss}</button>
|
|
252
|
+
</form>
|
|
253
|
+
) : canRequest && (
|
|
254
|
+
<form method="post" onsubmit={confirm(q.r.kind === 'new' ? w.confirmWithdrawRequest : w.confirmWithdrawChanges)}>
|
|
255
|
+
<input type="hidden" name="op" value="withdraw" />
|
|
256
|
+
<input type="hidden" name="id" value={q.e.id} />
|
|
257
|
+
<button class="pc-mem-btn is-small is-danger" type="submit">{w.withdraw}</button>
|
|
258
|
+
</form>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
) : (
|
|
262
|
+
<div class="pc-mem-actions">
|
|
263
|
+
<form method="post" onsubmit={q.state === 'pending' ? confirm(w.confirmWithdrawRequest) : undefined}>
|
|
264
|
+
<input type="hidden" name="op" value={q.state === 'pending' ? 'withdraw-request' : 'dismiss-request'} />
|
|
265
|
+
<input type="hidden" name="request" value={q.req.id} />
|
|
266
|
+
<button class={`pc-mem-btn is-small${q.state === 'pending' ? ' is-danger' : ''}`} type="submit">{q.state === 'pending' ? w.withdraw : w.dismiss}</button>
|
|
143
267
|
</form>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<input type="hidden" name="op" value="withdraw" />
|
|
147
|
-
<input type="hidden" name="id" value={e.id} />
|
|
148
|
-
<button class="pc-mem-btn is-small is-danger" type="submit">Withdraw</button>
|
|
149
|
-
</form>
|
|
150
|
-
)}
|
|
151
|
-
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
152
270
|
</li>
|
|
153
271
|
))}
|
|
154
272
|
</ul>
|
|
@@ -156,62 +274,138 @@ const pastShown = past.slice(0, 10);
|
|
|
156
274
|
)}
|
|
157
275
|
|
|
158
276
|
<section aria-labelledby="pc-mem-upcoming">
|
|
159
|
-
<h2 id="pc-mem-upcoming">
|
|
277
|
+
<h2 id="pc-mem-upcoming">{w.upcoming}</h2>
|
|
160
278
|
{upcoming.length === 0 ? (
|
|
161
279
|
<p class="pc-mem-empty">
|
|
162
|
-
|
|
163
|
-
{canRequest && requests.length === 0 && <> <a href={`${home}/new`}>
|
|
280
|
+
{w.noUpcoming}
|
|
281
|
+
{canRequest && requests.length === 0 && <> <a href={`${home}/new`}>{w.requestOne}</a>{w.requestOneRest}</>}
|
|
164
282
|
</p>
|
|
165
283
|
) : (
|
|
166
284
|
<ul class="pc-mem-list">
|
|
167
|
-
{upcoming.map(({ e, dates }) =>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
285
|
+
{upcoming.map(({ e, dates }) => {
|
|
286
|
+
const ev = shown(e);
|
|
287
|
+
const rooms = roomChoices(e);
|
|
288
|
+
return (
|
|
289
|
+
<li class="pc-mem-card">
|
|
290
|
+
{e.cancelled ? <p class="pc-mem-state is-declined">{w.cancelledState}</p>
|
|
291
|
+
: e.status === 'scheduled' && <p class="pc-mem-state is-pending">{e.scheduledAt ? fill(w.goesLive, { date: day(e.scheduledAt) }) : w.scheduled}</p>}
|
|
292
|
+
<h3>{ev.title}</h3>
|
|
293
|
+
{meta(repeats(e), ev.location.name) && <p class="pc-mem-meta">{meta(repeats(e), ev.location.name)}</p>}
|
|
294
|
+
<ul class="pc-mem-dates">
|
|
295
|
+
{dates.map((o) => {
|
|
296
|
+
const off = e.cancelledDates.includes(o.key);
|
|
297
|
+
const open = e.rrule && !e.cancelled && o.start > now;
|
|
298
|
+
const waiting = waitingMove.get(`${e.id}|${o.key}`);
|
|
299
|
+
const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
|
|
300
|
+
const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
|
|
301
|
+
const target = waiting && requestTarget(e, waiting);
|
|
302
|
+
const v = moveValues(e, o);
|
|
303
|
+
const reopen = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
|
|
304
|
+
return (
|
|
305
|
+
<li class={o.cancelled ? 'is-cancelled' : undefined}>
|
|
306
|
+
<span>
|
|
307
|
+
{o.cancelled ? <s>{when(o)}</s> : when(o)}
|
|
308
|
+
{o.cancelled && <em>{w.cancelledDate}</em>}
|
|
309
|
+
{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>{fill(w.movedFrom, { when: when({ ...o, start: o.moved.from.start, end: o.moved.from.end }) })}</em>}
|
|
310
|
+
{waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
|
|
311
|
+
</span>
|
|
312
|
+
{open && (
|
|
313
|
+
<span class="pc-mem-date-acts">
|
|
314
|
+
{ownMove && !off && (
|
|
315
|
+
<form method="post" onsubmit={confirm(w.confirmMoveBack)}>
|
|
316
|
+
<input type="hidden" name="op" value="move-back" />
|
|
317
|
+
<input type="hidden" name="id" value={e.id} />
|
|
318
|
+
<input type="hidden" name="key" value={o.key} />
|
|
319
|
+
<button class="pc-mem-btn is-small" type="submit">{w.moveBack}</button>
|
|
320
|
+
</form>
|
|
321
|
+
)}
|
|
322
|
+
<form method="post" onsubmit={off ? undefined : confirm(w.confirmCancelDate)}>
|
|
323
|
+
<input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
|
|
324
|
+
<input type="hidden" name="id" value={e.id} />
|
|
325
|
+
<input type="hidden" name="key" value={o.key} />
|
|
326
|
+
<button class={`pc-mem-btn is-small${off ? '' : ' is-danger'}`} type="submit" aria-label={fill(off ? w.restoreLabel : w.cancelLabel, { when: when(o) })}>{off ? w.restore : w.cancel}</button>
|
|
327
|
+
</form>
|
|
328
|
+
</span>
|
|
329
|
+
)}
|
|
330
|
+
{clashHere && (
|
|
331
|
+
<div class="pc-mem-clash pc-mem-move" role="alert">
|
|
332
|
+
<p><strong>{clashHere.message}</strong></p>
|
|
333
|
+
<p>{w.sendInstead}</p>
|
|
334
|
+
<form method="post">
|
|
335
|
+
<input type="hidden" name="op" value={clashHere.back ? 'request-back' : 'request-move'} />
|
|
336
|
+
<input type="hidden" name="id" value={e.id} />
|
|
337
|
+
<input type="hidden" name="key" value={o.key} />
|
|
338
|
+
{!clashHere.back && (
|
|
339
|
+
<>
|
|
340
|
+
<input type="hidden" name="date" value={v.date} />
|
|
341
|
+
<input type="hidden" name="startTime" value={v.startTime} />
|
|
342
|
+
<input type="hidden" name="endTime" value={v.endTime} />
|
|
343
|
+
<input type="hidden" name="note" value={v.note} />
|
|
344
|
+
{posted?.form.get('rooms') === '1' && <input type="hidden" name="rooms" value="1" />}
|
|
345
|
+
{posted?.form.get('rooms') === '1' && v.roomIds.map((r) => <input type="hidden" name="roomIds" value={r} />)}
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
<label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{v.message}</textarea></label>
|
|
349
|
+
<div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{w.sendAsRequest}</button></div>
|
|
350
|
+
</form>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
{open && !off && !clashHere && (
|
|
354
|
+
<details class="pc-mem-move" open={reopen}>
|
|
355
|
+
<summary class="pc-mem-btn is-small" aria-label={fill(w.moveDateLabel, { when: when(o) })}>{w.moveDate}</summary>
|
|
356
|
+
<form method="post">
|
|
357
|
+
<input type="hidden" name="op" value={movesNeedApproval ? 'request-move' : 'move-date'} />
|
|
358
|
+
<input type="hidden" name="id" value={e.id} />
|
|
359
|
+
<input type="hidden" name="key" value={o.key} />
|
|
360
|
+
<div class="pc-mem-row">
|
|
361
|
+
<label class="pc-bk-field"><span>{w.date}</span><input type="date" name="date" required value={v.date} /></label>
|
|
362
|
+
{!e.allDay && <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="startTime" required value={v.startTime} /></label>}
|
|
363
|
+
{!e.allDay && <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="endTime" value={v.endTime} /></label>}
|
|
364
|
+
</div>
|
|
365
|
+
{rooms.length > 0 && (
|
|
366
|
+
<fieldset>
|
|
367
|
+
<legend>{w.rooms}</legend>
|
|
368
|
+
<input type="hidden" name="rooms" value="1" />
|
|
369
|
+
{rooms.map((r) => <label class="pc-bk-check"><input type="checkbox" name="roomIds" value={r.id} checked={v.roomIds.includes(r.id)} /> <span>{r.name}</span></label>)}
|
|
370
|
+
<p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
|
|
371
|
+
</fieldset>
|
|
372
|
+
)}
|
|
373
|
+
<label class="pc-bk-field"><span>{w.publicNote} <em>{w.form.optional}</em></span><input name="note" maxlength="500" value={v.note} /><small>{w.publicNoteHint}</small></label>
|
|
374
|
+
{movesNeedApproval && <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{v.message}</textarea></label>}
|
|
375
|
+
{movesNeedApproval && <p class="pc-mem-hint">{w.approvalHint}</p>}
|
|
376
|
+
<div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{movesNeedApproval ? w.sendForApproval : w.moveNow}</button></div>
|
|
377
|
+
</form>
|
|
378
|
+
</details>
|
|
379
|
+
)}
|
|
380
|
+
</li>
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
383
|
+
</ul>
|
|
384
|
+
<div class="pc-mem-actions">
|
|
385
|
+
{canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>{w.suggestChanges}</a>}
|
|
386
|
+
{eventIsPublic(e, calById) && <a class="pc-mem-btn is-small" href={here(eventUrl(e.slug))}>{w.view}</a>}
|
|
387
|
+
</div>
|
|
388
|
+
</li>
|
|
389
|
+
);
|
|
390
|
+
})}
|
|
197
391
|
</ul>
|
|
198
392
|
)}
|
|
199
393
|
</section>
|
|
200
394
|
|
|
201
395
|
{past.length > 0 && (
|
|
202
|
-
<section aria-label=
|
|
396
|
+
<section aria-label={fill(w.pastEvents, { count: String(past.length) })}>
|
|
203
397
|
<details class="pc-mem-past">
|
|
204
|
-
<summary>
|
|
398
|
+
<summary>{fill(w.pastEvents, { count: String(past.length) })}</summary>
|
|
205
399
|
<ul class="pc-mem-list">
|
|
206
400
|
{pastShown.map(({ e, last }) => (
|
|
207
401
|
<li class="pc-mem-card">
|
|
208
|
-
<h3>{e.title}</h3>
|
|
402
|
+
<h3>{shown(e).title}</h3>
|
|
209
403
|
{last && <p class="pc-mem-meta">{when(last)}</p>}
|
|
210
|
-
{eventIsPublic(e, calById) && <div class="pc-mem-actions"><a class="pc-mem-btn is-small" href={eventUrl(e.slug)}>
|
|
404
|
+
{eventIsPublic(e, calById) && <div class="pc-mem-actions"><a class="pc-mem-btn is-small" href={here(eventUrl(e.slug))}>{w.view}</a></div>}
|
|
211
405
|
</li>
|
|
212
406
|
))}
|
|
213
407
|
</ul>
|
|
214
|
-
{past.length > pastShown.length && <p class="pc-mem-hint">
|
|
408
|
+
{past.length > pastShown.length && <p class="pc-mem-hint">{fill(w.showingRecent, { count: String(pastShown.length) })}</p>}
|
|
215
409
|
</details>
|
|
216
410
|
</section>
|
|
217
411
|
)}
|
|
@@ -6,16 +6,17 @@ import { currentUser } from '../../../server';
|
|
|
6
6
|
import { readForm } from '../../../lib/forms/http';
|
|
7
7
|
import { MEMBERS, memberEventsPath, submitEventRequest } from '../../../lib/calendar/requests';
|
|
8
8
|
import { blankEventForm, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
|
|
9
|
-
import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
|
|
9
|
+
import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
|
|
10
10
|
|
|
11
11
|
// "Request an event": a draft the member hosts, waiting for the team's
|
|
12
12
|
// approval (submitEventRequest checks who may ask and what they sent).
|
|
13
13
|
if (!MEMBERS.enabled) return notFound();
|
|
14
14
|
const user = await currentUser(Astro);
|
|
15
|
-
if (!user) return signInRedirect(Astro.url);
|
|
15
|
+
if (!user) return signInRedirect(Astro.url, Astro.locals);
|
|
16
16
|
keepPrivate(Astro.response.headers);
|
|
17
17
|
const repo = await getRepo(Astro.locals);
|
|
18
|
-
const
|
|
18
|
+
const { w, lang, here } = memberLanguage(Astro, await repo.getSettings());
|
|
19
|
+
const home = here(memberEventsPath());
|
|
19
20
|
const fields = memberEventFields();
|
|
20
21
|
const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native' && c.visibility === 'public');
|
|
21
22
|
|
|
@@ -28,7 +29,7 @@ if (Astro.request.method === 'POST') {
|
|
|
28
29
|
await submitEventRequest(repo, user, eventRequestInput(values), { url: Astro.url, locals: Astro.locals });
|
|
29
30
|
return redirectTo(`${home}?sent=1`);
|
|
30
31
|
} catch (err) {
|
|
31
|
-
const f = failure(err);
|
|
32
|
+
const f = failure(err, w, lang);
|
|
32
33
|
error = f.message;
|
|
33
34
|
Astro.response.status = f.status;
|
|
34
35
|
}
|
|
@@ -36,13 +37,13 @@ if (Astro.request.method === 'POST') {
|
|
|
36
37
|
const canRequest = user.can('events.request');
|
|
37
38
|
---
|
|
38
39
|
|
|
39
|
-
<Layout title=
|
|
40
|
+
<Layout title={w.newTitle} user={user} w={w}>
|
|
40
41
|
<div>
|
|
41
|
-
<a class="pc-mem-back" href={home}
|
|
42
|
-
<div class="pc-mem-head"><h1>
|
|
43
|
-
<p class="pc-mem-lede">
|
|
42
|
+
<a class="pc-mem-back" href={home}>{w.back}</a>
|
|
43
|
+
<div class="pc-mem-head"><h1>{w.newTitle}</h1></div>
|
|
44
|
+
<p class="pc-mem-lede">{w.newLede}</p>
|
|
44
45
|
</div>
|
|
45
46
|
{canRequest
|
|
46
|
-
? <EventForm values={values} calendars={calendars} fields={fields} op="submit" submitLabel=
|
|
47
|
-
: <p class="pc-bk-error" role="alert">
|
|
47
|
+
? <EventForm values={values} calendars={calendars} fields={fields} op="submit" submitLabel={w.submit} error={error} w={w} lang={lang} />
|
|
48
|
+
: <p class="pc-bk-error" role="alert">{w.noRequestRole}</p>}
|
|
48
49
|
</Layout>
|
package/src/server/index.ts
CHANGED
|
@@ -300,3 +300,56 @@ export async function declineEventRequest(repo: Repo, who: Who, eventId: string,
|
|
|
300
300
|
export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string, occurrenceKey: string, cancelled: boolean, context: WriteContext): Promise<CalEvent> {
|
|
301
301
|
return (await requests()).setHostedOccurrence(internal(repo), who, eventId, occurrenceKey, cancelled, context);
|
|
302
302
|
}
|
|
303
|
+
|
|
304
|
+
// ---- hosts moving dates ------------------------------------------------------
|
|
305
|
+
// A host moves one date of an event they host (no permission beyond being in
|
|
306
|
+
// hostIds). acms({ members: { hostMoves } }) decides how: 'direct' (default)
|
|
307
|
+
// moves it at once while its rooms are free (409 room-taken otherwise, and
|
|
308
|
+
// the same move can then be sent with requestDateMove); 'approve' turns every
|
|
309
|
+
// host move into a request. Requests are RequestRecord rows (kind 'move')
|
|
310
|
+
// that staff who can publish approve or decline; both sides are emailed.
|
|
311
|
+
|
|
312
|
+
const hostRequests = () => import('../lib/calendar/host-requests');
|
|
313
|
+
export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
|
|
314
|
+
export type { RequestRecord } from '../lib/schema';
|
|
315
|
+
|
|
316
|
+
/** A host moves one date: `{ moved }` at once, or `{ request }` when moves need approval. Errors as moveOccurrence's, plus `code` room-not-allowed (a room the event doesn't use and nobody may request). */
|
|
317
|
+
export async function moveHostedDate(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
|
|
318
|
+
return (await hostRequests()).moveHostedDate(internal(repo), who, eventId, occurrenceKey, input, context);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A host puts a date they moved back where the series has it: at once, or as a request. */
|
|
322
|
+
export async function moveHostedDateBack(repo: Repo, who: Who, eventId: string, occurrenceKey: string, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
|
|
323
|
+
return (await hostRequests()).moveHostedDateBack(internal(repo), who, eventId, occurrenceKey, context);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A host asks staff to move one date (`input`), or to move it back (null),
|
|
328
|
+
* with an optional `message` for staff. Checked like the move, except that a
|
|
329
|
+
* taken room is allowed: staff decide. Asking again about the same date
|
|
330
|
+
* replaces the waiting request. Staff are emailed.
|
|
331
|
+
*/
|
|
332
|
+
export async function requestDateMove(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput | null, message: string, context: WriteContext): Promise<import('../lib/schema').RequestRecord> {
|
|
333
|
+
return (await hostRequests()).requestDateMove(internal(repo), who, eventId, occurrenceKey, input, message, context);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A host's own date requests (waiting, approved, declined and withdrawn), newest first. */
|
|
337
|
+
export async function listMyDateRequests(repo: Repo, who: Who, eventId?: string): Promise<import('../lib/schema').RequestRecord[]> {
|
|
338
|
+
if (!who) return [];
|
|
339
|
+
return (await hostRequests()).myDateRequests(internal(repo), who.id, eventId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A host withdraws a waiting date request of theirs. */
|
|
343
|
+
export async function withdrawDateRequest(repo: Repo, who: Who, requestId: string): Promise<void> {
|
|
344
|
+
return (await hostRequests()).withdrawDateRequest(internal(repo), who, requestId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Staff ("Publish content"): approve a host's date move. It runs as them (room clashes come back as warnings) and is recorded as the host's. Pass `seen`, the request's `updatedAt` you showed. */
|
|
348
|
+
export async function approveDateRequest(repo: Repo, who: Who, requestId: string, context: WriteContext, seen?: number): Promise<{ request: import('../lib/schema').RequestRecord; moved: import('../lib/calendar/moves').MoveResult }> {
|
|
349
|
+
return (await hostRequests()).approveDateRequest(internal(repo), who, requestId, context, seen);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Staff: decline a host's date request, with a note the host reads. */
|
|
353
|
+
export async function declineDateRequest(repo: Repo, who: Who, requestId: string, reply: string, context: WriteContext, seen?: number): Promise<import('../lib/schema').RequestRecord> {
|
|
354
|
+
return (await hostRequests()).declineDateRequest(internal(repo), who, requestId, reply, context, seen);
|
|
355
|
+
}
|