Commit Graph

72 Commits

Author SHA1 Message Date
Matthew Meszaros 4500aeba0f feat: add integration oauth connections
Adds OAuth-backed integration connection management across the API, repository, event dispatch, migrations, docs, and dashboard UI.

Includes realtime invalidation and small dashboard type compatibility fixes needed for the web typecheck gate.
2026-06-01 04:25:14 +02:00
Matthew Meszaros 545dcea940 feat: show scheduled replies in threads
Add a thread_id filter for scheduled Unibox sends and expose a per-thread hook for the dashboard.

Render queued sends inline in ThreadView with cancellation, refreshing the thread, scheduled list, and overview caches after cancel.
2026-05-31 05:19:39 +00:00
Matthew Meszaros 7b5a87d1d1 feat: add unibox scheduled send backend
Add Unibox overview, snooze, and scheduled-send endpoints with task repository support, queue caps, execution-time guards, and snooze persistence.
2026-05-31 04:26:26 +00:00
Matthew Meszaros 4663333d2c feat: update billing and email settings 2026-05-30 15:45:10 +00:00
Matthew Meszaros a227938778 feat: seed unibox trial fixtures 2026-05-30 14:06:36 +00:00
Matthew Meszaros d08c984c42 feat: snapshot current dashboard changes 2026-05-30 13:56:27 +00:00
Matthew Meszaros 44c837d8ac Merge branch 'main' into feature/dashboard-plans
# Conflicts:
#	AGENTS.md
2026-05-30 09:49:12 +00:00
Matthew Meszaros b168f4d466 feat: simplify worker installer route 2026-05-30 09:37:26 +00:00
Matthew Meszaros d8a546eca5 feat: add worker enrollment install 2026-05-30 05:10:27 +00:00
Matthew Meszaros cb0c2b4fac feat: harden webhook endpoints 2026-05-30 04:33:43 +00:00
Matthew Meszaros 878e8d921e feat: enforce api key email scopes 2026-05-30 04:28:25 +00:00
Matthew Meszaros 67bbd72777 feat: harden api permission gates 2026-05-30 04:24:41 +00:00
Matthew Meszaros ff763c4483 feat: add role and team-size onboarding questions and rework site SEO me 2026-05-29 14:47:16 +00:00
Matthew Meszaros 6766031cc5 feat: add discount code support for checkout and plan changes 2026-05-29 05:49:19 +00:00
Matthew Meszaros e7ef328b2d Merge pull request #18 from warmbly/feature/admin-management
feat: admin management surface with overrides, ban scope, and throttles
2026-05-29 05:01:00 +02:00
Matt 3ffa416e40 feat(admin): mailboxes admin (cross-org triage)
Adds GET /admin/mailboxes — paginated platform-wide mailbox list that
joins email_accounts → users → organizations so the table answers
"whose mailbox is this and where does it live" without N+1 fetches.

Search covers mailbox email / owner email / org name; status filter
defaults to active so the active surface shows first ("inactive" /
"all" both available). Provider filter speeds up "show me every Gmail
mailbox" investigations. Cursor pagination matches the rest of the
admin lists.

Frontend page surfaces warmup-on/off, send budget, and last-sync time
with red-when-never / amber-when-stale-over-24h tone so an
investigator can spot dead mailboxes fast. Mailbox email links into
the owning user's detail page; org name links into the workspace
admin so the pivot path stays one click in either direction.

Gated on AdminPermViewUsers since mailbox triage is tightly coupled to
user/org context today; a dedicated bit can be carved later if
mailbox-specific actions land.
2026-05-28 12:38:08 +02:00
Matt bd6a045751 feat(admin): outreach composer (platform mailer + reply-to + audit log)
Adds a dedicated admin path for sending platform email — distinct from
the campaign emailsend service (which sends through customer mailboxes)
so the two abuse surfaces never share code paths.

Schema (000047) adds admin_outreach_messages: every send is recorded
with sent_by, the resolved to_email, the optional reply_to, subject,
body, and a queued → sent/failed status. Failed sends keep their error
column populated for the audit log.

