Stack of optimizations against the same Hetzner→Neon transatlantic link.
The trace revealed every visible ms was network/proxy overhead — DB
execution itself is sub-millisecond per query (verified via EXPLAIN
ANALYZE: index scans on every hot path).
Connection layer:
- DB_HOST → Neon pooler endpoint (-pooler suffix). PgBouncer
transaction-mode keeps backend Postgres connections warm so we no
longer pay the ~110ms Postgres-startup RTT on cold queries.
- GORM pool tuned: MaxIdleConns 10→20, MaxLifetime 600s→1800s,
MaxIdleTime added (default 0 = never close idle).
- Eager pool warm-up at boot via parallel pings — first user request
no longer pays the ~440ms TCP+TLS+startup handshake.
- Redis maxmemory-policy noeviction → allkeys-lru. Cache writes will
evict cold keys instead of erroring at the 256MB limit.
Auth layer:
- TokenCacheTTL 5min → 1 hour (Redis token cache).
- UserCacheTTL 30s → 5min (in-memory User cache, per pod).
- UserCache gains a 5,000-entry LRU cap so a flood of unique users
can't blow up pod RSS. ~5MB worst-case per pod.
- Token + user lookup collapsed from 2 GORM Preload queries into a
single INNER JOIN. Saves 1 RTT per cold-cache request.
- Auth middleware's m.db.* now use db.WithContext(ctx) so the SQL
spans nest under the parent HTTP request in Jaeger.
Service layer:
- TaskService.ListTasks: replaced two-step
FindResidenceIDsByUser → GetKanbanDataForMultipleResidences
with a single GetKanbanDataForUser that uses a Postgres subquery
for residence-access. One round-trip instead of two.
- New CacheService residence-IDs cache: \"residence_ids_user:<id>\"
with 5-min TTL. Wired into Task/Residence/Contractor/Document
services for the four hot read paths that need this list.
- Cache invalidation on every relevant mutation: CreateResidence,
DeleteResidence, JoinWithCode, RemoveUser. DeleteResidence
invalidates every member of the residence, not just the owner.
What this stacks up to (Hetzner→Neon, before US migration):
Path Before After (target)
Cache-warm authed read ~800ms ~100-200ms
Cache-cold authed read (1st in 1hr) ~2500ms ~500-700ms
First request after deploy ~2500ms ~700-900ms
The endgame US-region migration on top of this gets us to ~30-50ms
warm-cache, but we're shippable at ~150ms warm right now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The auth middleware's m.db.Preload + m.db.First calls were running without
ctx, so on cache miss the resulting SQL queries appeared as orphan
gorm.Query / gorm.Row spans in Jaeger. Now they nest under the parent
HTTP request span like every other repo call.
This was the last orphaned-SQL source on the request hot path. Combined
with the seven service migrations, every authenticated API call now
produces a fully-nested flame graph: HTTP → auth-token-lookup (cache hit)
or HTTP → auth-token-SQL (cache miss) → service → service-SQL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.
Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths
Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx
~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.
Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every public method on TaskService and ResidenceService now takes
ctx context.Context as the first arg and routes its repo calls through
.WithContext(ctx). With otelgorm registered, this means every API
endpoint backed by these two services produces a flame graph in Jaeger
where the SQL spans nest under the parent HTTP request span — instead
of appearing as orphaned queries.
Endpoints now fully traced (HTTP → service → SQL):
- GET /api/tasks/ (already shipped)
- GET /api/tasks/by-residence/:id/ (already shipped)
- GET /api/tasks/:id/
- POST /api/tasks/
- POST /api/tasks/bulk/
- PUT /api/tasks/:id/
- DELETE /api/tasks/:id/
- POST /api/tasks/:id/in-progress/
- POST /api/tasks/:id/cancel/
- POST /api/tasks/:id/uncancel/
- POST /api/tasks/:id/archive/
- POST /api/tasks/:id/unarchive/
- POST /api/tasks/:id/complete/
- POST /api/tasks/:id/quick-complete/
- GET /api/tasks/completions/* (CRUD)
- GET /api/static_data/ (categories, priorities, frequencies)
- GET /api/residences/
- GET /api/residences/my/
- GET /api/residences/summary/
- GET /api/residences/:id/
- POST /api/residences/
- PUT /api/residences/:id/
- DELETE /api/residences/:id/
- Share-code + member management endpoints
- GET /api/residences/:id/report/
Mechanical work: ~50 method signatures, ~80 handler call sites,
~25 test call sites updated. Internal sendTaskCompletedNotification
helper also takes ctx so background notification SQL nests correctly.
The remaining services (ContractorService, DocumentService,
AuthService, NotificationService, SubscriptionService) follow the same
pattern; they continue to emit untraced SQL until migrated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.
Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.
Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.
OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.
ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Echo gzip middleware was wrapping promhttp's pre-gzipped output, so
vmagent received double-compressed bytes that failed the Prometheus
parser with binary garbage. Skipping /metrics in the gzip Skipper.
Three deploy-script fixes uncovered while shipping this:
- _config.sh had backticks around \"kubectl get cm\" inside the python
heredoc, which bash treated as command substitution when KUBECONFIG
was set. Quoted the literal instead.
- 03-deploy.sh now passes --platform linux/amd64 to all docker builds
so arm64 Macs don't push images that fail with \"exec format error\"
on the Hetzner CX nodes.
- OBS_INGEST_TOKEN lookup was reading deploy-k3s/prod.env instead of
the actual deploy/prod.env at the repo root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds internal/prom package with histograms for HTTP, GORM, B2, APNs, and
FCM, wired into the Echo router (HTTPMiddleware + /metrics) and GORM via
statement-level callbacks (no ctx plumbing needed). Storage and push
clients call ObserveB2Upload / ObserveAPNsSend / ObserveFCMSend at the
network round-trip points.
Existing internal/monitoring metrics move to /metrics/legacy so the
canonical /metrics emits proper histogram buckets for p50/p95/p99 rollups.
deploy-k3s/manifests/observability/vmagent.yaml deploys a single-replica
vmagent in the honeydue namespace that scrapes api Pods on :8000/metrics
every 15s and remote-writes to https://obs.88oakapps.com/api/v1/write
with a bearer token (substituted at deploy time from OBS_INGEST_TOKEN
in deploy/prod.env). NetworkPolicies allow vmagent egress to api Pods
and to the public obs endpoint over :443; the obs side runs
VictoriaMetrics + Jaeger + Grafana on 88oakappsUpdate.
docs/observability-plan.md captures the full plan including resource
budget, instrumentation table, 4-step rollout, and migration triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a data_migration that runs seeds/001_lookups.sql,
seeds/003_admin_user.sql, and seeds/003_task_templates.sql exactly
once on startup and invalidates the Redis seeded_data cache afterwards
so /api/static_data/ returns fresh results. Removes the need to
remember `./dev.sh seed-all`; the data_migrations tracking row prevents
re-runs, and each INSERT uses ON CONFLICT DO UPDATE so re-execution is
safe.
Clients that send users through a multi-task onboarding step no longer
loop N POST /api/tasks/ calls and no longer create "orphan" tasks with
no reference to the TaskTemplate they came from.
Task model
- New task_template_id column + GORM FK (migration 000016)
- CreateTaskRequest.template_id, TaskResponse.template_id
- task_service.CreateTask persists the backlink
Bulk endpoint
- POST /api/tasks/bulk/ — 1-50 tasks in a single transaction,
returns every created row + TotalSummary. Single residence access
check, per-entry residence_id is overridden with batch value
- task_handler.BulkCreateTasks + task_service.BulkCreateTasks using
db.Transaction; task_repo.CreateTx + FindByIDTx helpers
Climate-region scoring
- templateConditions gains ClimateRegionID; suggestion_service scores
residence.PostalCode -> ZipToState -> GetClimateRegionIDByState against
the template's conditions JSON (no penalty on mismatch / unknown ZIP)
- regionMatchBonus 0.35, totalProfileFields 14 -> 15
- Standalone GET /api/tasks/templates/by-region/ removed; legacy
task_tasktemplate_regions many-to-many dropped (migration 000017).
Region affinity now lives entirely in the template's conditions JSON
Tests
- +11 cases across task_service_test, task_handler_test, suggestion_
service_test: template_id persistence, bulk rollback + cap + auth,
region match / mismatch / no-ZIP / unknown-ZIP / stacks-with-others
Docs
- docs/openapi.yaml: /tasks/bulk/ + BulkCreateTasks schemas, template_id
on TaskResponse + CreateTaskRequest, /templates/by-region/ removed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Swarm stack
- Resource limits on all services, stop_grace_period 60s on api/worker/admin
- Dozzle bound to manager loopback only (ssh -L required for access)
- Worker health server on :6060, admin /api/health endpoint
- Redis 200M LRU cap, B2/S3 env vars wired through to api service
Deploy script
- DRY_RUN=1 prints plan + exits
- Auto-rollback on failed healthcheck, docker logout at end
- Versioned-secret pruning keeps last SECRET_KEEP_VERSIONS (default 3)
- PUSH_LATEST_TAG default flipped to false
- B2 all-or-none validation before deploy
Code
- cmd/api takes pg_advisory_lock on a dedicated connection before
AutoMigrate, serialising boot-time migrations across replicas
- cmd/worker exposes an HTTP /health endpoint with graceful shutdown
Docs
- deploy/DEPLOYING.md: step-by-step walkthrough for a real deploy
- deploy/shit_deploy_cant_do.md: manual prerequisites + recurring ops
- deploy/README.md updated with storage toggle, worker-replica caveat,
multi-arch recipe, connection-pool tuning, renumbered sections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces a StorageBackend interface with local filesystem and S3
implementations. The StorageService delegates raw I/O to the backend
while keeping validation, encryption, and URL generation unchanged.
Backend selection is config-driven: set B2_ENDPOINT + B2_KEY_ID +
B2_APP_KEY + B2_BUCKET_NAME for S3 mode, or STORAGE_UPLOAD_DIR for
local mode. STORAGE_USE_SSL=false for in-cluster MinIO (HTTP).
All existing tests pass unchanged — the local backend preserves
identical behavior to the previous direct-filesystem implementation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Suggestion engine now purely uses home profile features (heating,
cooling, pool, etc.) for template matching. Climate region field
and matching block removed — ZIP code is no longer collected.
14 new optional residence fields (heating, cooling, water heater, roof,
pool, sprinkler, septic, fireplace, garage, basement, attic, exterior,
flooring, landscaping) with JSONB conditions on templates.
Suggestion engine scores templates against home profile: string match
+0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful
degradation from minimal to full profile info.
GET /api/tasks/suggestions/?residence_id=X returns ranked templates.
54 template conditions across 44 templates in seed data.
8 suggestion service tests.
Custom rate limiter replacing Echo built-in, with per-IP token bucket.
Every response includes X-RateLimit-Limit, Remaining, Reset headers.
429 responses additionally include Retry-After (seconds).
CORS updated to expose rate limit headers to mobile clients.
4 unit tests for header behavior and per-IP isolation.
Rate limiters on login/register/password-reset endpoints cause 429 errors
when running parallel UI tests that create many accounts. In debug mode,
skip rate limiters entirely so test suites can run without throttling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add UnicodeTranslatorFromDescriptor to convert UTF-8 strings to
Windows-1252 for gofpdf built-in fonts. Prevents garbled characters
in residence names, task titles, categories, priorities, and statuses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The email icon URL was pointing to honeyDue.treytartt.com which now returns 404.
Updated to api.myhoneydue.com along with BASE_URL, FROM_EMAIL, and CORS defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove Next.js basePath "/admin" — admin now serves at root
- Update all internal links from /admin/xxx to /xxx
- Change Go proxy to host-based routing: admin subdomain requests
proxy to Next.js, /admin/* redirects to main web app
- Update timeout middleware skipper for admin subdomain
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When ADMIN_HOST is set, redirects root "/" to "/admin/" so
admin.myhoneydue.com works without needing the /admin path suffix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a new endpoint GET /api/tasks/templates/by-region/?zip= that resolves
ZIP codes to IECC climate regions and returns relevant home maintenance
task templates. Includes climate region model, region lookup service with
tests, seed data for all 8 climate zones with 50+ templates, and OpenAPI spec.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Stripe integration: add StripeService with checkout sessions, customer
portal, and webhook handling for subscription lifecycle events.
- Free trials: auto-start configurable trial on first subscription check,
with admin-controllable duration and enable/disable toggle.
- Cross-platform guard: prevent duplicate subscriptions across iOS, Android,
and Stripe by checking existing platform before allowing purchase.
- Subscription model: add Stripe fields (customer_id, subscription_id,
price_id), trial fields (trial_start, trial_end, trial_used), and
SubscriptionSource/IsTrialActive helpers.
- API: add trial and source fields to status response, update OpenAPI spec.
- Clean up stale migration and audit docs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrote all 11 email templates to use the Casera web brand: Outfit font via
Google Fonts, sage green (#6B8F71) brand stripe, cream (#FAFAF7) background,
pill-shaped clay (#C4856A) CTA buttons, icon-badge feature cards, numbered
tip cards, linen callout boxes, and refined light footer. Extracted reusable
helpers (emailButton, emailCodeBox, emailCalloutBox, emailAlertBox,
emailFeatureItem, emailTipCard) for consistent component composition.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The TimeoutMiddleware wraps the response writer in *http.timeoutWriter which
doesn't implement http.Flusher. When the admin reverse proxy or WebSocket
upgrader tries to flush, it panics and crashes the container (502 Bad Gateway).
Skip timeout for /admin, /_next, and /ws routes.
Also fix the Dockerfile HEALTHCHECK to detect the worker process — the worker
has no HTTP server so the curl-based check always failed, marking it unhealthy.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add document list filter support (residence, type, category, contractor, is_active, expiring_soon, search) to handler/service/repo
- Add `days` query param parsing to ListTasks handler (matches ListTasksByResidence)
- Add `error.invalid_token` i18n key to all 9 non-English locale files
- Update contract test to include VerificationResponse mapping
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment out the cancelled column from API responses to reduce clutter.
Code preserved for easy re-enablement by searching for "TEMPORARILY DISABLED".
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When a user explicitly edited a task's due date, the backend was only
updating NextDueDate if the task had no completions. For recurring tasks
with completions, this caused the UI to show stale NextDueDate values
since effectiveDueDate prioritizes NextDueDate over DueDate.
Now always updates NextDueDate when user explicitly edits due date.
Completion logic will still recalculate NextDueDate when task is completed.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The daily digest notification count was inconsistent with the kanban UI
because the server used UTC time while the client used local time.
A task due Dec 24 would appear overdue on the server (UTC Dec 25) but
still show as "due today" for the user (local Dec 24).
Changes:
- Add timezone column to notification_preference table
- Auto-capture user's timezone from X-Timezone header when fetching tasks
- Use stored timezone in HandleDailyDigest for accurate overdue calculation
The mobile app already sends X-Timezone on every request, so no client
changes are needed. The timezone is captured on each app launch when
the tasks API is called.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace raw SQL in HandleDailyDigest with repository functions that use
the canonical task scopes. This ensures the daily digest push notification
uses the exact same overdue/due-soon logic as the kanban display.
Changes:
- Add residenceRepo to Handler struct for user residence lookups
- Use taskRepo.GetOverdueTasks() instead of raw SQL (uses ScopeOverdue)
- Use taskRepo.GetDueSoonTasks() instead of raw SQL (uses ScopeDueSoon)
- Set IncludeInProgress: false to match kanban behavior
Fixes bug where notification reported 3 overdue tasks when kanban showed 2
(in-progress tasks were incorrectly counted as overdue in the digest).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
GetOverdueCountByResidence now uses ScopeNotInProgress to match
the kanban overdue column behavior. This ensures the overdue count
shown on residence cards matches what's displayed in the task board.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Refactored to avoid duplicate queries:
- One SQL query gets users with flags for which notification types they want
- One task query gets all active tasks for those users
- Single loop processes tasks, using map lookup for user preferences
Previously called processSmartRemindersForType twice (due_soon, overdue),
each doing separate user query + task query. Users with both types at
same hour had their tasks queried twice.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Each notification type (due_soon, overdue) now runs at its own configured
hour. Due-soon uses task_due_soon_hour preference, overdue uses
task_overdue_hour preference. Previously only task_due_soon_hour was
checked, causing overdue notifications to never fire for users with
custom overdue hours.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add TaskReminderLog to AutoMigrate (table was missing)
- Remove HandleTaskReminder and HandleOverdueReminder registrations
- Register HandleSmartReminder which uses frequency-aware scheduling
and tracks sent reminders to prevent duplicates
The old handlers sent ALL overdue tasks daily regardless of age.
Smart reminder tapers off (daily for 3 days, then every 3 days, stops at 14).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>