Skip to content

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_active flag (suspend / unsuspend) and subscription_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

  • usersis_active, subscription_status, plus the account_type (tomoda / standard / partner) and role (admin / …) pair the admin gate checks
  • locationssecondary_providers (touched by the merge tool)
  • audit_logsuser_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 writes
  • admin.LocationAdmin — the location domain's merge surface
  • admin.AuditLogger — writes to audit_logs after every state-changing admin action (satisfied by the platform audit service)
  • AsyncHandler — an Asynq Inspector + Client built against the same Redis as the worker (config.RedisConfig)
  • access.Require(access.TomodaAdmin) — verifies account_type = tomoda + role = admin before any handler runs
  • internal/version — exposes Version + CommitSHA so /admin/stats/system echoes 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

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:

  1. Rewrite every cross-reference — driven by the locationFKRewrites manifest in backend/internal/services/location/location_store.go. Today that's events.location_id and moments.location_id; any new table with a location_id FK gets registered there. The startup verifier (VerifyMergeCoverage) fails the boot if any FK is missing from the manifest.
  2. Cross-link providers — append source's (provider, provider_id) to target's secondary_providers JSONB, 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.
  3. 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 in audit_logs (action location.merge) — there is no merged_into redirect column.
  4. Audit log — actor, source, target, reason recorded in audit_logs (action location.merge).
  5. 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.