Opgave 11b (del 3): Mail-skabeloner + annoncér

TemplatesPage (/skabeloner) — én sektion pr. event_type
(round_announced/order_confirmed/payment_confirmed), hver med
opret/gem/slet og et hint om hvilke {{variabler}} der er tilgængelige
for netop den mailtype. Ingen backend-ændringer nødvendige, al CRUD
fandtes allerede (opgave 7a).

"Annoncér runde"-knap på RoundDetailPage (kun ved status "open"), med
et eksplicit confirm() da handlingen sender rigtig mail til alle
aktive deltagere på ruten og ikke er idempotent. Kalder det
eksisterende /announce-endpoint (opgave 7b) og viser resultatet.

Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for at undgå en utilsigtet rigtig annoncering til de 308
rigtige deltagere under udvikling. Brugeren oprettede under
browser-test rigtige mail-skabeloner for alle tre event-typer på
rute 4 — det tidligere udskudte punkt er dermed løst.

Dette afslutter opgave 11b (alle tre dele).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 02:34:07 +02:00
parent 588888cd9e
commit cae8657ec8
5 changed files with 260 additions and 15 deletions

View file

@ -203,12 +203,11 @@ offentlige side, egen `Jinja2Templates`-instans i
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
(scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring
at være for stort til én omgang og er selv delt i tre dele, alle
stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt
frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2**
(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en
måde at oprette en `round_announced`-skabelon på). **11c**
(ordreoversigt/markér betalt) er heller ikke bygget endnu.
stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**:
**del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2**
(runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget
endnu.
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
@ -313,9 +312,34 @@ fandtes allerede (opgave 6):
token). Åbne runder kan aldrig slettes (håndhævet server-side,
ingen UI-vej udenom).
- **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og
mail-skabeloner (del 3); link til opgave 10's afhentningsliste
(autentificeret HTML-endpoint — kræver en særskilt løsning, da et
almindeligt `<a href>` ikke sender `Authorization`-headeren).
mail-skabeloner (del 3, se nedenfor); link til opgave 10's
afhentningsliste (autentificeret HTML-endpoint — kræver en særskilt
løsning, da et almindeligt `<a href>` ikke sender
`Authorization`-headeren).
**11b del 3** (mail-skabeloner + annoncér) afslutter 11b. Ingen
backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og
7b's `/announce`-endpoint fandtes allerede, blot uden noget UI:
- **`TemplatesPage`** (`/skabeloner`) — én sektion pr. `event_type`
(Runde åbnet/Ordre bekræftet/Betaling bekræftet), hver med Emne +
Indhold (rå HTML i en `<textarea>`, bevidst ingen rich-text-editor-
afhængighed) og et hint om hvilke `{{variabler}}` der er
tilgængelige for netop den event-type (genlæst fra
`send_order_event_mail`/`announce_purchase_round`). Opret/gem/slet
pr. sektion — ingen elevation nødvendig (backend kræver kun
`CurrentUser`).
- **"Annoncér runde"-knap** på `RoundDetailPage`, vist når
`status === 'open'`. Sender rigtig mail til **alle aktive
deltagere på ruten** og er ikke idempotent — derfor et eksplicit
`window.confirm(...)` først. Viser resultatet
(sendt/fejlet/deltagere i alt) direkte på siden.
- **Rute 4 har nu rigtige mail-skabeloner for alle tre event-typer**
(oprettet af brugeren under browser-test af editoren — det tidligere
udskudte punkt er dermed løst som en sideeffekt). "Annoncér" er
fortsat kun afprøvet i en fuldstændig isoleret sandbox (en
midlertidig separat rute, ikke rute 4) for at undgå at sende en
utilsigtet rigtig annoncering til de 308 rigtige deltagere under
udvikling.
## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
@ -349,7 +373,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope
**Færdige opgaver (1-10, 11a, 11b del 1-2):**
**Færdige opgaver (1-10, 11a, 11b):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -400,11 +424,10 @@ logges/advares om i stedet.
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
"Admin-UI-arkitektur" ovenfor.
- **11b**: Deltagere, runder/vinliste, annoncering — delt i tre
(se "Admin-UI-arkitektur" ovenfor): **del 1** ✅
(frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅
(runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget).
- **11b**: ✅ Deltagere, runder/vinliste, annoncering — delt i tre
(se "Admin-UI-arkitektur" ovenfor), alle færdige: **del 1**
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder +
vinliste), **del 3** (mail-skabelon-editor + annoncér-knap).
- **11c**: Ordreoversigt/markér betalt — ikke bygget.
**Resterende opgaver:**

View file

@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'
import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage'
import { TemplatesPage } from './pages/TemplatesPage'
import './App.css'
function AppRoutes() {
@ -19,6 +20,7 @@ function AppRoutes() {
<Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="/skabeloner" element={<TemplatesPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} />
</Route>
</Routes>

View file

@ -23,6 +23,7 @@ export function Layout() {
<div className="navbar-links">
<NavLink to="/deltagere">Deltagere</NavLink>
<NavLink to="/runder">Runder</NavLink>
<NavLink to="/skabeloner">Skabeloner</NavLink>
{currentUser?.is_superadmin &&
(isElevated ? (
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">

View file

@ -65,6 +65,12 @@ interface NewWineForm {
const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' }
interface AnnounceResult {
sent: number
failed: number
total_participants: number
}
export function RoundDetailPage() {
const { roundId } = useParams<{ roundId: string }>()
const navigate = useNavigate()
@ -75,6 +81,7 @@ export function RoundDetailPage() {
const [wines, setWines] = useState<WineOffering[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [announceResult, setAnnounceResult] = useState<AnnounceResult | null>(null)
const [newWine, setNewWine] = useState<NewWineForm>(emptyWineForm)
const [editingWineId, setEditingWineId] = useState<number | null>(null)
const [editWineForm, setEditWineForm] = useState<NewWineForm>(emptyWineForm)
@ -159,6 +166,24 @@ export function RoundDetailPage() {
}
}
async function handleAnnounce() {
if (!roundId) return
if (
!window.confirm(
'Send "runde åbnet"-mail til alle aktive deltagere på ruten? Dette kan ikke fortrydes, og sender igen til alle hvis du klikker mere end én gang.',
)
)
return
setError(null)
setAnnounceResult(null)
try {
const result = await apiFetch<AnnounceResult>(`/purchase-rounds/${roundId}/announce`, { method: 'POST' })
setAnnounceResult(result)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke annoncere runden')
}
}
async function handleCreateWine(event: FormEvent) {
event.preventDefault()
if (!roundId) return
@ -296,6 +321,11 @@ export function RoundDetailPage() {
<button type="button" onClick={handleCopy}>
Kopiér denne runde
</button>
{round.status === 'open' && (
<button type="button" onClick={handleAnnounce}>
Annoncér runde
</button>
)}
{(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
<button type="button" onClick={handleDelete}>
Slet
@ -305,6 +335,12 @@ export function RoundDetailPage() {
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.</p>
)}
</form>
{announceResult && (
<p className="hint">
Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '}
{announceResult.total_participants}
</p>
)}
<h2>Vinliste</h2>
<form onSubmit={handleCreateWine} className="inline-form">

View file

@ -0,0 +1,183 @@
import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api'
interface Route {
id: number
name: string
}
type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed'
interface MailTemplate {
id: number
event_type: EventType
subject: string
body_html: string
route_id: number
}
const ORDER_MAIL_VARIABLES = [
'participant_name',
'route_name',
'round_name',
'order_number',
'order_deadline_at',
'pickup_at',
'pickup_info_text',
'order_lines_html',
'order_total_eur',
'order_total_dkk',
]
const EVENT_TYPES: { value: EventType; label: string; variables: string[] }[] = [
{
value: 'round_announced',
label: 'Runde åbnet',
variables: [
'participant_name',
'route_name',
'round_name',
'opens_at',
'order_deadline_at',
'pickup_at',
'intro_text',
'pickup_info_text',
],
},
{ value: 'order_confirmed', label: 'Ordre bekræftet', variables: ORDER_MAIL_VARIABLES },
{ value: 'payment_confirmed', label: 'Betaling bekræftet', variables: ORDER_MAIL_VARIABLES },
]
function TemplateSection({
eventType,
label,
variables,
template,
routeId,
onChange,
}: {
eventType: EventType
label: string
variables: string[]
template: MailTemplate | undefined
routeId: number
onChange: () => Promise<void>
}) {
const [subject, setSubject] = useState(template?.subject ?? '')
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? '')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setSubject(template?.subject ?? '')
setBodyHtml(template?.body_html ?? '')
}, [template])
async function handleSave(event: FormEvent) {
event.preventDefault()
setError(null)
try {
if (template) {
await apiFetch(`/mail-templates/${template.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, body_html: bodyHtml }),
})
} else {
await apiFetch('/mail-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event_type: eventType, subject, body_html: bodyHtml, route_id: routeId }),
})
}
await onChange()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme skabelonen')
}
}
async function handleDelete() {
if (!template) return
if (!window.confirm(`Slet skabelonen for "${label}"?`)) return
setError(null)
try {
await apiFetch(`/mail-templates/${template.id}`, { method: 'DELETE' })
await onChange()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette skabelonen')
}
}
return (
<section>
<h2>{label}</h2>
<p className="hint">Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')}</p>
<form onSubmit={handleSave} className="stacked-form">
<label>
Emne
<input value={subject} onChange={(e) => setSubject(e.target.value)} required />
</label>
<label>
Indhold (HTML)
<textarea value={bodyHtml} onChange={(e) => setBodyHtml(e.target.value)} rows={8} required />
</label>
<button type="submit">{template ? 'Gem' : 'Opret'}</button>
{template && (
<button type="button" onClick={handleDelete}>
Slet
</button>
)}
</form>
{error && <p className="error">{error}</p>}
</section>
)
}
export function TemplatesPage() {
const [routeId, setRouteId] = useState<number | null>(null)
const [templates, setTemplates] = useState<MailTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadTemplates(forRouteId: number) {
const data = await apiFetch<MailTemplate[]>(`/mail-templates?route_id=${forRouteId}`)
setTemplates(data)
}
useEffect(() => {
apiFetch<Route[]>('/routes')
.then(async (routes) => {
if (routes.length === 0) {
setError('Ingen ruter fundet for din organisation')
setLoading(false)
return
}
setRouteId(routes[0].id)
await loadTemplates(routes[0].id)
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner')
})
.finally(() => setLoading(false))
}, [])
if (loading) return <p>Indlæser...</p>
if (routeId === null) return <p className="error">{error}</p>
return (
<div>
<h1>Mail-skabeloner</h1>
{error && <p className="error">{error}</p>}
{EVENT_TYPES.map(({ value, label, variables }) => (
<TemplateSection
key={value}
eventType={value}
label={label}
variables={variables}
template={templates.find((t) => t.event_type === value)}
routeId={routeId}
onChange={() => loadTemplates(routeId)}
/>
))}
</div>
)
}