Admin¶
Purpose¶
The admin domain (backend/internal/services/admin/) surfaces dashboard-grade statistics about the platform (user counts, event counts, daily active users by platform, signup-method breakdowns), lets admins moderate individual users (toggle active, change subscription plan), folds duplicate places together via location merge, and exposes operational controls over the async worker (queue overview, dead-letter replay, on-demand S3 orphan sweep, stamp/challenge re-evaluation).
There is no AdminService — the admin Handler goes straight to admin.Store for the read-heavy stats queries and to the location service (via the LocationAdmin interface) for merge. The async subgroup is served by a separate AsyncHandler that talks to the Asynq Inspector + Client directly. Every admin mutation is recorded to audit_logs through the AuditLogger interface (see ActivityLog vs. AuditLog).
The category-creation endpoint that used to live here is gone — the canonical category and tag taxonomy lives in backend/data/*.json (see Discovery) and edits ship via code review, not live admin mutation.
Responsibilities¶
- Aggregate platform-wide stats (totals, DAU, signup-method breakdown, platform breakdown, 7-day platform DAU)
- Paginated user list + per-user activity stats, daily activity stats over N days
- Update a user's
is_activeflag (suspend / unsuspend) andsubscription_status(grant comps / debug billing) - Merge duplicate places
- Operate the async worker: queue overview, dead-letter (archived) inspect / replay / delete, on-demand S3 orphan sweep, stamp/challenge re-evaluation
- Record every admin mutation to
audit_logs
HTTP endpoints¶
All routes are JWT-gated and gated by access.Require(access.TomodaAdmin), which requires account_type = tomoda and role = admin (see backend/internal/access/). Routes are registered by admin.RegisterRoutes, which takes the admin gate as a func(http.Handler) http.Handler middleware parameter.
Console¶
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/stats/system |
Totals, DAU, event counts, version + commit SHA, platform / signup-method / daily-platform stats |
| GET | /api/v1/admin/stats/users |
Paginated per-user stats (limit, offset) |
| GET | /api/v1/admin/stats/daily |
Activity per day (days query, default 30) |
| GET | /api/v1/admin/users |
Paginated user list + total count (limit, offset) |
| PATCH | /api/v1/admin/users/{id}/status |
Body { "is_active": bool }, audit-logged |
| PATCH | /api/v1/admin/users/{id}/subscription |
Body { "status": string }, audit-logged |
| POST | /api/v1/admin/locations/merge |
Body { "source_id": uuid, "target_id": uuid, "reason"?: string }, folds source into target, audit-logged |
Async operations — /api/v1/admin/async/...¶
Served by AsyncHandler, which drives the Asynq Inspector + Client (see backend/internal/services/admin/async_handler.go). Generic across every task type, not just one queue.
| Method | Path | Description |
|---|---|---|
| GET | /queues |
Every queue with a stats snapshot (size, active, pending, retry, archived, latency, paused) |
| GET | /queues/{queue}/archived |
Paginated dead-letter (archived) tasks (page, page_size, max 200) |
| POST | /queues/{queue}/archived/{id}/run |
Pull one archived task back to pending for retry |
| DELETE | /queues/{queue}/archived/{id} |
Drop one archived task (invalid payload) |
| POST | /queues/{queue}/archived/run-all |
Bulk replay a queue's archived tasks; returns { "moved": n } |
| POST | /s3-orphan-sweep |
Enqueue an S3 orphan sweep for one prefix (curated prefixes refused) |
| POST | /replay/challenge/{id} |
Enqueue a retroactive challenge evaluation (low queue, 24h dedup) |
| POST | /replay/stamp/{id} |
Enqueue a retroactive stamp evaluation (low queue, 24h dedup) |
Key types¶
// admin.Store — read-heavy stats + user moderation
type Store interface {
GetSystemStats() (*SystemStats, error)
GetUserStats(limit, offset int) ([]UserStats, error)
GetDailyStats(days int) ([]DailyStats, error)
GetAllUsers(limit, offset int) ([]models.User, int64, error)
UpdateUserStatus(userID string, isActive bool) error
UpdateUserSubscription(userID, status string) error
GetPlatformStats() ([]PlatformStats, error)
GetSignupMethodStats() ([]SignupMethodStats, error)
GetDailyActiveUsersByPlatform(days int) ([]DailyPlatformStats, error)
}
// admin.LocationAdmin — merge, backed by the location domain
type LocationAdmin interface {
Merge(ctx context.Context, src, dst uuid.UUID) (*models.Location, error)
}
// admin.AuditLogger — forensic trail for every mutation
type AuditLogger interface {
Log(userID uuid.UUID, action models.AuditAction,
resourceType, resourceID, details, ip, userAgent string) error
}
Data model¶
users—is_active,subscription_status, plus theaccount_type(tomoda/standard/partner) androle(admin/ …) pair the admin gate checkslocations—secondary_providers(touched by the merge tool)audit_logs—user_id,action,resource_type,resource_id,details,ip_address,user_agent,created_at
Dependencies¶
admin.Store— direct stats aggregation (raw SQL with Postgres date functions) + user moderation writesadmin.LocationAdmin— the location domain's merge surfaceadmin.AuditLogger— writes toaudit_logsafter every state-changing admin action (satisfied by the platform audit service)AsyncHandler— an AsynqInspector+Clientbuilt against the same Redis as the worker (config.RedisConfig)access.Require(access.TomodaAdmin)— verifiesaccount_type = tomoda+role = adminbefore any handler runsinternal/version— exposesVersion+CommitSHAso/admin/stats/systemechoes the running build
Notable behavior¶
Mutations always go through Audit
Every mutating handler (UpdateUserStatus, UpdateUserSubscription, MergeLocations) calls auditService.Log(adminID, AuditActionUpdate, ...) after a successful write. Don't add a new admin mutation without that call — it's the only forensic trail.
No AdminService
Unlike most other domains, the admin layer is intentionally thin. Store performs the heavy SQL (cohorts, platform breakdowns, daily aggregates) and the Handler glues it together, delegating cross-domain work to the location service and the Asynq inspector. If a future admin feature needs its own cross-domain logic, introduce a Service; until then the direct handler-to-store path is fine.
ActivityLog vs. AuditLog¶
The codebase distinguishes two append-only logs and they are not interchangeable:
audit_logs (AuditService) |
user_activity_logs (ActivityLogService) |
|
|---|---|---|
| Purpose | Forensic record of admin / sensitive actions | Product analytics for user behaviour (joined event, sent friend request, location check-in) |
| Path | Direct synchronous write | Enqueued to the low Asynq queue |
| Read access | Admin tooling | Internal analytics / recommendation features |
| Touched by | The admin mutating handlers, plus various sensitive flows | Event, friend, and session flows |
Where to look¶
backend/internal/services/admin/handler.go— console handlersbackend/internal/services/admin/async_handler.go— queue / DLQ / replay / orphan-sweep handlersbackend/internal/services/admin/store.go— stats aggregation + user moderation SQLbackend/internal/services/admin/deps.go—AuditLogger,LocationAdmininterfacesbackend/internal/platform/audit/— the audit service satisfyingAuditLoggerbackend/internal/access/—TomodaAdminpolicy +access.Requirebackend/internal/models/audit_log.go,activity_log.go
Location merge¶
Auto-dedup (provider-id match + spatial+name fuzzy — see Locations → Dedup story) catches most duplicates at write time, but when it misses — most commonly "same physical place, different display names" like "Apple Store - Ginza" vs "Apple Ginza" — an admin folds the two together via this endpoint.
POST /api/v1/admin/locations/merge folds a duplicate into the surviving row. Body:
{
"source_id": "<uuid-of-the-row-to-collapse>",
"target_id": "<uuid-of-the-survivor>",
"reason": "(optional) free-form note for the audit log"
}
The merge runs in a transaction:
- Rewrite every cross-reference — driven by the
locationFKRewritesmanifest inbackend/internal/services/location/location_store.go. Today that'sevents.location_idandmoments.location_id; any new table with alocation_idFK gets registered there. The startup verifier (VerifyMergeCoverage) fails the boot if any FK is missing from the manifest. - Cross-link providers — append source's
(provider, provider_id)to target'ssecondary_providersJSONB, plus any of source's own secondary providers, deduped. This keeps target reachable by either provider's id from now on, so a later Photon vs. Google lookup of the same physical place won't spawn a third row. - Hard-delete source via
tx.Unscoped().Delete(). Step 1 has already rewritten every live reference; nothing alive points at it any more. The forensic trail lives entirely inaudit_logs(actionlocation.merge) — there is nomerged_intoredirect column. - Audit log — actor, source, target, reason recorded in
audit_logs(actionlocation.merge). - Invalidate Redis — the provider-pair cache entries on the surviving row are cleared so callers stop hitting stale pointers.
The merge endpoint refuses source_id == target_id, refuses already-merged sources, and is gated by the same access.TomodaAdmin middleware as the stats endpoints.