Offers Database Schema

Database table and enum for offers

Enums

offer_status

Fourteen states (OFFER_STATUS_VALUES). The lifecycle is enforced by OFFER_TRANSITIONS in @zooly/types (packages/types/src/types/Offer.ts); see Architecture for the diagram.

CREATE TYPE "public"."offer_status" AS ENUM(
  'DRAFT',
  'ADMIN_REVIEW',
  'APPROVED',
  'COUNTERED',
  'ACCEPTED',
  'PENDING_PAY_CAPTURE',
  'PAID',
  'DELIVERED',
  'REVISION_REQUESTED',
  'COMPLETED',
  'DISPUTED',
  'REJECTED',
  'CANCELLED',
  'EXPIRED'
);

offer_expire_policy

What the auto-expiry cron does when an APPROVED / COUNTERED offer's expires_at elapses (EXPIRE_POLICY_VALUES).

CREATE TYPE "public"."offer_expire_policy" AS ENUM(
  'expire',
  'remind_talent',
  'ping_brand'
);

image_kind

Defined alongside image_candidate_session. Carried on offers.image_kind for image campaigns so the talent UI can render the picked sample with the right framing (DRAFT vs FINAL).

Table: offers

Source of truth: packages/db/src/schema/offers.ts. Mirrored as Offer in packages/types/src/types/Offer.ts.

Identity & participants

ColumnTypeNullableDefaultDescription
idtextNOT NULLnanoid()Primary key
seller_account_idtextNOT NULLFK to account.id (talent / seller)
buyer_account_idtextNOT NULLFK to account.id (brand / buyer)
buyer_brand_nametextNULLFree-text brand label captured at submit time (independent of account display name)
blast_offer_idtextNULLFK to blast_offers.id when this row was fanned out from a blast
idempotency_keytextNULL (unique)Per-request key set by the submit / respond / deliver / complete routes

Campaign & creative brief

ColumnTypeNullableDefaultDescription
campaign_typetextNULLSingle canonical campaign type from the CAMPAIGN_TYPES registry. Set at submit.
campaign_typestext[]NOT NULL'{}'Legacy multi-select chips (kept for back-compat with older payloads)
campaign_descriptiontextNOT NULLFree-text brief
script_texttextNULLRadio / voice-over script
usage_typestext[]NOT NULL'{}'Where the content will be used
custom_usagetextNULLFree-text custom usage
wants_sharingbooleanNULLWhether the brand wants a social-share bundle
sharing_descriptiontextNULLFree-text sharing-bundle details
product_image_urlstext[]NOT NULL'{}'Up to 3 brand-supplied product photos used as image-generation input
image_kindimage_kindNULLDRAFT or FINAL; only meaningful for IMAGE campaign types
selected_sample_urltextNULLURL of the image candidate the brand picked at submit
generated_sample_urlsjsonbNOT NULL[]Array of { modelKey, modelLabel, url } — every image candidate rendered for this offer
image_candidate_session_idtextNULLSet on blast-created image/video offers so the talent's review UI can poll the candidate session. ZLink offers extract URLs into the columns above at submit, so this is typically null for them.
voice_candidate_session_idtextNULLVoice-flow analogue of image_candidate_session_id
selected_voice_urltextNULLURL of the voice candidate the brand picked at submit
generated_voice_urlsjsonbNOT NULL[]Array of { modelKey, modelLabel, url } — every voice candidate for this offer

Pricing

All amounts are stored as integers in the currency's minor unit (cents for USD, yen for JPY, etc.). See packages/util/src/currency.ts for zero-decimal handling. Multi-currency landed in 6a07f00f.

