Content Service

Content service owns merchant-created content and static file storage for each store. It manages custom pages, blog posts, multilingual content, and the file library from which merchant JS, CSS, fonts, and images are served. It also owns the content sandbox — the staging, approval, and publish mechanism for all content changes and high-risk settings changes across the platform.

Schema#

Pages and Blogs#

CREATE TABLE page (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id    UUID         NOT NULL,
  slug        VARCHAR(255) NOT NULL,
  status      VARCHAR(16)  NOT NULL DEFAULT 'DRAFT'
                CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (store_id, slug)
);

CREATE TABLE page_translation (
  page_id     UUID         NOT NULL REFERENCES page(id) ON DELETE CASCADE,
  locale      CHAR(5)      NOT NULL,
  title       VARCHAR(512) NOT NULL,
  body_html   TEXT,
  meta_title  VARCHAR(255),
  meta_desc   VARCHAR(512),
  PRIMARY KEY (page_id, locale)
);

CREATE TABLE blog (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id    UUID         NOT NULL,
  slug        VARCHAR(255) NOT NULL,
  UNIQUE (store_id, slug)
);

CREATE TABLE blog_translation (
  blog_id     UUID         NOT NULL REFERENCES blog(id) ON DELETE CASCADE,
  locale      CHAR(5)      NOT NULL,
  name        VARCHAR(255) NOT NULL,
  PRIMARY KEY (blog_id, locale)
);

CREATE TABLE blog_post (
  id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  blog_id      UUID         NOT NULL REFERENCES blog(id) ON DELETE CASCADE,
  store_id     UUID         NOT NULL,
  slug         VARCHAR(255) NOT NULL,
  status       VARCHAR(16)  NOT NULL DEFAULT 'DRAFT'
                 CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
  author_id    UUID,                              -- principal UUID from AGM
  published_at TIMESTAMPTZ,
  created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (store_id, slug)
);

CREATE TABLE blog_post_translation (
  post_id     UUID         NOT NULL REFERENCES blog_post(id) ON DELETE CASCADE,
  locale      CHAR(5)      NOT NULL,
  title       VARCHAR(512) NOT NULL,
  body_html   TEXT,
  excerpt     TEXT,
  meta_title  VARCHAR(255),
  meta_desc   VARCHAR(512),
  PRIMARY KEY (post_id, locale)
);

File Library#

Static files (merchant JS, CSS, fonts, images) are stored in S3. Content service holds the metadata record and serves as the origin for CloudFront delivery.

CREATE TABLE store_file (
  id            UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id      UUID         NOT NULL,
  filename      VARCHAR(512) NOT NULL,
  s3_key        TEXT         NOT NULL,
  content_type  VARCHAR(128) NOT NULL,
  size_bytes    BIGINT       NOT NULL,
  cdn_url       TEXT,                             -- CloudFront URL after propagation
  created_at    TIMESTAMPTZ  NOT NULL DEFAULT now()
);

Content Sandbox#

The sandbox mechanism coordinates staged changes for content and high-risk settings across the platform. Content service owns the sandbox record and approval workflow. Participating services expose an ApplySandbox API that content service calls at publish time.

