Skip to main content

Content Management

Content Management lets admins and content curators create, organize, schedule and publish learning resources (articles, videos, PDFs) and in‑app surfaces (banners, FAQs). This page documents the Web/Admin UX and the GraphQL contracts used by the app.

Roles & Permissions

  • Admin: full access (CRUD, publish, archive)
  • Content Curator: create/edit own content, request publish
  • Viewer: read‑only

Enforced via server auth and UI feature flags. The list endpoints support created_by filtering.

Data Model

  • Content: id, title, type (ARTICLE | VIDEO | PDF | BANNER | FAQ), body, url, tags[], status (DRAFT | PUBLISHED | ARCHIVED), visibility (role scoping), publish_at, created_at, updated_at.

UI Flows

List, Search, Filter

const filtered = rows
.filter((r) => !type || r.type === type)
.filter((r) => !status || r.status === status)
.filter((r) => !query || r.title.toLowerCase().includes(query.toLowerCase()));

Create / Edit

  1. Author enters title, type, body/url, tags
  2. Optional schedule (publish_at) and visibility
  3. Save as DRAFT, then Publish

Media Upload (presigned)

  1. Request presigned URL
  2. Upload file to object store
  3. Save returned publicUrl on the content record

GraphQL Contracts

import { gql } from '@apollo/client';

export const UPSERT_CONTENT = gql`
mutation UpsertContent($input: UpsertContentInput!) {
upsertContent(input: $input) {
success
message
data {
id
title
type
status
publish_at
url
}
}
}
`;

export const SET_PUBLISH = gql`
mutation SetPublish($input: SetPublishInput!) {
setPublish(input: $input) {
success
message
}
}
`;

export const PRESIGN_UPLOAD = gql`
mutation PresignUpload($input: PresignUploadInput!) {
presignUpload(input: $input) {
success
url
headers
publicUrl
}
}
`;

export const LIST_CONTENTS = gql`
query Contents($filter: ContentFilter!) {
contents(filter: $filter) {
id
title
type
status
updated_at
}
}
`;

Helper:

async function uploadWithPresigned(client: any, file: File) {
const { data } = await client.mutate({
mutation: PRESIGN_UPLOAD,
variables: { input: { filename: file.name, type: file.type } },
});
const info = data?.presignUpload;
if (!info?.success) throw new Error('Failed to presign');
await fetch(info.url, { method: 'PUT', headers: info.headers, body: file });
return info.publicUrl as string;
}

Validation & Rules

  • Title: required, 3–120 chars
  • ARTICLE body or external URL required depending on type
  • VIDEO/PDF must have url (uploaded or external)
  • publish_at cannot be in the past when scheduling

Troubleshooting

  • "Upload succeeded but file not visible": ensure you save the returned publicUrl on the record
  • "Video does not play": check MIME type and CORS on the object store
  • "Scheduled item didn’t publish": verify server cron/worker and publish_at timezone

Exports

const csv = toCsv(rows);
downloadCsv('content.csv', csv);

Mapping Table

ActionOperation
List/Filtercontents(filter)
UpsertupsertContent(input)
Publish togglesetPublish(input)
Presign uploadpresignUpload(input)