ColumnTypeNullableDefaultDescription
currencyvarchar(3)NOT NULL'USD'ISO 4217 currency code
below_talent_thresholdbooleanNOT NULLfalseSet at submit when offer_amount_minor_unit is under the talent's effective minimum. Drives the below-threshold policy (flag / ask / auto-counter / auto-reject).
offer_amount_minor_unitintegerNOT NULLBrand's offered price (talent take-home, before fees)
counter_amount_minor_unitintegerNULLLegacy talent counter price. New clients should set counter_changes.offerAmountMinorUnit instead.
counter_notetextNULLTalent's note attached to a counter
counter_changesjsonbNULLWhitelisted talent overrides on a counter (OfferCounterChanges). Applied onto the canonical columns when the brand accepts.
zooly_fee_minor_unitintegerNOT NULL0Platform fee snapshot
total_brand_price_minor_unitintegerNOT NULL0What the brand actually pays (offer + fees)

counter_changes is a Postgres jsonb typed as Partial<Offer>. The whitelist of allowed keys lives in offerCounterChangesSchema (packages/contracts/src/schemas/offers.ts):

{
  offerAmountMinorUnit?: number;
  usageTypes?: string[];
  customUsage?: string | null;
  wantsSharing?: boolean | null;
  sharingDescription?: string | null;
  imageKind?: ImageKind | null;
  scriptText?: string | null;
  selectedSampleUrl?: string | null;
  selectedVoiceUrl?: string | null;
}

Status & admin review

ColumnTypeNullableDefaultDescription
statusoffer_statusNOT NULL'DRAFT'Current state in the 14-state FSM
admin_reviewed_bytextNULLAdmin account id who approved/rejected the most recent ADMIN_REVIEW
admin_reviewed_attimestamptzNULLWhen the admin acted
admin_reject_reasontextNULLFree-text rejection reason

Delivery, payment & lifecycle

ColumnTypeNullableDefaultDescription
revision_countintegerNOT NULL0Number of REVISION_REQUESTED cycles
delivery_file_urlstext[]NULLTalent-uploaded deliverable URLs
delivery_notestextNULLOptional note the talent attaches to the delivery
delivery_submitted_attimestamptzNULLWhen the talent uploaded the deliverable
payment_stripe_idtextNULLStripe PaymentIntent id for the brand's payment
payment_authorized_attimestamptzNULLWhen the card was authorized (hold placed) on PENDING_PAY_CAPTURE; used by the cron to void auths nearing Stripe's 7-day limit
auto_release_attimestamptzNULLWhen DELIVERED auto-promotes to COMPLETED (auto-release timer, set +30d on payment)
deleted_attimestamptzNULLSoft delete
created_attimestamptzNOT NULLnow()
updated_attimestamptzNOT NULLnow()

Expiry & re-engagement

ColumnTypeNullableDefaultDescription
expires_attimestamptzNULLAuto-expiry deadline; set when the offer enters APPROVED / COUNTERED
expire_policyoffer_expire_policyNOT NULL'remind_talent'What the expiry cron does at the deadline
expires_in_daysintegerNOT NULL30Brand override for the expiry window (1–365)
stale_reminder_sent_attimestamptzNULLDedup signal + deadline-bump marker for OFFER_STALE_REMINDER
suggest_similar_sent_attimestamptzNULLDedup signal for the BRAND_SUGGEST_SIMILAR re-engagement card
suggest_similar_dismissed_attimestamptzNULLWhen the brand dismissed the suggestion card

Indexes

  • offers_seller_idx on seller_account_id
  • offers_buyer_idx on buyer_account_id
  • offers_status_idx on status
  • offers_expires_at_idx on expires_at

Foreign Keys

  • seller_account_idaccount.id
  • buyer_account_idaccount.id
  • blast_offer_idblast_offers.id

Migrations

Key migrations in chronological order (full list in packages/db/drizzle/):

  • 0016_moaning_risque.sql — initial offers table
  • 0017_offers_buyer_account.sql — replace buyer_email / buyer_name with buyer_account_id
  • amount_minor_unit_migration — switch all amounts to minor units (6eaa6062)
  • 0072_complete_randall_flagg.sql — add counter_changes jsonb (3b6f6cbf, made idempotent in 4371456c)

Generate new migrations with:

cd packages/db && npm run db:generate

Apply migrations with:

cd packages/db && npm run db:migrate

(Never use drizzle-kit push — only generated migrations, per project rules.)