CREATE TABLE sandbox (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id    UUID         NOT NULL,
  name        VARCHAR(255) NOT NULL,
  status      VARCHAR(16)  NOT NULL DEFAULT 'DRAFT'
                CHECK (status IN ('DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'PUBLISHED', 'ROLLED_BACK')),
  created_by  UUID         NOT NULL,              -- principal UUID
  approved_by UUID,
  scheduled_at TIMESTAMPTZ,
  published_at TIMESTAMPTZ,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- Each change within a sandbox, keyed by owning service + entity
CREATE TABLE sandbox_change (
  id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  sandbox_id   UUID         NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
  service      VARCHAR(64)  NOT NULL,             -- e.g. "catalog", "content", "tax"
  entity_type  VARCHAR(64)  NOT NULL,
  entity_id    UUID         NOT NULL,
  diff         JSONB        NOT NULL,             -- JSON Patch (RFC 6902)
  created_at   TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX ON sandbox_change (sandbox_id, service, entity_type, entity_id);

-- Sandbox audit log
CREATE TABLE sandbox_event (
  id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  sandbox_id   UUID         NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
  action       VARCHAR(32)  NOT NULL,
  actor_id     UUID         NOT NULL,
  note         TEXT,
  occurred_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

Conflict detection runs at publish time: if two sandboxes both carry a sandbox_change for the same (service, entity_type, entity_id) tuple, the second publish is blocked until the conflict is resolved manually.

Sandbox Publishing Flow#

1. Sandbox reaches APPROVED status (manual or via scheduled_at)
2. content-service groups sandbox_change rows by service
3. For each service, content-service calls ApplySandbox(sandboxId, changes[]) via gRPC
4. Each service applies its changes atomically and acknowledges
5. If all services acknowledge → sandbox status = PUBLISHED, publish Kafka event
6. If any service fails → rollback: content-service calls RollbackSandbox on all
   services that already acknowledged, sandbox status = ROLLED_BACK

REST API#

Pages#

MethodRouteDescription
GET/content/pagesList pages
POST/content/pagesCreate page
GET/content/pages/{id}Get page
PATCH/content/pages/{id}Update page
DELETE/content/pages/{id}Archive page
GET/content/pages/{id}/translationsList translations
PUT/content/pages/{id}/translations/{locale}Upsert translation

Blog#

MethodRouteDescription
GET/content/blogsList blogs
POST/content/blogsCreate blog
GET/content/blogs/{id}/postsList posts
POST/content/blogs/{id}/postsCreate post
PATCH/content/blogs/{id}/posts/{postId}Update post
PUT/content/blogs/{id}/posts/{postId}/translations/{locale}Upsert translation

File Library#

MethodRouteDescription
GET/content/filesList files
POST/content/filesUpload file (S3 presigned URL flow)
DELETE/content/files/{id}Delete file

Sandbox#

MethodRouteDescription
GET/content/sandboxesList sandboxes
POST/content/sandboxesCreate sandbox
GET/content/sandboxes/{id}Get sandbox and its changes
POST/content/sandboxes/{id}/changesAdd change to sandbox
DELETE/content/sandboxes/{id}/changes/{changeId}Remove change
POST/content/sandboxes/{id}/submitSubmit for approval
POST/content/sandboxes/{id}/approveApprove sandbox
POST/content/sandboxes/{id}/rejectReject sandbox
POST/content/sandboxes/{id}/scheduleSet scheduled publish time
POST/content/sandboxes/{id}/publishPublish immediately
POST/content/sandboxes/{id}/rollbackRoll back a published sandbox
GET/content/sandboxes/{id}/preview-tokenGenerate shareable preview token

gRPC Interface#

Storefront-service calls content service to resolve pages and blog posts during SSR.

service ContentReaderService {
  rpc GetPage(GetPageRequest) returns (PageResponse);
  rpc GetBlogPost(GetBlogPostRequest) returns (BlogPostResponse);
  rpc GetSandboxPreviewToken(GetSandboxPreviewTokenRequest) returns (PreviewTokenResponse);
}

All services that participate in the sandbox mechanism implement:

service SandboxParticipant {
  rpc ApplySandbox(ApplySandboxRequest) returns (ApplySandboxResponse);
  rpc RollbackSandbox(RollbackSandboxRequest) returns (RollbackSandboxResponse);
}

Kafka Events#

TopicTriggerPayload
content.sandbox.publishedSandbox publish completes{ sandboxId, storeId, publishedAt }
content.sandbox.rolled_backSandbox rollback completes{ sandboxId, storeId, rolledBackAt }
content.page.publishedPage goes active{ pageId, storeId, slug }
content.blog_post.publishedPost goes active{ postId, blogId, storeId, slug }

Storefront-service consumes content.sandbox.published to invalidate its page render cache.

Vault Configuration#

Key pathPurpose
ss3/kv/content-service/db.urlPostgreSQL JDBC URL
ss3/kv/content-service/db.usernameDatabase username
ss3/kv/content-service/db.passwordDatabase password
ss3/kv/content-service/s3.bucketFile library S3 bucket
ss3/kv/content-service/s3.regionAWS region
ss3/kv/content-service/cdn.base_urlCloudFront base URL for file delivery
ss3/kv/shared/Shared Kafka bootstrap and OTel config