Phase 5: Fix export, slide edit, static files; add README

- Fix PPTX/PDF export: Puppeteer URL port mismatch (80 → 3000)
- Fix backend export_utils to use NEXT_INTERNAL_URL env var
- Add Chromium to frontend Dockerfile for Docker-based export
- Fix slide edit socket hang up with asyncio.wait_for() timeouts
- Add FastAPI StaticFiles mounts for /static and /app_data
- Add Next.js rewrite for /static/ to proxy to backend
- Show template thumbnail in master decks admin page
- Add error logging to ReviewWorkflow component
- Add Docker env vars for web service (APP_DATA_DIRECTORY, app_data volume)
- Add project README in English

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-27 15:40:36 +00:00
parent 71ebbf3626
commit ff9cdffc32
13 changed files with 349 additions and 24 deletions

221
README.md Normal file
View file

@ -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.

View file

@ -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 = ["*"]

View file

@ -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,22 +32,43 @@ async def edit_slide(
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
try:
presentation_layout = presentation.get_layout()
slide_layout = await get_slide_layout_from_prompt(
prompt, presentation_layout, slide
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(
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())
# This will mutate edited_slide_content
new_assets = await process_old_and_new_slides_and_fetch_assets(
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

View file

@ -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())),

View file

@ -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

View file

@ -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

View file

@ -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);
}

View file

@ -402,7 +402,18 @@ function DeckCard({
<button onClick={onToggle} className="text-gray-400 hover:text-gray-600">
{expanded ? <ChevronDown className="w-5 h-5" /> : <ChevronRight className="w-5 h-5" />}
</button>
<Layers className="w-5 h-5 text-[#5146E5] flex-shrink-0" />
{deck.thumbnail_path ? (
<img
src={`/api/v1/admin/master-decks/${deck.id}/screenshot/${deck.thumbnail_path.split('/').pop()}`}
alt={deck.name}
className="w-16 h-10 object-cover rounded border flex-shrink-0 bg-white"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<Layers className={`w-5 h-5 text-[#5146E5] flex-shrink-0 ${deck.thumbnail_path ? 'hidden' : ''}`} />
<div className="min-w-0">
<p className="font-medium truncate">{deck.name}</p>
<p className="text-xs text-gray-500">

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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%;

View file

@ -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<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked = false, onCheckedChange, ...props }, ref) => {
return (
<button
ref={ref}
role="checkbox"
aria-checked={checked}
type="button"
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked && "bg-primary text-primary-foreground",
className
)}
{...props}
>
{checked && <Check className="h-3.5 w-3.5" />}
</button>
)
}
)
Checkbox.displayName = "Checkbox"
export { Checkbox }

View file

@ -17,6 +17,10 @@ const nextConfig = {
source: '/app_data/:path*',
destination: `${API_URL}/app_data/:path*`,
},
{
source: '/static/:path*',
destination: `${API_URL}/static/:path*`,
},
];
},