@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
@@ -8,7 +8,7 @@ import ContentList, { type ContentItem } from '../../../../components/ContentLis
8
8
  import { getRepo } from '../../../../lib/repo';
9
9
  import { slugify, type Status } from '../../../../lib/schema';
10
10
  import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
11
- import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
11
+ import { describeSeries, featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
12
12
  import { defaultPlaceLocation } from '../../../../lib/maps/places';
13
13
  import { describeRepeat } from '../../../../lib/calendar/rrule';
14
14
  import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
@@ -25,6 +25,7 @@ import { approveEventRequest, declineEventRequest, pendingRequests } from '../..
25
25
  import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
26
26
  import { mergeEventDraft } from '../../../../lib/drafts';
27
27
  import { ApiError } from '../../../../lib/api';
28
+ import { REQUESTS } from '../../../../lib/calendar/request-config';
28
29
 
29
30
  // Schedule → List: upcoming first, grouped by the month each event next
30
31
  // happens. Subscribed calendars appear as read-only rows with a "Make an
@@ -96,10 +97,11 @@ if (Astro.request.method === 'POST') {
96
97
  try {
97
98
  // a host's date move (host-requests.ts): `id` is the request's
98
99
  if (op === 'approve-date') {
99
- const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
100
- const n = moved.clashes.length;
100
+ const { moved, changed } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
101
+ const done = (moved ?? changed)!;
102
+ const n = done.clashes.length;
101
103
  const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
102
- return toRequests('Approved. “' + moved.event.title + '” has the date moved' + shared + '.');
104
+ return toRequests('Approved. “' + done.event.title + '” ' + (moved ? 'has the date moved' : 'is changed from that date on') + shared + '.');
103
105
  }
104
106
  if (op === 'decline-date') {
105
107
  await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
@@ -232,7 +234,7 @@ const selected = rows
232
234
  const items: ContentItem[] = selected.map(({ e, o }) => {
233
235
  const f = o ? formatOccurrence(o, tz, locale) : null;
234
236
  const cals = e.calendarIds.map((id) => calById.get(id)?.name).filter(Boolean);
235
- const repeats = describeRepeat(e.rrule, e.start);
237
+ const repeats = describeSeries(e, settings, now);
236
238
  return {
237
239
  id: e.id,
238
240
  href: `/admin/calendar/events/${e.id}`,
@@ -306,8 +308,8 @@ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
306
308
  const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
307
309
  return {
308
310
  id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
309
- ...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
310
- line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
311
+ ...(await dateRequestTimes(repo, r)), back: Boolean(d.back), label: r.kind === 'from-date' ? 'From this date on' : d.back ? 'Move back' : 'Date move',
312
+ line: `${r.kind === 'from-date' ? 'Change from this date on' : d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
311
313
  };
312
314
  }));
313
315
 
@@ -510,7 +512,7 @@ const subBits = [
510
512
  {r.publicNote && <small>Note on the date: {r.publicNote}</small>}
511
513
  {r.clash && <small class="ev-req-clash">{r.clash}</small>}
512
514
  </a>
513
- <span class="bk-st is-pending">{r.back ? 'Move back' : 'Date move'}</span>
515
+ <span class="bk-st is-pending">{r.label}</span>
514
516
  <div class="bk-acts">
515
517
  <a class="btn sm ghost" href={r.href}>Open</a>
516
518
  {canApprove && (
@@ -527,6 +529,7 @@ const subBits = [
527
529
  ))}
528
530
  </section>
529
531
  )}
532
+ {REQUESTS.enabled && <p class="hint">Every request, including those from the app and the website, is on <a href="/admin/calendar/requests">Requests</a>.</p>}
530
533
  {(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
531
534
  {canApprove && dateRows.map((r) => (
532
535
  <Modal id={`decline-date-${r.id}`} title="Decline this date change">
@@ -0,0 +1,298 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import Modal from '../../../components/Modal.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { ApiError } from '../../../lib/api';
6
+ import { extraLocales, localeName, localePath } from '../../../lib/i18n';
7
+ import { PUSH } from '../../../lib/push/config';
8
+ import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
9
+ import type { Permission } from '../../../lib/permissions/catalog';
10
+ import { REQUESTS } from '../../../lib/calendar/request-config';
11
+ import { MEMBERS } from '../../../lib/calendar/members-config';
12
+ import { approveRequest, declineRequest, deleteRequest, replyToRequest, requestRecipients, requestableRooms, requestSettings } from '../../../lib/calendar/request-service';
13
+ import { pendingCount, requestRows, settingsFromForm, type RequestTab } from '../../../lib/calendar/request-admin';
14
+ import { approveEventRequest, declineEventRequest } from '../../../lib/calendar/requests';
15
+
16
+ // Calendar → Requests (acms({ requests })): requests from the app, the
17
+ // website and hosts in one list, with Pending, Approved and Declined tabs and
18
+ // what takes the rooms on each row. Approve opens a filled-in draft (or the
19
+ // event a change is about); Reply and Decline email the person in their
20
+ // language. Reading needs "Create and edit content", answering "Publish
21
+ // content", the settings "Manage rooms and calendars". The rules are
22
+ // lib/calendar/request-service.ts; the list lib/calendar/request-admin.ts.
23
+ if (!REQUESTS.enabled) return new Response(null, { status: 404 });
24
+ const repo = await getRepo(Astro.locals);
25
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
26
+ if (denied) return denied;
27
+ const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
28
+ const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
29
+ const hooks = { url: Astro.url, locals: Astro.locals };
30
+ const back = (notice: string, tab = Astro.url.searchParams.get('tab') ?? '') => {
31
+ const p = new URLSearchParams();
32
+ if (tab) p.set('tab', tab);
33
+ if (notice) p.set('notice', notice);
34
+ const q = p.toString();
35
+ return Astro.redirect(q ? '/admin/calendar/requests?' + q : '/admin/calendar/requests');
36
+ };
37
+
38
+ if (Astro.request.method === 'POST') {
39
+ const form = await Astro.request.formData();
40
+ const op = String(form.get('op') ?? '');
41
+ const id = String(form.get('id') ?? '');
42
+ const seen = Number(form.get('at')) || undefined;
43
+ const nope = await forbiddenUnless(Astro.request, Astro.cookies, repo, op === 'settings' ? 'schedule.manage' : 'content.publish');
44
+ if (nope) return nope;
45
+ try {
46
+ if (op === 'approve') {
47
+ const done = await approveRequest(repo, who, id, hooks, seen, { message: String(form.get('message') ?? ''), email: form.get('email') === '1' });
48
+ return Astro.redirect(done.next);
49
+ }
50
+ if (op === 'decline') {
51
+ // the email choice is only offered for requests from the app or the website (hosts are always told)
52
+ const email = form.has('email-offered') ? form.get('email') === '1' : true;
53
+ await declineRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen, { email });
54
+ return back(email ? 'Declined. The person was emailed.' : 'Declined, without an email.');
55
+ }
56
+ if (op === 'delete') {
57
+ await deleteRequest(repo, who, id);
58
+ return back('Deleted as spam. Nobody was emailed.');
59
+ }
60
+ if (op === 'reply') {
61
+ await replyToRequest(repo, who, id, String(form.get('message') ?? ''), hooks);
62
+ return back('Reply sent.');
63
+ }
64
+ // members' requests staged on their events (My events)
65
+ if (op === 'member-approve') {
66
+ const e = await approveEventRequest(repo, who, id, hooks, seen);
67
+ return back(`Approved “${e.title}”.`);
68
+ }
69
+ if (op === 'member-decline') {
70
+ const e = await declineEventRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen);
71
+ return back(`Declined “${e.title}”.`);
72
+ }
73
+ if (op === 'settings') {
74
+ const settings = await repo.getSettings();
75
+ const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id);
76
+ const requests = settingsFromForm(form, requestSettings(settings), calendars);
77
+ await repo.updateSettings({ calendar: { ...settings.calendar, requests } });
78
+ return back('Request settings saved.');
79
+ }
80
+ return back('');
81
+ } catch (err) {
82
+ return back(err instanceof ApiError ? err.message : 'Couldn’t do that. Try again.');
83
+ }
84
+ }
85
+
86
+ const settings = await repo.getSettings();
87
+ const rq = requestSettings(settings);
88
+ const tab = (['pending', 'approved', 'declined'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'pending';
89
+ const page = Math.min(1000, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') ?? '', 10) || 1));
90
+ const [{ rows, more }, waiting, rooms, calendars, recipients] = await Promise.all([
91
+ requestRows(repo, settings, tab as RequestTab, Date.now(), page), pendingCount(repo), requestableRooms(repo),
92
+ repo.listCalendars().then((cs) => cs.filter((c) => c.kind === 'native')), requestRecipients(repo, settings),
93
+ ]);
94
+ const notice = Astro.url.searchParams.get('notice') ?? '';
95
+ const canAnswer = access.can('content.publish');
96
+ const canSet = access.can('schedule.manage');
97
+ const langs = extraLocales();
98
+ // the kinds there are, plus a few empty rows to add more (12 at most)
99
+ const typeRows = [...rq.types, ...Array.from({ length: Math.min(3, 12 - rq.types.length) }, () => null)];
100
+ const TABS = [['pending', 'Pending'], ['approved', 'Approved'], ['declined', 'Declined']] as const;
101
+ const formUrl = REQUESTS.page ? REQUESTS.path : '';
102
+ const pageHref = (n: number) => '/admin/calendar/requests?' + new URLSearchParams({ ...(tab === 'pending' ? {} : { tab }), ...(n > 1 ? { page: String(n) } : {}) }).toString();
103
+ const empty = { pending: 'Nothing waiting. Requests from the app, the website and hosts show up here.', approved: 'No approved requests yet.', declined: 'No declined requests.' }[tab];
104
+ ---
105
+
106
+ <Admin title="Requests" section="calendar:requests">
107
+ <div class="page-head">
108
+ <div>
109
+ <h1>Requests</h1>
110
+ <p class="sub">From the app, the website{MEMBERS.enabled ? ' and hosts' : ''}, in one list{formUrl && <> · the form is at <a href={formUrl} target="_blank" rel="noopener">{formUrl}</a>{langs.map((l) => <> and <a href={localePath(formUrl, l)} target="_blank" rel="noopener">{localePath(formUrl, l)}</a></>)}</>}</p>
111
+ </div>
112
+ <div class="page-actions">
113
+ {!rq.open && <span class="rq-closed">Closed</span>}
114
+ {canSet && <button class="btn" type="button" data-modal="rq-settings">Settings</button>}
115
+ </div>
116
+ </div>
117
+
118
+ {notice && <p class="notice" role="status">{notice}</p>}
119
+
120
+ <nav class="seg rq-tabs" aria-label="Show">
121
+ {TABS.map(([k, l]) => (
122
+ <a href={k === 'pending' ? '/admin/calendar/requests' : '/admin/calendar/requests?tab=' + k} class={tab === k ? 'on' : ''} aria-current={tab === k ? 'page' : undefined}>
123
+ {l}{k === 'pending' && waiting > 0 && <span class="rq-count">{waiting}</span>}
124
+ </a>
125
+ ))}
126
+ </nav>
127
+
128
+ {rows.length === 0 ? <p class="hint rq-empty">{empty}</p> : (
129
+ <section class="card rq-list">
130
+ {rows.map((r) => (
131
+ <article class="rq-row" id={r.dom}>
132
+ <small class="rq-head">{r.head}</small>
133
+ <b class="rq-title">{r.href ? <a href={r.href}>{r.title}</a> : r.title}</b>
134
+ {r.line && <span class="rq-line">{r.line}</span>}
135
+ <span class="rq-who">{r.who}</span>
136
+ {r.note && <p class="rq-note">“{r.note}”</p>}
137
+ {r.clash && <p class={r.clash.taken ? 'rq-clash is-taken' : 'rq-clash'}>{r.clash.text}</p>}
138
+ {r.answer && <p class="rq-answer">{r.answer}{r.reply && <> — “{r.reply}”</>}</p>}
139
+ {tab === 'pending' && canAnswer && (
140
+ <div class="rq-acts">
141
+ {(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
142
+ {(r.kind === 'move' || r.kind === 'from-date') && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
143
+ {(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
144
+ {r.kind === 'member-new' || r.kind === 'member-changes' ? (
145
+ <>
146
+ <a class="btn sm ghost" href={r.href}>Open</a>
147
+ <form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
148
+ </>
149
+ ) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
150
+ <button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>
151
+ </div>
152
+ )}
153
+ {tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
154
+ </article>
155
+ ))}
156
+ </section>
157
+ )}
158
+ {(page > 1 || more) && (
159
+ <nav class="rq-pager" aria-label="Pages">
160
+ {page > 1 && <a class="btn sm ghost" href={pageHref(page - 1)}>Previous</a>}
161
+ <span class="hint">Page {page}</span>
162
+ {more && <a class="btn sm ghost" href={pageHref(page + 1)}>Next</a>}
163
+ </nav>
164
+ )}
165
+ {tab === 'pending' && rows.length > 0 && !canAnswer && <p class="hint">Someone whose role can publish content answers these.</p>}
166
+
167
+ {canAnswer && rows.map((r) => (
168
+ <>
169
+ {tab === 'pending' && (r.kind === 'new' || r.kind === 'change') && (
170
+ <Modal id={'approve-' + r.dom} title={r.kind === 'new' ? 'Approve and make a draft' : 'Approve this change'}>
171
+ <form method="post">
172
+ <input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
173
+ <p class="hint">{r.kind === 'new'
174
+ ? 'You’ll get a draft with the title, time, repeat, rooms held and the kind’s tag and calendar. Check it, then publish it. Their contact details stay here, never on the event.'
175
+ : 'The event opens (or that date’s Move… dialog) so you can make the change.'}</p>
176
+ <label class="field"><span>Message <em class="hint">optional</em></span><textarea name="message" rows="3" maxlength="2000" placeholder="See you then!"></textarea></label>
177
+ <label class="check-row"><input type="checkbox" name="email" value="1" checked /> Email them that it’s approved, in their language</label>
178
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Approve</button></div>
179
+ </form>
180
+ </Modal>
181
+ )}
182
+ {r.canReply && r.email && (
183
+ <Modal id={'reply-' + r.dom} title="Reply">
184
+ <form method="post">
185
+ <input type="hidden" name="op" value="reply" /><input type="hidden" name="id" value={r.id} />
186
+ <p class="hint">Emailed to {r.email} with a short line in their language. The request stays {tab === 'pending' ? 'waiting' : tab}.</p>
187
+ <label class="field"><span>Your reply</span><textarea name="message" rows="5" maxlength="2000" required placeholder="Could you come at 3 instead? The room is free then."></textarea></label>
188
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Send reply</button></div>
189
+ </form>
190
+ </Modal>
191
+ )}
192
+ {tab === 'pending' && (
193
+ <Modal id={'decline-' + r.dom} title="Decline this request">
194
+ <form method="post">
195
+ <input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
196
+ <p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move' || r.kind === 'from-date'
197
+ ? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : r.kind === 'from-date' ? '; the schedule stays as it is.' : '.')
198
+ : 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
199
+ <label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
200
+ {(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
201
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Decline</button></div>
202
+ </form>
203
+ {(r.kind === 'new' || r.kind === 'change') && (
204
+ <form method="post" class="rq-spam">
205
+ <input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={r.id} />
206
+ <p class="hint">Spam? Delete it now, without an email to the address it gave.</p>
207
+ <button class="btn sm ghost" type="submit">Delete as spam</button>
208
+ </form>
209
+ )}
210
+ </Modal>
211
+ )}
212
+ </>
213
+ ))}
214
+
215
+ {canSet && (
216
+ <Modal id="rq-settings" title="Request settings" open={Astro.url.searchParams.has('settings')}>
217
+ <form method="post" class="rq-settings">
218
+ <input type="hidden" name="op" value="settings" />
219
+ <div>
220
+ <label class="check-row"><input type="checkbox" name="open" value="1" checked={rq.open} /> Taking requests</label>
221
+ <p class="hint">When off, the form and the app say so and show the office phone. They never disappear.</p>
222
+ </div>
223
+ <fieldset>
224
+ <legend>Kinds people choose from</legend>
225
+ <p class="hint">Approving a request makes a draft with the kind’s tag, in its calendar (else the default calendar).</p>
226
+ <div class="rq-type rq-type-h" aria-hidden="true"><span>Kind</span>{langs.map((l) => <span>{localeName(l, true)}</span>)}<span>Tag</span><span>Calendar</span></div>
227
+ {typeRows.map((t, i) => (
228
+ <div class="rq-type">
229
+ {t && <input type="hidden" name={'type-id-' + i} value={t.id} />}
230
+ <input name={'type-label-' + i} value={t?.label ?? ''} placeholder={t || i > rq.types.length ? '' : rq.types.length ? 'Add a kind' : 'Class'} aria-label="Kind" maxlength="60" />
231
+ {langs.map((l) => <input name={'type-' + l + '-' + i} value={t?.translations[l] ?? ''} placeholder={localeName(l, true)} aria-label={'Kind in ' + localeName(l)} maxlength="60" />)}
232
+ <input name={'type-tag-' + i} value={t?.tag ?? ''} placeholder="Tag" aria-label="Tag" maxlength="60" />
233
+ <select name={'type-cal-' + i} aria-label="Calendar">
234
+ <option value="">Default calendar</option>
235
+ {calendars.map((c) => <option value={c.id} selected={t?.calendarId === c.id}>{c.name}</option>)}
236
+ </select>
237
+ </div>
238
+ ))}
239
+ </fieldset>
240
+ <label class="field"><span>Reply time <em class="hint">“We usually answer within …”</em></span><input name="replyTime" value={rq.replyTime} placeholder="3 days" maxlength="60" /></label>
241
+ <label class="field"><span>Notice at the top of the form <em class="hint">optional</em></span><textarea name="notice" rows="2" maxlength="400" placeholder="The church office reviews every request. It isn’t on the calendar until they confirm it.">{rq.notice}</textarea></label>
242
+ {langs.map((l) => (
243
+ <fieldset>
244
+ <legend>{localeName(l, true)}</legend>
245
+ <label class="field"><span>Reply time</span><input name={'replyTime-' + l} value={rq.translations[l]?.replyTime ?? ''} maxlength="60" /></label>
246
+ <label class="field"><span>Notice</span><textarea name={'notice-' + l} rows="2" maxlength="400">{rq.translations[l]?.notice ?? ''}</textarea></label>
247
+ </fieldset>
248
+ ))}
249
+ <label class="field"><span>Office phone <em class="hint">shown when requests are closed, and after sending</em></span><input name="phone" inputmode="tel" autocomplete="off" value={rq.phone} maxlength="40" /></label>
250
+ <label class="field"><span>Who hears about new requests</span><textarea name="notify" rows="2" placeholder={recipients.join(', ')}>{rq.notify.join(', ')}</textarea><em class="hint">Email addresses. Empty: {MEMBERS.notify.length ? 'the members notify list in the site’s config' : 'the owners'} ({recipients.join(', ') || 'nobody yet'}).</em></label>
251
+ {PUSH.enabled && (
252
+ <div>
253
+ <label class="check-row"><input type="hidden" name="phones" value="0" /><input type="checkbox" name="phones" value="1" checked={rq.phones} /> Also notify their linked phones</label>
254
+ <p class="hint">A push saying a request arrived and for when, without names.</p>
255
+ </div>
256
+ )}
257
+ <div class="field-row">
258
+ <label class="field"><span>New requests a day <em class="hint">0 = no limit</em></span><input name="perDay" type="number" min="0" max="10000" value={rq.perDay} /></label>
259
+ <label class="field"><span>Keep requests for (days) <em class="hint">after the answer, or after they arrive if nobody answers</em></span><input name="keepDays" type="number" min="7" max="1095" value={rq.keepDays} /></label>
260
+ </div>
261
+ <p class="hint">Rooms people can ask for: {rooms.length ? rooms.map((r) => r.name).join(', ') : 'none yet'}. Turn on “Anyone can check if it’s free” on a room to offer it.</p>
262
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
263
+ </form>
264
+ </Modal>
265
+ )}
266
+ </Admin>
267
+
268
+ <style>
269
+ .rq-tabs { margin: 0 0 14px; }
270
+ .rq-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; margin-left: 6px; }
271
+ .rq-closed { font-size: 12.5px; font-weight: 600; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 3px 10px; }
272
+ .rq-empty { padding: 24px 8px; }
273
+ .rq-list { padding: 0; }
274
+ .rq-row { display: grid; gap: 3px; padding: 14px 18px; border-top: 1px solid var(--line); scroll-margin-top: 80px; }
275
+ .rq-row:first-child { border-top: 0; }
276
+ .rq-row:target { background: var(--amber-soft); }
277
+ .rq-head { color: var(--text-2); font-size: 12.5px; }
278
+ .rq-title { font-size: 15.5px; }
279
+ .rq-title a { color: inherit; }
280
+ .rq-line, .rq-who { font-size: 13.5px; color: var(--text-2); }
281
+ .rq-note { margin: 4px 0 0; font-style: italic; font-size: 14px; }
282
+ .rq-clash { margin: 4px 0 0; font-size: 13px; color: var(--green); }
283
+ .rq-clash.is-taken { color: var(--amber); }
284
+ .rq-answer { margin: 4px 0 0; font-size: 13px; color: var(--text-2); }
285
+ .rq-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
286
+ .rq-acts form { margin: 0; }
287
+ .rq-pager { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; }
288
+ .rq-spam { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); margin-top: 14px; padding-top: 12px; }
289
+ .rq-spam .hint { margin: 0; }
290
+ .rq-settings { display: grid; gap: 12px; }
291
+ .rq-settings fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
292
+ .rq-settings legend { font-weight: 600; font-size: 13.5px; padding: 0 4px; }
293
+ .rq-settings .hint { margin: 2px 0 0; }
294
+ .rq-type { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
295
+ .rq-type > * { min-width: 0; }
296
+ .rq-type-h span { font-size: 12px; color: var(--text-2); }
297
+ :global(dialog#rq-settings.modal) { width: min(680px, calc(100vw - 32px)); max-width: none; }
298
+ </style>
@@ -106,8 +106,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
106
106
  from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
107
107
  note: o.moved.note, location: o.moved.location ?? null,
108
108
  } : null,
109
- // that date's rooms
109
+ // that date's rooms, and its own place (moved there, or from its segment; null = the event's)
110
110
  rooms: roomsToJson(o.roomIds, resources, full),
111
+ location: o.location ?? null,
111
112
  event: toJson(o.event.id),
112
113
  };
113
114
  }),
@@ -0,0 +1,6 @@
1
+ import { postRequest } from '../../../../lib/mobile/requests';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const POST = privateRoute(postRequest, { write: true, status: 201 });
6
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,7 @@
1
+ import { getRequestOptions } from '../../../../lib/mobile/requests';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // never cached: closing requests or marking a room requestable shows at once
6
+ export const GET = privateRoute(getRequestOptions);
7
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -5,9 +5,11 @@ import { currentUser } from '../../../server';
5
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
+ import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, requestFromDate, withdrawDateRequest, type FromDateRequestData, type MoveRequestData } from '../../../lib/calendar/host-requests';
9
+ import { changeFromDate, changeFromDefaults, type ChangeFromInput } from '../../../lib/calendar/segments';
10
+ import { rruleMap, WEEKDAY_CODES, weekdayOf } from '../../../lib/calendar/rrule';
9
11
  import { eventUrl } from '../../../lib/calendar/config';
10
- import { eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
12
+ import { describeSeries, eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
11
13
  import { tzParts } from '../../../lib/calendar/ics';
12
14
  import { describeRepeat } from '../../../lib/calendar/rrule';
13
15
  import { requestLabel } from '../../../lib/calendar/member-form';
@@ -57,6 +59,25 @@ function nextDay(day: string): string {
57
59
  return d.toISOString().slice(0, 10);
58
60
  }
59
61
 
62
+ /**
63
+ * The "Change from this date on" form → ChangeFromInput: the first date and
64
+ * times as for a move, and a repeat choice worded around that first date
65
+ * ('same' keeps the rule in effect, moved to its weekday; the others end as
66
+ * that rule does).
67
+ */
68
+ async function fromDateInput(form: FormData, e: CalEvent | null, key: string): Promise<ChangeFromInput> {
69
+ const base = moveInput(form, e);
70
+ const choice = String(form.get('repeat') ?? 'same');
71
+ if (choice === 'same' || !e) return base;
72
+ const date = String(form.get('date') ?? '').trim();
73
+ const wd = weekdayOf(date);
74
+ const code = WEEKDAY_CODES[wd.dow];
75
+ const kept = rruleMap((await changeFromDefaults(repo, e.id, key))?.rrule ?? '');
76
+ const ends = kept.COUNT ? `;COUNT=${kept.COUNT}` : kept.UNTIL ? `;UNTIL=${kept.UNTIL}` : '';
77
+ const rule = { weekly: `FREQ=WEEKLY;BYDAY=${code}`, biweekly: `FREQ=WEEKLY;INTERVAL=2;BYDAY=${code}`, monthly: `FREQ=MONTHLY;BYDAY=${wd.nth >= 5 ? -1 : wd.nth}${code}`, daily: 'FREQ=DAILY' }[choice];
78
+ return { ...base, rrule: choice === 'once' ? '' : rule ? rule + ends : undefined };
79
+ }
80
+
60
81
  let error = '';
61
82
  // a move refused because a room is taken: shown under its date, with "Send as a request"
62
83
  let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
@@ -108,6 +129,16 @@ if (Astro.request.method === 'POST') {
108
129
  clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
109
130
  Astro.response.status = 409;
110
131
  }
132
+ } else if (op === 'from-date') {
133
+ // staff who host apply it; other hosts send it to them
134
+ posted = { id, key, form };
135
+ const input = await fromDateInput(form, await repo.getEvent(id), key);
136
+ if (user.can('content.edit')) {
137
+ await changeFromDate(repo, user, id, key, input, hooks);
138
+ return redirectTo(`${home}?fromDateDone=1`);
139
+ }
140
+ await requestFromDate(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
141
+ return redirectTo(`${home}?fromDateSent=1`);
111
142
  } else if (op === 'withdraw-request') {
112
143
  await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
113
144
  return redirectTo(`${home}?withdrawn=1`);
@@ -122,7 +153,7 @@ if (Astro.request.method === 'POST') {
122
153
  }
123
154
  }
124
155
 
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]]);
156
+ const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent', 'fromDateDone'] as const).map((k) => [k, w.flash[k]]);
126
157
  const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
127
158
 
128
159
  const DAY = 86_400_000;
@@ -147,7 +178,8 @@ const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>
147
178
  };
148
179
  const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
149
180
  const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
150
- const repeats = (e: CalEvent) => describeRepeat(e.rrule, e.start, undefined, lang);
181
+ // the rule in effect, and a change from a date on still to come
182
+ const repeats = (e: CalEvent) => describeSeries(e, settings, now, lang, undefined, dateLocale);
151
183
  const pad = (n: number) => String(n).padStart(2, '0');
152
184
  /** an instant as the move form's date and time, in the event's zone */
153
185
  const wallParts = (e: CalEvent, ms: number) => {
@@ -169,12 +201,21 @@ const requestTarget = (e: CalEvent, r: RequestRecord) => {
169
201
  // …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
202
  const requestWhen = (r: RequestRecord) => {
171
203
  const e = byId.get(r.eventId);
204
+ if (e && r.kind === 'from-date') {
205
+ // the date it changes from (while it's still there), then the new first date and its repeat
206
+ const f = r.data as FromDateRequestData;
207
+ const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
208
+ const from = r.state === 'pending' ? findOccurrence({ ...e, movedDates: {} }, r.key, settings) : null;
209
+ const to = [first ? when(first) : '', describeRepeat(f.rrule, f.start, undefined, lang)].filter(Boolean).join(' · ');
210
+ return from ? `${when(from)} → ${to}` : to;
211
+ }
172
212
  const at = e && findOccurrence(e, r.key, settings);
173
213
  if (!e || !at) return '';
174
214
  const to = r.state === 'pending' ? requestTarget(e, r) : null;
175
215
  return to ? `${when(at)} → ${when(to)}` : when(at);
176
216
  };
177
217
  const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
218
+ const waitingFrom = new Set(dateRequests.filter((r) => r.kind === 'from-date' && r.state === 'pending').map((r) => `${r.eventId}|${r.key}`));
178
219
 
179
220
  // requests: waiting ones first, newest first; shown as they'd be once approved
180
221
  const requests = [
@@ -294,13 +335,16 @@ function moveValues(e: CalEvent, o: Occurrence) {
294
335
  <ul class="pc-mem-dates">
295
336
  {dates.map((o) => {
296
337
  const off = e.cancelledDates.includes(o.key);
297
- const open = e.rrule && !e.cancelled && o.start > now;
338
+ // a date cancelled with every date after it (a cancelled part) is the team's to restore
339
+ const open = e.rrule && !e.cancelled && o.start > now && (!o.cancelled || off);
298
340
  const waiting = waitingMove.get(`${e.id}|${o.key}`);
299
341
  const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
300
342
  const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
301
343
  const target = waiting && requestTarget(e, waiting);
302
344
  const v = moveValues(e, o);
303
- const reopen = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
345
+ const failed = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
346
+ const reopen = failed && posted!.form.get('op') !== 'from-date';
347
+ const reopenFrom = failed && posted!.form.get('op') === 'from-date';
304
348
  return (
305
349
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
306
350
  <span>
@@ -308,6 +352,7 @@ function moveValues(e: CalEvent, o: Occurrence) {
308
352
  {o.cancelled && <em>{w.cancelledDate}</em>}
309
353
  {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
354
  {waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
355
+ {waitingFrom.has(`${e.id}|${o.key}`) && <em class="is-pending">{fill(w.changeFromRequested, { when: day(o.start) })}</em>}
311
356
  </span>
312
357
  {open && (
313
358
  <span class="pc-mem-date-acts">
@@ -377,6 +422,39 @@ function moveValues(e: CalEvent, o: Occurrence) {
377
422
  </form>
378
423
  </details>
379
424
  )}
425
+ {open && !off && !clashHere && (
426
+ <details class="pc-mem-move" open={reopenFrom}>
427
+ <summary class="pc-mem-btn is-small" aria-label={fill(w.changeFromLabel, { title: ev.title, when: when(o) })}>{w.changeFrom}</summary>
428
+ <form method="post">
429
+ <input type="hidden" name="op" value="from-date" />
430
+ <input type="hidden" name="id" value={e.id} />
431
+ <input type="hidden" name="key" value={o.key} />
432
+ <p class="pc-mem-hint">{w.changeFromHint}</p>
433
+ <div class="pc-mem-row">
434
+ <label class="pc-bk-field"><span>{w.firstDate}</span><input type="date" name="date" required value={reopenFrom ? v.date : wallParts(e, o.start).date} /></label>
435
+ {!e.allDay && <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="startTime" required value={reopenFrom ? v.startTime : wallParts(e, o.start).time} /></label>}
436
+ {!e.allDay && <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="endTime" value={reopenFrom ? v.endTime : wallParts(e, o.end).time} /></label>}
437
+ </div>
438
+ <label class="pc-bk-field"><span>{w.form.repeats}</span>
439
+ <select name="repeat">
440
+ {([['same', w.sameRepeat], ['weekly', w.form.repeat.weekly], ['biweekly', w.form.repeat.biweekly], ['monthly', w.form.repeat.monthly], ['daily', w.form.repeat.daily], ['once', w.justThisDate]] as const)
441
+ .map(([value, label]) => <option value={value} selected={reopenFrom && posted!.form.get('repeat') === value}>{label}</option>)}
442
+ </select>
443
+ </label>
444
+ {rooms.length > 0 && (
445
+ <fieldset>
446
+ <legend>{w.rooms}</legend>
447
+ <input type="hidden" name="rooms" value="1" />
448
+ {rooms.map((r) => <label class="pc-bk-check"><input type="checkbox" name="roomIds" value={r.id} checked={(reopenFrom ? v.roomIds : o.roomIds).includes(r.id)} /> <span>{r.name}</span></label>)}
449
+ <p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
450
+ </fieldset>
451
+ )}
452
+ <label class="pc-bk-field"><span>{w.publicNote} <em>{w.form.optional}</em></span><input name="note" maxlength="500" value={reopenFrom ? v.note : ''} /><small>{w.publicNoteHint}</small></label>
453
+ {!user.can('content.edit') && <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{reopenFrom ? v.message : ''}</textarea></label>}
454
+ <div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{user.can('content.edit') ? w.changeFrom.replace(/…$/, '') : w.sendForApproval}</button></div>
455
+ </form>
456
+ </details>
457
+ )}
380
458
  </li>
381
459
  );
382
460
  })}