Disable the linters that fire on legacy code without flagging real
bugs: `unused` (orphan repos kept for future feature flags),
`unconvert` (defensive type conversions), `gosimple` (style
suggestions in code we don't want to touch).
govet: disable `shadow` (idiomatic `err :=` re-decls in transaction
patterns) and `nilness` (legitimate defensive nil checks that look
tautological to the analyzer).
Ran `gofmt -w internal/ cmd/` — every Go file now passes
gofmt -l with no output.
Kept: govet, staticcheck, ineffassign, typecheck, bodyclose, noctx,
sqlclosecheck, gofmt, goimports, misspell — the real-bug checks.
Backend:
- Fix contact-create 500 (nil custom_fields, doubled slice, bad RETURNING SQL)
- Avatar upload: migration 000033, S3 public-read, PNG/JPG only,
client-resized to 512px + server dimension cap (1024px)
- Pull avatar_url through user + organization repo queries
Frontend:
- Settings restructured into nested routes with a rail layout
(/app/settings/{profile,notifications,security,members,roles,
workspace,billing,danger}); flat Section/Row primitives replace
the per-card rectangles; Save buttons only render when dirty
- Standalone /app/billing and /app/team removed; legacy URLs
redirect to the settings sections; UserNav trimmed accordingly
- CRM rebuilt: Pipelines CRUD + stage editor, Deals kanban with
HTML5 drag/drop, Tasks bucketed by due-date with inline toggle.
Frontend models realigned with backend (Deal.name, CRMTask.status
enum, paginated list shapes)
- Avatars: AvatarUploader component, client-side canvas resize,
wired into Profile + Workspace settings; UserNav + OrgSwitcher
render the uploaded image with initials fallback
- RBAC: lib/permissions.ts mirrors organization_permission.go;
inline role picker in Members; Roles & access section shows the
permission matrix and per-role member counts
- Audit log page at /app/audit, gated to owner+admin via canManage
- Plans aligned with warmbly-web pricing: Starter/Grow/Business/
Enterprise via lib/plans.ts; PlanPill, billing page, sidebar
badges and LockedSurface all read from the same catalogue
- Header PlanPill shows current plan with status-aware coloring;
sidebar locked rows show the required-plan badge instead of a
generic lock icon
Perf:
- QueryClient defaults (staleTime: 30s, refetchOnWindowFocus: false,
retry: 1) — kills 3-5 round-trip storm on every navigation
- useSubscription, usePlans → staleTime: Infinity (only invalidate
on plan-change mutations); useUser/Timezones/Orgs get long stales
with refetchOnMount: false
- vite.config: optimizeDeps for heavy libs + server.warmup for the
most-mounted entry pages
Inbox filter:
- Backend: MailSearchParams gained EmailAccountIDs []uuid.UUID; the
search SQL filters with `email_id = ANY($)`. /unibox handler now
accepts both `email_id=<uuid>` (legacy) and `email_ids=<csv>`.
- Frontend: UniboxSearchParams gained accountIds[] and a UI-only
tagId. searchIncoming sends email_ids=csv. UniboxFilterSheet:
Accounts section is now (a) a row of tag chips backed by user.tags
with per-tag account counts and (b) a multi-select list of every
connected mailbox with an inline checkbox + avatar; accounts that
belong to the active tag get a "via tag" affordance. Picking a tag
resolves to the underlying account IDs at Apply time. "Select all"
/ "Clear" inline in the SectionBar header.
Org gate + onboarding:
- New /select-org page. Three sections: pending invitations (one-
click Join), existing memberships (pick one to enter), and a
Create New Workspace form (slate-900 primary). Routed at
/select-org.
- OrgGate hook lives inside RealtimeManager. On load, if the user
has zero orgs and no current org, it navigates to /select-org
replace. Renders null so it doesn't displace AppLayout.
Invite + join:
- Team page rebuilt with real data: useMembers + usePendingInvitations,
plus InviteDialog (email + role popover, slate-900 send button).
Inline remove on member rows (skip "owner"), inline cancel on
pending invitations.
- Pending invitations show up on /select-org too — a freshly
invited user can accept without ever entering the dashboard first.
Response unwrapping:
- Org/member/invitation list clients now tolerate the backend's
{data: T[] | null} envelope (it's the consistent shape across the
Go handlers). Map nested membership rows into the flat
Organization shape the rest of the app expects.
Seeder: re-run verified — dev@warmbly.com still gets "Dev's
Organization" so they don't bounce through /select-org.
User: "when I click on delete the confirm appears behind the form and
it looks really bad, doesn't fit in the theme; and also after I reload
the page, nothing appears after creation".
Two distinct bugs:
1) Confirm dialog stacking + styling
FoldersModal/TagsModal render at z-[110]. ConfirmProvider rendered
the confirm overlay at z-101 with bg-black/30 + scale animation +
poppins styling — visually it landed BEHIND the folders modal and
clicks went through to the backdrop instead.
Rewrote ConfirmProvider in the brae chrome:
- z-[200] so it stacks above page-level overlays AND nested
dialogs.
- Hairline-bordered card, 48px header (red alert tile + "Confirm"
eyebrow), prose body, slate-900 footer (Cancel / red Confirm).
- Escape closes; backdrop closes (both gated on !loading).
- Spinner inside Confirm during the awaited action.
2) Created folders/tags disappeared after page reload
POST /folders + /tags persisted to Postgres fine. The frontend
optimistic-updated the cached user via setQueryData. But
/auth/me did not return folders/tags/categories — the User payload
omitted them entirely. On reload the cache refetched /auth/me,
got missing fields, defaulted to [], and the items vanished from
the UI.
Backend fix:
- models.User now carries Folders/Tags/Categories ([]Group),
always serialized as arrays.
- GroupRepository + GroupService gained a List(ctx, userID)
method; ordered by position then created_at.
- /auth/me handler now calls List on FolderService, TagService,
CategoryService and attaches them to the user before responding.
Verified end-to-end:
GET /auth/me → 200 with full folders/tags arrays populated.
Create a folder, reload the page → folder still in the list.
Two real bugs surfaced from "All folders / Newest dropdowns don't open"
and "hex color must be a valid string":
1) Dropdowns silently no-op (broken across the whole dashboard)
PopoverMenuTrigger asChild uses React.cloneElement to inject
onClick / ref / aria-expanded onto the trigger child. SelectButton
was a plain function component that destructured a fixed prop set
and rendered its own <button> — so the injected props were
dropped on the floor. Click did nothing.
Fix: SelectButton is now React.forwardRef + spreads {...rest} onto
the inner button. The injected click handler reaches the real
element, the dropdown opens, the menu renders, and selection
actually applies state.
Every PopoverMenu trigger using SelectButton was affected — that's
campaigns (folders + sort), emails (tag filter), contacts (sort +
filters page rows). All now work.
2) Adding a folder/tag failed with "hex color must be a valid string"
The /folders + /tags POST landed on groupRepository.Create with
an empty color and the validator rejected. Even before the color
check, the INSERT used tx.QueryRow + Scan against an INSERT with
no RETURNING clause, which always errored with
"sql: no rows in result set" once it got past validation.
API improvements (kept the design but made it forgiving):
- Color defaults: if the request omits color, the server picks one
from an 8-swatch palette based on the new item's position. Two
consecutive creates won't end up identical. Non-empty but
invalid still 400s — that's a client bug worth surfacing.
- Title min length 3 → 1. "Q1", "VIP", short names are common
and shouldn't fail. Trimmed before validation so " " doesn't
pass.
- INSERT now uses tx.Exec instead of QueryRow.Scan — the broken
code would never reach success even when validation passed.
Verified end-to-end:
POST /folders {"title":"Q1"} → 200, color=#94a3b8 (default).
POST /folders {"title":"Q2","color":"#38bdf8"} → 200.
POST /tags {"title":"VIP","color":"#10b981"} → 200.
Frontend:
- createFolder / createTag clients accept an optional color param.
- LabelListModal now picks a default palette color when entering
add-row mode (rotating with item count) and offers a swatch
popover to override before submitting. Selected color is sent to
the backend.
CAMPAIGN_SELECT_FULL had:
array_agg(cet.tag_id) FILTER (WHERE cet.tag IS NOT NULL)
array_agg(cec.folder_id) FILTER (WHERE cec.folder IS NOT NULL)
The columns referenced in the FILTER clauses don't exist:
warmbly_dev=# \d campaign_email_tags
Column | Type
-----------+------
tag_id | uuid
campaign_id | uuid
warmbly_dev=# \d campaign_folders
Column | Type
-----------+------
campaign_id | uuid
folder_id | uuid
Result: every GET /campaigns returned 500 with
*pgconn.PgError: ERROR: column cet.tag does not exist (SQLSTATE 42703)
which is why the frontend page was perpetually blank — the request was
failing before any data could land. Fixed both FILTER predicates to
use the actual *_id columns.
Verified after rebuild:
- dev@warmbly.com (no campaigns): 200 with empty data array.
- beth@beta.test (owns seeded campaign): 200 with the Beta Cold
Outreach Q1 record.
1) Contacts crash "c is null":
contactRepository.Search declared `var contacts []models.Contact` so
an empty result set returned a nil slice, which Go marshals as JSON
null. The frontend's flatMap((p) => p.data) over null yields [null],
and the page then accesses c.subscribed → throws. Initialize as
make([]models.Contact, 0, limit+1) so the wire format is always [].
Also defensive on the client: useSearchContacts + useCampaigns now
coerce p.data ?? [] and drop nulls before returning.
2) Campaigns panic on any non-empty result:
campaignRepository.Search allocated `make([]models.Campaign, 0, limit+1)`
(length 0) then did `campaigns[i] = campaign`. That's an
index-out-of-range on the first iteration. Switched to `append`.
Anyone with at least one campaign would see a 500 / blank screen.
3) Websocket "Token expired":
SocketTTL was 60s. The frontend reconnect backoff caps at 30s, so
after a rejected handshake the next attempt could fire 30-60s
later. Combined with rare back-pressure on /getaway the token was
already past exp by the time the realtime saw it. Bumped to 10 min
— short enough to keep the token low-impact, long enough to outlast
the backoff schedule.
4) Websocket "Connection limit exceeded":
Realtime.Connections only untracked on channel terminate, never on
socket disconnect. Sockets that connected and disconnected without
joining a channel leaked. Each reconnect loop bumped the counter
until the per-user limit (10) was hit, after which every legitimate
connect was rejected even after fixing #3.
Fix: GenServer Process.monitor's the socket pid on track, and
`:DOWN` handler calls do_untrack with the right (user_id, ip).
5) Phoenix protocol mismatch:
Frontend appended vsn=2.0.0 to the WS URL, but sendRaw + joinChannel
send the V1 object format. Realtime's Phoenix.Socket.V2.JSONSerializer
crashed with a badmatch on the first phx_join, killing the socket
right after connect. Switched to vsn=1.0.0 to match what the client
actually emits.
Root cause for the 10-min auto-logout (confirmed via pg_stat_activity):
the postgres pool MaxConns was 4, and four repository functions opened
a tx without committing or rolling back. After four calls each leaked
a connection in "idle in transaction" state. Once all four were gone
the pool was permanently exhausted — every new request that needed a
connection blocked until the client gave up. The 10-min trigger is
because that's when the first /auth/refresh fires; refresh tries to
acquire a connection, hangs, eventually the browser aborts the request,
the frontend treats the failure as session expiry, kicks the user.
The four leaking sites:
- emailRepository.Search (drove the leak — Accounts page)
- campaignRepository.Search
- sequenceRepository.Create
- contactRepository.BulkUpdate
Each now has `defer tx.Rollback(ctx)` immediately after Begin, matching
the pattern used in the non-leaky sites in the same files. Rollback is
a no-op after Commit, so this is safe for both read-only tx (Search)
and read-write tx (Create / BulkUpdate).
Additional hardening so a future leak can't silently brick the backend:
- MaxConns 4 → 25. 4 was reckless even without leaks; one bursty
admin page would saturate. 25 is still well under postgres'
default max_connections=100.
- MinConns 0 → 2. Keep a couple of warm connections at idle so the
first request after a quiet period doesn't pay the connect cost.
- idle_in_transaction_session_timeout=300000 (5 min) as a session
RuntimeParam. If a code path forgets the defer, postgres aborts
the leaked tx after 5 min and reclaims the connection.
- statement_timeout=60000 (60 s) as a session RuntimeParam.
Statement runaway can't pin a connection forever.
Verified after backend restart:
SELECT count(*) FROM pg_stat_activity
WHERE datname='warmbly_dev' AND state='idle in transaction';
→ 0
Migration 000032 + repo + endpoints for arbitrary string tags on
workers. The fixed attributes (worker_type, free_tier, risk_pool)
cover the dimensions assignment logic uses. Tags cover everything
else admins want to group by: region (eu-west, fra), provider
(hetzner, ovh), role (warmup-only, burst-capacity), customer cohort —
whatever.
Schema:
- worker_tags(worker_id, tag) composite PK
- tag VARCHAR(64), lowercase + dashed via CHECK constraint
- ON DELETE CASCADE so deleting a worker drops its tags
Endpoints:
- GET /admin/workers/tags list distinct tags (autocomplete)
- PUT /admin/workers/:id/tags replace tag set; normalizes input
Repo:
- GetWorkerTags / SetWorkerTags / ListAllWorkerTags
- HydrateWorkerTags batch-loads tags onto a slice of workers in one
round-trip so the dashboard list doesn't do N+1 queries
PUT is transactional (delete + bulk insert) so the list view never
catches a worker mid-tag-swap. Auto-derived "smart" labels
(tier:free, pool:risky, state:error) are NOT stored — those are
computed client-side from the worker row so they stay in sync with
the source attributes automatically. Next commit wires the UI.
Worker list grows a Pool column (clean=green, risky=amber,
quarantine=red badge). Dedicated workers render "n/a" — risk pools are
a shared-worker concept since dedicated workers don't share IPs across
customers.
Worker detail page (shared workers only) gets a "Risk pool" section
with three big buttons. Clicking a non-current pool confirms, then
calls PUT /admin/workers/:id/risk-pool. Action audited with the new
pool value.
Saving doesn't migrate accounts directly — the hourly rebalancer
notices the mismatch and moves mailboxes to a matching-pool worker
on its next tick. Documented in the section's helper text.
Endpoint accepts {risk_pool: "clean"|"risky"|"quarantine"} and is
gated by AdminPermManageWorkers. The worker detail row scan now
includes risk_pool so the column actually has data.
Threat-level segregation, schema layer. Two new concepts:
workers.risk_pool ∈ {clean, risky, quarantine}
buckets shared workers by acceptable risk. Dedicated workers don't
use it (single tenant = no cross-contamination risk).
email_accounts.risk_band ∈ {clean, risky, quarantine}
per-mailbox classification, derived from warmup_health_state by the
rebalancer (next commit). Never set by user input.
The mapping is one-way and intentionally simple:
healthy → clean
watch, throttled → risky
quarantined, → quarantine
blocked
Rebalancer code lands in the next commit. This commit just adds:
- migration 000031 with enums + columns + filtered indexes
- WorkerRiskPool / EmailRiskBand types + RiskBandFromHealth helper
- WorkerRepository methods: SetWorkerRiskPool, SetEmailAccountRiskBand,
GetSharedWorkersByTierAndPool, ListRiskCandidates
- RiskCandidate result type joining email_accounts + warmup health
(picks WORST state across pools via CASE ranking) + worker columns
so the rebalancer can decide migrations in one scan
Workers are no longer curl|sh-only. Admins add and manage them from the
dashboard over SSH, with all runtime config (Kafka, Schema Registry,
Redis, AWS keys) stored encrypted via the existing KMS-envelope cipher
service.
Worker lifecycle:
1. Admin POSTs host/port/user. Backend generates an ed25519 keypair,
encrypts the private key under uuid.Nil (platform identity), and
stores the row in 'pending' state.
2. Admin pastes the returned public key into the VPS's authorized_keys.
3. Test connection — runs `true` over SSH, pins the host SHA256
fingerprint on first success (TOFU).
4. Install — backend scp's install-worker.sh + a per-worker env file
and runs it. State moves pending → provisioning → installed.
5. From then on: restart, update image, apply config, uninstall,
rotate keys, tail logs, live status, OS package update, reboot —
all dashboard buttons backed by SSH operations.
Credentials are reusable entities:
- aws_credentials: named keypair, secret encrypted at rest
- worker_profiles: bundles Kafka + Schema Registry + Redis + image +
release channel, references one AWS credentials row
- workers.profile_id links a worker to a profile; many workers can
share one profile
Saving a profile doesn't restart anything. The dashboard compares
profile.updated_at to each worker's config_applied_at and shows a
"stale config" badge; Apply rewrites /etc/warmbly/worker.env over SSH
and restarts the unit.
Auto-update on GitHub release:
- profile.release_channel ∈ {pinned, stable, dev}
- profile.auto_update toggles automatic rollout
- Trigger model is push, not poll: one check on backend boot, then
the /webhooks/github/releases endpoint (HMAC-validated with
RELEASES_WEBHOOK_SECRET) on every release event. Manual "Check now"
button as fallback.
- When a new tag resolves, the orchestrator SSHes into each assigned
worker, runs install-worker.sh --update --image <new>, which now
rewrites the systemd unit (not just `docker pull`) so the image
actually changes. workers.image_version captures the running tag
for the UI's "v1.2.3 → v1.2.4" diff.
Self-hostable: every release knob is env-driven —
RELEASES_GITHUB_REPO, RELEASES_WORKER_IMAGE_REPO,
RELEASES_WEBHOOK_SECRET, RELEASES_GITHUB_TOKEN, RELEASES_ENABLED. Set
RELEASES_ENABLED=false to disable the feature entirely.
OS-level updates and reboot are also exposed: detect apt / dnf / yum /
pacman / apk, run the right upgrade noninteractively, return the full
output and a reboot-required flag. Reboots are never automatic.
Migrations:
000028_worker_ssh — ssh fields, install_state enum, last_seen,
host fingerprint
000029_worker_credentials — aws_credentials + worker_profiles +
workers.profile_id + workers.config_applied_at
000030_worker_releases — release_channel enum, auto_update,
resolved_image_tag, workers.image_version
Endpoints added:
POST /admin/workers (create + keypair)
GET /admin/workers/managed
GET /admin/workers/:id/managed
POST /admin/workers/:id/{test,install,restart,upgrade,uninstall,rotate-keys,apply,system-update,reboot}
PUT /admin/workers/:id/profile
GET /admin/workers/:id/{live-status,logs}
DELETE /admin/workers/:id
GET /admin/aws-credentials CRUD
GET /admin/worker-profiles CRUD + /workers + /apply + /release
GET /admin/releases/state
POST /admin/releases/check
POST /webhooks/github/releases public, HMAC-validated
Admin UI:
/app/admin/workers list with status + version columns
/app/admin/workers/new add form with profile dropdown
/app/admin/workers/:id detail with all actions + logs + system update
/app/admin/credentials tabs: AWS credentials + worker profiles,
Releases panel, channel selector +
auto-update toggle in profile form