feat(admin-console): Stage 6 — mail & notifications domain
Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.
- GET /_gm/mail deliveries (paginated) + dead-letters
- GET /_gm/mail/deliveries/{id} delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend re-enqueue a non-sent delivery
- GET /_gm/notifications notifications + dead-letters + malformed
- GET/POST /_gm/broadcast multi-game admin diplomatic broadcast
Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.
Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).
Docs: backend/docs/admin-console.md page inventory completed.
This commit is contained in:
@@ -100,3 +100,4 @@ button.danger { background: var(--danger); color: #1a0606; }
|
||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package adminconsole
|
||||
|
||||
// MailDeliveryRow is one line in the mail deliveries table.
|
||||
type MailDeliveryRow struct {
|
||||
DeliveryID string
|
||||
Template string
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttempt string
|
||||
Created string
|
||||
}
|
||||
|
||||
// MailDeadLetterRow is one line in the mail dead-letters table.
|
||||
type MailDeadLetterRow struct {
|
||||
DeliveryID string
|
||||
Reason string
|
||||
Archived string
|
||||
}
|
||||
|
||||
// MailData is the view model for the mail page: a paginated deliveries list
|
||||
// plus a snapshot of dead-letters.
|
||||
type MailData struct {
|
||||
Deliveries []MailDeliveryRow
|
||||
DeadLetters []MailDeadLetterRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int64
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// MailAttemptRow is one delivery attempt on the mail detail page.
|
||||
type MailAttemptRow struct {
|
||||
AttemptNo int32
|
||||
Outcome string
|
||||
Started string
|
||||
Finished string
|
||||
Error string
|
||||
}
|
||||
|
||||
// MailDeliveryDetail is the view model for a single delivery.
|
||||
type MailDeliveryDetail struct {
|
||||
DeliveryID string
|
||||
Template string
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttempt string
|
||||
LastError string
|
||||
Created string
|
||||
Sent string
|
||||
DeadLettered string
|
||||
CanResend bool
|
||||
AttemptRows []MailAttemptRow
|
||||
}
|
||||
|
||||
// NotificationRow is one line in the notifications table.
|
||||
type NotificationRow struct {
|
||||
NotificationID string
|
||||
Kind string
|
||||
UserID string
|
||||
Created string
|
||||
}
|
||||
|
||||
// NotificationDeadLetterRow is one line in the notification dead-letters table.
|
||||
type NotificationDeadLetterRow struct {
|
||||
NotificationID string
|
||||
RouteID string
|
||||
Reason string
|
||||
Archived string
|
||||
}
|
||||
|
||||
// MalformedRow is one line in the malformed-intents table.
|
||||
type MalformedRow struct {
|
||||
ID string
|
||||
Reason string
|
||||
Received string
|
||||
}
|
||||
|
||||
// NotificationsData is the view model for the notifications overview page.
|
||||
type NotificationsData struct {
|
||||
Notifications []NotificationRow
|
||||
DeadLetters []NotificationDeadLetterRow
|
||||
Malformed []MalformedRow
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Broadcast</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
<section class="panel">
|
||||
<h2>Admin broadcast</h2>
|
||||
<form method="post" action="/_gm/broadcast" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Scope
|
||||
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
||||
</label>
|
||||
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
||||
<label>Recipients
|
||||
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
||||
</label>
|
||||
<label>Subject <input type="text" name="subject"></label>
|
||||
<label>Body <input type="text" name="body" required></label>
|
||||
<button type="submit">Send broadcast</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
<h1>Mail</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
{{with .Data}}
|
||||
<section class="panel">
|
||||
<h2>Deliveries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Deliveries}}
|
||||
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Dead-letters</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/mail">« mail</a></p>
|
||||
<h1>Delivery</h1>
|
||||
<section class="panel">
|
||||
<ul class="kv">
|
||||
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
||||
<li>Template: {{.Template}}</li>
|
||||
<li>Status: {{.Status}}</li>
|
||||
<li>Attempts: {{.Attempts}}</li>
|
||||
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
||||
<li>Created: {{.Created}}</li>
|
||||
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
||||
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
||||
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
{{if .CanResend}}
|
||||
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
||||
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Attempts</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content" -}}
|
||||
<h1>Notifications</h1>
|
||||
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||
{{with .Data}}
|
||||
<section class="panel">
|
||||
<h2>Recent notifications</h2>
|
||||
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
||||
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Dead-letters</h2>
|
||||
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
||||
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Malformed intents</h2>
|
||||
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
||||
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
||||
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
||||
</tbody></table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
Reference in New Issue
Block a user