Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesMinisitesMinisites — Data Model

Minisites — Data Model

minisite_content

Defined in libs/db/src/lib/schema/minisites.ts. One row per organization (unique on organization_id).

ColumnTypePurpose
iduuid PKRow identity.
organization_iduuid FK → organizations.id, uniqueOne minisite per org.
contentjsonbWorking draft: pages[], theme, leadPopup, founderStory, plus legacy sections mirrored from the home page. Edited by the dashboard.
published_contentjsonb nullableSnapshot served to the public. Null until first publish.
themejsonbTheme tokens: primaryColor, secondaryColor, fontFamily, headingFontFamily?, buttonStyle, cornerStyle, sectionDensity. The DB column default still carries the pre-revamp values; the application default (DEFAULT_THEME in @taikan/shared) and the layout are warm copper, and modern.astro normalizes legacy primaries (#000000, etc.) to the copper default.
custom_domainvarchar(255) uniqueApex/subdomain owned by the gym (e.g. fit.gymname.com).
subdomainvarchar(255) unique*.taikan.fit subdomain assigned by Taikan.
seo_titlevarchar(255)<title> override.
seo_descriptiontextMeta description.
favicon_urltextCustom favicon.
is_publishedboolean (default false)Public visibility gate.
created_at / updated_attimestamp tzAudit columns.

Relations

  • minisite_contentorganizations one-to-one. Deleting an org cascades the minisite row via the FK.

minisite_events

Defined in libs/db/src/lib/schema/minisites.ts; created by migration libs/db/drizzle/0066_lying_siren.sql. Dated, promotable events/workshops for an org. Many rows per org. Events are NOT purchasable; registration is lead-capture only (the CTA points at the contact page/section), so there is no payment/entitlement coupling.

ColumnTypePurpose
iduuid PKRow identity.
organization_iduuid FK → organizations.idOwning org.
titlevarchar(255)Event title.
descriptiontext nullableEvent blurb.
image_urltext nullablePromo image.
starts_attimestamp tzStart time.
ends_attimestamp tz nullableOptional end time.
location_textvarchar(255) nullableFree-text location.
price_labelvarchar(80) nullableDisplay-only label (e.g. “₪450” or “Free”), not a real price.
cta_labelvarchar(80) nullableOptional override for the lead CTA button.
sort_orderinteger (default 0)Ordering within the same start time.
is_publishedboolean (default false)Whether it surfaces on the public site.
created_at / updated_attimestamp tzAudit columns.
deleted_attimestamp tz nullableSoft-delete marker.
  • Index minisite_events_org_idx on (organization_id, starts_at).
  • The resolve endpoint surfaces upcoming published rows in platformData.events[]; the editor manages them via the events CRUD endpoints.

Live data (not persisted in this module)

The platformData payload composed by the resolve endpoint pulls from existing tables:

SectionSource
programsprograms
upcomingSessionsclass_sessions joined to class_types
plansplans (+ derived, gated, see below)
coursescourses
coachesmemberships filtered by role = 'coach' joined to users
eventsminisite_events (upcoming + published)
organizationorganizations

events is the one platform-data source authored inside the minisites module (the minisite_events table below); the rest are external tables. No copy of these lives in minisite_content.content, so the minisite always sees fresh data.

Pricing DTO extensions (FIT-287)

Each element of platformData.plans[] carries the pre-existing 8 fields (id, name, description, type, priceInCents, currency, interval, classCredits) plus five optional fields. These were once gated on a plan-intro-pricing PostHog flag; that flag was removed (2026-08-19) and the fields are now emitted for every org. They stay optional because each is absent on its own terms — absent from the JSON entirely, not merely null:

FieldTypeSource
introPriceInCentsnumber | nullplans.intro_price_in_cents, copied as-is.
introDiscountPercentnumber | nullplans.intro_discount_percent, copied as-is.
introDurationCyclesnumber | nullplans.intro_duration_cycles, copied as-is.
seatsLeftnumber | nullDerived, not a column — the effective (binding plan-vs-group) seats-remaining, via computeSeatFields/loadGroupCaps (apps/api/src/plans/plan-capacity.util.ts), shared with PlansService’s enforcement path (FIT-289). Absent when nothing is capped, and when the binding cap is a group with show_cap_to_members = false (see below).
soldOutbooleanDerived: seatsLeft === 0. Emitted whenever anything is capped, including for a hidden-count group.

Internal-only derived fields (seatsTaken, capScope, maxPurchases, planGroupId) never reach this public DTO.

Per-group show_cap_to_members (FIT-289). plan_groups.show_cap_to_members (boolean NOT NULL DEFAULT true) is an owner toggle on the group. The minisite resolves seats for a member audience (computeSeatFields(..., 'member')), so when a plan’s binding cap is a group with the toggle off, the DTO emits soldOut and omits seatsLeft — the two are emitted independently for exactly this reason. Consumers must key buyability off soldOut, never off whether seatsLeft is present. Staff-facing payloads (PlansService, dashboard) are unaffected and always carry the full seat set.

platformData also gains a top-level optional joinUrl: string | null (FIT-287) — the org’s public join link (${FRONTEND_URL}/join/${org.joinToken}), present only when the full purchase-CTA gate passes: minisite-plan-purchase flag on, org.joinEnabled with a token, published pricing section isEnabled with ctaMode: 'purchase', and an active payment config. See behavior.md § Pricing and purchase CTA for the full rule and rendering.

Both extensions are additive to the shared type MinisitePlatformData (libs/shared/src/lib/minisite.ts) — old published payloads and flag-off orgs parse unchanged.

Indexes & uniqueness

  • custom_domain and subdomain are independently unique to support either resolution path.
  • Lookup happens by host header at request time — both columns are read; performance relies on the unique indexes.

Content shape (informal)

Authoritative Zod schemas live in libs/shared/src/lib/minisite.ts (MinisiteContent, MinisitePage, MinisiteSection, MinisiteTheme).

type Section = { type: 'hero' | 'about' | 'classes' | 'schedule' | 'pricing' | 'courses' | 'trainers' | 'contact' | 'gallery' | 'testimonials' | 'faq' | 'social' | 'events'; order: number; isEnabled: boolean; data: Record<string, unknown>; // section-specific payload }; type Page = { kind: 'home' | 'courses' | 'workshops' | 'about' | 'contact'; slug: string; // '' for home; mirrors PAGE_SLUGS[kind] navLabel: string; isEnabled: boolean; // page renders + can appear in nav showInNav: boolean; // surface in top nav (home often hidden) order: number; sections: Section[]; }; type MinisiteContent = { title?: string; locale?: 'he' | 'en' | 'ru'; theme?: { primaryColor?: string; // default copper #b86a35 secondaryColor?: string; // default cream #f4efe7 fontFamily?: string; headingFontFamily?: string; // optional separate display font buttonStyle?: 'rounded' | 'pill' | 'square'; cornerStyle?: 'sharp' | 'soft' | 'round'; sectionDensity?: 'compact' | 'normal' | 'spacious'; }; founderStory?: { enabled: boolean; title: string; body: string; credentials?: string; ctaHook?: string; }; leadPopup?: { enabled: boolean; title: string; body: string; ctaText: string; imageUrl?: string; delaySeconds: number; // 0-120, default 8 }; pages: Page[]; // multi-page source of truth (FIT-209) sections?: Section[]; // @deprecated legacy single-page list, mirrored to the home page };

Back-compat shim

  • pages[] is the source of truth. Legacy rows carry only sections (no pages).
  • getMinisitePages(content) (in @taikan/shared, mirrored client-side by getPages in apps/minisites/src/lib/pages.ts) returns content.pages when present, otherwise wraps content.sections into a single home page.
  • The API service ensurePages() upgrades rows in place: shims legacy sections into Home, backfills missing default pages (disabled), and backfills missing section types into Home. It keeps content.sections mirrored to the Home page’s sections.

New section data shapes

  • social (SocialSectionData): { title, description, instagramHandle?, instagramUrl?, tiktokUrl?, facebookUrl?, youtubeUrl?, posts: { imageUrl, link? }[] }. Manual.
  • events (EventsSectionData): { title, description, showEventIds: string[] }. Config-only; the renderer pulls rows from platformData.events[] (empty showEventIds shows all upcoming published events).

pricing section data (PricingSectionData)

{ title, featuredPlanIds: string[], highlightPlanId?, valuePillar?, stackReductionNote?, ctaMode: 'lead' | 'purchase' }.

ctaMode (FIT-287, default 'lead') is the owner’s opt-in toggle for the purchase CTA — set via the PricingEditor’s radio control, published like any other section field. On its own it changes nothing: the Astro renderer never reads ctaMode directly, only the API-computed platformData.joinUrl, which additionally requires the minisite-plan-purchase flag and an active payment config (see Pricing DTO extensions above). No migration — the field lives in the existing content/published_content jsonb and defaults old rows to 'lead' on parse.

The Astro page treats data as opaque per-section JSON; schema is enforced in the dashboard editor and @taikan/shared.