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({ - + {deck.thumbnail_path ? ( + {deck.name} { + (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, "onChange"> { + checked?: boolean + onCheckedChange?: (checked: boolean) => void +} + +const Checkbox = React.forwardRef( + ({ className, checked = false, onCheckedChange, ...props }, ref) => { + return ( + + ) + } +) +Checkbox.displayName = "Checkbox" + +export { Checkbox } diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 60e8b4c..3f282ad 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -17,6 +17,10 @@ const nextConfig = { source: '/app_data/:path*', destination: `${API_URL}/app_data/:path*`, }, + { + source: '/static/:path*', + destination: `${API_URL}/static/:path*`, + }, ]; },