Files
Matthew Meszaros 93e8451738 feat: organization data export and import for moving a workspace between instances (#132)
* feat: add the org_export_jobs and org_import_jobs tables plus the models behind them, so a whole organization can be written to a portable archive and read back on another instance, keeping the option columns typed (a text[] of data groups, an include_secrets boolean, a conflict_strategy check constraint) rather than a settings blob because the option set is small and fixed, and reserving jsonb only for the genuinely free-form parts that are read back for display alone (the source archive's manifest, per-table row counts, the import warning list), with partial indexes on the in-flight and expiring rows so the maintenance sweep stays cheap however much transfer history accumulates, an OrgDataGroup catalog that names the twelve slices of a workspace and carries the dependencies between them, and an org_archive audit entity so an export or import rides the existing audit spine into every teammate's dashboard

* feat: add the schema-generic repository behind workspace archives, which reads and writes tables by name rather than through typed structs because that is the only way an archive stays correct as the schema grows, moving rows as jsonb in both directions via to_jsonb on the way out and jsonb_populate_recordset on the way in so Postgres performs every type conversion and no hand-written Go column mapping can drift from arrays, jsonb, tsvector, inet or enums, lifting the pool's 60s statement_timeout inside the export transaction because a full inbox read legitimately runs longer than that, introspecting generated, identity and not-null columns plus primary keys and foreign keys from the catalog rather than trusting a compiled list, and treating identifier safety as structural: table names come from the compiled registry and column names are always intersected against the destination catalog before reaching a query, so nothing out of an uploaded archive is ever interpolated

* feat: add the workspace archive registry and on-disk format, covering all 110 organization-owned relations with their scope SQL, dependency order and per-table policy, plus 10 explicitly excluded ones each carrying the reason it must never travel (the KMS-wrapped org data key, in-flight OAuth handshakes, the websocket outbox, live sessions, a pending deletion that would otherwise schedule the destination workspace for destruction), naming the two key domains separately because Warmbly seals mailbox credentials under the instance CREDENTIALS_ENCRYPTION_KEY and everything else under the per-organization DEK and confusing them produces mailboxes that authenticate against nothing, defining the archive as a plain zip of newline-delimited JSON so an operator can unzip it and read the data in a text editor and so the manifest can be written last yet still be read first, and sealing archive secrets under an argon2id passphrase key with parameters deliberately heavier than the login hash since it is derived once per archive and guards every credential in the workspace against offline grinding

* feat: implement the workspace export and import engines, streaming rows straight through untouched for the tables that have neither secrets nor blobs so a million-row inbox export stays cheap and only decoding the rows that must change, opening every sealed value against whichever key domain wrote it and re-sealing it under the archive passphrase on the way out then against the destination's own keys on the way in, blanking a credential rather than sinking the whole export when one mailbox cannot be read and clearing the guard flag alongside it so no row is left claiming ciphertext it no longer holds, applying an import inside a single transaction because a half-applied workspace is far worse than a long-running one, rewriting the organization id and matching members to destination accounts by email with unresolvable people blanked where the column is nullable and redirected to the importer where it is not, and running transfers in the accepting process rather than through a queue for the one reason that matters: the passphrase is then never written down anywhere

* feat: make the per-organization DEK cache nil-safe in internal/app/cipher so a process built without Redis falls through to KMS on every call instead of dereferencing a nil cache handle, which is what lets warmblyctl run the workspace export and import commands at all: it deliberately attaches Redis as optional because the whole point of that CLI is working while the rest of the instance is down, and the decrypted-key cache was always an optimisation rather than a requirement

* feat: add the hourly workspace-archive maintenance job that deletes finished archives past their seven-day retention window, since each one is a complete copy of a workspace sitting in object storage and must not accumulate, and closes out any export or import whose process died mid-run, which is the necessary counterpart to executing transfers in the accepting process so the passphrase is never persisted: without this sweep a restart would leave a job reporting running forever

* feat: expose workspace export and import over the JWT-only organization routes and wire the service into the backend, gating every endpoint on workspace ownership through the existing requireOrgOwner check rather than a permission bit because an export with credentials is the single most sensitive artifact this product can produce and an import rewrites the workspace wholesale, so both belong at the same level as deleting it, spooling uploads to a temporary file since a zip needs random access and a length that a multi-gigabyte archive cannot supply from memory, handing that file's ownership to the background import so it outlives the request and is closed exactly when the job ends, streaming downloads with the archive's sha256 in a response header, and constructing the service with both key domains plus object storage so an archive can be opened, re-keyed and stored

* feat: add warmblyctl org list, export and import so a self-hoster can move a workspace from the box without a browser, running the same engine in-process against Postgres and adding no HTTP surface to a CLI whose entire trust model is container or host access, resolving --org from whichever handle the operator has (id, slug, or the owner's email), streaming the archive to a file or to stdout so it can be piped straight into ssh with progress still readable on stderr, prompting for the credential passphrase twice through the existing password prompt so the terminal and pipe rules stay identical across every command, and defaulting the import path to a preflight report that names what already exists here and which members have no account before anything is written, with --dry-run to stop there

* feat: add the dashboard API layer for workspace archives, fetching the data-group catalog from the server rather than restating it in the client so a new group appears the moment the backend knows about it, mirroring the server's group-dependency closure in expandGroups so the toggles a user sees always match what the archive actually gets, polling only while a transfer is in flight and dropping to no interval the moment none are active since a running job has no realtime event of its own, and downloading a finished archive as a blob through the authenticated client because the endpoint is bearer-authenticated and a plain anchor href cannot carry the token

* feat: build the Settings and Data dashboard page for exporting and importing a workspace, following the settings section conventions and the in-app confirm rather than window.confirm, defaulting the export to every data group because a migration that quietly leaves data behind is worse than one that takes a while, marking the heavy groups so nobody exports a decade of inbox history unaware, requiring the credential passphrase twice behind a confirm that states plainly what the file will contain, and making the import a two-step flow where a preflight reads the archive and reports its origin, row counts, unsealable credentials, existing rows and unknown members before a single byte is written, so confirming is never a leap of faith

* feat: register the Data settings section in the dashboard rail, route and realtime spine, placing it under Advanced beside the danger zone and gating it to the workspace owner so the nav matches what the endpoints actually allow, and mapping the new org_archive audit entity to the export and import query keys in useRealtimeEvents so an archive starting or landing refreshes the page for every teammate through the existing audit spine rather than a bespoke event

* feat: document workspace export and import as a customer guide registered under Account and team, covering what each of the twelve data groups contains and which four dominate archive size, why credentials need a passphrase to travel at all and what happens to mailboxes when they do not, how members are matched to destination accounts by email and what becomes of anyone without one, the difference between keeping existing rows and replacing them, and a table of what deliberately does not import with the reason for each, because billing, plan overrides, worker placement, sync checkpoints and warmup pool membership belong to an instance rather than to a workspace

* feat: document org list, export and import in the warmblyctl reference and point the deployment guide at them as the supported route between a self-hosted install and the hosted service in either direction, adding every flag with what it does, the two extra environment variables those commands read and the difference between them (a missing KMS provider stops the command because sealed values cannot be opened, while a missing CREDENTIALS_ENCRYPTION_KEY is only a warning that mailbox credentials will not move), the behaviour when Redis is down, and the warning that an archive carrying credentials is the most sensitive file this product produces

* feat: record in AGENTS.md that a migration adding an organization-scoped table is not finished until that table is registered in internal/app/orgtransfer/spec.go, either in Tables with its group and scope or in ExcludedTables with the reason it must not travel, because data left out of the registry is silently absent from every archive and nobody discovers it until a customer's migration lands on the other side missing a feature's data, and spelling out the four things that are easy to get wrong when adding one: dependency order, the group boundary that needs a Requires entry only when a NOT NULL foreign key crosses it, which of the two key domains seals a ciphertext column, and which columns name something only the source instance knows
2026-08-18 07:53:39 -07:00

319 lines
14 KiB
Go

package models
import (
"time"
"github.com/google/uuid"
)
// OrgTransferFormatVersion is the archive layout version written into every
// manifest. Bump it only for a change an older importer could not read; the
// importer already tolerates unknown tables and unknown columns, so adding
// either is not a format change.
const OrgTransferFormatVersion = 1
// OrgTransferStatus is the lifecycle of one export or import job. Export adds
// "expired": the archive is deleted from blob storage after its retention
// window, and the row survives so the history stays honest about what ran.
type OrgTransferStatus string
const (
OrgTransferStatusQueued OrgTransferStatus = "queued"
OrgTransferStatusRunning OrgTransferStatus = "running"
OrgTransferStatusCompleted OrgTransferStatus = "completed"
OrgTransferStatusFailed OrgTransferStatus = "failed"
OrgTransferStatusExpired OrgTransferStatus = "expired"
)
// IsTerminal reports whether a job has stopped moving.
func (s OrgTransferStatus) IsTerminal() bool {
switch s {
case OrgTransferStatusCompleted, OrgTransferStatusFailed, OrgTransferStatusExpired:
return true
}
return false
}
// OrgDataGroup names one slice of a workspace. Groups exist so a migration can
// leave the bulky history behind: "core" plus "contacts" plus "campaigns"
// rebuilds a working workspace, while "inbox" and "events" can multiply the
// archive size by a hundred on a busy org without changing what it does.
type OrgDataGroup string
const (
// OrgDataGroupCore is the workspace itself: the organization row, members,
// roles, teams, settings, API keys, webhooks, and the mailbox records.
// Always included; an archive without it cannot be imported.
OrgDataGroupCore OrgDataGroup = "core"
// OrgDataGroupContacts is contacts, categories, notes, activities, and
// suppression.
OrgDataGroupContacts OrgDataGroup = "contacts"
// OrgDataGroupCampaigns is campaigns, sequences, senders, attachments, and
// per-campaign settings.
OrgDataGroupCampaigns OrgDataGroup = "campaigns"
// OrgDataGroupCRM is pipelines, deals, CRM tasks, and meeting bookings.
OrgDataGroupCRM OrgDataGroup = "crm"
// OrgDataGroupAutomations is automations, integrations, and lead sync.
OrgDataGroupAutomations OrgDataGroup = "automations"
// OrgDataGroupAI is the assistant: sessions, messages, skills, MCP servers,
// and AI settings.
OrgDataGroupAI OrgDataGroup = "ai"
// OrgDataGroupWarmup is warmup participation, statistics, and appeals.
OrgDataGroupWarmup OrgDataGroup = "warmup"
// OrgDataGroupInbox is the unified inbox: mailboxes, threads, message
// bodies, and mailbox sync checkpoints. Usually the largest group.
OrgDataGroupInbox OrgDataGroup = "inbox"
// OrgDataGroupSending is the send pipeline: tasks, their payloads, and
// per-day counters.
OrgDataGroupSending OrgDataGroup = "sending"
// OrgDataGroupEvents is delivery, tracking, and placement history.
OrgDataGroupEvents OrgDataGroup = "events"
// OrgDataGroupLogs is audit logs, campaign logs, and notifications.
OrgDataGroupLogs OrgDataGroup = "logs"
// OrgDataGroupBilling is subscription, credit ledger, and referral state.
// Never applied verbatim on import: the destination instance owns billing.
OrgDataGroupBilling OrgDataGroup = "billing"
)
// AllOrgDataGroups is every group in display order. The dashboard renders the
// toggle list from this, so order here is the order the user sees.
var AllOrgDataGroups = []OrgDataGroup{
OrgDataGroupCore,
OrgDataGroupContacts,
OrgDataGroupCampaigns,
OrgDataGroupCRM,
OrgDataGroupAutomations,
OrgDataGroupAI,
OrgDataGroupWarmup,
OrgDataGroupInbox,
OrgDataGroupSending,
OrgDataGroupEvents,
OrgDataGroupLogs,
OrgDataGroupBilling,
}
// OrgDataGroupInfo describes a group for the settings UI.
type OrgDataGroupInfo struct {
Key OrgDataGroup `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
// Required groups cannot be switched off.
Required bool `json:"required"`
// Heavy marks the groups that dominate archive size on a busy workspace,
// so the UI can warn before someone exports a decade of inbox history.
Heavy bool `json:"heavy"`
// Requires names the groups this one cannot travel without, because its
// rows hold NOT NULL references into them. Selecting a group selects these
// too, on the server and in the dashboard, so what the toggles show is
// what the archive actually gets.
Requires []OrgDataGroup `json:"requires,omitempty"`
}
// OrgDataGroupCatalog is the user-facing description of every group, and the
// single source of truth for the dependencies between them.
//
// Each Requires entry is a NOT NULL foreign key crossing a group boundary:
// campaign_leads keys on a contact, unibox_thread_labels on a category,
// campaign_logs on a campaign. Leaving the target behind would not degrade an
// import, it would abort it. Nullable crossings need no entry — the importer
// blanks those when their target is not part of the run.
var OrgDataGroupCatalog = []OrgDataGroupInfo{
{Key: OrgDataGroupCore, Label: "Workspace", Description: "Organization, members, roles, teams, mailboxes, API keys, webhooks, and settings.", Required: true},
{Key: OrgDataGroupContacts, Label: "Contacts", Description: "Contacts, categories, notes, activities, and the suppression list."},
{Key: OrgDataGroupCampaigns, Label: "Campaigns", Description: "Campaigns, sequences, senders, attachments, and per-campaign settings.", Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupCRM, Label: "CRM", Description: "Pipelines, deals, tasks, and meeting bookings."},
{Key: OrgDataGroupAutomations, Label: "Automations", Description: "Automations, connected integrations, and lead sync sources."},
{Key: OrgDataGroupAI, Label: "Assistant", Description: "Assistant sessions and messages, skills, MCP servers, and AI settings."},
{Key: OrgDataGroupWarmup, Label: "Warmup", Description: "Warmup participation, routing rules, statistics, and appeals."},
{Key: OrgDataGroupInbox, Label: "Inbox", Description: "Unified inbox threads, message bodies, and mailbox sync state.", Heavy: true, Requires: []OrgDataGroup{OrgDataGroupContacts}},
{Key: OrgDataGroupSending, Label: "Send history", Description: "Queued and completed send tasks with their payloads.", Heavy: true},
{Key: OrgDataGroupEvents, Label: "Delivery events", Description: "Bounces, complaints, opens, clicks, and placement tests.", Heavy: true},
{Key: OrgDataGroupLogs, Label: "Logs", Description: "Audit log, campaign logs, and notifications.", Heavy: true, Requires: []OrgDataGroup{OrgDataGroupCampaigns}},
{Key: OrgDataGroupBilling, Label: "Billing history", Description: "Subscription, credit ledger, and referral records. Read-only on import: the destination instance owns billing."},
}
// GroupRequirements indexes the catalog's dependencies for closure walks.
func GroupRequirements() map[OrgDataGroup][]OrgDataGroup {
out := make(map[OrgDataGroup][]OrgDataGroup, len(OrgDataGroupCatalog))
for _, g := range OrgDataGroupCatalog {
if len(g.Requires) > 0 {
out[g.Key] = g.Requires
}
}
return out
}
// OrgImportConflict is what to do when the archive carries a row whose primary
// key already exists in the destination.
type OrgImportConflict string
const (
// OrgImportConflictSkip keeps the row that is already here. The safe
// default: an import never destroys data that was not in the archive.
OrgImportConflictSkip OrgImportConflict = "skip"
// OrgImportConflictOverwrite replaces the existing row with the archive's.
OrgImportConflictOverwrite OrgImportConflict = "overwrite"
)
// OrgExportJob is one archive build.
type OrgExportJob struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
RequestedBy *uuid.UUID `json:"requested_by,omitempty"`
Status OrgTransferStatus `json:"status"`
Groups []OrgDataGroup `json:"groups"`
IncludeSecrets bool `json:"include_secrets"`
FormatVersion int `json:"format_version"`
ProgressPercent int `json:"progress_percent"`
ProgressStage string `json:"progress_stage"`
ArchiveKey *string `json:"-"`
ArchiveBytes *int64 `json:"archive_bytes,omitempty"`
ArchiveSHA256 *string `json:"archive_sha256,omitempty"`
RowCounts map[string]int64 `json:"row_counts"`
ErrorMessage *string `json:"error_message,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TotalRows sums every table's row count, for the "12,481 rows" summary line.
func (j *OrgExportJob) TotalRows() int64 {
var total int64
for _, n := range j.RowCounts {
total += n
}
return total
}
// OrgImportJob is one archive application.
type OrgImportJob struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
RequestedBy *uuid.UUID `json:"requested_by,omitempty"`
Status OrgTransferStatus `json:"status"`
ArchiveKey *string `json:"-"`
ArchiveBytes *int64 `json:"archive_bytes,omitempty"`
ArchiveSHA256 *string `json:"archive_sha256,omitempty"`
SourceManifest *OrgArchiveInfo `json:"source_manifest,omitempty"`
Groups []OrgDataGroup `json:"groups"`
ConflictStrategy OrgImportConflict `json:"conflict_strategy"`
ProgressPercent int `json:"progress_percent"`
ProgressStage string `json:"progress_stage"`
RowCounts map[string]int64 `json:"row_counts"`
Warnings []string `json:"warnings"`
ErrorMessage *string `json:"error_message,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OrgArchiveInfo is the manifest summary the dashboard shows before an import
// runs and stores on the job afterwards. It is the subset of the on-disk
// manifest that is safe and useful to display.
type OrgArchiveInfo struct {
FormatVersion int `json:"format_version"`
SourceInstance string `json:"source_instance"`
SourceAppVersion string `json:"source_app_version"`
OrganizationID uuid.UUID `json:"organization_id"`
OrganizationName string `json:"organization_name"`
ExportedAt time.Time `json:"exported_at"`
Groups []OrgDataGroup `json:"groups"`
HasSecrets bool `json:"has_secrets"`
RowCounts map[string]int64 `json:"row_counts"`
BlobCount int `json:"blob_count"`
Members []OrgArchiveUser `json:"members"`
}
// TotalRows sums the manifest's per-table counts.
func (a *OrgArchiveInfo) TotalRows() int64 {
var total int64
for _, n := range a.RowCounts {
total += n
}
return total
}
// OrgArchiveUser is a member carried by the archive. The importer matches
// these to destination accounts by email; there is no password material here,
// so an archive can never mint a usable login on the destination.
type OrgArchiveUser struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
IsOwner bool `json:"is_owner"`
}
// CreateOrgExportRequest is the export endpoint's body.
type CreateOrgExportRequest struct {
// Groups to include. Empty means every group.
Groups []OrgDataGroup `json:"groups,omitempty"`
// IncludeSecrets re-seals mailbox and integration credentials into the
// archive under Passphrase so the destination can bring mailboxes back up
// without every user reconnecting. Requires Passphrase.
IncludeSecrets bool `json:"include_secrets,omitempty"`
// Passphrase protects the secrets bundle. Never stored: it is used once to
// derive the archive's sealing key and then discarded.
Passphrase string `json:"passphrase,omitempty"`
}
// CreateOrgImportRequest is the import endpoint's options form field. The
// archive itself arrives as the multipart file.
type CreateOrgImportRequest struct {
// Groups to apply. Empty means every group present in the archive.
Groups []OrgDataGroup `json:"groups,omitempty"`
// ConflictStrategy decides what happens to rows that already exist here.
ConflictStrategy OrgImportConflict `json:"conflict_strategy,omitempty"`
// Passphrase unseals the archive's secrets bundle. Omit it and the import
// still runs; mailboxes and integrations just arrive needing a reconnect.
Passphrase string `json:"passphrase,omitempty"`
}
// OrgImportPreflight is what the dashboard shows before anything is written:
// what the archive holds, and what will happen when it is applied.
type OrgImportPreflight struct {
Archive *OrgArchiveInfo `json:"archive"`
// SecretsUnsealed reports whether the supplied passphrase actually opened
// the secrets bundle, so the UI can say "12 mailboxes will reconnect
// automatically" rather than promising it blindly.
SecretsUnsealed bool `json:"secrets_unsealed"`
// Conflicts is the per-table count of primary keys already present here.
Conflicts map[string]int64 `json:"conflicts"`
// UnknownMembers are archive members with no account on this instance.
// They are imported as pending invitations rather than as new accounts.
UnknownMembers []OrgArchiveUser `json:"unknown_members"`
// SkippedTables are tables in the archive this instance does not have,
// usually because the archive came from a newer release.
SkippedTables []string `json:"skipped_tables"`
Warnings []string `json:"warnings"`
}
// MaxOrgArchiveUploadBytes caps an uploaded archive. A full workspace with
// inbox history can be large, but not this large, and the cap keeps a hostile
// upload from filling the disk before the manifest is ever read.
const MaxOrgArchiveUploadBytes = 8 << 30 // 8 GiB
// OrgExportRetention is how long a finished archive stays downloadable. It is
// a complete copy of a workspace sitting in blob storage, so it does not live
// forever; the operator can always run another export.
const OrgExportRetention = 7 * 24 * time.Hour
// MinOrgExportPassphraseLength is the floor for a secrets passphrase. The
// passphrase is the only thing standing between the archive file and every
// mailbox credential in the workspace, so it is held to a higher bar than a
// login password.
const MinOrgExportPassphraseLength = 12