From 3875604a50542b2925e3a696af29f6fb9ebce1e2 Mon Sep 17 00:00:00 2001 From: mqmalagris <63169613+mqmalagris@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:56:38 -0300 Subject: [PATCH 1/5] feat: admin search and analytics dashboard - Search box on the admin inbox view filters the message list client-side by from/subject/preview substrings. - New stats package exposes user, premium, active inbox, and message (24h/7d) counts via DynamoDB scans with paginated SelectCount. - GET /admin/stats returns the snapshot; AdminView renders a five-tile strip at the top so the operator sees platform health at a glance. Scan-based aggregation is fine at current volumes; if the table grows we will move to per-counter sentinel items or CloudWatch metrics. --- apps/web/src/components/AdminView.svelte | 74 ++++++++++++- services/cmd/api-local/main.go | 2 +- services/cmd/api/main.go | 4 +- services/internal/handler/api.go | 26 ++++- services/internal/handler/api_test.go | 2 +- services/internal/stats/service.go | 129 +++++++++++++++++++++++ terraform/api_gateway.tf | 6 ++ 7 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 services/internal/stats/service.go diff --git a/apps/web/src/components/AdminView.svelte b/apps/web/src/components/AdminView.svelte index c8d17fc..dcf5555 100644 --- a/apps/web/src/components/AdminView.svelte +++ b/apps/web/src/components/AdminView.svelte @@ -23,6 +23,40 @@ let selectedMessage = $state(null); let loading = $state(true); let pollInterval: ReturnType | undefined; + let searchQuery = $state(""); + + type StatsSnapshot = { + users: number; + premium_users: number; + active_inboxes: number; + messages_24h: number; + messages_7d: number; + generated_at: number; + }; + let stats = $state(null); + let statsLoading = $state(false); + + async function loadStats() { + if (!user.apiKey) return; + statsLoading = true; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/stats`, { + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + if (res.ok) stats = await res.json(); + } catch { /* silent */ } + finally { statsLoading = false; } + } + + let filteredMessages = $derived.by(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return messages; + return messages.filter((m) => + (m.from || "").toLowerCase().includes(q) || + (m.subject || "").toLowerCase().includes(q) || + (m.preview || "").toLowerCase().includes(q) + ); + }); // Compose state let showCompose = $state(false); @@ -145,6 +179,7 @@ await initAdminInboxes(); await loadMessages(); + loadStats(); loading = false; pollInterval = setInterval(loadMessages, 10000); @@ -170,6 +205,30 @@
{:else} + +
+
+
USERS
+
{stats?.users ?? "—"}
+
+
+
PREMIUM
+
{stats?.premium_users ?? "—"}
+
+
+
ACTIVE INBOXES
+
{stats?.active_inboxes ?? "—"}
+
+
+
MSG / 24h
+
{stats?.messages_24h ?? "—"}
+
+
+
MSG / 7d
+
{stats?.messages_7d ?? "—"}
+
+
+
diff --git a/services/internal/handler/api.go b/services/internal/handler/api.go index a017917..00db4d8 100644 --- a/services/internal/handler/api.go +++ b/services/internal/handler/api.go @@ -114,6 +114,8 @@ func (a *API) routeInternal(method string, parts []string) http.HandlerFunc { // Admin routes case method == "GET" && len(parts) == 2 && parts[0] == "admin" && parts[1] == "stats": return a.GetAdminStats + case method == "GET" && len(parts) == 2 && parts[0] == "admin" && parts[1] == "users": + return a.GetAdminUsers case method == "POST" && len(parts) == 2 && parts[0] == "admin" && parts[1] == "inbox": return a.CreateAdminInbox case method == "GET" && len(parts) == 3 && parts[0] == "admin" && parts[1] == "inbox": @@ -183,6 +185,20 @@ func (a *API) GetAdminStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, snap) } +func (a *API) GetAdminUsers(w http.ResponseWriter, r *http.Request) { + if _, err := a.authenticateAdmin(r); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + users, err := a.userSvc.ListAllUsers(r.Context()) + if err != nil { + log.Printf("list users error: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list users"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + func (a *API) CreateAdminInbox(w http.ResponseWriter, r *http.Request) { if _, err := a.authenticateAdmin(r); err != nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) diff --git a/services/internal/user/model.go b/services/internal/user/model.go index b28512d..534b998 100644 --- a/services/internal/user/model.go +++ b/services/internal/user/model.go @@ -74,6 +74,18 @@ type UserInfo struct { CreatedAt int64 `json:"created_at"` } +// AdminUserSummary is one row in the admin user list. We deliberately omit +// the api key and the webhook secret; admins shouldn't see those. +type AdminUserSummary struct { + UserID string `json:"user_id"` + Email string `json:"email,omitempty"` + Tier string `json:"tier"` + Role string `json:"role,omitempty"` + NoAds bool `json:"no_ads"` + HasWebhook bool `json:"has_webhook"` + CreatedAt int64 `json:"created_at"` +} + // MagicLinkResponse is returned after sending a magic link. type MagicLinkResponse struct { Message string `json:"message"` diff --git a/services/internal/user/repository.go b/services/internal/user/repository.go index 5d50dfe..57fc55b 100644 --- a/services/internal/user/repository.go +++ b/services/internal/user/repository.go @@ -170,6 +170,38 @@ func (r *Repository) ClearWebhook(ctx context.Context, userID string) error { return err } +// ListUsers returns every user record. Uses Scan with a sentinel filter; fine +// while volume is low. If we outgrow this, switch to a "tier-index" GSI. +func (r *Repository) ListUsers(ctx context.Context) ([]*UserRecord, error) { + var out []*UserRecord + var lastKey map[string]types.AttributeValue + for { + resp, err := r.client.Scan(ctx, &dynamodb.ScanInput{ + TableName: &r.tableName, + FilterExpression: aws.String("message_id = :sentinel"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":sentinel": &types.AttributeValueMemberS{Value: UserSentinel}, + }, + ExclusiveStartKey: lastKey, + }) + if err != nil { + return nil, err + } + for _, item := range resp.Items { + var rec UserRecord + if err := attributevalue.UnmarshalMap(item, &rec); err != nil { + continue + } + rec.UserID = strings.TrimPrefix(rec.InboxAddress, UserPKPrefix) + out = append(out, &rec) + } + if resp.LastEvaluatedKey == nil { + return out, nil + } + lastKey = resp.LastEvaluatedKey + } +} + // CountUserInboxes returns the number of active inboxes for a user. func (r *Repository) CountUserInboxes(ctx context.Context, userID string) (int, error) { out, err := r.client.Query(ctx, &dynamodb.QueryInput{ diff --git a/services/internal/user/service.go b/services/internal/user/service.go index ba7ce00..daf8e98 100644 --- a/services/internal/user/service.go +++ b/services/internal/user/service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "errors" + "sort" "time" "github.com/ephemask/services/internal/mailer" @@ -354,6 +355,30 @@ func (s *Service) ClearWebhook(ctx context.Context, userID string) error { return s.repo.ClearWebhook(ctx, userID) } +// ListAllUsers returns a summary of every registered user, sorted by created +// time (newest first). Intended for the admin dashboard only. +func (s *Service) ListAllUsers(ctx context.Context) ([]AdminUserSummary, error) { + records, err := s.repo.ListUsers(ctx) + if err != nil { + return nil, err + } + out := make([]AdminUserSummary, 0, len(records)) + for _, r := range records { + out = append(out, AdminUserSummary{ + UserID: r.UserID, + Email: r.Email, + Tier: r.Tier, + Role: r.Role, + NoAds: r.NoAds, + HasWebhook: r.WebhookURL != "", + CreatedAt: r.CreatedAt, + }) + } + // Newest first. + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + return out, nil +} + // GetTTLForTier returns the TTL in seconds for the given tier. func GetTTLForTier(tier string) int64 { if tier == TierPremium { diff --git a/terraform/api_gateway.tf b/terraform/api_gateway.tf index 26693e1..3826c7c 100644 --- a/terraform/api_gateway.tf +++ b/terraform/api_gateway.tf @@ -193,6 +193,12 @@ resource "aws_apigatewayv2_route" "get_admin_stats" { target = "integrations/${aws_apigatewayv2_integration.api.id}" } +resource "aws_apigatewayv2_route" "get_admin_users" { + api_id = aws_apigatewayv2_api.main.id + route_key = "GET /admin/users" + target = "integrations/${aws_apigatewayv2_integration.api.id}" +} + resource "aws_apigatewayv2_route" "post_admin_inbox" { api_id = aws_apigatewayv2_api.main.id route_key = "POST /admin/inbox" From 68b1b12cbec92287146e8ee3d1bbae4ddbb3c71b Mon Sep 17 00:00:00 2001 From: mqmalagris <63169613+mqmalagris@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:06:15 -0300 Subject: [PATCH 3/5] feat: admin email templates for compose New template package persists per-admin compose presets in DynamoDB under PK __template__:. Admin endpoints GET/POST/DELETE /admin/templates list, create (max 25 per user), and remove templates. Compose form gets a 'Insert template' dropdown that fills subject and body, and a 'Save template' input next to SEND that captures the current draft under a name. Saved templates are listed below the form with inline delete buttons. --- apps/web/src/components/AdminView.svelte | 131 +++++++++++++++++++-- services/cmd/api-local/main.go | 2 +- services/cmd/api/main.go | 4 +- services/internal/handler/api.go | 83 ++++++++++++- services/internal/handler/api_test.go | 2 +- services/internal/template/service.go | 142 +++++++++++++++++++++++ terraform/api_gateway.tf | 18 +++ 7 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 services/internal/template/service.go diff --git a/apps/web/src/components/AdminView.svelte b/apps/web/src/components/AdminView.svelte index 4394c60..3bd1bcd 100644 --- a/apps/web/src/components/AdminView.svelte +++ b/apps/web/src/components/AdminView.svelte @@ -92,6 +92,74 @@ if (users.length === 0) loadUsers(); } + type AdminTemplate = { + id: string; + name: string; + subject: string; + body: string; + created_at: number; + }; + let templates = $state([]); + let templateSelected = $state(""); + let savingTemplate = $state(false); + let savingTemplateName = $state(""); + + async function loadTemplates() { + if (!user.apiKey) return; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates`, { + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + if (res.ok) { + const data = await res.json(); + templates = data.templates || []; + } + } catch { /* silent */ } + } + + function applyTemplate(id: string) { + if (!id) return; + const t = templates.find((x) => x.id === id); + if (!t) return; + composeSubject = t.subject; + composeBody = t.body; + templateSelected = ""; + } + + async function saveAsTemplate() { + if (!user.apiKey || !savingTemplateName.trim() || !composeBody.trim()) return; + savingTemplate = true; + try { + const res = await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${user.apiKey}` }, + body: JSON.stringify({ + name: savingTemplateName.trim(), + subject: composeSubject, + body: composeBody, + }), + }); + if (res.ok) { + savingTemplateName = ""; + await loadTemplates(); + } + } finally { + savingTemplate = false; + } + } + + async function deleteTemplate(id: string) { + if (!user.apiKey) return; + if (!confirm("Delete this template?")) return; + try { + await fetch(`${import.meta.env.PUBLIC_API_URL || ""}/admin/templates/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${user.apiKey}` }, + }); + templates = templates.filter((t) => t.id !== id); + } catch { /* silent */ } + } + let filteredMessages = $derived.by(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return messages; @@ -224,6 +292,7 @@ await initAdminInboxes(); await loadMessages(); loadStats(); + loadTemplates(); loading = false; pollInterval = setInterval(loadMessages, 10000); @@ -408,6 +477,21 @@ SUBJECT + {#if templates.length > 0} +
+ TEMPLATE + +
+ {/if}