Extends notify.EmailNotificationService with SendOutreach so both
backends (SES + SMTP) support custom Reply-To: SES via the native
ReplyToAddresses field, SMTP via a forged Reply-To header. The
existing transactional Send() remains unchanged so no other caller is
affected.

Service (internal/app/adminoutreach) resolves recipients three ways:
to_email (raw address), to_user_id (sends to the user's account email),
or to_org_id (sends to the workspace owner). Persist-then-send-then-
mark ensures the audit row exists even if the mailer hangs, and
mark-failed captures the error string verbatim.

Routes:
  POST /admin/outreach            manage_organizations
  GET  /admin/outreach            view_organizations

Admin UI: composer with recipient mode picker (email / user_id / org_id),
configurable Reply-To (defaults to support@warmbly.com so customers can
actually reply), subject + HTML body editor, and an outreach log below
showing the last 50 sends with status badges and error details. Sidebar
entry under Accounts (Send icon).
2026-05-28 12:25:18 +02:00
Matt e6753f7c24 feat(limits): limit-increase request workflow (backend)
Wire the customer self-serve path for asking "please give me more
mailboxes / campaigns / contacts." Migration 000046 adds
limit_increase_requests with a partial unique index ensuring only one
pending request per (org, field) so the queue can't be spammed, plus
a CHECK requiring requested > current_effective so no-op rows never
reach an admin.

Service layer:
  - SubmitLimitIncreaseRequest validates membership, rejects unknown
    fields, snapshots the user's current effective limit at submission
    time so the queue row carries the context the admin needs.
  - CancelLimitRequest lets the original submitter walk back a pending
    request; approved/rejected rows are immutable as the audit record.
  - ApproveLimitRequest stamps the row and writes the corresponding
    column on organization_limit_overrides via SetLimitOverrides —
    same write path direct admin overrides use, so granted_by and
    notes carry through and the audit log treats both flows uniformly.
  - RejectLimitRequest stamps the row with required review notes.

Routes:
  POST   /v1/organization/:orgId/limit-requests
  GET    /v1/organization/:orgId/limit-requests
  DELETE /v1/limit-requests/:id                 (submitter only)
  GET    /admin/limit-requests?status=pending
  POST   /admin/limit-requests/:id/approve
  POST   /admin/limit-requests/:id/reject

Admin approval and rejection both fire admin audit log entries with
field + requested + notes so the decision history survives any future
reorg of the request table.

UI (admin queue page + dashboard request form) plus the ToS clause
giving Warmbly the right to refuse any increase land in the next commit.
2026-05-28 12:05:32 +02:00
Matt 7de29b0fb0 feat(admin): ban scope bitmask (schema + UI; enforcement is staged)
Add users.ban_scope INT NOT NULL DEFAULT 0 in migration 000045 so admins
can describe what a ban concretely stops (login / workspace creation /
outbound send) instead of relying on a single boolean banned_at flag
that meant "everything".

Wire flags in the BanScope enum (kept in sync with the migration) plus
a CHECK constraint guaranteeing non-negative values. Existing bans
backfill to BanScopeLogin so the historical "you can't log in"
semantics is preserved exactly — no behaviour changes silently at
deployment.

BanUserRequest gains an optional scope field, BanUser threads it through
the service to the repo write, and the UserBanDialog grows a checkbox
group with one option per flag. Reason still required; at least one
scope must be picked. Audit details now include the scope bitmask.

Runtime enforcement (refusing login when BanScopeLogin is set, etc.) is
intentionally separate from this commit — the existing codebase doesn't
yet have an active ban check anywhere, so wiring that lives across the
auth middleware, org-create handler, and emailsend service. This slice
ships the schema, the audit story, and the UI vocabulary so the
enforcement PR can land without database churn.
2026-05-28 10:00:29 +02:00
Matt 0f3552b1bf feat(admin): per-org limit overrides + product hard caps
Wire the write path for the override table from the previous migration:

  GET /admin/organizations/:id/overrides   view_organizations
  PUT /admin/organizations/:id/overrides   manage_organizations

PUT is a partial upsert — nil fields leave existing values untouched,
and 0 explicitly removes that column's override (back to plan default
or the product hard cap). Every write stamps granted_by/granted_at and
fires an admin audit log with the diff the admin asked for.

