diff --git a/.github/workflows/api-notion-fetch.yml b/.github/workflows/api-notion-fetch.yml new file mode 100644 index 00000000..f1378ff3 --- /dev/null +++ b/.github/workflows/api-notion-fetch.yml @@ -0,0 +1,386 @@ +name: Notion Fetch via API + +on: + workflow_dispatch: + inputs: + job_type: + description: "Job type to run" + required: true + default: "notion:fetch-all" + type: choice + options: + - notion:fetch-all + - notion:fetch + - notion:translate + - notion:count-pages + - notion:status-translation + - notion:status-draft + - notion:status-publish + - notion:status-publish-production + max_pages: + description: "Maximum pages to fetch (for notion:fetch-all)" + required: false + default: "5" + type: string + force: + description: "Force refetch even if content exists" + required: false + default: false + type: boolean + repository_dispatch: + types: [notion-fetch-request] + schedule: + # Run daily at 2 AM UTC (adjust as needed) + - cron: "0 2 * * *" + +concurrency: + group: notion-api-fetch + cancel-in-progress: false + +jobs: + fetch-via-api: + name: Fetch Notion Content via API + runs-on: ubuntu-latest + timeout-minutes: 60 + + environment: + name: production + url: ${{ steps.create-job.outputs.api_url }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Configure API endpoint + id: config + env: + API_ENDPOINT: ${{ secrets.API_ENDPOINT }} + run: | + # Set API endpoint from secrets or default + if [ -n "$API_ENDPOINT" ]; then + echo "endpoint=$API_ENDPOINT" >> $GITHUB_OUTPUT + echo "api_url=$API_ENDPOINT" >> $GITHUB_OUTPUT + echo "mode=production" >> $GITHUB_OUTPUT + else + # For testing: start API server locally + echo "endpoint=http://localhost:3001" >> $GITHUB_OUTPUT + echo "api_url=http://localhost:3001" >> $GITHUB_OUTPUT + echo "mode=local" >> $GITHUB_OUTPUT + fi + + - name: Setup Bun (local mode only) + if: steps.config.outputs.mode == 'local' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies (local mode only) + if: steps.config.outputs.mode == 'local' + run: bun install + + - name: Rebuild Sharp (local mode only) + if: steps.config.outputs.mode == 'local' + run: | + echo "🔧 Rebuilding Sharp native bindings for Linux x64..." + bun add sharp --force + + - name: Start API server (local mode only) + if: steps.config.outputs.mode == 'local' + env: + NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }} + DATA_SOURCE_ID: ${{ secrets.DATA_SOURCE_ID }} + DATABASE_ID: ${{ secrets.DATABASE_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + API_KEY_GITHUB_ACTIONS: ${{ secrets.API_KEY_GITHUB_ACTIONS }} + run: | + # Set environment variables (already set via env block above) + # NOTE: Don't set NODE_ENV=test here - it forces random port binding + # The workflow needs deterministic port 3001 for health checks + export API_PORT=3001 + export API_HOST=localhost + + # Start server in background + bun run api:server & + SERVER_PID=$! + + # Save PID for cleanup + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + + # Wait for server to be ready + echo "⏳ Waiting for API server to start..." + for i in {1..30}; do + if curl -s http://localhost:3001/health > /dev/null 2>&1; then + echo "✅ API server is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ API server failed to start" + exit 1 || exit 1 + fi + sleep 1 + done + + - name: Create job via API + id: create-job + env: + API_KEY_GITHUB_ACTIONS: ${{ secrets.API_KEY_GITHUB_ACTIONS }} + run: | + set -e + + ENDPOINT="${{ steps.config.outputs.endpoint }}" + JOB_TYPE="${{ github.event.inputs.job_type || 'notion:fetch-all' }}" + MAX_PAGES="${{ github.event.inputs.max_pages || '5' }}" + FORCE="${{ github.event.inputs.force || 'false' }}" + + # Build request body using jq for proper JSON construction + BODY=$(jq -n \ + --arg type "$JOB_TYPE" \ + --argjson maxPages "$MAX_PAGES" \ + --argjson force "$FORCE" \ + '{type: $type, options: {maxPages: $maxPages, force: $force}}') + + echo "📤 Creating job: $JOB_TYPE" + echo "📊 Options: maxPages=$MAX_PAGES, force=$FORCE" + + # Make API request + RESPONSE=$(curl -s -X POST "$ENDPOINT/jobs" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_KEY_GITHUB_ACTIONS" \ + -d "$BODY") + + # Parse response + JOB_ID=$(echo "$RESPONSE" | jq -r '.data.jobId // empty') + + if [ -z "$JOB_ID" ] || [ "$JOB_ID" = "null" ]; then + echo "❌ Failed to create job" + echo "Response: $RESPONSE" + exit 1 + fi + + echo "✅ Job created: $JOB_ID" + echo "job_id=$JOB_ID" >> $GITHUB_OUTPUT + echo "job_url=$ENDPOINT/jobs/$JOB_ID" >> $GITHUB_OUTPUT + + # Set initial GitHub status as pending + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/statuses/${{ github.sha }} \ + -f state="pending" \ + -f context="Notion API Job ($JOB_TYPE)" \ + -f description="Job $JOB_ID is running" \ + -f target_url="$ENDPOINT/jobs/$JOB_ID" || true + + - name: Poll job status + id: poll-status + env: + API_KEY_GITHUB_ACTIONS: ${{ secrets.API_KEY_GITHUB_ACTIONS }} + run: | + set -e + + ENDPOINT="${{ steps.config.outputs.endpoint }}" + JOB_ID="${{ steps.create-job.outputs.job_id }}" + JOB_TYPE="${{ github.event.inputs.job_type || 'notion:fetch-all' }}" + + echo "⏳ Polling job status..." + MAX_WAIT=3600 # 60 minutes in seconds + ELAPSED=0 + POLL_INTERVAL=10 # Check every 10 seconds + + while [ $ELAPSED -lt $MAX_WAIT ]; do + # Get job status + RESPONSE=$(curl -s -X GET "$ENDPOINT/jobs/$JOB_ID" \ + -H "Authorization: Bearer $API_KEY_GITHUB_ACTIONS") + + STATUS=$(echo "$RESPONSE" | jq -r '.data.status // empty') + + # Extract result data for later use + PAGES_PROCESSED=$(echo "$RESPONSE" | jq -r '.data.result.pagesProcessed // 0') + COMMIT_HASH=$(echo "$RESPONSE" | jq -r '.data.result.commitHash // empty') + + echo "📊 Status: $STATUS (elapsed: ${ELAPSED}s)" + + case "$STATUS" in + "completed") + echo "✅ Job completed successfully" + echo "job_status=completed" >> $GITHUB_OUTPUT + echo "pages_processed=$PAGES_PROCESSED" >> $GITHUB_OUTPUT + echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT + + # Build description with commit info + DESCRIPTION="Job $JOB_ID completed - $PAGES_PROCESSED pages" + if [ -n "$COMMIT_HASH" ]; then + DESCRIPTION="$DESCRIPTION (commit: $COMMIT_HASH)" + fi + + # Update GitHub status to success + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/statuses/${{ github.sha }} \ + -f state="success" \ + -f context="Notion API Job ($JOB_TYPE)" \ + -f description="$DESCRIPTION" \ + -f target_url="$ENDPOINT/jobs/$JOB_ID" || true + + exit 0 + ;; + "failed") + echo "❌ Job failed" + echo "job_status=failed" >> $GITHUB_OUTPUT + + # Get error details + ERROR=$(echo "$RESPONSE" | jq -r '.data.result.error // "Unknown error"') + echo "Error: $ERROR" + + # Update GitHub status to failure + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/statuses/${{ github.sha }} \ + -f state="failure" \ + -f context="Notion API Job ($JOB_TYPE)" \ + -f description="Job $JOB_ID failed: $ERROR" \ + -f target_url="$ENDPOINT/jobs/$JOB_ID" || true + + exit 1 + ;; + "running"|"pending") + # Continue polling + ;; + *) + echo "⚠️ Unknown status: $STATUS" + ;; + esac + + sleep $POLL_INTERVAL + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + done + + echo "⏱️ Job timed out after $MAX_WAIT seconds" + echo "job_status=timeout" >> $GITHUB_OUTPUT + + # Update GitHub status to error (timeout) + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/${{ github.repository }}/statuses/${{ github.sha }} \ + -f state="error" \ + -f context="Notion API Job ($JOB_TYPE)" \ + -f description="Job $JOB_ID timed out" \ + -f target_url="$ENDPOINT/jobs/$JOB_ID" || true + + exit 1 + + # ------------------------------------------------------------ + # Update Notion pages status after successful fetch + # After fetching "ready-to-publish" pages and writing to content branch, + # update their status to "Published" and set the published date. + # ------------------------------------------------------------ + - name: Update Notion status to Published + if: steps.poll-status.outputs.job_status == 'completed' && (github.event.inputs.job_type == 'notion:fetch-all' || github.event.inputs.job_type == 'notion:fetch' || github.event.inputs.job_type == '') + env: + NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }} + DATA_SOURCE_ID: ${{ secrets.DATA_SOURCE_ID }} + DATABASE_ID: ${{ secrets.DATABASE_ID }} + run: | + set -e + + PAGES_PROCESSED="${{ steps.poll-status.outputs.pages_processed }}" + COMMIT_HASH="${{ steps.poll-status.outputs.commit_hash }}" + JOB_ID="${{ steps.create-job.outputs.job_id }}" + + echo "📝 Updating Notion page status to Published..." + echo " Pages processed: $PAGES_PROCESSED" + if [ -n "$COMMIT_HASH" ]; then + echo " Commit hash: $COMMIT_HASH" + fi + + # Build reference string for Notion + REF_INFO="Job: $JOB_ID" + if [ -n "$COMMIT_HASH" ]; then + REF_INFO="$REF_INFO | Commit: $COMMIT_HASH" + fi + REF_INFO="$REF_INFO | Workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # Run the Notion status update script + # This updates pages from "Ready to publish" to "Published" status + bun run notionStatus:publish + + echo "✅ Notion status updated to Published" + + - name: Stop API server (local mode only) + if: always() && steps.config.outputs.mode == 'local' + run: | + if [ -n "$SERVER_PID" ]; then + echo "🛑 Stopping API server (PID: $SERVER_PID)" + kill $SERVER_PID 2>/dev/null || true + fi + + - name: Job summary + id: summary + if: always() + run: | + JOB_ID="${{ steps.create-job.outputs.job_id }}" + JOB_STATUS="${{ steps.poll-status.outputs.job_status }}" + JOB_TYPE="${{ github.event.inputs.job_type || 'notion:fetch-all' }}" + MAX_PAGES="${{ github.event.inputs.max_pages || '5' }}" + PAGES_PROCESSED="${{ steps.poll-status.outputs.pages_processed }}" + COMMIT_HASH="${{ steps.poll-status.outputs.commit_hash }}" + + echo "## 📋 Notion API Job Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Job ID:** \`${JOB_ID}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Job Type:** $JOB_TYPE" >> $GITHUB_STEP_SUMMARY + echo "- **Status:** $JOB_STATUS" >> $GITHUB_STEP_SUMMARY + echo "- **Max Pages:** $MAX_PAGES" >> $GITHUB_STEP_SUMMARY + echo "- **API Endpoint:** ${{ steps.config.outputs.endpoint }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch sync contract:** API service must sync \`content\` with \`origin/main\` before pushing generated content" >> $GITHUB_STEP_SUMMARY + echo "- **Safety contract:** API service must never push generated content directly to \`main\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$JOB_STATUS" = "completed" ]; then + echo "✅ Job completed successfully" >> $GITHUB_STEP_SUMMARY + if [ -n "$PAGES_PROCESSED" ] && [ "$PAGES_PROCESSED" != "0" ]; then + echo "- **Pages Processed:** $PAGES_PROCESSED" >> $GITHUB_STEP_SUMMARY + fi + if [ -n "$COMMIT_HASH" ]; then + echo "- **Commit Hash:** \`$COMMIT_HASH\`" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Notion Status:** Updated to Published" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ This workflow cannot yet verify branch-sync metadata returned by the API service." >> $GITHUB_STEP_SUMMARY + elif [ "$JOB_STATUS" = "failed" ]; then + echo "❌ Job failed - check logs for details" >> $GITHUB_STEP_SUMMARY + elif [ "$JOB_STATUS" = "timeout" ]; then + echo "⏱️ Job timed out - may need investigation" >> $GITHUB_STEP_SUMMARY + fi + + - name: Notify Slack + if: always() && env.SLACK_WEBHOOK_URL != '' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + uses: slackapi/slack-github-action@v2.1.1 + with: + webhook: ${{ env.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: "*Notion API Job*: ${{ steps.poll-status.outputs.job_status }}" + blocks: + - type: "section" + text: + type: "mrkdwn" + text: "*Notion API Job*: ${{ steps.poll-status.outputs.job_status }}\nJob: ${{ steps.create-job.outputs.job_id }}\nType: ${{ github.event.inputs.job_type || 'notion:fetch-all' }}" + - type: "section" + text: + type: "mrkdwn" + text: "Workflow: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>" + - type: "section" + text: + type: "mrkdwn" + text: "Trigger: " + - type: "section" + text: + type: "mrkdwn" + text: "Notion Status: ${{ steps.poll-status.outputs.job_status == 'completed' && 'Updated to Published' || 'Not updated' }}" diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 00000000..cdcae8c9 --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,208 @@ +# CoMape Documentation Workflow + + +This document defines the official workflow for how CoMapeo documentation moves from Notion to staging and production. + +--- + +# System Overview + +Our system connects: + +* Notion (editorial source of truth) +* API Server (content processing) +* GitHub (version control and automation) +* GitHub Pages (staging and production) +* Slack (notifications) + +Notion is the source of truth. +GitHub is the automation and deployment engine. +Slack notifications are always sent from GitHub workflows — never directly from the API server. + +--- + +# Architecture Principle + +The API server never sends Slack notifications. + +Instead: + +1. The API server performs content operations. +2. It commits changes to the `content` branch. +3. That push triggers GitHub Actions. +4. GitHub Actions handle: + + * Deployments + * Slack webhooks + * Status reporting + +This ensures all notifications are centralized and traceable inside GitHub. + +--- + +# Editorial Lifecycle + +The complete lifecycle of a page is: + +Content Ready for Translation +→ Auto-translation generated +→ Ready to Publish +→ Draft Published (Staging) +→ Published (Production) + +This separates translation generation, editorial review, staging validation, and production release. + +--- + +# 1. Auto-Translate + +Triggered from Notion → Runs on GitHub + +Purpose: Generate translation drafts inside Notion. + +When triggered: + +* GitHub Actions runs the translation workflow. +* It fetches pages marked "Content Ready for Translation." +* It generates translations. +* It publishes translations back into Notion under the appropriate language pages. +* It updates the page status to "Auto-translation generated." +* GitHub sends a Slack notification with the result. + +Important: + +* If translation pages are blank or contain only empty strings, they are ignored. +* This workflow does not commit to the `content` branch. +* It does not deploy. +* It only updates Notion. + +--- + +# 2. fetch-ready + +Triggered from Notion → Runs on API Server + +Purpose: Move reviewed content into GitHub and deploy to staging. + +After translations are reviewed, the page is manually set to "Ready to Publish." + +When triggered: + +1. The API server fetches: + + * The English page + * All available translations +2. It ignores any pages that are blank or contain only empty strings. +3. It converts content to Markdown. +4. It commits everything to the `content` branch. +5. It updates Notion status from "Ready to Publish" to "Draft Published." + +After the commit: + +* The push to `content` automatically triggers the GitHub Pages staging deployment. +* The staging deployment workflow runs automatically on every push to `main` or `content`. +* Slack webhooks are sent from that GitHub deployment workflow. + +Important: + +* The API server does not send Slack notifications. +* GitHub sends Slack notifications after the staging deploy completes. +* This step publishes content to staging only. + +--- + +# 3. Deploy to Production + +Triggered from Notion → Runs on GitHub + +Purpose: Release documentation publicly. + +When triggered: + +* GitHub runs the production deployment workflow. +* It builds the site using: + + * `main` branch for application code + * `content` branch for documentation +* It deploys to the production documentation system. + +After successful deployment: + +* The Notion status is updated to "Published." +* Slack notifications are sent from GitHub. + +This is the final publishing step. + +--- + +# Sensitive Actions (GitHub Only) + +These actions are restricted and must be manually dispatched in GitHub. +They do not run from Notion. + +--- + +# 4. Clean All + +Manual GitHub Dispatch + +Purpose: Reset the documentation layer. + +This action: + +* Deletes all content from the `content` branch. +* Fully clears generated documentation. + +Slack notifications are sent from the GitHub workflow. + +--- + +# 5. fetch-all + +Manual GitHub Dispatch → Runs on API Server + +Purpose: Full rebuild from Notion. + +When triggered: + +1. GitHub manually dispatches the workflow. +2. The API server fetches: + + * All valid English pages + * All available translations +3. It filters out pages marked with removal tags. +4. It ignores pages that are blank or contain only empty strings. +5. It converts everything to Markdown. +6. It commits all content to the `content` branch. + +After the commit: + +* The push triggers the GitHub Pages staging deployment automatically. +* Slack notifications are sent from GitHub. + +Important: + +* This action rebuilds both English content and translations. + +--- + +# Automatic Deployment Rules + +* GitHub Pages staging runs automatically on every push to `main` or `content`. +* Production deployment runs only when explicitly triggered. +* Slack webhooks are always sent from GitHub workflows. +* The API server never sends Slack notifications directly. + +--- + +# Design Philosophy + +* Notion is the editorial source of truth. +* Translation generation is automated but requires human review. +* The API server transforms and commits content. +* GitHub controls deployment and notifications. +* Staging happens automatically on push. +* Production requires explicit release. +* Sensitive resets are GitHub-only. +* Empty or invalid translation pages are ignored. + diff --git a/api-server/openapi-spec.ts b/api-server/openapi-spec.ts new file mode 100644 index 00000000..5b433c53 --- /dev/null +++ b/api-server/openapi-spec.ts @@ -0,0 +1,659 @@ +/** + * OpenAPI 3.0.0 specification for CoMapeo Documentation API + */ +import { VALID_JOB_TYPES } from "./validation"; + +const HOST = process.env.API_HOST || "localhost"; +const PORT = parseInt(process.env.API_PORT || "3001"); + +export const OPENAPI_SPEC = { + openapi: "3.0.0", + info: { + title: "CoMapeo Documentation API", + version: "1.0.0", + description: "API for managing Notion content operations and jobs", + }, + servers: [ + { + url: `http://${HOST}:${PORT}`, + description: "Local development server", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "API Key", + description: "Bearer token authentication using API key", + }, + apiKeyAuth: { + type: "http", + scheme: "api-key", + description: "Api-Key header authentication using API key", + }, + }, + schemas: { + // Standard response envelopes + ApiResponse: { + type: "object", + required: ["data", "requestId", "timestamp"], + properties: { + data: { + type: "object", + description: "Response data (varies by endpoint)", + }, + requestId: { + type: "string", + description: "Unique request identifier for tracing", + pattern: "^req_[a-z0-9]+_[a-z0-9]+$", + }, + timestamp: { + type: "string", + format: "date-time", + description: "ISO 8601 timestamp of response", + }, + pagination: { + $ref: "#/components/schemas/PaginationMeta", + }, + }, + }, + ErrorResponse: { + type: "object", + required: ["code", "message", "status", "requestId", "timestamp"], + properties: { + code: { + type: "string", + description: "Machine-readable error code", + enum: [ + "VALIDATION_ERROR", + "INVALID_INPUT", + "MISSING_REQUIRED_FIELD", + "INVALID_FORMAT", + "INVALID_ENUM_VALUE", + "UNAUTHORIZED", + "FORBIDDEN", + "INVALID_API_KEY", + "API_KEY_INACTIVE", + "NOT_FOUND", + "RESOURCE_NOT_FOUND", + "ENDPOINT_NOT_FOUND", + "CONFLICT", + "INVALID_STATE_TRANSITION", + "RESOURCE_LOCKED", + "RATE_LIMIT_EXCEEDED", + "INTERNAL_ERROR", + "SERVICE_UNAVAILABLE", + "JOB_EXECUTION_FAILED", + ], + }, + message: { + type: "string", + description: "Human-readable error message", + }, + status: { + type: "integer", + description: "HTTP status code", + }, + requestId: { + type: "string", + description: "Unique request identifier for tracing", + }, + timestamp: { + type: "string", + format: "date-time", + description: "ISO 8601 timestamp of error", + }, + details: { + type: "object", + description: "Additional error context", + }, + suggestions: { + type: "array", + items: { + type: "string", + }, + description: "Suggestions for resolving the error", + }, + }, + }, + PaginationMeta: { + type: "object", + required: [ + "page", + "perPage", + "total", + "totalPages", + "hasNext", + "hasPrevious", + ], + properties: { + page: { + type: "integer", + minimum: 1, + description: "Current page number (1-indexed)", + }, + perPage: { + type: "integer", + minimum: 1, + description: "Number of items per page", + }, + total: { + type: "integer", + minimum: 0, + description: "Total number of items", + }, + totalPages: { + type: "integer", + minimum: 1, + description: "Total number of pages", + }, + hasNext: { + type: "boolean", + description: "Whether there is a next page", + }, + hasPrevious: { + type: "boolean", + description: "Whether there is a previous page", + }, + }, + }, + HealthResponse: { + type: "object", + properties: { + status: { + type: "string", + example: "ok", + }, + timestamp: { + type: "string", + format: "date-time", + }, + uptime: { + type: "number", + description: "Server uptime in seconds", + }, + auth: { + type: "object", + properties: { + enabled: { + type: "boolean", + }, + keysConfigured: { + type: "integer", + }, + }, + }, + }, + }, + JobTypesResponse: { + type: "object", + properties: { + types: { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "string", + }, + description: { + type: "string", + }, + }, + }, + }, + }, + }, + JobsListResponse: { + type: "object", + required: ["items", "count"], + properties: { + items: { + type: "array", + items: { + $ref: "#/components/schemas/Job", + }, + }, + count: { + type: "integer", + }, + }, + }, + Job: { + type: "object", + properties: { + id: { + type: "string", + }, + type: { + type: "string", + enum: VALID_JOB_TYPES, + }, + status: { + type: "string", + enum: ["pending", "running", "completed", "failed"], + }, + createdAt: { + type: "string", + format: "date-time", + }, + startedAt: { + type: "string", + format: "date-time", + nullable: true, + }, + completedAt: { + type: "string", + format: "date-time", + nullable: true, + }, + progress: { + $ref: "#/components/schemas/JobProgress", + }, + result: { + type: "object", + nullable: true, + }, + }, + }, + JobProgress: { + type: "object", + properties: { + current: { + type: "integer", + }, + total: { + type: "integer", + }, + message: { + type: "string", + }, + }, + }, + CreateJobRequest: { + type: "object", + required: ["type"], + properties: { + type: { + type: "string", + enum: VALID_JOB_TYPES, + }, + options: { + type: "object", + properties: { + maxPages: { + type: "integer", + }, + statusFilter: { + type: "string", + }, + force: { + type: "boolean", + }, + dryRun: { + type: "boolean", + }, + includeRemoved: { + type: "boolean", + }, + }, + }, + }, + }, + CreateJobResponse: { + type: "object", + properties: { + jobId: { + type: "string", + }, + type: { + type: "string", + }, + status: { + type: "string", + enum: ["pending"], + }, + message: { + type: "string", + }, + _links: { + type: "object", + properties: { + self: { + type: "string", + }, + status: { + type: "string", + }, + }, + }, + }, + }, + JobStatusResponse: { + $ref: "#/components/schemas/Job", + }, + CancelJobResponse: { + type: "object", + properties: { + id: { + type: "string", + }, + status: { + type: "string", + enum: ["cancelled"], + }, + message: { + type: "string", + }, + }, + }, + }, + }, + headers: { + "X-Request-ID": { + description: "Unique request identifier for tracing", + schema: { + type: "string", + pattern: "^req_[a-z0-9]+_[a-z0-9]+$", + }, + required: false, + }, + }, + security: [ + { + bearerAuth: [], + }, + { + apiKeyAuth: [], + }, + ], + tags: [ + { + name: "Health", + description: "Health check endpoints", + }, + { + name: "Jobs", + description: "Job management endpoints", + }, + ], + paths: { + "/health": { + get: { + summary: "Health check", + description: "Check if the API server is running", + tags: ["Health"], + security: [], + responses: { + "200": { + description: "Server is healthy", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/HealthResponse", + }, + }, + }, + }, + }, + }, + }, + "/docs": { + get: { + summary: "API documentation", + description: "Get OpenAPI specification for this API", + tags: ["Health"], + security: [], + responses: { + "200": { + description: "OpenAPI specification", + content: { + "application/json": { + schema: { + type: "object", + description: "OpenAPI 3.0.0 specification document", + }, + }, + }, + }, + }, + }, + }, + "/jobs/types": { + get: { + summary: "List job types", + description: "Get a list of all available job types", + tags: ["Jobs"], + security: [], + responses: { + "200": { + description: "List of job types", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/JobTypesResponse", + }, + }, + }, + }, + }, + }, + }, + "/jobs": { + get: { + summary: "List jobs", + description: "Retrieve all jobs with optional filtering", + tags: ["Jobs"], + parameters: [ + { + name: "status", + in: "query", + schema: { + type: "string", + enum: ["pending", "running", "completed", "failed"], + }, + description: "Filter by job status", + }, + { + name: "type", + in: "query", + schema: { + type: "string", + enum: VALID_JOB_TYPES, + }, + description: "Filter by job type", + }, + ], + responses: { + "200": { + description: "List of jobs", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/JobsListResponse", + }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + }, + }, + post: { + summary: "Create job", + description: + "Create and trigger a new job. Fetch jobs return 202 Accepted after enqueue; legacy job types may return 201 Created.", + tags: ["Jobs"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/CreateJobRequest", + }, + }, + }, + }, + responses: { + "202": { + description: "Job accepted and enqueued (fetch job types)", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/CreateJobResponse", + }, + }, + }, + }, + "201": { + description: "Job created successfully (legacy job types)", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/CreateJobResponse", + }, + }, + }, + }, + "400": { + description: "Bad request", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + }, + }, + }, + "/jobs/{id}": { + get: { + summary: "Get job status", + description: "Retrieve detailed status of a specific job", + tags: ["Jobs"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { + type: "string", + }, + description: "Job ID", + }, + ], + responses: { + "200": { + description: "Job details", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/JobStatusResponse", + }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + "404": { + description: "Job not found", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + }, + }, + delete: { + summary: "Cancel job", + description: "Cancel a pending or running job", + tags: ["Jobs"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { + type: "string", + }, + description: "Job ID", + }, + ], + responses: { + "200": { + description: "Job cancelled successfully", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/CancelJobResponse", + }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + "404": { + description: "Job not found", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + "409": { + description: "Cannot cancel job in current state", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ErrorResponse", + }, + }, + }, + }, + }, + }, + }, + }, +};