A production-minded SaaS demo that helps marketers generate, manage, and improve marketing content using AI. Every generated piece can be paired with an AI-generated image in one flow.
Status: in active development. The repo is functional from day one — see the dev log for what works today vs. what's next.
The hosted demo was taken down after the submission window to avoid ongoing AWS charges. The full stack (Amplify static site + App Runner backend + RDS Postgres + Secrets Manager + ECR) is reproducible from scratch via infra/DEPLOY.md; a local one-command stack runs via docker compose up (see §3 below). The demo account is seeded with one brand voice, three content pieces (blog / LinkedIn / email), and one improvement so the dashboard renders real rows immediately on first run.
Demo credentials (local): demo@magnacms.dev / DemoPass123.
If you're skimming the repo to evaluate engineering judgment, the load-bearing things this project demonstrates are:
- Full-stack product delivery end-to-end — Next.js 15 App Router frontend + FastAPI/async-SQLAlchemy backend + Postgres + AWS deploy, every layer shipped and live.
- AI provider abstraction —
ILLMProvider+IImageProviderProtocols with OpenAI, Bedrock-stub, and Mock implementations behind a factory. Swapping providers is a one-class edit. - Three-stage parse fallback for LLM JSON output (structured outputs → corrective retry → graceful degrade with status banner). Live demos don't surface model-misbehavior errors.
- Custom JWT auth — short-lived access token (in-memory) + httpOnly refresh cookie + rotation + Origin-based CSRF guard. No Cognito.
- Async repositories + service-layer architecture — clean boundaries, single-source-of-truth result projection, FOR UPDATE NOWAIT on image-regen contention.
- CI gates — ruff format + lint, mypy, pytest with 80% coverage gate, openapi-typescript spec validation, prettier, eslint, vitest,
next build, jest snapshots for every CDK stack. - AWS CDK infrastructure — five stacks (Network / Data / Compute / Edge / Observability), strict CDK context validation that fails synth on bad endpoint values, App Runner autoscaling explicitly capped to match RDS connection budget.
- Honest engineering trade-offs — every demo-acceptable compromise (public RDS endpoint, local image storage, deferred ElastiCache) is documented in
ARCHITECTURE.mdwith the production-grade alternative spelled out. - Living development log —
DEVLOG.mdcaptures decisions, rejected reviewer findings (with the reason), and round-on-round refinements as PRs landed.
Suggested reading order: this README → ARCHITECTURE.md (one page) → §12 "What I'd add next" below → DEVLOG.md newest entry.
Five core flows:
- Generate — pick a content type (blog, LinkedIn, ad copy, email), describe topic/tone/audience, get polished output.
- Pair with image — one click to auto-generate a matching image from the content.
- Improve — paste existing text, pick a goal, get a refined version with an explanation of what changed.
- Manage — dashboard of all past generations with search, filter, export, soft delete with undo.
- Brand voice — saved profiles that pre-fill style across generations.
| Layer | Choice |
|---|---|
| Frontend | Next.js 15 + TypeScript + Tailwind + shadcn/ui + TanStack Query + Zod + React Hook Form |
| Backend | FastAPI + Python 3.12 + Pydantic v2 + async SQLAlchemy 2.0 + Alembic |
| Database | PostgreSQL 16 |
| Cache / rate limit / idempotency | Redis 7 (plumbed in .env.example; backend runs with the in-memory fallback today — USE_REDIS=false. ElastiCache provisioning is deferred until the VPC-connector + refresh-token-blocklist work in Phase 11.) |
| AI — text | OpenAI gpt-5.4-mini-2026-03-17 |
| AI — image | OpenAI gpt-image-1 |
| Object storage | S3 bucket provisioned (BlockPublic + SSE) (generated images currently live on the App Runner local disk via IImageStorage → LocalImageStorage; the S3 + CloudFront adapter swap is tracked in #49.) |
| Auth | Custom JWT (httpOnly refresh cookie + rotation + Origin-based CSRF guard) |
| Hosting | AWS App Runner (backend) + Amplify Hosting (frontend) + RDS Postgres (ElastiCache Serverless Redis lights up with Phase 11 — wired in NetworkStack, not yet provisioned.) |
| Infra-as-code | AWS CDK in TypeScript |
| Observability | structlog + CloudWatch + Sentry |
| CI/CD | GitHub Actions |
Rationale for each pick is in ARCHITECTURE.md.
git clone https://github.com/Eslam93/MagnaCMS.git
cd MagnaCMS
cp .env.example .env
# Fill in OPENAI_API_KEY and JWT_SECRET. Everything else has sane defaults.
docker-compose up --buildServices come up on:
| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| Backend | http://localhost:8000 |
| Postgres | localhost:5432 (user/pass: app / app) |
| Redis | localhost:6379 |
First-time database setup + a seeded demo account:
docker-compose exec backend alembic upgrade head
docker-compose exec backend python -m app.scripts.seedTarget architecture (CloudFront + Redis are the next-batch additions — see the caveats in §3):
Browser
│
├─── app.<domain> ────────→ Amplify Hosting (Next.js SSR + static assets)
│ │
│ ▼
│ api calls to api.<domain>
│
└─── images.<domain> ────→ CloudFront ──→ S3 (private generated images)
(today: images served by App Runner's
`/local-images` static mount until the
S3 adapter ships in the deploy batch)
api.<domain>
│
▼
App Runner — FastAPI containers (auto-scale, PUBLIC egress, no VPC connector)
│
├──→ RDS Postgres (public endpoint; SG open on 5432, gated by `rds.force_ssl=1` + strong auto-generated password — App Runner's egress prefix list isn't stable enough to allowlist)
├──→ ElastiCache Redis (rate limit, cache, idempotency, refresh-token blocklist)
│ (today: in-memory fallback — USE_REDIS=false until Phase 11)
├──→ OpenAI API (gpt-5.4-mini text, gpt-image-1 image)
└──→ S3 (target; today: local-disk via IImageStorage protocol)
Full rationale and key trade-offs: ARCHITECTURE.md.
FastAPI generates an OpenAPI 3.1 spec from the route signatures. The
live UI is only exposed in local and dev environments — the
staging and production builds hide /docs, /redoc, and
/openapi.json so the API surface isn't enumerable from the public
internet (see app/main.py).
-
Local / dev only:
- Swagger UI:
<api-url>/docs - ReDoc:
<api-url>/redoc - Raw spec:
<api-url>/openapi.json
- Swagger UI:
-
Staging / production: live docs are disabled. Dump the spec from the source tree instead:
uv run python -c "import json; from app.main import app; \ print(json.dumps(app.openapi(), indent=2))" > openapi.json
Load the resulting
openapi.jsoninto a local Swagger viewer or any OpenAPI tool.
| Group | Endpoints |
|---|---|
| Auth | POST /auth/register, POST /auth/login, POST /auth/refresh, POST /auth/logout, GET /auth/me |
| Content | POST /content/generate (blog post, LinkedIn post, email, ad copy), GET /content, GET /content/:id, DELETE /content/:id (soft delete), POST /content/:id/restore (24-hour window) |
| Images | POST /content/:id/image (generate or regenerate), GET /content/:id/images (every version, newest first) |
| Improver | POST /improve (analyze → rewrite), GET /improvements, GET /improvements/:id, DELETE /improvements/:id |
| Brand voices | GET /brand-voices, POST /brand-voices, GET /brand-voices/:id, PATCH /brand-voices/:id, DELETE /brand-voices/:id |
| System | GET /health (always on); GET /openapi.json, GET /docs, GET /redoc (local/dev only — gated in app/main.py) |
| Group | Endpoints |
|---|---|
| Usage | GET /usage/summary |
| Exports | GET /content/:id/export?format=pdf|docx|markdown |
MagnaCMS/
├── README.md # this file
├── ARCHITECTURE.md # key trade-offs + cost estimate
├── DEVLOG.md # running journal of decisions and progress
├── docker-compose.yml # local-dev orchestration
├── .env.example # environment template
├── .github/workflows/ # CI pipelines
├── backend/ # FastAPI service
├── frontend/ # Next.js App Router
└── infra/ # AWS CDK in TypeScript
| Variable | Required | Default | Purpose |
|---|---|---|---|
AI_PROVIDER_MODE |
yes | openai |
openai / bedrock / mock |
OPENAI_API_KEY |
yes (unless mode=mock) | — | OpenAI key, prepaid |
OPENAI_TEXT_MODEL |
no | gpt-5.4-mini-2026-03-17 |
Pinned text model |
OPENAI_IMAGE_MODEL |
no | gpt-image-1 |
Image gen model |
OPENAI_IMAGE_QUALITY |
no | medium |
low / medium / high |
DATABASE_URL |
yes | local compose default | asyncpg connection string |
USE_REDIS |
no | true |
Toggle in-memory fallback |
REDIS_URL |
yes if USE_REDIS=true |
local compose default | Redis URL |
JWT_SECRET |
yes | — | openssl rand -hex 32 |
JWT_ACCESS_TOKEN_TTL_SECONDS |
no | 900 |
15 min |
JWT_REFRESH_TOKEN_TTL_SECONDS |
no | 2592000 |
30 days |
S3_BUCKET_IMAGES |
yes in prod | dev default | Image bucket |
IMAGES_CDN_BASE_URL |
yes in prod | local fallback | CloudFront base URL |
NEXT_PUBLIC_API_BASE_URL |
yes (frontend) | http://localhost:8000/api/v1 |
API base URL |
SENTRY_DSN |
optional | — | If unset, Sentry silently no-ops |
LOG_LEVEL |
no | INFO |
structlog level |
AWS_REGION |
yes for deploy | us-east-1 |
AWS region |
Full annotated set in .env.example.
The CI workflows (.github/workflows/backend-ci.yml, frontend-ci.yml,
infra-ci.yml) run more than just the test suites — they also gate on
formatting (ruff format --check, prettier --check), type-checks,
coverage thresholds, and the production build. Run the full set
locally before pushing to avoid the "tests passed locally but CI failed
on prettier" trap:
# Backend — mirrors backend-ci.yml exactly
cd backend
uv run ruff check .
uv run ruff format --check . # format gate (CI fails if dirty)
uv run mypy app
uv run pytest --cov=app --cov-fail-under=80 # 80% coverage gate
# OpenAPI spec must be consumable by the frontend's codegen:
uv run python -c "import json; from app.main import app; \
json.dump(app.openapi(), open('openapi-tmp.json', 'w'))"
npx openapi-typescript@7.4 openapi-tmp.json -o /dev/null
rm openapi-tmp.json
# Frontend — mirrors frontend-ci.yml exactly
cd frontend
pnpm install --frozen-lockfile
pnpm lint
pnpm format:check # format gate (CI fails if dirty)
pnpm typecheck
pnpm test
pnpm build # next build must succeed
# Infra — mirrors infra-ci.yml exactly
cd infra
npm ci
npm run build
npm testFix surfaces:
| If this fails | Run |
|---|---|
ruff format --check |
uv run ruff format . |
pnpm format:check |
pnpm format |
pnpm lint (auto-fixable) |
pnpm lint --fix |
Playwright E2E (@playwright/test + a happy-path spec) is on the backlog
but not wired yet — see the open backlog.
Infrastructure is defined in infra/ using AWS CDK in TypeScript. Five stacks compose linearly:
| Stack | Owns |
|---|---|
magnacms-dev-network |
VPC + 2 AZs (public subnets only) + RDS/Redis security groups |
magnacms-dev-data |
RDS Postgres, S3 images bucket, Secrets Manager (JWT + OpenAI key). ElastiCache Serverless Redis was previously here but is currently dropped pending the Phase-11 VPC-connector work. |
magnacms-dev-compute |
ECR repo, App Runner backend service, IAM roles, Fargate task definition for migrations |
magnacms-dev-edge |
Amplify hosting app (CloudFront-for-images deferred to Phase 5; see DEVLOG.md) |
magnacms-dev-observability |
CloudWatch log groups with 14-day retention |
See infra/DEPLOY.md for the 11-step runbook. Highlights:
aws configure
cd infra && npm ci
npx cdk bootstrap aws://<account>/us-east-1
npx cdk deploy --all -c env=dev
# Paste OpenAI API key into Secrets Manager
# Run migrations as one-off Fargate task
# Smoke-test /api/v1/health
# IP-identity preflight (DEPLOY.md step 8 — critical).github/workflows/deploy.yml is workflow_dispatch-only and uses an OIDC-assumed role (no long-lived AWS keys in GitHub Secrets). The OIDC trust relationship + AWS_DEPLOY_ROLE_ARN repo secret aren't yet configured, so until that's wired the deploy path is manual:
# Manual deploy (current path) — full sequence in infra/DEPLOY.md
ECR_URI=$(aws ecr describe-repositories --repository-names magnacms-dev-backend --query 'repositories[0].repositoryUri' --output text)
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$ECR_URI"
docker buildx build --platform linux/amd64 --provenance=false \
-t "$ECR_URI:$(git rev-parse --short HEAD)" -t "$ECR_URI:latest" --load backend/
docker push "$ECR_URI:$(git rev-parse --short HEAD)"
docker push "$ECR_URI:latest"
# Run alembic via the migration Fargate task (DEPLOY.md §6 has the full command)
APPRUNNER_ARN=$(aws cloudformation describe-stacks --stack-name magnacms-dev-compute \
--query "Stacks[0].Outputs[?ExportName=='magnacms-dev-compute-apprunner-service-arn'].OutputValue" --output text)
aws apprunner start-deployment --service-arn "$APPRUNNER_ARN"Frontend deploys via aws amplify create-deployment + presigned-URL zip upload (avoids needing GitHub→Amplify OAuth):
cd frontend
NEXT_OUTPUT=export NEXT_PUBLIC_API_BASE_URL=<api-base> pnpm build
python -c "import os, zipfile; root='out'; \
z = zipfile.ZipFile('magnacms-frontend.zip','w',zipfile.ZIP_DEFLATED); \
[z.write(os.path.join(d,f), os.path.relpath(os.path.join(d,f), root).replace(os.sep,'/')) \
for d,_,files in os.walk(root) for f in files]; z.close()"
CREATE=$(aws amplify create-deployment --app-id $AMPLIFY_APP_ID --branch-name main)
ZIP_URL=$(echo "$CREATE" | jq -r '.zipUploadUrl'); JOB_ID=$(echo "$CREATE" | jq -r '.jobId')
curl -fsS -X PUT --data-binary @magnacms-frontend.zip "$ZIP_URL"
aws amplify start-deployment --app-id $AMPLIFY_APP_ID --branch-name main --job-id "$JOB_ID"Once the OIDC role is wired, deploy.yml (manual or push-triggered) supersedes both of the above.
| Workflow | Triggers on | Runs |
|---|---|---|
backend-ci |
backend/** changes |
uv sync, ruff, mypy, alembic upgrade, pytest |
frontend-ci |
frontend/** changes |
pnpm install, lint, prettier, tsc, vitest, next build |
infra-ci |
infra/** changes |
npm ci, jest snapshots, cdk synth --all -c env=dev |
cd infra && npx cdk destroy --all -c env=dev
# Plus delete the Amplify app manually from console (CDK has trouble with
# Amplify apps connected to GitHub)Detailed in ARCHITECTURE.md. Headlines:
- OpenAI direct over AWS Bedrock — one key covers text + image, no Anthropic use-case form, no Nova Canvas LEGACY/EOL story.
- No VPC connector on App Runner — keeps AWS APIs and OpenAI reachable without NAT Gateway.
- Public RDS, SG open on 5432, gated by TLS + strong password — App Runner has no stable egress prefix list to allowlist, so security relies on
rds.force_ssl=1+ the auto-generated Secrets-Manager-managed password. - Custom JWT over Cognito — full control over refresh rotation + Redis blocklist; smaller IAM surface.
- Non-streaming content generation — structured JSON outputs don't stream cleanly; staged loading UI gives the perceived-performance benefit without the bug surface.
If the clock kept running, in priority order:
- S3-backed
IImageStorageadapter (#49). The protocol seam already exists —LocalImageStoragelives behindIImageStorage, and the projection layer already builds image URLs froms3_key+ storage config at response time (not from a persistedcdn_url). The swap is a one-class implementation (S3ImageStoragecallingboto3.upload_fileobj), a config flag, and an alembic-safe column-nullable migration. Removes the only known limitation in the demo flow (the cross-container caveat where Fargate-seeded image bytes never reach the App Runner instance). - CloudFront in front of S3. Public-read distribution with signed-URL option for protected images. Drops App Runner egress charges, adds CDN caching, and the existing
IMAGES_CDN_BASE_URLenv var already isolates the change to a CDK + config update — no application code needs to know. - Real OIDC role for
deploy.ymlso the manualdocker push+aws apprunner start-deployment+aws amplify create-deploymentdance becomes one workflow click. The workflow exists but the IAM trust relationship isn't wired; ~30 min of one-time AWS setup.
- Markdown export for content pieces (#75, size:S). The brief asks for view/copy/download/delete in the dashboard; download isn't shipped. Markdown lift is small because
rendered_textis already the canonical Markdown — it's aGET /content/:id/export?format=mdroute + a download button. PDF/DOCX exports are the bigger lift; tracked separately as #73 / #74.
- Move RDS into a private subnet + add RDS Proxy + IAM-auth. The current
0.0.0.0/0SG +rds.force_ssl=1+ strong-password posture is acceptable for a demo behind custom auth with no regulated data; not production-grade. Requires an App Runner VPC connector (~$32/mo NAT or per-service VPC endpoints), so this also unlocks ElastiCache (which is provisioned in the network stack but not in the data stack — see PR #143's removal). - Redis-backed refresh-token blocklist (#88) and idempotency middleware (#89) — both wait on the VPC connector. Today's in-memory fallback covers the demo but won't survive App Runner instance restarts.
- Job-table dedupe for image regeneration. PR #144 added a NOWAIT lock that fails the second concurrent request fast with
409 IMAGE_GENERATION_IN_PROGRESS, but the winner still holds the DB connection through ~20s of upstream LLM + image-gen + storage calls. The proper fix is ageneration_jobstable with the in-flight job ID returned to subsequent callers; out of scope while the demo budget is small.
- Loading skeletons on the dashboard and detail modal (#87). Today the dashboard shows "Loading…" text — fine for a demo, jarring in a real product.
- Mobile responsive pass (#84). The layout works on a phone but the dashboard cards are awkward; needs a sweep, not a redesign.
- Accessibility audit (#85). Buttons are keyboard-reachable and the form inputs have proper labels, but I haven't run axe or screen-readered the flows.
- Password reset / email verification. Would need an SES domain identity and transactional templates — out of scope for the deliverable. Users who forget their password today get told to re-register; not great, intentional.
- AWS Bedrock Nova Canvas migration plan. Nova Canvas is
LEGACYwith EOL 2026-09-30, so the longer-term migration is "wait for Nova Image v2."gpt-image-1is the only image-gen path today; if Bedrock becomes cheaper or required, theIImageProviderinterface accepts a one-class swap (the Bedrock stub is already inapp/providers/image/bedrock.py). - Multi-tenant / org scoping. Current auth is per-user. Org/workspace scoping would touch every owner-filter in the repositories — not on the roadmap unless a real customer asks.
See DEVLOG.md — an ongoing journal of decisions, trade-offs, and progress (newest entries first). Includes actual elapsed time per phase as the project moves forward.
MIT.