Introduce product-level hard caps in config/constants.go so plans that
advertise "unlimited" still have a real backstop: 200 mailboxes, 500
total campaigns, 100 active campaigns, 100 team members, 1M contacts,
1k daily campaign sends. GetEffectiveLimits resolves per-field as
override > 0 ?: plan ?: hard_cap and now never returns nil pointers,
so downstream limit checks compare against a concrete ceiling on every
plan tier. CanAddMember / CanAddCampaign / CanAddEmailAccount now call
GetEffectiveLimits instead of GetOrganizationLimits, so admin overrides
and the product hard cap both bite at runtime.

AdminOrgDetail surfaces three limit blocks side-by-side — plan, raw
override row (0 = inherit), and effective limits — so the UI can show
exactly where each enforced number came from. Slice 2 UI lands in the
next commit.

Daily creation throttles ("no 1000 new campaigns in one day even on an
unlimited plan") are explicitly out of scope; they need a per-day
counter, tracked as a TODO on the hard-cap block.
2026-05-28 08:54:07 +02:00
Matt 8d3b6b5d05 refactor(integration): drop Postmaster, SNDS, DMARC, DNS providers
The deliverability-data and DNS-write integrations were over-engineered
for the cold-email segment. Postmaster and SNDS require sending volume
our base typically does not hit, and no comparable cold-email tool
exposes DMARC ingestion or native DNS writes. Replaces the catalog with
the standard set: HubSpot, Salesforce, Pipedrive, Close, Zapier, Make,
n8n, Slack, Discord, Calendly, Cal.com, Google Sheets.

Removes dmarc_reports, dmarc_record_rows, postmaster_snapshots, and
dns_verifications tables from the migration. Deletes dmarc.go, dns.go,
cloudflare.go, postmaster.go from the integration package. Prunes the
matching repository methods and HTTP handlers.
2026-05-28 08:53:39 +02:00
Matt 4b7a0be93d feat(admin): read-only organization endpoints
Wire three GET endpoints behind the existing admin middleware so the
admin app can browse workspaces alongside users:

  GET /admin/organizations              list with q/cursor/limit/sort
  GET /admin/organizations/:id          detail + plan/sub + limits + counts
  GET /admin/organizations/:id/members  full member list with joined users

The list query inlines member/email-account/campaign/active-campaign
counts via subqueries so the table can render usage without an extra
fetch per row. Detail layers GetOrganizationLimits + GetOrganizationCounts
on top of the list shape, ensuring admin sees the same numbers the in-app
limit checks enforce.

Gated on AdminPermViewUsers for now since orgs are tightly coupled to
user admin context today; a dedicated ViewOrganizations/ManageOrganizations
pair will land alongside the write paths (per-org overrides, ban scope)
in the next slice.
2026-05-28 08:39:13 +02:00
Matt 20f1e93c4b feat(integration): backend foundation for tier 1+2 integrations
Adds an integrations app module covering the providers from the tier 1/2
plan: Calendly, Cal.com, Google Sheets, Google Postmaster, Microsoft SNDS,
DMARC ingestion, and Cloudflare/GoDaddy/Namecheap DNS. One unified
migration provisions integration_connections, dmarc_reports + record
rows, postmaster_snapshots, dns_verifications, and meeting_bookings.

The service exposes a generic CRUD surface for connection state with
per-provider files for parsing (calendly.go, dmarc.go), HTTP clients
(cloudflare.go, postmaster.go, google_sheets.go), and DNS verification
(dns.go). Inbound webhook routes use per-org URL-embedded secrets so
Calendly/Cal.com/DMARC providers post directly without Warmbly auth.
DNS verifier resolves SPF/DKIM/DMARC + tracking CNAME and surfaces
fixes when a record is missing.
2026-05-28 08:19:10 +02:00
Matthew Meszaros d2414ad29f ci(go): gofmt all flagged files
Go CI fails on golangci-lint's gofmt check. Ran gofmt -w against
every file the linter named plus a handful of others that drifted
during the autonomous-fleet work. No semantic changes — alignment
of struct field whitespace and one mis-indented import block.

gofmt -l ./... is now empty; go build + go vet are clean.
2026-05-27 16:40:23 +00:00
Matthew Meszaros 8347237547 merge: resolve main into feature/workers-support
Brings in PR #15 (email warmup process 4) plus its preceding commits:
customer-defined warmup routing on premium pool, free-trial warmup +
1 inbox for 14 days, customer webhook subscriptions with HMAC signing
+ retry, bumped default API rate limits to 100 req/s with flat per-
user/per-plan caps, plus dev-fixture additions.

One real conflict: internal/client/smtpimap/imap/client.go added
distinct imports on each side (this branch added 'net' for the
*net.TCPAddr BindIP field; main added 'sync' for a Mutex). Kept both.

Everything else auto-merged additively:
  cmd/backend/main.go     - imports + handler fields + DI lines
  internal/api/handler/handler.go - new fields next to existing ones
  internal/api/routes.go  - new route group next to existing ones

Full build + test suite pass (no regressions).
2026-05-27 16:29:18 +00:00
Matthew Meszaros 1540ba39be admin: cloud credentials, templates, jobs, policy endpoints
/admin/cloud-credentials              CRUD + /:id/test (Hetzner Verify)
/admin/cloud-providers/:p/locations
/admin/cloud-providers/:p/server-types  catalog for admin form dropdowns
/admin/cloud-providers/:p/images
/admin/provisioning-templates         CRUD
/admin/provisioning-jobs              list, detail, create (from
                                       template or inline custom config)
/admin/provisioning-policy            list, update (per-provider budget
                                       caps + AUTO_PROVISION toggle)

All gated by AdminPermManageSettings (jobs use AdminPermManageWorkers).

Creating a job snapshots the template into the row's config jsonb so
mutating the template later doesn't retroactively change in-flight or
historical jobs.

The catalog endpoints proxy directly to the Hetzner API client,
returning whatever Hetzner exposes — admin UI doesn't need to know
the provider-specific shape, dropdowns just render Locations/
ServerTypes/Images verbatim.
2026-05-27 15:56:46 +00:00
Matthew Meszaros 0c306ceb73 worker: auto-register on first heartbeat
POST /api/v1/internal/worker/heartbeat now inserts a row into the
workers table the first time an unknown worker_id checks in. Tier and
egress_kind come from the heartbeat body; subsequent heartbeats just
keep ip_addr fresh.

Means provisioned workers self-register without admin clicks — the
state machine waits for all 16 expected UUIDv5(IP) workers to ping in
during the verify step, then marks the job completed.

tierToColumns collapses the higher-level (shared_free / shared_premium /
dedicated) name down into the existing (worker_type, free_tier) columns
so the rest of the assignment logic keeps working unchanged.
2026-05-27 15:56:15 +00:00
Matthew Meszaros e0e4a010a0 admin: storage_backends registry + settings/dek/worker-config endpoints
New storage_backends table is the runtime inventory of pluggable
infrastructure choices (KMS, encrypted_keys, blob, eventbus, cache).
Each kind has exactly one active row, enforced via a partial unique
index. Read-only rows are env-var driven; UI-mutable rows can be
flipped via SetActive.

settings.Registrar reflects boot-time backend choices into the table
so the admin UI sees what's actually running.

New admin endpoints under /admin/settings/backends:
  GET    /settings/backends?kind=...
  GET    /settings/backends/active/:kind
  POST   /settings/backends/:id/activate

New internal endpoints under /api/v1/internal:
  GET  /worker/config       - workers fetch runtime config on boot
  POST /worker/heartbeat    - liveness ping
(DEK endpoints added in the encryptedkeys commit.)

handler.Handler grows EncryptedKeys + StorageBackendRepo fields.

5 registrar tests cover create / update-and-activate / skip-when-active /
lookup-error propagation / RegisterAll stop-on-first-error using a
mock repository.
2026-05-27 14:43:36 +00:00
Matthew Meszaros 99226338c9 infra(encryptedkeys): pluggable DEK store with HTTP proxy for workers
New encryptedkeys.Store interface with three impls:

  postgres  - backend default, durable via PG
  dynamodb  - existing AWS path, also covers Scylla Alternator via
              AWS_ENDPOINT_URL_DYNAMODB
  http      - worker-side adapter that talks to the backend's new
              /api/v1/internal/dek/:userID endpoint, so workers never
              connect directly to Postgres

The HTTP endpoint sits behind a new InternalAuthMiddleware that does
constant-time bearer-token compare against INTERNAL_API_TOKEN. Fail-
closed if the env var is unset.

cipher.Service now takes an encryptedkeys.Store instead of a Dynamo
repository. The old internal/repository/dynamo_user_encrypted_keys.go
is deleted (the file also had a pre-existing copy-paste bug using
EmailMessageMapTable in Get/Del that's gone with it).

New migration 38 adds user_encrypted_keys (user_id PK, encrypted_data_key,
created_at, updated_at).

20 tests cover HTTP round-trip, conflict semantics, factory selection,
middleware auth (fail-closed / wrong-scheme / timing-safe / happy path),
and DEK handler responses through gin's test harness.
2026-05-27 14:42:11 +00:00
Matthew Meszaros 59b515cad4 callers: migrate blob access from raw *s3 inputs to storage.Store
Seven call sites stop reaching through the embedded *s3.Client and
instead use the high-level storage.Store methods. Same runtime behavior
on the AWS path; opens the door to the Filesystem backend for
self-hosters.

avatar.go retains an S3-specific path for public-ACL + cache-control
on uploaded avatars and falls back to ServiceUnavailable on non-S3
backends. A future PublicStore interface could clean that up.

unibox/storage.go GetBody now propagates the emsg.DecodeBinary error
that the original code dropped on the floor.
2026-05-27 14:41:53 +00:00
Matthew Meszaros ab445e2207 feat: customer webhook subscriptions with hmac signing and retry
- webhook_endpoints / webhook_deliveries schema (migration 42)
- service: dispatch + endpoint crud + hmac-sha256 signing
- delivery worker drains queue using FOR UPDATE SKIP LOCKED so multiple
  api replicas can run safely without duplicate dispatch
- exponential backoff (30s → 1h cap, 8 attempts then abandoned)
- REST API under /webhooks: list/create/update/delete/rotate-secret/
  list-deliveries. secret only returned at create + rotate
- header convention matches stripe-style: X-Warmbly-Signature: t=<unix>,v1=<hex>
- legacy header X-Warmbly-Token already renamed; new outbound webhook
  headers are X-Warmbly-Signature / X-Warmbly-Event / X-Warmbly-Event-Id
- wired into email account connect/remove and warmup health transitions;
  campaign/tracking/deliverability call sites will reuse the same
  webhookService.Dispatch interface
2026-05-25 16:03:13 +00:00
Matthew Meszaros 43d24bc3d7 feat: customer-defined warmup routing rules on premium pool
new warmup_routing_rules table + repo + REST endpoints under /warmup/
routing. each rule matches a (sender, recipient) pair by domain, TLD,
provider bucket, or any wildcard, with a weight multiplier on the
selector. weight > 1 prefers the pairing, < 1 discourages, 0 excludes.
rules are evaluated in priority order (ascending) and combined with the
existing domain-diversity weighting. example use case: a customer can
say 'send Gmail-recipient warmup only from Google-classified senders'
with one rule; or 'never send to acme.com from this org's mailboxes'
with weight=0. premium pool only — free pool ignores rules.
2026-05-25 15:42:50 +00:00
Matthew Meszaros c790812207 Merge pull request #11 from warmbly/feature/api-keys-dashboard
feat: ship api keys dashboard with smart limits and analytics
2026-05-25 12:26:54 +02:00
Matthew Meszaros e38d571610 Merge pull request #12 from warmbly/feature/campaign-creation-overhaul
feat: fix campaign creation 500 and ship full-config wizard
2026-05-25 12:26:44 +02:00
Matthew Meszaros 512fea76ff feat: add contact 360 detail, sent-emails, and timeline endpoints 2026-05-25 08:30:19 +00:00
Matthew Meszaros 03dc3bce95 feat: overhaul campaign creation with atomic transactional create + multi-step wizard
- fix 500 on POST /campaigns: insert now sets NOT NULL created_at/updated_at
- atomic create writes campaign + sequences + ab variants + tag/folder links + advanced overrides in one tx
- create accepts full optional config (schedule, sender pool, tracking, sequences, variants)
- handler forwards orgID so future start/stop and org-scoped routes resolve
- fix Get/GetByID joining campaign_email_tags/campaign_folders on wrong column
- publish CAMPAIGN_CREATED realtime event after successful create
- replace name+description modal with 4-step wizard (basics, schedule, sender pool, first email + follow-ups)
2026-05-24 16:13:22 +00:00
Matthew Meszaros 9123c4bfcf feat: full api keys product (smart limits, analytics, dashboard) 2026-05-24 16:07:18 +00:00
Matthew Meszaros 07b0c8b5ff feat: backend contacts categories, smart export, csv/xlsx import 2026-05-24 16:04:48 +00:00
Matthew Meszaros 9bce2ad29e feat: full reply templates (search/reorder/duplicate/render + UI) 2026-05-24 12:01:37 +00:00
Matthew Meszaros 939f2160d9 Merge pull request #6 from warmbly/feature/redesign-email-onboarding
ci: fix CI permissions, add web pipeline, prune go linters
2026-05-24 13:26:25 +02:00
Matthew Meszaros 779222bd4f feat(web): redesign add-email modal in the slate-900 theme 2026-05-24 04:16:23 +00:00
Matthew Meszaros c822e95e7f Merge remote-tracking branch 'origin/main' into feature/danger-zone-delayed-deletions
# Conflicts:
#	cmd/backend/main.go
#	internal/api/handler/handler.go
#	internal/api/routes.go
#	internal/models/audit.go
#	internal/models/organization.go
#	internal/models/user.go
#	internal/repository/pg_organization.go
#	internal/repository/pg_user.go
2026-05-24 04:12:10 +00:00
Matthew Meszaros 337d823703 merge: bring main into branch, reconcile email service constructor 2026-05-24 04:09:16 +00:00
Matthew Meszaros e42feac0e4 feat: danger zone with delayed deletion for orgs and accounts 2026-05-24 04:06:07 +00:00
Matthew Meszaros 45f48ba93b feat: redesign email account onboarding flow 2026-05-24 04:01:16 +00:00
Matthew Meszaros d227038ca0 ci: drop unused/unconvert/gosimple + shadow/nilness, run gofmt
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.
2026-05-23 16:54:12 +00:00
Matthew Meszaros 4eb8f7babe feat: settings overhaul, avatars, RBAC, plan alignment, perf
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
2026-05-23 16:04:24 +00:00
Matthew Meszaros 5845fc3069 feat: inbox tag+multi-account filter + org gate + invite/join flow
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.
2026-05-23 10:19:56 +00:00
Matthew Meszaros c8c4440b50 fix: confirm dialog z + theme, persist folders/tags across reload
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.
2026-05-23 09:38:20 +00:00
Matthew Meszaros 7766b690b4 feat(workers): free-form tags for categorizing the fleet
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.
2026-05-20 14:15:52 +00:00