DOMAIN_MODEL — theme-config-service
Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS
Strategic anchors: 06 Data Models §4.12 ThemeConfig · standards/NAMING · standards/ERROR_CODES
This document is the single source of truth for the domain shape — aggregates, entities, value objects, invariants, state machines, and domain events. It is framework-free; everything here is reproducible in any TypeScript runtime. The application layer (APPLICATION_LOGIC) orchestrates these aggregates; the infrastructure layer (DATA_MODEL) persists them.
1. Bounded-context glossary
| Term | Meaning |
|---|---|
| Theme | The logical brand identity for a tenant (Phase 0) or property (Phase 2). One per (tenantId, propertyId?). |
| ThemeVersion | An immutable snapshot of every authoring artefact (tokens, layout selections, content, navigation, booking-flow, email overrides, locale packs) at one point in time. |
| ThemePublication | The pointer that names the active version. Exactly one active publication per Theme; rollback creates a new publication pointing at an older version. |
| DesignTokenSet | The semantic token vocabulary: colors, typography, spacing, radius, shadow, motion. |
| SemanticColor | A token name that maps to a brand role (primary, secondary, surface, accent, …) plus its on-color (textOnPrimary, …). |
| TypographyPair | A heading + body font family pairing with a base size and a modular scale. |
| LayoutPreset | A platform-registered, tenant-selectable page layout (e.g., hero-with-search for the home page). |
| ContentBlock | A page-attached piece of content (about, policies, faq, gallery) with per-locale rich-text body. |
| NavigationConfig | An ordered tree of menu items per surface (header, footer, mobile_drawer). |
| BookingFlowConfig | The configurable shape of the booking funnel (which steps shown, which optional fields collected). |
| EmailTheme | The override block consumed by notification-service to render transactional emails in tenant brand. |
| LocalePack | A per-locale keyed string bundle (cta.book_now, errors.required_field, …). |
| PreviewToken | A short-lived signed token granting anonymous read of one unpublished ThemeVersion. |
| AssetRef | A MediaRef URL into file-storage-service. We never store bytes. |
| AIProvenance | Reference VO from @ghasi/contracts-melmastoon; stored on any draft mutation that incorporates AI-generated content (palette / translation). |
2. Branded ID types
All identifiers are ULIDs prefixed per standards/NAMING.md:
import { Branded } from '@ghasi/domain-primitives';
type ThemeId = Branded<string, 'ThemeId'>; // thm_…
type ThemeVersionId = Branded<string, 'ThemeVersionId'>; // thv_…
type ThemePublicationId= Branded<string, 'ThemePublicationId'>; // thp_…
type LayoutPresetId = Branded<string, 'LayoutPresetId'>; // lpr_…
type ContentBlockId = Branded<string, 'ContentBlockId'>; // cnb_…
type NavigationConfigId= Branded<string, 'NavigationConfigId'>; // nvc_…
type BookingFlowId = Branded<string, 'BookingFlowId'>; // bfc_…
type EmailThemeId = Branded<string, 'EmailThemeId'>; // emt_…
type PreviewTokenId = Branded<string, 'PreviewTokenId'>; // pvt_…
TenantId, PropertyId, UserId, MediaRef, Locale, AIProvenance are imported from @ghasi/contracts-melmastoon and are used as-is.
3. State machine: ThemeVersion
┌────────────────────────┐
│ │
│ draft │ ◀── initial (created via POST /themes/:id/versions)
│ │
└─────┬──────────┬───────┘
│ │
patch (PATCH) │ │ POST /preview
▼ ▼
│ ┌───────────────┐
│ │ preview_ready │ ◀── shareable preview URL minted
│ └─────┬─────────┘
│ │
│ │ patch → returns to draft (preview tokens revoked)
│ │
│ │ publish (final validation passes)
│ ▼
│ ┌───────────────┐
│ │ published │ ◀── ThemePublication points here
│ └─────┬─────────┘
│ │
│ │ a *new* version becomes published (this version is superseded)
│ │ OR rollback chooses a different active version
│ ▼
│ ┌───────────────┐
└──▶│ archived │ ◀── immutable; readable for audit
└───────────────┘
Illegal transitions (rejected with MELMASTOON.RESERVATION.INVALID_STATE_TRANSITION
— domain code MELMASTOON.THEME.INVALID_STATE_TRANSITION):
- published → draft (must clone into a new draft)
- archived → anything (immutable)
- draft → archived (only via supersession, never directly)
A ThemeVersion's state is owned by the ThemeVersion aggregate root; only the Theme aggregate (which holds the ThemePublication pointer) may mutate published ↔ archived. All other transitions are local to the ThemeVersion.
4. Aggregate: Theme
Identity: ThemeId (ULID, thm_…)
Tenancy: mandatory TenantId; optional PropertyId (Phase 2)
Concurrency: OCC via version: number (incremented on every mutation)
4.1 TypeScript shape
import {
TenantId, PropertyId, UserId, ISODate, Locale,
} from '@ghasi/contracts-melmastoon';
export interface Theme {
readonly id: ThemeId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId | null;
name: string; // "Hotel Kabul Plaza — default"
scope: 'tenant' | 'property'; // 'property' iff propertyId !== null
activePublicationId: ThemePublicationId | null;// null only between provisioning and first publish
defaultLocale: Locale; // 'ps-AF' | 'fa-AF' | 'en-US' | …
enabledLocales: ReadonlyArray<Locale>; // must include defaultLocale
fallbackChain: ReadonlyArray<Locale>; // ordered; must terminate at defaultLocale or 'en'
status: 'active' | 'suspended' | 'soft_deleted';
createdAt: ISODate;
createdBy: UserId | null; // null when system-provisioned on tenant.created
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC counter
}
4.2 Construction
export class ThemeAggregate {
static create(input: {
id: ThemeId;
tenantId: TenantId;
propertyId?: PropertyId | null;
name: string;
defaultLocale: Locale;
enabledLocales: Locale[];
fallbackChain: Locale[];
createdBy: UserId | null;
now: ISODate;
}): { theme: Theme; events: DomainEvent[] } {
if (!input.tenantId) throw new CrossTenantReferenceError();
if (!input.enabledLocales.includes(input.defaultLocale)) {
throw new DefaultLocaleRequiredError();
}
const last = input.fallbackChain[input.fallbackChain.length - 1];
if (last !== input.defaultLocale && last !== 'en' && last !== 'en-US') {
throw new FallbackChainInvalidError(`chain must terminate at defaultLocale or 'en'`);
}
if (new Set(input.fallbackChain).size !== input.fallbackChain.length) {
throw new FallbackChainInvalidError('chain contains a cycle');
}
const theme: Theme = {
id: input.id,
tenantId: input.tenantId,
propertyId: input.propertyId ?? null,
name: input.name,
scope: input.propertyId ? 'property' : 'tenant',
activePublicationId: null,
defaultLocale: input.defaultLocale,
enabledLocales: Object.freeze([...input.enabledLocales]),
fallbackChain: Object.freeze([...input.fallbackChain]),
status: 'active',
createdAt: input.now,
createdBy: input.createdBy,
updatedAt: input.now,
updatedBy: input.createdBy,
version: 1,
};
return { theme, events: [] }; // theme.created event lives on tenant provisioning saga
}
}
4.3 Invariants
| # | Invariant | Error |
|---|---|---|
| T1 | tenantId is present and matches the request X-Tenant-Id | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
| T2 | scope === 'property' iff propertyId !== null | MELMASTOON.THEME.SCOPE_INCONSISTENT |
| T3 | enabledLocales includes defaultLocale | MELMASTOON.THEME.DEFAULT_LOCALE_REQUIRED |
| T4 | fallbackChain terminates at defaultLocale or en/en-US; no cycles | MELMASTOON.THEME.FALLBACK_CHAIN_INVALID |
| T5 | At most one Theme per (tenantId, propertyId) (Phase 0: one per tenant) | MELMASTOON.THEME.DUPLICATE_THEME |
| T6 | activePublicationId references a ThemePublication with status = 'active' and themeId === id | enforced by FK + unique index; runtime invariant MELMASTOON.THEME.PUBLISH_CONFLICT |
| T7 | Every mutation increments version; If-Match checked at the controller boundary | MELMASTOON.GENERAL.PRECONDITION_FAILED |
5. Aggregate: ThemeVersion
Identity: ThemeVersionId (ULID, thv_…)
Owner: Theme (FK themeId)
Concurrency: OCC via version (the aggregate OCC, not the version number; that is ordinal)
5.1 TypeScript shape
export type ThemeVersionStatus = 'draft' | 'preview_ready' | 'published' | 'archived';
export interface ThemeVersion {
readonly id: ThemeVersionId;
readonly tenantId: TenantId;
readonly themeId: ThemeId;
readonly ordinal: number; // monotonic, per-theme; 1, 2, 3, …
readonly source: 'scaffold' | 'clone' | 'blank';
readonly clonedFromVersionId: ThemeVersionId | null;
status: ThemeVersionStatus;
archivedAt: ISODate | null;
archivedReason: 'superseded' | 'rolled_back' | 'soft_deleted' | null;
tokens: DesignTokenSet; // §6
layoutSelections: LayoutSelections; // §7
contentBlockIds: ReadonlyArray<ContentBlockId>; // ordered per page
navigationConfigIds: ReadonlyArray<NavigationConfigId>;
bookingFlowConfigId: BookingFlowId;
emailThemeId: EmailThemeId;
localePackIds: ReadonlyArray<string>; // composite (versionId, locale)
aiProvenance: ReadonlyArray<AIProvenance>; // every AI contribution recorded
publishedAt: ISODate | null;
publishedBy: UserId | null;
publishedBundleUrl: string | null; // gs://… immutable
publishedBundleSha256: string | null;
publishedBundleSize: number | null; // bytes (gzipped)
createdAt: ISODate;
createdBy: UserId | null;
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC counter (separate from ordinal)
}
5.2 State transitions
| From → To | Trigger | Domain event | Notes |
|---|---|---|---|
(none) → draft | POST /themes/:id/versions | theme.draft_created.v1 | clonedFromVersionId set if cloned |
draft → draft | PATCH /theme-versions/:id | theme.draft_updated.v1 | OCC checked |
draft → preview_ready | POST /theme-versions/:id/preview | theme.preview_generated.v1 | Mints PreviewToken |
preview_ready → draft | further PATCH | theme.draft_updated.v1 | All preview tokens for this version are revoked |
draft or preview_ready → published | POST /theme-versions/:id/publish | theme.published.v1 + theme.tokens_changed.v1 (with diff vs prev) + theme.cdn_cache_invalidated.v1 | Atomic flip; previous published version transitions to archived with reason superseded |
published → archived | system, when superseded by a new publish | theme.version_archived.v1 (internal) | reason: superseded |
published → archived | rollback to a different version | theme.version_archived.v1 | reason: rolled_back |
draft or preview_ready → archived | DELETE /theme-versions/:id (soft-delete unused draft) | theme.version_archived.v1 | reason: soft_deleted |
Illegal transitions are rejected with MELMASTOON.THEME.INVALID_STATE_TRANSITION.
5.3 Invariants
| # | Invariant | Error |
|---|---|---|
| V1 | tenantId matches parent Theme.tenantId | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
| V2 | ordinal is monotonic and unique per themeId | enforced by unique index (themeId, ordinal) |
| V3 | A published or archived version is immutable; any PATCH returns 409 MELMASTOON.THEME.VERSION_IMMUTABLE | controller + aggregate guard |
| V4 | publishedAt, publishedBy, publishedBundleUrl, publishedBundleSha256, publishedBundleSize are all-or-none and only set when status === 'published' || 'archived' | aggregate guard |
| V5 | archivedAt set iff status === 'archived'; archivedReason set iff archivedAt set | aggregate guard |
| V6 | If aiProvenance.length > 0, the publish workflow requires approvedBy + approvedAt recorded in the publish command | MELMASTOON.AI.HITL_REQUIRED |
| V7 | Every layout selection references a registered LayoutPreset | MELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN |
| V8 | Token contrast invariant (see §6) | MELMASTOON.THEME.TOKEN_INVALID |
| V9 | RTL parity: every spacing.* and padding.* token has a logical-property derivative | MELMASTOON.THEME.RTL_VARIANT_MISSING |
6. Value object: DesignTokenSet
The token vocabulary is flat-by-category, semantic-by-name. Tenants cannot add new token names; they can only override the values. New tokens are added by Frontend Platform via a registry-bumping migration with consumer-app coordination.
6.1 Shape
export interface DesignTokenSet {
color: ColorTokens;
typography: TypographyTokens;
spacing: SpacingScale;
radius: RadiusScale;
shadow: ShadowScale;
motion: MotionScale;
direction: 'ltr' | 'rtl' | 'auto'; // 'auto' resolves per locale at render
}
export interface ColorTokens {
// Brand
primary: HexColor;
primaryHover: HexColor;
primaryActive: HexColor;
textOnPrimary: HexColor;
secondary: HexColor;
secondaryHover: HexColor;
textOnSecondary: HexColor;
accent: HexColor;
textOnAccent: HexColor;
// Surfaces
surface: HexColor; // page background
surfaceMuted: HexColor; // card / panel background
surfaceElevated: HexColor; // modal / popover background
textOnSurface: HexColor; // body text on surface
textOnSurfaceMuted: HexColor; // helper text
border: HexColor;
divider: HexColor;
// Status
success: HexColor;
textOnSuccess: HexColor;
warning: HexColor;
textOnWarning: HexColor;
error: HexColor;
textOnError: HexColor;
info: HexColor;
textOnInfo: HexColor;
// Focus + interaction
focusRing: HexColor;
overlay: HexColor; // modal scrim (rgba string)
}
export type HexColor = string & { readonly __brand: 'HexColor' }; // /^#[0-9a-fA-F]{6}$/
export interface TypographyTokens {
fontFamilyHeading: string; // CSS family stack
fontFamilyBody: string;
fontFamilyMono: string;
baseSizePx: number; // 14 | 16 | 18
scaleRatio: number; // 1.125 | 1.2 | 1.25 (modular scale)
// Resolved scale (computed at publish):
size: { xs: number; sm: number; md: number; lg: number; xl: number; xxl: number };
weight: { regular: 400; medium: 500; semibold: 600; bold: 700 };
lineHeight: { tight: 1.2; snug: 1.35; normal: 1.5; relaxed: 1.7 };
letterSpacing: { tight: number; normal: number; wide: number };
}
export interface SpacingScale {
unit: number; // 4 | 6 | 8 (px)
// Resolved scale at publish:
values: { 0: 0; 1: number; 2: number; 3: number; 4: number; 6: number; 8: number; 12: number; 16: number };
// Logical-property derivatives generated at publish for direction:auto handling:
start: SpacingScale['values']; // padding-inline-start, margin-inline-start
end: SpacingScale['values'];
}
export interface RadiusScale { none: 0; sm: number; md: number; lg: number; pill: number }
export interface ShadowScale { none: 'none'; sm: string; md: string; lg: string; focus: string }
export interface MotionScale {
durationMsFast: number; // 120
durationMsBase: number; // 200
durationMsSlow: number; // 320
easingStandard: string; // cubic-bezier(0.2, 0, 0, 1)
easingEntrance: string;
easingExit: string;
}
6.2 Validation invariants
| # | Rule | Error |
|---|---|---|
| C1 | Every HexColor matches /^#[0-9a-fA-F]{6}$/ (no shorthand, no alpha — alpha lives in dedicated tokens) | MELMASTOON.THEME.TOKEN_INVALID |
| C2 | Contrast (foreground, background) for every documented pair meets WCAG AA at publish: (textOnPrimary, primary) ≥ 4.5, (textOnSurface, surface) ≥ 4.5, (textOnError, error) ≥ 4.5, etc. Body text ≥ 4.5; large text (≥ 24 px or ≥ 19 px bold) ≥ 3.0. | MELMASTOON.THEME.TOKEN_INVALID (with errors[] listing each violating pair) |
| C3 | baseSizePx ∈ {14, 16, 18} | MELMASTOON.GENERAL.VALIDATION_FAILED |
| C4 | scaleRatio ∈ [1.067, 1.5] | MELMASTOON.GENERAL.VALIDATION_FAILED |
| C5 | spacing.unit ∈ {4, 6, 8} | MELMASTOON.GENERAL.VALIDATION_FAILED |
| C6 | direction ∈ {ltr, rtl, auto} | MELMASTOON.GENERAL.VALIDATION_FAILED |
| C7 | At publish, spacing.start and spacing.end are computed from spacing.values and serialized into the bundle (logical properties); raw left / right keys are forbidden | MELMASTOON.THEME.RTL_VARIANT_MISSING |
| C8 | Font family stacks must include at least one safe fallback (system-ui, -apple-system, sans-serif); Noto Naskh Arabic and Noto Sans Arabic are recommended for RTL locales | warning event theme.token_validation_warning.v1 |
The contrast checker is a pure function (contrastRatio(fg: HexColor, bg: HexColor): number) implemented in the domain layer using the WCAG 2.1 luminance formula; it has no I/O dependency and is unit-tested exhaustively.
7. Value object: LayoutSelections
export type SurfaceKey = 'home' | 'listing' | 'detail' | 'booking' | 'post_stay';
export interface LayoutSelections {
home: LayoutPresetId; // e.g., 'lpr_HERO_WITH_SEARCH'
listing: LayoutPresetId; // e.g., 'lpr_MOSAIC_GRID' | 'lpr_LIST_WITH_MAP'
detail: LayoutPresetId; // e.g., 'lpr_GALLERY_TOP_INFO_BOTTOM'
booking: LayoutPresetId; // e.g., 'lpr_VERTICAL_STEPPER' | 'lpr_SINGLE_PAGE'
post_stay: LayoutPresetId; // e.g., 'lpr_THANK_YOU_WITH_REVIEW_CTA'
}
Every preset id must exist in the platform-global LayoutPresetRegistry (read-through cache). Unknown preset → MELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN.
LayoutPreset (platform-global aggregate):
export interface LayoutPreset {
readonly id: LayoutPresetId;
readonly key: string; // 'hero-with-search'
readonly title: string; // human label for the authoring console
readonly applicableSurfaces: SurfaceKey[];
readonly supportsRtl: boolean; // must be true for any preset usable on RTL locales
readonly previewImageUrl: string;
readonly minTokens: ReadonlyArray<keyof DesignTokenSet['color']>; // contract: which color tokens this preset uses
readonly version: number;
readonly createdAt: ISODate;
readonly deprecatedAt: ISODate | null;
}
8. Aggregate: ContentBlock
export type ContentBlockKind =
| 'about_us' | 'amenities_grid' | 'gallery' | 'testimonials'
| 'faq' | 'contact' | 'policies' | 'location_map' | 'custom_markdown';
export interface ContentBlock {
readonly id: ContentBlockId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly kind: ContentBlockKind;
surface: SurfaceKey; // which page this block lives on
ordinal: number; // ordering within the surface
visible: boolean;
body: I18nMarkup; // per-locale body, see §8.1
meta: ContentBlockMeta; // kind-specific structured data
createdAt: ISODate;
createdBy: UserId | null;
updatedAt: ISODate;
updatedBy: UserId | null;
version: number; // OCC
}
8.1 I18nMarkup value object
export interface I18nMarkup {
// For each enabled locale, an allow-listed Markdown string OR a sanitized HTML island:
readonly entries: ReadonlyMap<Locale, MarkupEntry>;
}
export interface MarkupEntry {
readonly format: 'markdown' | 'html_safe';
readonly content: string; // sanitized via DOMPurify (allow-list per kind)
readonly aiProvenance?: AIProvenance; // present only if AI-translated
readonly updatedAt: ISODate;
readonly updatedBy: UserId | null;
}
The Markdown allow-list bans script tags, inline event handlers, javascript: URLs, <iframe> (except YouTube/Vimeo via the allow-listed embed widget), and arbitrary CSS. Validation runs at save and again at publish; failure → MELMASTOON.THEME.CONTENT_INVALID.
8.2 ContentBlockMeta discriminated union
export type ContentBlockMeta =
| { kind: 'about_us'; heroImage?: MediaRef; cta?: { labelKey: string; href: string } }
| { kind: 'amenities_grid'; items: { iconKey: string; titleKey: string; bodyKey: string }[] }
| { kind: 'gallery'; media: MediaRef[]; layout: 'mosaic' | 'carousel' | 'grid' }
| { kind: 'testimonials'; items: { author: string; quoteI18n: I18nMarkup; rating?: 1|2|3|4|5 }[] }
| { kind: 'faq'; items: { questionI18n: I18nMarkup; answerI18n: I18nMarkup }[] }
| { kind: 'contact'; phone?: string; email?: string; whatsapp?: string; addressI18n?: I18nMarkup }
| { kind: 'policies'; documents: { typeKey: 'cancellation'|'house'|'pet'|'privacy'; bodyI18n: I18nMarkup }[] }
| { kind: 'location_map'; lat: number; lng: number; zoom: number; markerLabelKey?: string }
| { kind: 'custom_markdown'; titleI18n?: I18nMarkup };
8.3 Invariants
| # | Rule | Error |
|---|---|---|
| B1 | Parent ThemeVersion must be draft or preview_ready to mutate | MELMASTOON.THEME.VERSION_IMMUTABLE |
| B2 | surface is a valid SurfaceKey and the chosen LayoutPreset for that surface accepts blocks of this kind (per registry contract) | MELMASTOON.THEME.BLOCK_NOT_ALLOWED_ON_SURFACE |
| B3 | body.entries must have an entry for defaultLocale; missing other locales fall back via the chain | MELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED |
| B4 | Every MediaRef in meta must resolve at publish (HEAD against file-storage-service) | MELMASTOON.THEME.ASSET_BROKEN |
| B5 | body content sanitised per the allow-list; rejected content does not enter persistence | MELMASTOON.THEME.CONTENT_INVALID |
| B6 | ordinal is unique per (themeVersionId, surface) | enforced by unique index |
9. Aggregate: NavigationConfig
One per (themeVersionId, surface ∈ {header, footer, mobile_drawer}).
export type NavSurface = 'header' | 'footer' | 'mobile_drawer';
export interface NavigationConfig {
readonly id: NavigationConfigId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly surface: NavSurface;
items: ReadonlyArray<MenuItem>; // ordered tree
version: number; // OCC
createdAt: ISODate;
updatedAt: ISODate;
}
export interface MenuItem {
readonly id: string; // local UUID, stable across saves
labelI18n: I18nMarkup; // typically plain text per locale
target:
| { kind: 'route'; routeId: string; params?: Record<string, string> } // 'rooms', 'about', …
| { kind: 'external'; href: string; openInNewTab: boolean }
| { kind: 'anchor'; surface: SurfaceKey; blockOrdinal: number };
icon?: string; // icon-key from the platform icon set
visible: boolean;
children: ReadonlyArray<MenuItem>; // recursive (max depth 3)
}
9.1 Invariants
| # | Rule | Error |
|---|---|---|
| N1 | routeId must exist in the canonical route registry | MELMASTOON.THEME.ROUTE_UNKNOWN |
| N2 | external.href must be HTTPS and parsable URL | MELMASTOON.THEME.MENU_HREF_INVALID |
| N3 | Tree depth ≤ 3 | MELMASTOON.THEME.MENU_DEPTH_EXCEEDED |
| N4 | At most 8 top-level items per surface | MELMASTOON.THEME.MENU_TOO_MANY_ITEMS |
| N5 | id uniqueness within the tree (recursive) | enforced in aggregate |
10. Aggregate: BookingFlowConfig
export type BookingStepKey =
| 'select_dates' | 'select_room' | 'select_rate' | 'guest_details'
| 'add_ons' | 'review' | 'payment' | 'confirmation';
export interface BookingFlowConfig {
readonly id: BookingFlowId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
steps: ReadonlyArray<BookingStepKey>; // ordered subset of canonical keys
fieldRequirements: ReadonlyMap<BookingStepKey, FieldRequirement[]>;
toggles: BookingFlowToggles;
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}
export interface FieldRequirement {
readonly field: string; // 'guest.passportNumber', 'guest.dateOfBirth', …
required: boolean;
visibleByDefault: boolean;
helperTextKey?: string;
}
export interface BookingFlowToggles {
captureGuestPassport: boolean;
captureGuestNationalId: boolean;
requestAirportTransport: boolean;
allowGiftBooking: boolean;
requireGuestSignature: boolean;
showCancellationPolicySummary: boolean;
showPriceBreakdown: 'always' | 'on_review' | 'never';
allowCashOnArrival: boolean;
allowPayLaterByLink: boolean;
promptForReviewOnPostStay: boolean;
}
10.1 Invariants
| # | Rule | Error |
|---|---|---|
| F1 | steps is a non-empty ordered subset of the canonical step registry; select_dates, select_room, review, payment, confirmation are mandatory | MELMASTOON.THEME.BOOKING_STEP_REQUIRED |
| F2 | Every field in fieldRequirements exists in the canonical field registry; unknown field → MELMASTOON.THEME.BOOKING_FIELD_UNKNOWN | |
| F3 | A field marked required: true must have visibleByDefault: true | MELMASTOON.THEME.BOOKING_FIELD_INCONSISTENT |
| F4 | If requireGuestSignature: true, the review step must be present | MELMASTOON.THEME.BOOKING_FLOW_INVALID |
| F5 | If allowCashOnArrival: false, payment step is mandatory and online | MELMASTOON.THEME.BOOKING_FLOW_INVALID |
11. Aggregate: EmailTheme
export interface EmailTheme {
readonly id: EmailThemeId;
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
brand: {
logoUrl: MediaRef; // height target: 48 px in email
logoAltKey: string; // i18n key in LocalePack
headerBackground: HexColor;
headerForeground: HexColor;
primary: HexColor;
textOnPrimary: HexColor;
surface: HexColor;
textOnSurface: HexColor;
};
typography: {
fontFamilyEmail: string; // email-safe stack (no web fonts in email)
baseSizePx: 14 | 15 | 16;
};
footer: {
addressI18n: I18nMarkup;
socialLinks: Array<{ platform: 'whatsapp'|'facebook'|'instagram'|'x'|'tiktok'|'youtube'; href: string }>;
legalI18n: I18nMarkup; // boilerplate (unsubscribe / company id / …)
showPoweredByMelmastoon: boolean;
};
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}
11.1 Invariants
| # | Rule | Error |
|---|---|---|
| E1 | logoUrl must resolve and be ≤ 1 MiB (delegated to file-storage-service checks) | MELMASTOON.THEME.ASSET_BROKEN / MELMASTOON.THEME.ASSET_TOO_LARGE |
| E2 | fontFamilyEmail must be in the email-safe fallback list (no @import web fonts) | MELMASTOON.THEME.EMAIL_FONT_UNSAFE |
| E3 | Contrast (textOnPrimary, primary) ≥ 4.5; (textOnSurface, surface) ≥ 4.5 | MELMASTOON.THEME.TOKEN_INVALID |
| E4 | addressI18n and legalI18n must include defaultLocale | MELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED |
12. Aggregate: LocalePack
export interface LocalePack {
readonly tenantId: TenantId;
readonly themeVersionId: ThemeVersionId;
readonly locale: Locale; // 'ps-AF', 'fa-AF', 'en-US', …
formatting: {
dateFormatPattern: string; // e.g., 'YYYY/MM/DD' for fa-AF (Persian calendar handled in app)
timeFormat: '12h' | '24h';
currencyDisplay: 'symbol' | 'code' | 'narrowSymbol';
phoneFormat: 'international' | 'national';
decimalSeparator: '.' | ',' | '٫';
thousandSeparator: ',' | '.' | ' ' | '٬';
firstDayOfWeek: 0|1|2|3|4|5|6;
};
copy: ReadonlyMap<string, string>; // key → translated string (e.g., 'cta.book_now' → 'هم اوس حجز کړئ')
aiProvenance: AIProvenance | null; // present only if any copy entry is AI-translated and pending HITL approval
approvedBy: UserId | null;
approvedAt: ISODate | null;
version: number;
createdAt: ISODate;
updatedAt: ISODate;
}
12.1 Invariants
| # | Rule | Error |
|---|---|---|
| L1 | locale is a BCP-47 string in the platform's enabled set | MELMASTOON.THEME.LOCALE_UNKNOWN |
| L2 | copy keys are a subset of the canonical LocalePackKeyRegistry; unknown keys rejected | MELMASTOON.THEME.COPY_KEY_UNKNOWN |
| L3 | If aiProvenance is set and the parent version is being published, approvedBy + approvedAt are required | MELMASTOON.AI.HITL_REQUIRED |
| L4 | dateFormatPattern parseable by @formatjs | MELMASTOON.GENERAL.VALIDATION_FAILED |
13. Aggregate: PreviewToken
export interface PreviewToken {
readonly id: PreviewTokenId; // pvt_…
readonly tenantId: TenantId;
readonly themeId: ThemeId;
readonly themeVersionId: ThemeVersionId;
readonly tokenHash: string; // sha256(secret); the secret itself never persisted
readonly createdBy: UserId;
readonly issuedAt: ISODate;
readonly expiresAt: ISODate; // ≤ 7 days from issue
readonly revokedAt: ISODate | null;
readonly revokedBy: UserId | null;
readonly note: string | null; // free-text (e.g., "share with VP of brand")
readonly accessCount: number; // incremented on every preview fetch (sampled)
readonly lastAccessAt: ISODate | null;
}
13.1 Invariants
| # | Rule | Error |
|---|---|---|
| P1 | expiresAt - issuedAt ≤ 7 days | MELMASTOON.GENERAL.VALIDATION_FAILED |
| P2 | themeVersionId references a version with status ∈ {draft, preview_ready} | MELMASTOON.THEME.VERSION_NOT_PREVIEWABLE |
| P3 | Verification of an incoming preview request requires tokenHash === sha256(presented) and revokedAt === null and now < expiresAt | MELMASTOON.THEME.PREVIEW_TOKEN_INVALID |
| P4 | When the parent ThemeVersion transitions back to draft from preview_ready (i.e., further edits), all outstanding PreviewTokens for that version are revoked atomically | aggregate event |
| P5 | When the parent ThemeVersion is published, all outstanding PreviewTokens for it are revoked atomically | aggregate event |
14. Domain events
Subjects follow melmastoon.theme.<aggregate>.<verb-past-tense>.v1 per standards/NAMING §Events. All events carry the standard envelope (see EVENT_SCHEMAS.md) with tenantId, eventId, occurredAt, causationId, correlationId, aggregateId.
| Subject | Producer aggregate | Trigger | Retention class |
|---|---|---|---|
melmastoon.theme.draft_created.v1 | ThemeVersion | New ThemeVersion enters draft | warm |
melmastoon.theme.draft_updated.v1 | ThemeVersion | Draft mutated (any patch) | warm |
melmastoon.theme.preview_generated.v1 | PreviewToken | Preview token minted | warm |
melmastoon.theme.preview_revoked.v1 | PreviewToken | Token revoked (manual or via state transition) | warm |
melmastoon.theme.published.v1 | Theme + ThemeVersion | Atomic publish flip succeeds | warm + audit |
melmastoon.theme.publish_rejected.v1 | ThemeVersion | Final validation failed at publish | warm |
melmastoon.theme.rolled_back.v1 | Theme | Previous version re-published | warm + audit |
melmastoon.theme.tokens_changed.v1 | ThemeVersion | Token diff between two consecutive published versions (carries the diff) | warm |
melmastoon.theme.content_block.created.v1 | ContentBlock | Block added | warm |
melmastoon.theme.content_block.updated.v1 | ContentBlock | Block edited | warm |
melmastoon.theme.content_block.deleted.v1 | ContentBlock | Block removed | warm |
melmastoon.theme.locale_added.v1 | Theme | Locale added to enabled set | warm |
melmastoon.theme.locale_removed.v1 | Theme | Locale removed | warm |
melmastoon.theme.layout_preset_changed.v1 | ThemeVersion | Layout preset selection changed for a surface | warm |
melmastoon.theme.booking_flow_config_updated.v1 | BookingFlowConfig | Booking flow toggles or steps changed | warm |
melmastoon.theme.email_theme_updated.v1 | EmailTheme | Email theme override changed | warm |
melmastoon.theme.cdn_cache_invalidated.v1 | Theme | CDN purge issued for the published bundle | warm |
melmastoon.theme.broken_asset_detected.v1 | ThemeVersion | An asset URL referenced in a published version returns 404 | warm |
melmastoon.theme.token_validation_failed.v1 | ThemeVersion | Audit-only: tokens failed schema/contrast on save (not on publish — that just blocks) | warm |
melmastoon.theme.version_archived.v1 | ThemeVersion | Version archived (superseded / rolled_back / soft_deleted) | warm |
Detailed payload schemas live in EVENT_SCHEMAS.md.
15. Domain errors
| Error class (TS) | Code (registry) | HTTP |
|---|---|---|
CrossTenantReferenceError | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE | 422 |
OptimisticConcurrencyError | MELMASTOON.GENERAL.PRECONDITION_FAILED | 412 |
ThemeNotFoundError | MELMASTOON.THEME.NOT_FOUND | 404 |
TokenInvalidError | MELMASTOON.THEME.TOKEN_INVALID | 422 |
LayoutPresetUnknownError | MELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN | 422 |
RtlVariantMissingError | MELMASTOON.THEME.RTL_VARIANT_MISSING | 422 |
AssetTooLargeError | MELMASTOON.THEME.ASSET_TOO_LARGE | 413 |
AssetBrokenError | MELMASTOON.THEME.ASSET_BROKEN | 422 |
VersionImmutableError | MELMASTOON.THEME.VERSION_IMMUTABLE | 409 |
InvalidStateTransitionError | MELMASTOON.THEME.INVALID_STATE_TRANSITION | 409 |
PublishConflictError | MELMASTOON.THEME.PUBLISH_CONFLICT | 409 |
DefaultLocaleRequiredError | MELMASTOON.THEME.DEFAULT_LOCALE_REQUIRED | 422 |
FallbackChainInvalidError | MELMASTOON.THEME.FALLBACK_CHAIN_INVALID | 422 |
LocaleUnknownError | MELMASTOON.THEME.LOCALE_UNKNOWN | 422 |
LocaleInUseError | MELMASTOON.THEME.LOCALE_IN_USE | 409 |
ContentInvalidError | MELMASTOON.THEME.CONTENT_INVALID | 422 |
ContentDefaultLocaleRequiredError | MELMASTOON.THEME.CONTENT_DEFAULT_LOCALE_REQUIRED | 422 |
BlockNotAllowedOnSurfaceError | MELMASTOON.THEME.BLOCK_NOT_ALLOWED_ON_SURFACE | 422 |
RouteUnknownError | MELMASTOON.THEME.ROUTE_UNKNOWN | 422 |
MenuHrefInvalidError | MELMASTOON.THEME.MENU_HREF_INVALID | 422 |
MenuDepthExceededError | MELMASTOON.THEME.MENU_DEPTH_EXCEEDED | 422 |
MenuTooManyItemsError | MELMASTOON.THEME.MENU_TOO_MANY_ITEMS | 422 |
BookingStepRequiredError | MELMASTOON.THEME.BOOKING_STEP_REQUIRED | 422 |
BookingStepUnknownError | MELMASTOON.THEME.BOOKING_STEP_UNKNOWN | 422 |
BookingFieldUnknownError | MELMASTOON.THEME.BOOKING_FIELD_UNKNOWN | 422 |
BookingFlowInvalidError | MELMASTOON.THEME.BOOKING_FLOW_INVALID | 422 |
EmailFontUnsafeError | MELMASTOON.THEME.EMAIL_FONT_UNSAFE | 422 |
PreviewTokenInvalidError | MELMASTOON.THEME.PREVIEW_TOKEN_INVALID | 401 |
VersionNotPreviewableError | MELMASTOON.THEME.VERSION_NOT_PREVIEWABLE | 409 |
HitlRequiredError | MELMASTOON.AI.HITL_REQUIRED | 403 |
New codes added by this service (i.e., not already in the platform ERROR_CODES.md registry) must be added in the same PR as the code that throws them. The registry already lists MELMASTOON.THEME.NOT_FOUND, TOKEN_INVALID, LAYOUT_PRESET_UNKNOWN, RTL_VARIANT_MISSING, ASSET_TOO_LARGE; the additional codes used here (e.g., VERSION_IMMUTABLE, PUBLISH_CONFLICT, INVALID_STATE_TRANSITION, DEFAULT_LOCALE_REQUIRED, FALLBACK_CHAIN_INVALID, BOOKING_*, MENU_*, CONTENT_*, EMAIL_FONT_UNSAFE, PREVIEW_TOKEN_INVALID, VERSION_NOT_PREVIEWABLE, ASSET_BROKEN, LOCALE_*) ship as a registry update with this service.
16. Cross-aggregate invariants (publish-time orchestration)
Some invariants span aggregates and are enforced by the publish use case (in the application layer), not in any single aggregate constructor:
- Single active publication per Theme. Enforced by a partial unique index in Postgres (
UNIQUE (theme_id) WHERE status = 'active') and by the use case running the flip inside a single transaction. - Bundle integrity. The published bundle's SHA-256 written to
ThemeVersion.publishedBundleSha256must equal the SHA-256 of the bytes uploaded to GCS. The publish use case re-hashes after upload and rolls back the transaction on mismatch. - Asset integrity at publish. Every
MediaRefURL referenced anywhere in the version (content blocks, email theme, navigation icons) is HEAD-checked. Failure aborts the publish (override gated by an explicitallowBrokenAssets: trueflag for staged migrations only; never available via UI). - Locale completeness check (warning, not error). For every enabled locale, every
LocalePack.copykey in the canonical registry that's markedrequiredmust have a translation. Missing entries do not block publish but generatetheme.locale_completeness_warningmetrics and a non-blocking warning in the publish response. - Booking-flow ↔ payment consistency. If
bookingFlowConfig.toggles.allowCashOnArrival === false, the payment step must be present insteps; if bothfalseandpaymentis missing, publish is rejected withBookingFlowInvalidError.
17. References
- Source-of-truth aggregate sketch:
docs/06-data-models.md§4.12 - Event topic registry:
docs/04-event-driven-architecture.md§3 Property & Theme - Error code registry:
docs/standards/ERROR_CODES.md§THEME - Naming conventions:
docs/standards/NAMING.md - AI provenance VO:
services/ai-orchestrator-service/DOMAIN_MODEL.md - Asset reference VO and contracts:
services/file-storage-service/DOMAIN_MODEL.md