Core Entities
Tenant: tenant_id, name, slug, plan, custom_domain, storage_quota_gb, created_at. (Multi-tenant: each customer organization is a tenant with isolated content.) ContentType: type_id, tenant_id, name (e.g., “BlogPost”, “ProductPage”), schema (JSON Schema defining fields: title, body, author, tags, custom fields), is_singleton (for one-off pages like About Us). Entry: entry_id, tenant_id, content_type_id, status (DRAFT, IN_REVIEW, SCHEDULED, PUBLISHED, ARCHIVED), published_version_id, created_by, created_at, updated_at, scheduled_publish_at. EntryVersion: version_id, entry_id, version_number, data (JSONB — the field values for this version), created_by, created_at, publish_note. Asset: asset_id, tenant_id, filename, storage_key, mime_type, size_bytes, alt_text, metadata (JSONB), uploaded_by, created_at. Webhook: webhook_id, tenant_id, url, events (array: entry.published, entry.unpublished, asset.created), secret_key, is_active.
Publishing Workflow State Machine
class EntryService:
TRANSITIONS = {
"DRAFT": ["IN_REVIEW", "SCHEDULED", "PUBLISHED", "ARCHIVED"],
"IN_REVIEW": ["DRAFT", "SCHEDULED", "PUBLISHED", "ARCHIVED"],
"SCHEDULED": ["DRAFT", "PUBLISHED", "ARCHIVED"],
"PUBLISHED": ["DRAFT", "ARCHIVED"],
"ARCHIVED": ["DRAFT"],
}
def publish(self, entry_id: int, version_id: int,
actor_id: int) -> Entry:
with db.transaction():
entry = db.query(
"SELECT * FROM entries WHERE entry_id = %s FOR UPDATE",
entry_id
)
if "PUBLISHED" not in self.TRANSITIONS[entry.status]:
raise InvalidTransition(entry.status, "PUBLISHED")
db.execute(
"UPDATE entries SET status = 'PUBLISHED', "
"published_version_id = %s, "
"updated_at = NOW() WHERE entry_id = %s",
version_id, entry_id
)
# Invalidate CDN cache for this content
self.cdn.purge(entry.tenant_id, entry_id)
# Fire webhooks asynchronously
self.webhook_dispatcher.dispatch_async(
tenant_id=entry.tenant_id,
event="entry.published",
payload={"entry_id": entry_id, "version_id": version_id}
)
return db.get_entry(entry_id)
def schedule(self, entry_id: int, publish_at: datetime,
actor_id: int) -> Entry:
with db.transaction():
entry = db.query(
"SELECT * FROM entries WHERE entry_id = %s FOR UPDATE",
entry_id
)
db.execute(
"UPDATE entries SET status = 'SCHEDULED', "
"scheduled_publish_at = %s WHERE entry_id = %s",
publish_at, entry_id
)
# A background scheduler polls for SCHEDULED entries
# WHERE scheduled_publish_at <= NOW() and publishes them
return db.get_entry(entry_id)
Versioning and Draft Management
Every save creates a new EntryVersion. The Entry record points to the currently published version (published_version_id) and tracks the latest draft implicitly (the highest version_number with status=DRAFT). Version comparison: diff any two versions by comparing their JSONB data fields. Restore a previous version: create a new EntryVersion with the old version’s data (do not modify historical versions). Auto-save: save a draft version every 30 seconds while the editor is active (debounced). Distinguish auto-saves from manual saves with a is_autosave flag. Conflict detection: when two users edit simultaneously, use optimistic locking — include the base_version_id in save requests. If the current latest version differs from base_version_id, return a conflict error. The editor shows a diff and asks the user to resolve.
Content Delivery API and Multi-Tenancy
Content Delivery API (headless CMS mode): GET /api/{tenant_slug}/entries?content_type=BlogPost&status=PUBLISHED. Returns the published version’s data as JSON. Optimized for reads: cache published entry data in Redis (TTL = 5 minutes; invalidate on publish/unpublish). CDN caching: published content is served via CDN (Cloudflare) with Cache-Control: max-age=3600. On publish: CDN purge for the affected URLs. Multi-tenancy isolation: every database query includes tenant_id in the WHERE clause (enforced by a middleware layer — no query is allowed without tenant scope). Row-level security in PostgreSQL as defense-in-depth: CREATE POLICY tenant_isolation ON entries USING (tenant_id = current_setting(‘app.tenant_id’)). Asset storage: each tenant gets a scoped S3 prefix (s3://cms-assets/{tenant_id}/). Quota enforcement: check used_storage <= storage_quota_gb on each asset upload; reject with 402 if over quota.
See also: Atlassian Interview Prep
See also: Shopify Interview Prep
See also: LinkedIn Interview Prep