diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fece8ac
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
+# Oliver DeckForge
+
+AI-powered enterprise presentation generator with multi-tenant architecture, custom template support, and role-based access control.
+
+## Architecture
+
+```
+ ┌─────────┐
+ │ nginx │ :80
+ └────┬────┘
+ ┌─────┴─────┐
+ ┌────┴───┐ ┌────┴───┐
+ │ Next.js │ │FastAPI │
+ │ (web) │ │ (api) │
+ │ :3000 │ │ :8000 │
+ └────┬────┘ └───┬────┘
+ │ ┌───┴────┐
+ │ ┌────┴──┐ ┌───┴────┐
+ │ │Worker │ │Postgres│
+ │ │ (arq) │ │ :5432 │
+ │ └───┬───┘ └────────┘
+ │ │
+ └──────┴──── app_data volume
+```
+
+| Service | Stack | Purpose |
+|------------|--------------------------------------|----------------------------------------|
+| **web** | Next.js 14, Redux Toolkit, Shadcn UI | Frontend SPA + Puppeteer PDF/PPTX export |
+| **api** | FastAPI, SQLModel, Alembic | REST API, auth, RBAC, SSE streaming |
+| **worker** | arq (async Redis queue) | Background AI generation jobs |
+| **postgres** | PostgreSQL 16 | Primary database |
+| **redis** | Redis 7 | Job queue + caching |
+| **nginx** | nginx | Reverse proxy, static file serving |
+
+## Features
+
+- **AI Presentation Generation** — Upload documents (DOCX, PPTX, images) or provide a URL/topic, get a full slide deck
+- **Custom Templates** — Upload master PPTX decks, AI parses layouts into reusable React components
+- **Multi-Tenant** — Client-based data isolation with per-client branding and storage
+- **RBAC** — Super admin, client admin, and user roles with granular permissions
+- **SSO** — Azure AD authentication with dev bypass mode for local development
+- **Review Workflow** — Draft / In Review / Approved status tracking for presentations
+- **Export** — PDF and PPTX export via headless Chromium
+- **Admin Panel** — User/team/client management, analytics, storage, settings, audit logs
+- **Multi-Provider AI** — Anthropic (Claude), OpenAI, Google, Ollama for LLM; multiple image generation providers
+- **i18n** — Internationalization support via react-i18next
+
+## Quick Start
+
+### Prerequisites
+
+- Docker & Docker Compose
+- An Anthropic API key (for AI generation)
+- A Google API key (for image generation, optional)
+
+### 1. Configure environment
+
+```bash
+cp .env.example .env
+```
+
+Edit `.env` and set your API keys:
+
+```env
+ANTHROPIC_API_KEY=sk-ant-...
+GOOGLE_API_KEY=... # optional, for image generation
+```
+
+### 2. Start all services
+
+```bash
+make dev
+```
+
+This builds and starts all 6 services. The app will be available at:
+
+- **App**: http://localhost (via nginx) or http://localhost:3000 (direct)
+- **API docs**: http://localhost/docs
+- **Database**: localhost:5432
+
+### 3. Run database migrations
+
+```bash
+make migrate
+```
+
+### 4. Seed initial data
+
+```bash
+make seed
+```
+
+This creates the default super admin user and a sample client.
+
+### 5. Log in
+
+With Azure AD credentials not configured, the app uses dev auth bypass mode. Log in at http://localhost/login with the password set in `DEV_AUTH_PASSWORD` (default: `devpass123`).
+
+## Local Development (without Docker)
+
+### Backend
+
+```bash
+cd backend
+python -m venv venv
+source venv/bin/activate
+pip install -r requirements.txt
+
+# Set environment variables
+export DATABASE_URL="postgresql+asyncpg://deckforge:deckforge@localhost:5432/deckforge"
+export REDIS_URL="redis://localhost:6379/0"
+
+# Run API server
+uvicorn api.main:app --reload --port 8000
+
+# Run worker (separate terminal)
+python -m arq workers.main.WorkerSettings
+```
+
+### Frontend
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+The frontend dev server runs on port 3000 and proxies API requests to the backend via Next.js rewrites.
+
+## Makefile Commands
+
+| Command | Description |
+|---------------|--------------------------------------|
+| `make dev` | Build and start all services |
+| `make build` | Build Docker images |
+| `make up` | Start services (detached) |
+| `make down` | Stop all services |
+| `make migrate`| Run Alembic database migrations |
+| `make seed` | Seed initial data |
+| `make test` | Run backend pytest suite |
+| `make test-e2e`| Run Cypress E2E tests |
+| `make logs` | Tail all service logs |
+| `make shell-api` | Shell into API container |
+| `make shell-db` | psql into PostgreSQL |
+
+## Project Structure
+
+```
+├── backend/
+│ ├── api/ # FastAPI app, routers, middlewares
+│ │ ├── v1/
+│ │ │ ├── admin/ # Admin panel endpoints
+│ │ │ ├── auth/ # Authentication (Azure AD + dev bypass)
+│ │ │ └── ppt/ # Presentation CRUD, generation, export
+│ │ └── middlewares/ # Auth, RBAC, audit middlewares
+│ ├── models/ # SQLModel database models
+│ ├── services/ # Business logic (AI, templates, settings)
+│ ├── workers/ # arq background job definitions
+│ ├── utils/ # Helpers (export, layout, env)
+│ ├── alembic/ # Database migrations
+│ └── Dockerfile
+├── frontend/
+│ ├── app/ # Next.js 14 App Router pages
+│ │ ├── (presentation-generator)/ # Main app routes
+│ │ ├── admin/ # Admin panel pages
+│ │ └── api/ # API routes (export via Puppeteer)
+│ ├── components/ui/ # Shadcn UI components
+│ ├── store/slices/ # Redux Toolkit state management
+│ ├── locales/ # i18n translation files
+│ └── Dockerfile
+├── docker-compose.yml
+├── nginx.conf
+├── Makefile
+└── .env.example
+```
+
+## Environment Variables
+
+| Variable | Required | Default | Description |
+|------------------------|----------|--------------------|------------------------------------------|
+| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key for Claude |
+| `ANTHROPIC_MODEL` | No | claude-sonnet-4-6 | Claude model ID |
+| `GOOGLE_API_KEY` | No | — | Google API key for image generation |
+| `IMAGE_PROVIDER` | No | nanobanana_pro | Image provider (see below) |
+| `POSTGRES_PASSWORD` | No | deckforge | PostgreSQL password |
+| `JWT_SECRET_KEY` | Yes | — | Secret for JWT token signing |
+| `AZURE_AD_TENANT_ID` | No | — | Azure AD tenant (blank = dev auth mode) |
+| `AZURE_AD_CLIENT_ID` | No | — | Azure AD app client ID |
+| `AZURE_AD_CLIENT_SECRET`| No | — | Azure AD app secret |
+| `DEV_AUTH_PASSWORD` | No | devpass123 | Password for dev auth bypass |
+| `APP_DATA_DIRECTORY` | No | ./data | Directory for generated files |
+
+### Supported AI Providers
+
+**LLM**: `anthropic` (default), `openai`, `google`, `ollama`, `custom`
+
+**Image generation**: `nanobanana_pro` (default), `gemini_flash`, `dall-e-3`, `gpt-image-1.5`, `pexels`, `pixabay`, `comfyui`
+
+## Authentication
+
+### Azure AD SSO (Production)
+
+Configure `AZURE_AD_TENANT_ID`, `AZURE_AD_CLIENT_ID`, and `AZURE_AD_CLIENT_SECRET` in `.env`. Users authenticate via Microsoft login flow.
+
+### Dev Bypass (Local Development)
+
+When `AZURE_AD_TENANT_ID` is not set, the app uses a simple password-based login. Set the password via `DEV_AUTH_PASSWORD`.
+
+## Custom Templates
+
+1. Navigate to **Admin > Clients > [Client] > Master Decks**
+2. Upload a PPTX file — the system will:
+ - Extract slide layouts and XML definitions
+ - Generate PDF screenshots via LibreOffice
+ - Use AI (LLM vision) to convert each layout into a React TSX component
+3. The parsed layouts appear as available templates during presentation generation
+4. Manage layouts: delete unwanted ones, filter by type, toggle visibility
+
+## License
+
+Proprietary. All rights reserved.
diff --git a/backend/api/main.py b/backend/api/main.py
index 3435f64..bbb3fff 100644
--- a/backend/api/main.py
+++ b/backend/api/main.py
@@ -1,5 +1,8 @@
+import os
+
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
from api.lifespan import app_lifespan
from api.middlewares import UserConfigEnvUpdateMiddleware
from api.middlewares.auth_middleware import AuthMiddleware
@@ -46,6 +49,17 @@ app.include_router(EXPORT_ROUTER)
app.include_router(API_V1_WEBHOOK_ROUTER)
app.include_router(API_V1_MOCK_ROUTER)
+# Serve static assets (placeholder images, etc.)
+_static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
+if os.path.isdir(_static_dir):
+ app.mount("/static", StaticFiles(directory=_static_dir), name="static")
+
+# Serve data directory (images, exports, uploads) for local dev
+# In Docker, nginx serves these directly; this is a fallback for dev mode
+_data_dir = os.environ.get("APP_DATA_DIRECTORY", os.path.join(os.path.dirname(__file__), "..", "data"))
+os.makedirs(_data_dir, exist_ok=True)
+app.mount("/app_data", StaticFiles(directory=_data_dir), name="app_data")
+
# Middlewares (executed in reverse order: last added = first executed)
# 1. CORS must run first (handles preflight OPTIONS)
origins = ["*"]
diff --git a/backend/api/v1/ppt/endpoints/slide.py b/backend/api/v1/ppt/endpoints/slide.py
index 704c2eb..42c9728 100644
--- a/backend/api/v1/ppt/endpoints/slide.py
+++ b/backend/api/v1/ppt/endpoints/slide.py
@@ -1,3 +1,5 @@
+import asyncio
+import traceback
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +14,6 @@ from utils.llm_calls.edit_slide import get_edited_slide_content
from utils.llm_calls.edit_slide_html import get_edited_slide_html
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
-import uuid
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@@ -31,23 +32,44 @@ async def edit_slide(
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
- presentation_layout = presentation.get_layout()
- slide_layout = await get_slide_layout_from_prompt(
- prompt, presentation_layout, slide
- )
+ try:
+ presentation_layout = presentation.get_layout()
+ slide_layout = await asyncio.wait_for(
+ get_slide_layout_from_prompt(prompt, presentation_layout, slide),
+ timeout=60,
+ )
- edited_slide_content = await get_edited_slide_content(
- prompt, slide, presentation.language, slide_layout
- )
+ edited_slide_content = await asyncio.wait_for(
+ get_edited_slide_content(
+ prompt, slide, presentation.language, slide_layout
+ ),
+ timeout=90,
+ )
- image_generation_service = ImageGenerationService(get_images_directory())
+ image_generation_service = ImageGenerationService(get_images_directory())
- # This will mutate edited_slide_content
- new_assets = await process_old_and_new_slides_and_fetch_assets(
- image_generation_service,
- slide.content,
- edited_slide_content,
- )
+ # This will mutate edited_slide_content
+ new_assets = await asyncio.wait_for(
+ process_old_and_new_slides_and_fetch_assets(
+ image_generation_service,
+ slide.content,
+ edited_slide_content,
+ ),
+ timeout=120,
+ )
+ except asyncio.TimeoutError:
+ raise HTTPException(
+ status_code=504,
+ detail="Slide editing timed out. The AI model or image generation took too long. Please try again.",
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ traceback.print_exc()
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to edit slide: {str(e)}",
+ )
# Always assign a new unique id to the slide
slide.id = uuid.uuid4()
diff --git a/backend/utils/export_utils.py b/backend/utils/export_utils.py
index f5b371a..d9ab5b6 100644
--- a/backend/utils/export_utils.py
+++ b/backend/utils/export_utils.py
@@ -21,12 +21,14 @@ async def export_presentation(
client_id: Optional[uuid.UUID] = None,
session: Optional[AsyncSession] = None,
) -> PresentationAndPath:
+ next_url = os.environ.get("NEXT_INTERNAL_URL", "http://localhost:3000")
+
if export_as == "pptx":
# Get the converted PPTX model from the Next.js service
async with aiohttp.ClientSession() as http:
async with http.get(
- f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}"
+ f"{next_url}/api/presentation_to_pptx_model?id={presentation_id}"
) as response:
if response.status != 200:
error_text = await response.text()
@@ -76,7 +78,7 @@ async def export_presentation(
else:
async with aiohttp.ClientSession() as http:
async with http.post(
- "http://localhost/api/export-as-pdf",
+ f"{next_url}/api/export-as-pdf",
json={
"id": str(presentation_id),
"title": sanitize_filename(title or str(uuid.uuid4())),
diff --git a/docker-compose.yml b/docker-compose.yml
index 8468d84..d1810cc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,6 +39,7 @@ services:
REDIS_URL: redis://redis:6379/0
APP_DATA_DIRECTORY: /app_data
TEMP_DIRECTORY: /tmp/deckforge
+ NEXT_INTERNAL_URL: "http://web:3000"
volumes:
- app_data:/app_data
depends_on:
@@ -76,6 +77,10 @@ services:
environment:
CAN_CHANGE_KEYS: "false"
API_INTERNAL_URL: "http://api:8000"
+ APP_DATA_DIRECTORY: /app_data
+ TEMP_DIRECTORY: /tmp/deckforge
+ volumes:
+ - app_data:/app_data
depends_on:
- api
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 9221563..24a3a20 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -9,6 +9,14 @@ RUN npm run build
FROM node:20-alpine
+# Install Chromium for Puppeteer (used by PDF/PPTX export)
+RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
+
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
+ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
+ENV APP_DATA_DIRECTORY=/app_data
+ENV TEMP_DIRECTORY=/tmp/deckforge
+
WORKDIR /app
COPY --from=builder /app/.next-build ./.next-build
COPY --from=builder /app/node_modules ./node_modules
diff --git a/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx b/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx
index ba26253..3f40af4 100644
--- a/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx
+++ b/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx
@@ -62,8 +62,8 @@ export default function ReviewWorkflow({ presentationId }: ReviewWorkflowProps)
});
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch review info");
setInfo(data);
- } catch {
- // Silently fail — review info is supplementary
+ } catch (err) {
+ console.error("ReviewWorkflow: failed to fetch review info", err);
} finally {
setIsLoading(false);
}
diff --git a/frontend/app/admin/clients/[id]/master-decks/page.tsx b/frontend/app/admin/clients/[id]/master-decks/page.tsx
index 166873e..f4c64ee 100644
--- a/frontend/app/admin/clients/[id]/master-decks/page.tsx
+++ b/frontend/app/admin/clients/[id]/master-decks/page.tsx
@@ -402,7 +402,18 @@ function DeckCard({
- {
+ (e.target as HTMLImageElement).style.display = 'none';
+ (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
+ }}
+ />
+ ) : null}
+
{deck.name}
diff --git a/frontend/app/api/export-as-pdf/route.ts b/frontend/app/api/export-as-pdf/route.ts
index 8ea9023..4dff1c5 100644
--- a/frontend/app/api/export-as-pdf/route.ts
+++ b/frontend/app/api/export-as-pdf/route.ts
@@ -34,7 +34,8 @@ export async function POST(req: NextRequest) {
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
- await page.goto(`http://localhost/pdf-maker?id=${id}`, {
+ const baseUrl = process.env.PUPPETEER_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
+ await page.goto(`${baseUrl}/pdf-maker?id=${id}`, {
waitUntil: "networkidle0",
timeout: 300000,
});
diff --git a/frontend/app/api/presentation_to_pptx_model/route.ts b/frontend/app/api/presentation_to_pptx_model/route.ts
index b1ff184..ff35d20 100644
--- a/frontend/app/api/presentation_to_pptx_model/route.ts
+++ b/frontend/app/api/presentation_to_pptx_model/route.ts
@@ -98,7 +98,8 @@ async function getBrowserAndPage(id: string): Promise<[Browser, Page]> {
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
- await page.goto(`http://localhost/pdf-maker?id=${id}`, {
+ const baseUrl = process.env.PUPPETEER_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
+ await page.goto(`${baseUrl}/pdf-maker?id=${id}`, {
waitUntil: "networkidle0",
timeout: 300000,
});
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 6f37b1a..7b5ef65 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -572,7 +572,7 @@ thead {
box-shadow: 0 -0.25em 0 hsl(30,90%,80%) inset,
0.75em -1.55em 0 hsl(30,90%,90%) inset;
top: 0;
- left: -2em;
+ left: -1.5em;
width: 2.75em;
height: 2.5em;
transform-origin: 100% 50%;
@@ -616,7 +616,7 @@ thead {
box-shadow: 0.1em 0.75em 0 hsl(30,90%,55%) inset,
0.15em -0.5em 0 hsl(30,90%,80%) inset;
top: 0.25em;
- left: 2em;
+ left: 1.5em;
width: 4.5em;
height: 3em;
transform-origin: 17% 50%;
diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx
new file mode 100644
index 0000000..880a7a7
--- /dev/null
+++ b/frontend/components/ui/checkbox.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Check } from "lucide-react"
+
+export interface CheckboxProps
+ extends Omit