diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..67d6181b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(git mv:*)", + "Bash(git add:*)", + "Bash(git rm:*)" + ] + } +} diff --git a/.github/workflows/backup-n8n.yml b/.github/workflows/backup-n8n.yml new file mode 100644 index 00000000..ac16616d --- /dev/null +++ b/.github/workflows/backup-n8n.yml @@ -0,0 +1,113 @@ +name: Backup n8n Workflows + +on: + # Run daily at 2 AM UTC + schedule: + - cron: '0 2 * * *' + + # Allow manual triggering + workflow_dispatch: + + # Run on pushes to main (optional - comment out if not needed) + # push: + # branches: + # - main + +jobs: + backup: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install jq (for JSON processing) + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Backup workflows from n8n + env: + N8N_URL: ${{ secrets.N8N_URL }} + N8N_API_KEY: ${{ secrets.N8N_API_KEY }} + run: | + set -euo pipefail + echo "Fetching workflows from $N8N_URL..." + + # Fetch all workflows + response=$(curl -s \ + -H "X-N8N-API-KEY: $N8N_API_KEY" \ + "$N8N_URL/api/v1/workflows") + + # Check if request was successful + if [ $? -ne 0 ]; then + echo "Error: Failed to fetch workflows" + exit 1 + fi + + # Create backups directory + mkdir -p workflows/backups + + # Track counts for summary + saved_count=0 + error_count=0 + timestamp=$(date +%Y%m%d_%H%M%S) + + # Process each workflow + if ! workflows=$(echo "$response" | jq -c '.data[]'); then + echo "Error: Failed to parse workflows response" + exit 1 + fi + + while IFS= read -r workflow; do + workflow_id=$(echo "$workflow" | jq -r '.id') + workflow_name=$(echo "$workflow" | jq -r '.name') + + # Sanitize filename + safe_name=$(echo "$workflow_name" | tr ' ' '-' | tr -cd '[:alnum:]-_') + + if [ -z "$safe_name" ]; then + safe_name="workflow-${workflow_id}" + fi + + echo "Backing up: $workflow_name ($workflow_id)" + + # Save to workflows directory + if echo "$workflow" | jq '.' > "workflows/${safe_name}.json"; then + # Also save timestamped backup + echo "$workflow" | jq '.' > "workflows/backups/${safe_name}_${timestamp}.json" + echo "Saved: ${safe_name}.json" + saved_count=$((saved_count + 1)) + else + echo "ERROR: Failed to save ${safe_name}.json" + error_count=$((error_count + 1)) + fi + done <<< "$workflows" + + # Summary and exit status + echo "" + echo "Backup complete: $saved_count workflows saved" + if [ $error_count -gt 0 ]; then + echo "ERROR: $error_count workflows failed to backup" + exit 1 + fi + + - name: Configure Git + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + - name: Commit and push changes + run: | + git add workflows/ + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "chore: automated workflow backup $(date +%Y-%m-%d)" + git push + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e40d0a21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,96 @@ +# Environment files +.env +.env.local +.env.*.local +*.env +!.env.example + +# n8n data +.n8n/ +n8n_storage/ + +# Database +db_storage/ +*.db +*.sqlite + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Secrets and credentials +*secret* +*credentials* +*credential* +!*-example +*.key +*.pem +*.p12 +*.pfx + +# API keys (additional protection) +*apikey* +*api-key* +*api_key* + +# Node modules (for future JavaScript projects) +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist/ +build/ +*.tsbuildinfo +*.js.map + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# Backup files +*.bak +*.backup +*~ +*.tmp +*.temp + +# Workflow backups (keep in git, but ignore temp backups) +workflows/backups/* +!workflows/backups/.gitkeep + +# Test files +*.test.local.* +test-data/local-* + +# Docker +docker-compose.override.yml + +# Certificates +*.crt +*.cert +*.cer + +# Coverage +coverage/ +*.lcov +.nyc_output/ diff --git a/README.md b/README.md index c7ad2213..918a31c7 100644 --- a/README.md +++ b/README.md @@ -1 +1,352 @@ -# n8n-hosting +# Cyclone-S5 + +**Production-ready n8n workflows for AI-powered ad creative generation at scale** + +Automated image generation, prompt optimization, and workflow management for advertising campaigns using Airtable, fal.ai, and Bannerbear. + +--- + +## πŸš€ Quick Start + +### 1. Configure Environment + +```bash +# Copy the environment template +cp config/.env.example config/.env + +# Edit .env with your credentials +# - Airtable Personal Access Token +# - fal.ai API Key +# - Bannerbear API Key +# - n8n URL and API Key (for deployment/backup) +``` + +### 2. Deploy Workflows to n8n + +```bash +cd scripts + +# Set environment variables +export N8N_URL="https://your-n8n-instance.com" +export N8N_API_KEY="your_api_key" + +# Deploy all workflows +./deploy-workflow.sh + +# Or deploy a specific workflow +./deploy-workflow.sh palmaura-fal-image-generation +``` + +### 3. Validate Configuration + +```bash +# Run validation script +node scripts/validate-config.js +``` + +--- + +## πŸ“ Repository Structure + +``` +Cyclone-S5/ +β”œβ”€β”€ workflows/ # n8n workflow JSON files +β”‚ β”œβ”€β”€ palmaura-fal-image-generation.json +β”‚ β”œβ”€β”€ claude-n8n-http.json +β”‚ └── README.md # Workflow documentation +β”œβ”€β”€ config/ # Configuration templates +β”‚ β”œβ”€β”€ .env.example # Environment variables +β”‚ β”œβ”€β”€ airtable-schemas.json +β”‚ β”œβ”€β”€ api-endpoints.json +β”‚ └── prompt-templates.json +β”œβ”€β”€ utils/n8n-helpers/ # Reusable helper functions +β”‚ β”œβ”€β”€ airtable-ops.js # Airtable CRUD operations +β”‚ β”œβ”€β”€ image-generation.js +β”‚ β”œβ”€β”€ prompt-builder.js +β”‚ └── error-handler.js +β”œβ”€β”€ schemas/ # TypeScript type definitions +β”œβ”€β”€ scripts/ # Deployment & maintenance scripts +β”‚ β”œβ”€β”€ deploy-workflow.sh +β”‚ β”œβ”€β”€ backup-workflows.sh +β”‚ β”œβ”€β”€ validate-config.js +β”‚ └── sync-env.sh +β”œβ”€β”€ deployment/ # Legacy n8n deployment configs +└── .github/workflows/ # Automated workflow backups +``` + +--- + +## βš™οΈ Configuration + +### Required API Keys + +All configuration is centralized in [config/.env.example](config/.env.example): + +- **Airtable**: Personal Access Token from [airtable.com/create/tokens](https://airtable.com/create/tokens) +- **fal.ai**: API key from [fal.ai/dashboard/keys](https://fal.ai/dashboard/keys) +- **Bannerbear**: API key from [app.bannerbear.com](https://app.bannerbear.com/account/settings) +- **OpenAI**: API key from [platform.openai.com/api-keys](https://platform.openai.com/api-keys) (optional) + +### Airtable Setup + +Your Airtable base should include these tables: + +| Table | Purpose | +|-------|---------| +| **Ad Copy** | Ad concepts, prompts, and generation status | +| **Images** | Generated image metadata | +| **Actors** | Character descriptions for prompts | +| **Products** | Product information | +| **Scenes** | Scene/environment descriptions | + +See [config/airtable-schemas.json](config/airtable-schemas.json) for field definitions. + +--- + +## πŸ”„ Workflows + +### PalmAura - Image Generation at Scale + +**File**: [workflows/palmaura-fal-image-generation.json](workflows/palmaura-fal-image-generation.json) + +Automated pipeline that: +1. Fetches ad copy records from Airtable +2. Validates and cleans image prompts +3. Generates images using fal.ai Flux Dev model +4. Handles retries and error logging +5. Updates Airtable with results + +**Triggers**: +- **Schedule**: Hourly check for new records +- **Webhook**: `/webhook/palmaura-generate-images` + +**Environment Variables Used**: +- `AIRTABLE_BASE_ID`, `AIRTABLE_PAT` +- `AIRTABLE_TABLE_AD_COPY` +- `FAL_API_KEY` + +See [workflows/README.md](workflows/README.md) for detailed workflow documentation. + +--- + +## πŸ› οΈ Helper Scripts + +### Airtable Operations + +```javascript +// In n8n Code node +const { fetchRecord, updateRecord } = require('./utils/n8n-helpers/airtable-ops.js'); + +const record = fetchRecord('recXXXXXXXX', $env.AIRTABLE_TABLE_AD_COPY); +updateRecord('recXXXXXXXX', $env.AIRTABLE_TABLE_AD_COPY, { + 'Image Generated': true, + 'Image URL': imageUrl +}); +``` + +### Image Generation + +```javascript +const { generateImage, validatePrompt } = require('./utils/n8n-helpers/image-generation.js'); + +const result = generateImage(prompt, { + image_size: 'landscape_16_9', + num_inference_steps: 28 +}); +``` + +### Error Handling + +```javascript +const { isRetryable, getRetryDelay } = require('./utils/n8n-helpers/error-handler.js'); + +if (isRetryable(response.statusCode)) { + const delay = getRetryDelay(attemptNumber); + await wait(delay); + // Retry logic +} +``` + +--- + +## πŸ“œ Scripts + +### Deploy Workflows + +```bash +# Deploy all workflows to n8n instance +export N8N_URL="https://your-n8n-instance.com" +export N8N_API_KEY="your_api_key" +./scripts/deploy-workflow.sh + +# Deploy specific workflow +./scripts/deploy-workflow.sh palmaura-fal-image-generation +``` + +### Backup Workflows + +```bash +# Backup workflows from n8n to git +export N8N_URL="https://your-n8n-instance.com" +export N8N_API_KEY="your_api_key" +./scripts/backup-workflows.sh +``` + +Backups are saved to: +- `workflows/` - Latest version +- `workflows/backups/` - Timestamped copies + +### Validate Configuration + +```bash +# Validate all configuration files +node scripts/validate-config.js +``` + +Checks: +- JSON syntax in all config files +- Required environment variables in .env.example +- Airtable schema completeness +- Workflow file validity + +### Sync Environment Templates + +```bash +# Sync .env.example to deployment folders +./scripts/sync-env.sh +``` + +--- + +## πŸ” Security + +**Critical**: Never commit `.env` files to git! + +The `.gitignore` is configured to prevent this, but always verify: + +```bash +# Check what will be committed +git status + +# Ensure .env files are not listed +``` + +### API Key Management + +- Store API keys in `.env` files (local development) +- Use GitHub Secrets for CI/CD (production) +- Rotate keys regularly +- Use Personal Access Tokens (PAT) for Airtable, not API keys + +--- + +## πŸ€– Automated Backups + +GitHub Actions automatically backs up workflows daily: + +**Schedule**: 2 AM UTC daily +**Manual Trigger**: `Actions` β†’ `Backup n8n Workflows` β†’ `Run workflow` + +**Setup**: +1. Add GitHub Secrets: + - `N8N_URL`: Your n8n instance URL + - `N8N_API_KEY`: Your n8n API key + +2. The workflow will automatically: + - Fetch all workflows from n8n + - Save to `workflows/` directory + - Create timestamped backups in `workflows/backups/` + - Commit and push changes + +--- + +## πŸ“š Documentation + +- **[Workflows README](workflows/README.md)**: Detailed workflow documentation +- **[Deployment README](deployment/README.md)**: Legacy deployment configurations +- **[Config Schemas](config/)**: API endpoints, Airtable schemas, prompt templates +- **[TypeScript Schemas](schemas/)**: Type definitions for all data structures + +--- + +## πŸ§ͺ Testing + +### Test Configuration + +```bash +node scripts/validate-config.js +``` + +### Test Deployment + +```bash +export N8N_URL="https://test-n8n-instance.com" +export N8N_API_KEY="test_api_key" +./scripts/deploy-workflow.sh +``` + +### Test Workflows + +1. Import workflow into n8n +2. Configure credentials +3. Use "Execute Workflow" with test data +4. Check execution logs for errors + +--- + +## 🀝 Contributing + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature/amazing-feature` +3. **Make your changes** +4. **Test thoroughly**: Run `node scripts/validate-config.js` +5. **Commit your changes**: `git commit -m 'feat: add amazing feature'` +6. **Push to the branch**: `git push origin feature/amazing-feature` +7. **Open a Pull Request** + +--- + +## πŸ“ License + +This project uses configurations from [n8n-io/n8n-hosting](https://github.com/n8n-io/n8n-hosting) (Apache 2.0). + +Custom workflows and utilities: Proprietary + +--- + +## πŸ†˜ Support + +### Common Issues + +**"Missing Airtable credentials"** +- Ensure `.env` file exists in `config/` directory +- Verify `AIRTABLE_PAT` and `AIRTABLE_BASE_ID` are set + +**"Workflow deployment failed"** +- Check `N8N_URL` and `N8N_API_KEY` environment variables +- Verify n8n instance is accessible +- Check n8n API key has correct permissions + +**"Image generation timeout"** +- fal.ai requests can take 30-120 seconds +- Increase timeout in workflow settings +- Check fal.ai API key and rate limits + +### Getting Help + +- Check the [workflows/README.md](workflows/README.md) for workflow-specific help +- Review logs in n8n UI: Executions β†’ View Details +- Validate configuration: `node scripts/validate-config.js` + +--- + +## πŸ™ Acknowledgments + +- [n8n](https://n8n.io/) - Workflow automation platform +- [fal.ai](https://fal.ai/) - AI image generation +- [Airtable](https://airtable.com/) - Database and workflow management +- [Bannerbear](https://www.bannerbear.com/) - Image overlay templates + +--- + +**Built with ❀️ for AdScaler / PalmAura** diff --git a/clerk-nextjs-app/.gitignore b/clerk-nextjs-app/.gitignore new file mode 100644 index 00000000..fd418f1a --- /dev/null +++ b/clerk-nextjs-app/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules +.pnp +.pnp.js +.yarn/install-state.gz + +# Testing +coverage + +# Next.js +.next/ +out/ + +# Production +build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Env files +.env*.local + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/clerk-nextjs-app/README.md b/clerk-nextjs-app/README.md new file mode 100644 index 00000000..cc5e78ce --- /dev/null +++ b/clerk-nextjs-app/README.md @@ -0,0 +1,113 @@ +# Clerk + Next.js App Router + +A Next.js application with Clerk authentication pre-configured using the App Router architecture. + +## Features + +- **Next.js 15** with App Router +- **Clerk Authentication** with keyless mode +- **TypeScript** for type safety +- **Modern UI** with custom CSS + +## Getting Started + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Run the Development Server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +### 3. Sign Up / Sign In + +Click the **Sign Up** or **Sign In** button in the header. Clerk handles authentication automatically with **keyless mode**β€”no API keys or Clerk account required to start! + +### 4. Claim Your Application (Optional) + +When you're ready for production, click **"Claim your application"** in the bottom-right corner of your app to link it to your Clerk account and access the full Dashboard. + +## Project Structure + +``` +clerk-nextjs-app/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ app/ +β”‚ β”‚ β”œβ”€β”€ layout.tsx # Root layout with ClerkProvider +β”‚ β”‚ β”œβ”€β”€ page.tsx # Homepage +β”‚ β”‚ └── globals.css # Global styles +β”‚ └── proxy.ts # Clerk middleware +β”œβ”€β”€ public/ +β”œβ”€β”€ package.json +β”œβ”€β”€ tsconfig.json +β”œβ”€β”€ next.config.ts +└── README.md +``` + +## Key Files + +### `src/proxy.ts` + +The middleware file that enables Clerk authentication across your app: + +```typescript +import { clerkMiddleware } from '@clerk/nextjs/server' + +export default clerkMiddleware() + +export const config = { + matcher: [ + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + '/(api|trpc)(.*)', + ], +} +``` + +### `src/app/layout.tsx` + +The root layout wraps your app with `` and includes authentication components: + +```typescript +import { ClerkProvider, SignInButton, SignUpButton, SignedIn, SignedOut, UserButton } from '@clerk/nextjs' +``` + +## Protecting Routes + +To protect specific routes, modify the middleware: + +```typescript +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' + +const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/profile(.*)']) + +export default clerkMiddleware(async (auth, req) => { + if (isProtectedRoute(req)) { + await auth.protect() + } +}) +``` + +## Environment Variables (Optional) + +For production, you'll need Clerk API keys. After claiming your application: + +```env +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_*** +CLERK_SECRET_KEY=sk_*** +``` + +## Learn More + +- [Clerk Documentation](https://clerk.com/docs) +- [Next.js Documentation](https://nextjs.org/docs) +- [Clerk + Next.js Quickstart](https://clerk.com/docs/quickstarts/nextjs) + +## License + +MIT diff --git a/clerk-nextjs-app/next.config.ts b/clerk-nextjs-app/next.config.ts new file mode 100644 index 00000000..73290639 --- /dev/null +++ b/clerk-nextjs-app/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + /* config options here */ +} + +export default nextConfig diff --git a/clerk-nextjs-app/package.json b/clerk-nextjs-app/package.json new file mode 100644 index 00000000..14dae323 --- /dev/null +++ b/clerk-nextjs-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "clerk-nextjs-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@clerk/nextjs": "^6.12.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/clerk-nextjs-app/src/app/globals.css b/clerk-nextjs-app/src/app/globals.css new file mode 100644 index 00000000..a26162c6 --- /dev/null +++ b/clerk-nextjs-app/src/app/globals.css @@ -0,0 +1,231 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #6c47ff; + --primary-hover: #5a3dd6; + --secondary: #f1f5f9; + --secondary-hover: #e2e8f0; + --text-primary: #1e293b; + --text-secondary: #64748b; + --background: #ffffff; + --surface: #f8fafc; + --border: #e2e8f0; + --success: #10b981; + --success-bg: #ecfdf5; +} + +body { + font-family: + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + sans-serif; + background: var(--background); + color: var(--text-primary); + line-height: 1.6; +} + +/* Header */ +.header { + background: var(--background); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.nav { + max-width: 1200px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + text-decoration: none; +} + +.auth-buttons { + display: flex; + gap: 0.75rem; + align-items: center; +} + +/* Buttons */ +.btn { + padding: 0.625rem 1.25rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border: none; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--secondary-hover); +} + +/* Main Content */ +.main { + min-height: calc(100vh - 65px); +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 3rem 2rem; +} + +/* Hero Section */ +.hero { + text-align: center; + padding: 3rem 0; +} + +.title { + font-size: 3rem; + font-weight: 800; + margin-bottom: 1rem; + line-height: 1.2; +} + +.highlight { + color: var(--primary); +} + +.subtitle { + font-size: 1.25rem; + color: var(--text-secondary); + margin-bottom: 2.5rem; +} + +/* Cards */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 1rem; + padding: 2rem; + max-width: 600px; + margin: 0 auto; + text-align: left; +} + +.card h2 { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.card p { + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.card.success { + background: var(--success-bg); + border-color: var(--success); +} + +.card.success h2 { + color: var(--success); +} + +.features { + list-style: none; + margin-top: 1.5rem; +} + +.features li { + padding: 0.5rem 0; + padding-left: 1.5rem; + position: relative; + color: var(--text-secondary); +} + +.features li::before { + content: 'βœ“'; + position: absolute; + left: 0; + color: var(--primary); + font-weight: 600; +} + +/* Info Section */ +.info { + padding: 4rem 0; +} + +.info h2 { + text-align: center; + font-size: 2rem; + margin-bottom: 2.5rem; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.info-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.75rem; + padding: 1.5rem; +} + +.info-card h3 { + font-size: 1.125rem; + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +.info-card p { + color: var(--text-secondary); + font-size: 0.9375rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .title { + font-size: 2rem; + } + + .subtitle { + font-size: 1rem; + } + + .nav { + padding: 1rem; + } + + .container { + padding: 2rem 1rem; + } +} diff --git a/clerk-nextjs-app/src/app/layout.tsx b/clerk-nextjs-app/src/app/layout.tsx new file mode 100644 index 00000000..7fe87074 --- /dev/null +++ b/clerk-nextjs-app/src/app/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next' +import { + ClerkProvider, + SignInButton, + SignUpButton, + SignedIn, + SignedOut, + UserButton, +} from '@clerk/nextjs' +import './globals.css' + +export const metadata: Metadata = { + title: 'Clerk Next.js App', + description: 'Next.js App Router with Clerk authentication', +} + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + +
+ +
+
{children}
+ + +
+ ) +} diff --git a/clerk-nextjs-app/src/app/page.tsx b/clerk-nextjs-app/src/app/page.tsx new file mode 100644 index 00000000..9efdd905 --- /dev/null +++ b/clerk-nextjs-app/src/app/page.tsx @@ -0,0 +1,73 @@ +import { SignedIn, SignedOut } from '@clerk/nextjs' + +export default function Home() { + return ( +
+
+

+ Welcome to Clerk + Next.js +

+

+ A modern authentication solution with keyless development mode +

+ + +
+

Get Started

+

+ Click Sign Up or Sign In in the + header to authenticate. Clerk handles everything automatically + with keyless modeβ€”no API keys required to start developing! +

+
    +
  • Zero configuration required
  • +
  • Instant authentication setup
  • +
  • Claim your application when ready
  • +
+
+
+ + +
+

You're Signed In!

+

+ Congratulations! You've successfully authenticated with + Clerk. Your user session is now active. +

+

+ Click your avatar in the header to manage your account or sign + out. +

+
+
+
+ +
+

About This Template

+
+
+

App Router

+

+ Built with Next.js App Router architecture using the latest React + Server Components. +

+
+
+

Clerk Authentication

+

+ Pre-configured with Clerk for secure, modern authentication out of + the box. +

+
+
+

Keyless Mode

+

+ Start developing immediately. Claim your application when + you're ready for production. +

+
+
+
+
+ ) +} diff --git a/clerk-nextjs-app/src/proxy.ts b/clerk-nextjs-app/src/proxy.ts new file mode 100644 index 00000000..6f5aa9bd --- /dev/null +++ b/clerk-nextjs-app/src/proxy.ts @@ -0,0 +1,12 @@ +import { clerkMiddleware } from '@clerk/nextjs/server' + +export default clerkMiddleware() + +export const config = { + matcher: [ + // Skip Next.js internals and all static files, unless found in search params + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + // Always run for API routes + '/(api|trpc)(.*)', + ], +} diff --git a/clerk-nextjs-app/tsconfig.json b/clerk-nextjs-app/tsconfig.json new file mode 100644 index 00000000..c1334095 --- /dev/null +++ b/clerk-nextjs-app/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 00000000..7fe81cff --- /dev/null +++ b/config/.env.example @@ -0,0 +1,153 @@ +# ============================================== +# Cyclone-S5 Environment Configuration +# ============================================== +# Copy this file to .env and update with your actual values +# NEVER commit .env files to git + +# ============================================== +# n8n Configuration +# ============================================== +N8N_BASIC_AUTH_ACTIVE=true +N8N_BASIC_AUTH_USER=admin +N8N_BASIC_AUTH_PASSWORD=change_this_password + +N8N_HOST=0.0.0.0 +N8N_PORT=5678 +N8N_PROTOCOL=https +N8N_PATH=/ + +# Webhook URL (update with your domain) +WEBHOOK_URL=https://your-domain.com + +# Timezone (used by Cron nodes) +GENERIC_TIMEZONE=America/New_York + +# Encryption key for n8n data (generate with: openssl rand -base64 32) +N8N_ENCRYPTION_KEY=your_random_encryption_key_32_chars_minimum + +# Execution process (main or own) +EXECUTIONS_PROCESS=main + +# ============================================== +# Database Configuration (PostgreSQL) +# ============================================== +POSTGRES_USER=n8n_user +POSTGRES_PASSWORD=change_this_secure_password +POSTGRES_DB=n8n + +POSTGRES_NON_ROOT_USER=n8n_user +POSTGRES_NON_ROOT_PASSWORD=change_this_secure_password + +DB_TYPE=postgresdb +DB_POSTGRESDB_HOST=postgres +DB_POSTGRESDB_PORT=5432 +DB_POSTGRESDB_DATABASE=n8n +DB_POSTGRESDB_USER=n8n_user +DB_POSTGRESDB_PASSWORD=change_this_secure_password + +# ============================================== +# Airtable Configuration +# ============================================== +# Get your Personal Access Token from: https://airtable.com/create/tokens +AIRTABLE_PAT=patXXXXXXXXXXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Base ID (find in Airtable URL or API documentation) +AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX + +# Table IDs (find in Airtable URL when viewing each table) +AIRTABLE_TABLE_AD_COPY=tblAdCopyXXXXXXXX +AIRTABLE_TABLE_IMAGES=tblImagesXXXXXXXX +AIRTABLE_TABLE_ACTORS=tblActorsXXXXXXXX +AIRTABLE_TABLE_PRODUCTS=tblProductsXXXXXXXX +AIRTABLE_TABLE_SCENES=tblScenesXXXXXXXX + +# ============================================== +# AI & Image Generation APIs +# ============================================== + +# fal.ai (Image Generation with Flux models) +# Get API key from: https://fal.ai/dashboard/keys +FAL_API_KEY=your_fal_api_key_here + +# Bannerbear (Image Overlays and Templates) +# Get API key from: https://app.bannerbear.com/account/settings +BANNERBEAR_API_KEY=your_bannerbear_api_key_here + +# Bannerbear Template UIDs (create templates in Bannerbear dashboard) +BANNERBEAR_TEMPLATE_SQUARE=template_square_uid +BANNERBEAR_TEMPLATE_STORY=template_story_uid +BANNERBEAR_TEMPLATE_LANDSCAPE=template_landscape_uid + +# OpenAI (for prompt generation and AI assistance) +# Get API key from: https://platform.openai.com/api-keys +OPENAI_API_KEY=sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# ============================================== +# AWS S3 (Optional - for image storage) +# ============================================== +AWS_ACCESS_KEY_ID=your_aws_access_key +AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key +AWS_S3_BUCKET=your-bucket-name +AWS_REGION=us-east-1 + +# ============================================== +# Docker Caddy Deployment (for SSL/HTTPS) +# ============================================== +# Path where n8n data will be stored +DATA_FOLDER=/path/to/n8n-docker-caddy + +# Your domain configuration +DOMAIN_NAME=example.com +SUBDOMAIN=n8n +# Results in: https://n8n.example.com + +# Email for Let's Encrypt SSL certificate +SSL_EMAIL=your-email@example.com + +# ============================================== +# Workflow Settings +# ============================================== +# Batch processing configuration +MAX_BATCH_SIZE=5 +RETRY_MAX_ATTEMPTS=3 +RETRY_DELAY_SECONDS=5 + +# Image generation defaults +DEFAULT_IMAGE_SIZE=landscape_16_9 +DEFAULT_INFERENCE_STEPS=28 +DEFAULT_GUIDANCE_SCALE=3.5 +DEFAULT_NUM_IMAGES=1 + +# Rate limiting (milliseconds between API calls) +RATE_LIMIT_DELAY_MS=1000 + +# ============================================== +# Development & Testing +# ============================================== +# Set to true for development mode +NODE_ENV=production + +# n8n API credentials (for backup/deploy scripts) +N8N_API_KEY=your_n8n_api_key_here +N8N_URL=https://your-n8n-instance.com + +# ============================================== +# Logging +# ============================================== +# Log level: error, warn, info, verbose, debug +N8N_LOG_LEVEL=info +N8N_LOG_OUTPUT=console,file + +# ============================================== +# Security +# ============================================== +# JWT secret for n8n (generate with: openssl rand -hex 32) +N8N_JWT_AUTH_SECRET=your_jwt_secret_here + +# SMTP Configuration (for email notifications) +N8N_EMAIL_MODE=smtp +N8N_SMTP_HOST=smtp.example.com +N8N_SMTP_PORT=587 +N8N_SMTP_USER=your-email@example.com +N8N_SMTP_PASS=your-smtp-password +N8N_SMTP_SENDER=noreply@example.com diff --git a/config/airtable-schemas.json b/config/airtable-schemas.json new file mode 100644 index 00000000..08ba1162 --- /dev/null +++ b/config/airtable-schemas.json @@ -0,0 +1,293 @@ +{ + "version": "1.0.0", + "description": "Airtable schema definitions for Cyclone-S5 workflows", + "baseIdEnvVar": "AIRTABLE_BASE_ID", + "tables": { + "adCopy": { + "name": "Ad Copy", + "tableIdEnvVar": "AIRTABLE_TABLE_AD_COPY", + "description": "Store ad concepts, prompts, and image generation status", + "fields": { + "recordId": { + "type": "singleLineText", + "description": "Auto-generated record ID" + }, + "fullConcept": { + "type": "longText", + "fieldName": "Full Concept", + "description": "Complete ad concept description", + "required": true + }, + "avatarTarget": { + "type": "singleLineText", + "fieldName": "Avatar Target", + "description": "Target audience persona" + }, + "angle": { + "type": "singleLineText", + "fieldName": "Angle", + "description": "Marketing angle" + }, + "headline": { + "type": "singleLineText", + "fieldName": "Headline", + "description": "Ad headline" + }, + "cta": { + "type": "singleLineText", + "fieldName": "CTA", + "description": "Call to action text" + }, + "product": { + "type": "linkedRecord", + "fieldName": "Product", + "linkedTable": "Products", + "description": "Link to product record" + }, + "generateImagePrompts": { + "type": "singleSelect", + "fieldName": "Generate Image Prompts", + "options": ["Not Started", "In Progress", "Done"], + "description": "Status of image prompt generation" + }, + "prompt1": { + "type": "longText", + "fieldName": "Prompt 1", + "description": "First image prompt variation" + }, + "prompt2": { + "type": "longText", + "fieldName": "Prompt 2", + "description": "Second image prompt variation" + }, + "prompt3": { + "type": "longText", + "fieldName": "Prompt 3", + "description": "Third image prompt variation" + }, + "imagePrompts": { + "type": "multilineText", + "fieldName": "Image Prompts", + "description": "Alternative: Array of prompts as multiline text" + }, + "imageGenerated": { + "type": "checkbox", + "fieldName": "Image Generated", + "description": "Has image been generated successfully?" + }, + "imageGenTimestamp": { + "type": "dateTime", + "fieldName": "Image Gen Timestamp", + "description": "When image was successfully generated" + }, + "imageSeed": { + "type": "number", + "fieldName": "Image Seed", + "description": "AI model seed for reproducibility" + }, + "imageGenError": { + "type": "longText", + "fieldName": "Image Gen Error", + "description": "Error message if generation failed" + }, + "imageGenFailedAt": { + "type": "dateTime", + "fieldName": "Image Gen Failed At", + "description": "When generation failed" + }, + "imageGenAttempts": { + "type": "number", + "fieldName": "Image Gen Attempts", + "description": "Number of generation attempts" + }, + "baseImageUrl": { + "type": "url", + "fieldName": "Base Image URL", + "description": "URL of base generated image" + }, + "finalImageUrl": { + "type": "url", + "fieldName": "Final Image URL", + "description": "URL of final image with overlays" + }, + "status": { + "type": "singleSelect", + "fieldName": "Status", + "options": ["Pending", "Generated", "Failed"], + "description": "Overall generation status" + }, + "model": { + "type": "singleLineText", + "fieldName": "Model", + "description": "AI model used (e.g., fal-ai/flux/dev)" + }, + "width": { + "type": "number", + "fieldName": "Width", + "description": "Image width in pixels" + }, + "height": { + "type": "number", + "fieldName": "Height", + "description": "Image height in pixels" + } + } + }, + "images": { + "name": "Images", + "tableIdEnvVar": "AIRTABLE_TABLE_IMAGES", + "description": "Store generated image metadata", + "fields": { + "name": { + "type": "singleLineText", + "fieldName": "Name", + "description": "Image identifier", + "required": true + }, + "imageUrl": { + "type": "url", + "fieldName": "Image URL", + "description": "URL of generated image", + "required": true + }, + "sourceRecord": { + "type": "linkedRecord", + "fieldName": "Source Record", + "linkedTable": "Ad Copy", + "description": "Link to source ad copy record" + }, + "promptIndex": { + "type": "number", + "fieldName": "Prompt Index", + "description": "Which prompt was used (1-3)" + }, + "seed": { + "type": "number", + "fieldName": "Seed", + "description": "AI model seed" + }, + "model": { + "type": "singleLineText", + "fieldName": "Model", + "description": "AI model used" + }, + "generatedAt": { + "type": "dateTime", + "fieldName": "Generated At", + "description": "Generation timestamp" + }, + "width": { + "type": "number", + "fieldName": "Width", + "description": "Image width in pixels" + }, + "height": { + "type": "number", + "fieldName": "Height", + "description": "Image height in pixels" + } + } + }, + "actors": { + "name": "Actors", + "tableIdEnvVar": "AIRTABLE_TABLE_ACTORS", + "description": "Store character/person descriptions for prompts", + "fields": { + "name": { + "type": "singleLineText", + "fieldName": "Name", + "description": "Actor name or identifier", + "required": true + }, + "demographics": { + "type": "singleLineText", + "fieldName": "Demographics", + "description": "Age, gender, ethnicity" + }, + "description": { + "type": "longText", + "fieldName": "Description", + "description": "Detailed appearance description" + }, + "referenceImages": { + "type": "multipleAttachments", + "fieldName": "Reference Images", + "description": "Example images of this actor" + } + } + }, + "products": { + "name": "Products", + "tableIdEnvVar": "AIRTABLE_TABLE_PRODUCTS", + "description": "Store product information", + "fields": { + "name": { + "type": "singleLineText", + "fieldName": "Name", + "description": "Product name", + "required": true + }, + "description": { + "type": "longText", + "fieldName": "Description", + "description": "Product details and features" + }, + "category": { + "type": "singleSelect", + "fieldName": "Category", + "options": ["Electronics", "Fashion", "Home", "Beauty", "Food", "Other"], + "description": "Product category" + }, + "productImages": { + "type": "multipleAttachments", + "fieldName": "Product Images", + "description": "Product photos" + } + } + }, + "scenes": { + "name": "Scenes", + "tableIdEnvVar": "AIRTABLE_TABLE_SCENES", + "description": "Store scene/environment descriptions", + "fields": { + "name": { + "type": "singleLineText", + "fieldName": "Name", + "description": "Scene name", + "required": true + }, + "description": { + "type": "longText", + "fieldName": "Description", + "description": "Detailed scene description" + }, + "lighting": { + "type": "singleLineText", + "fieldName": "Lighting", + "description": "Lighting description (e.g., golden hour, studio, natural)" + }, + "mood": { + "type": "singleLineText", + "fieldName": "Mood", + "description": "Mood or atmosphere (e.g., energetic, calm, professional)" + } + } + } + }, + "relationships": { + "adCopyToProduct": { + "from": "adCopy.product", + "to": "products", + "type": "manyToOne" + }, + "imagesToAdCopy": { + "from": "images.sourceRecord", + "to": "adCopy", + "type": "manyToOne" + } + }, + "usage": { + "accessingFields": "Use the 'fieldName' property to access fields in Airtable API calls", + "example": "To access 'Full Concept' field: record.fields['Full Concept']" + } +} diff --git a/config/api-endpoints.json b/config/api-endpoints.json new file mode 100644 index 00000000..236edd2f --- /dev/null +++ b/config/api-endpoints.json @@ -0,0 +1,200 @@ +{ + "version": "1.0.0", + "description": "API endpoint configurations for Cyclone-S5 workflows", + "apis": { + "fal": { + "name": "fal.ai Image Generation", + "baseUrl": "https://fal.run", + "apiKeyEnvVar": "FAL_API_KEY", + "authHeader": "Authorization", + "authFormat": "Key {apiKey}", + "endpoints": { + "fluxDev": { + "path": "/fal-ai/flux/dev", + "method": "POST", + "description": "Flux Dev model - balanced quality and speed" + }, + "fluxSchnell": { + "path": "/fal-ai/flux/schnell", + "method": "POST", + "description": "Flux Schnell model - fastest generation" + }, + "fluxPro": { + "path": "/fal-ai/flux-pro", + "method": "POST", + "description": "Flux Pro model - highest quality" + } + }, + "defaultModel": "fluxDev", + "defaultParams": { + "image_size": "landscape_16_9", + "num_inference_steps": 28, + "guidance_scale": 3.5, + "num_images": 1, + "enable_safety_checker": false, + "output_format": "jpeg" + }, + "imageSizes": { + "square_hd": "1024x1024", + "square": "512x512", + "portrait_4_3": "768x1024", + "portrait_16_9": "768x1344", + "landscape_4_3": "1024x768", + "landscape_16_9": "1344x768" + }, + "rateLimit": { + "requestsPerMinute": 60, + "retryableStatusCodes": [429, 500, 502, 503, 504], + "maxRetries": 3, + "retryDelay": 5000 + }, + "timeout": 120000 + }, + "bannerbear": { + "name": "Bannerbear Image Overlays", + "baseUrl": "https://api.bannerbear.com/v2", + "apiKeyEnvVar": "BANNERBEAR_API_KEY", + "authHeader": "Authorization", + "authFormat": "Bearer {apiKey}", + "endpoints": { + "createImage": { + "path": "/images", + "method": "POST", + "description": "Create image from template" + }, + "getImage": { + "path": "/images/{uid}", + "method": "GET", + "description": "Get image status and URL" + }, + "listTemplates": { + "path": "/templates", + "method": "GET", + "description": "List all templates" + } + }, + "pollingConfig": { + "maxAttempts": 30, + "intervalSeconds": 2, + "statuses": { + "pending": "pending", + "completed": "completed", + "failed": "failed" + } + }, + "templates": { + "square": { + "envVar": "BANNERBEAR_TEMPLATE_SQUARE", + "format": "1080x1080", + "description": "Instagram square post" + }, + "story": { + "envVar": "BANNERBEAR_TEMPLATE_STORY", + "format": "1080x1920", + "description": "Instagram story" + }, + "landscape": { + "envVar": "BANNERBEAR_TEMPLATE_LANDSCAPE", + "format": "1200x628", + "description": "Facebook/Twitter landscape" + } + }, + "rateLimit": { + "requestsPerMinute": 60, + "retryableStatusCodes": [429, 500, 502, 503], + "maxRetries": 3, + "retryDelay": 2000 + }, + "timeout": 60000 + }, + "airtable": { + "name": "Airtable API", + "baseUrl": "https://api.airtable.com/v0", + "apiKeyEnvVar": "AIRTABLE_PAT", + "authHeader": "Authorization", + "authFormat": "Bearer {apiKey}", + "endpoints": { + "getRecord": { + "path": "/{baseId}/{tableId}/{recordId}", + "method": "GET", + "description": "Retrieve a single record" + }, + "updateRecord": { + "path": "/{baseId}/{tableId}/{recordId}", + "method": "PATCH", + "description": "Update a record" + }, + "createRecord": { + "path": "/{baseId}/{tableId}", + "method": "POST", + "description": "Create a new record" + }, + "listRecords": { + "path": "/{baseId}/{tableId}", + "method": "GET", + "description": "List records with optional filters" + }, + "deleteRecord": { + "path": "/{baseId}/{tableId}/{recordId}", + "method": "DELETE", + "description": "Delete a record" + } + }, + "queryParams": { + "filterByFormula": "Airtable formula to filter records", + "maxRecords": "Maximum number of records to return", + "pageSize": "Number of records per page (max 100)", + "sort": "Array of sort objects", + "view": "Name of view to use", + "fields": "Array of field names to return" + }, + "rateLimit": { + "requestsPerSecond": 5, + "retryableStatusCodes": [429, 500, 502, 503], + "maxRetries": 3, + "retryDelay": 30000 + }, + "timeout": 30000 + }, + "openai": { + "name": "OpenAI API", + "baseUrl": "https://api.openai.com/v1", + "apiKeyEnvVar": "OPENAI_API_KEY", + "authHeader": "Authorization", + "authFormat": "Bearer {apiKey}", + "endpoints": { + "chatCompletion": { + "path": "/chat/completions", + "method": "POST", + "description": "Create chat completion" + }, + "embeddings": { + "path": "/embeddings", + "method": "POST", + "description": "Create embeddings" + } + }, + "defaultModel": "gpt-4-turbo-preview", + "defaultParams": { + "model": "gpt-4-turbo-preview", + "temperature": 0.7, + "max_tokens": 1000 + }, + "rateLimit": { + "requestsPerMinute": 60, + "tokensPerMinute": 90000, + "retryableStatusCodes": [429, 500, 502, 503], + "maxRetries": 3, + "retryDelay": 20000 + }, + "timeout": 60000 + } + }, + "usage": { + "description": "This file centralizes all API configurations for easy management and updates", + "examples": { + "usingFal": "Import defaultParams from apis.fal.defaultParams and merge with custom params", + "checkingRateLimit": "Use apis.fal.rateLimit.retryableStatusCodes to determine if error is retryable" + } + } +} diff --git a/config/prompt-templates.json b/config/prompt-templates.json new file mode 100644 index 00000000..e0fc3778 --- /dev/null +++ b/config/prompt-templates.json @@ -0,0 +1,161 @@ +{ + "version": "1.0.0", + "description": "Reusable prompt templates for AI image generation and text processing", + "templates": { + "imageGeneration": { + "description": "Templates for AI image generation with Flux models", + "base": { + "suffix": ", clean composition, no text, no watermark, no logo, professional photography", + "description": "Append to all prompts for consistent quality" + }, + "negativePrompt": { + "default": "text, words, letters, watermark, logo, signature, writing, captions, subtitles, blurry, low quality, distorted, deformed", + "description": "Things to avoid in generated images" + }, + "styleModifiers": { + "photorealistic": { + "suffix": "photorealistic, highly detailed, 8k resolution, studio lighting, professional photography", + "description": "For realistic product photos and portraits" + }, + "artistic": { + "suffix": "artistic rendering, creative composition, vibrant colors, aesthetic", + "description": "For creative and stylized images" + }, + "minimal": { + "suffix": "minimal design, clean aesthetic, simple composition, negative space", + "description": "For minimalist product shots" + }, + "cinematic": { + "suffix": "cinematic composition, dramatic lighting, film grain, bokeh, depth of field", + "description": "For storytelling images" + }, + "lifestyle": { + "suffix": "lifestyle photography, natural lighting, authentic moment, candid", + "description": "For lifestyle and use-case images" + } + }, + "qualityEnhancers": { + "high": "ultra-detailed, sharp focus, high resolution, professional quality", + "medium": "detailed, good quality, clear", + "quick": "clean, simple" + }, + "lightingPresets": { + "goldenHour": "golden hour lighting, warm tones, soft shadows, sunset glow", + "studio": "studio lighting, soft box, even illumination, professional setup", + "natural": "natural daylight, window light, soft ambient", + "dramatic": "dramatic lighting, strong shadows, high contrast, moody", + "softDiffused": "soft diffused lighting, gentle shadows, flattering" + }, + "compositionPresets": { + "centerFocus": "centered composition, subject in focus, balanced frame", + "ruleOfThirds": "rule of thirds composition, dynamic framing", + "closeUp": "close-up shot, detailed view, shallow depth of field", + "wideAngle": "wide angle shot, environmental context, expansive view" + } + }, + "promptGeneration": { + "description": "Templates for generating image prompts using LLMs", + "system": "You are an expert at creating detailed image generation prompts for AI models like Flux and Stable Diffusion. Create vivid, specific prompts that describe scenes, lighting, composition, and mood. Focus on visual elements that can be rendered by an AI. Avoid abstract concepts. Be specific about colors, textures, and spatial relationships.", + "userTemplate": "Based on this ad concept:\n\n{fullConcept}\n\nTarget audience: {avatarTarget}\nMarketing angle: {angle}\nProduct: {product}\n\nCreate {numVariations} different image prompt variations that would work well for social media ads. Each prompt should:\n- Be 1-2 sentences long\n- Be highly specific and visual\n- Include lighting and mood\n- Be optimized for Flux AI image generation\n- Avoid any text, logos, or watermarks in the image\n\nReturn only the prompts, numbered 1-{numVariations}.", + "defaultNumVariations": 3, + "examples": [ + { + "concept": "Serene beach yoga promoting wellness app", + "prompt": "Professional photo of young woman in yoga pose on beach at golden hour, serene expression, turquoise ocean in background, soft warm lighting, peaceful atmosphere, high detail" + }, + { + "concept": "Energetic product launch for sports drink", + "prompt": "Dynamic action shot of athlete mid-motion, vibrant sports drink bottle in foreground, stadium lights, dramatic lighting, high energy, motion blur effect, professional sports photography" + } + ] + }, + "adCopyAnalysis": { + "description": "Templates for analyzing ad copy with LLMs", + "system": "You are an expert ad copywriter and marketing analyst. Analyze ad copy and extract key components with precision and clarity.", + "userTemplate": "Analyze this ad copy and extract the following components:\n\n{adCopy}\n\nPlease identify:\n1. Headline: The main attention-grabbing statement\n2. Call-to-Action (CTA): The action you want the reader to take\n3. Value Proposition: The main benefit or value offered\n4. Emotional Hooks: The emotional triggers used (fear, desire, urgency, etc.)\n5. Target Audience: Who this ad is aimed at\n\nReturn as structured JSON.", + "outputFormat": { + "headline": "string", + "cta": "string", + "valueProposition": "string", + "emotionalHooks": ["array of strings"], + "targetAudience": "string" + } + }, + "promptEnhancement": { + "description": "Templates for enhancing existing prompts", + "enhanceForQuality": { + "template": "{originalPrompt}, {qualityModifiers}", + "qualityModifiers": "professional photography, highly detailed, sharp focus, studio quality, 8k resolution" + }, + "enhanceForStyle": { + "template": "{originalPrompt}, {styleModifiers}", + "styleOptions": "Choose from: photorealistic, artistic, minimal, cinematic, lifestyle" + }, + "addContext": { + "template": "{subjectDescription}, {environment}, {lighting}, {mood}, {cameraAngle}", + "description": "Build complete prompt from components" + } + }, + "promptCleaning": { + "description": "Patterns for cleaning and validating prompts", + "removePatterns": [ + "\\n+", + "\\s+", + "[\"']", + "watermark", + "logo", + "text", + "writing" + ], + "replacePatterns": { + "multiple_spaces": { + "pattern": "\\s+", + "replacement": " " + }, + "newlines": { + "pattern": "\\n+", + "replacement": " " + }, + "quotes": { + "pattern": "[\"']", + "replacement": "" + } + }, + "validation": { + "minLength": 10, + "maxLength": 2000, + "required": ["subject", "style or mood"], + "forbidden": ["nsfw", "explicit", "graphic"] + } + }, + "variationGeneration": { + "description": "Templates for creating prompt variations", + "strategies": { + "angleVariation": { + "description": "Change camera angle", + "variations": ["aerial view", "close-up", "wide shot", "eye level", "low angle", "high angle"] + }, + "lightingVariation": { + "description": "Change lighting style", + "variations": ["golden hour", "studio lighting", "natural light", "dramatic lighting", "soft diffused"] + }, + "moodVariation": { + "description": "Change overall mood", + "variations": ["energetic", "calm", "professional", "playful", "elegant", "modern"] + }, + "styleVariation": { + "description": "Change visual style", + "variations": ["photorealistic", "artistic", "minimal", "cinematic", "lifestyle"] + } + } + } + }, + "usage": { + "description": "Import templates and merge with dynamic data", + "examples": { + "basicUsage": "const baseTemplate = templates.imageGeneration.base.suffix; const fullPrompt = `${userPrompt}${baseTemplate}`;", + "withStyle": "const styleModifier = templates.imageGeneration.styleModifiers.photorealistic.suffix; const fullPrompt = `${userPrompt}, ${styleModifier}`;", + "promptGeneration": "Use templates.promptGeneration.userTemplate and replace {placeholders} with actual data" + } + } +} diff --git a/docker-caddy/LICENSE b/deployment/docker-caddy/LICENSE similarity index 100% rename from docker-caddy/LICENSE rename to deployment/docker-caddy/LICENSE diff --git a/docker-caddy/README.md b/deployment/docker-caddy/README.md similarity index 100% rename from docker-caddy/README.md rename to deployment/docker-caddy/README.md diff --git a/docker-caddy/caddy_config/.gitkeep b/deployment/docker-caddy/caddy_config/.gitkeep similarity index 100% rename from docker-caddy/caddy_config/.gitkeep rename to deployment/docker-caddy/caddy_config/.gitkeep diff --git a/docker-caddy/caddy_config/Caddyfile b/deployment/docker-caddy/caddy_config/Caddyfile similarity index 100% rename from docker-caddy/caddy_config/Caddyfile rename to deployment/docker-caddy/caddy_config/Caddyfile diff --git a/docker-caddy/docker-compose.yml b/deployment/docker-caddy/docker-compose.yml similarity index 100% rename from docker-caddy/docker-compose.yml rename to deployment/docker-caddy/docker-compose.yml diff --git a/docker-caddy/local_files/.gitkeep b/deployment/docker-caddy/local_files/.gitkeep similarity index 100% rename from docker-caddy/local_files/.gitkeep rename to deployment/docker-caddy/local_files/.gitkeep diff --git a/docker-compose/subfolderWithSSL/README.md b/deployment/docker-compose/subfolderWithSSL/README.md similarity index 100% rename from docker-compose/subfolderWithSSL/README.md rename to deployment/docker-compose/subfolderWithSSL/README.md diff --git a/docker-compose/subfolderWithSSL/docker-compose.yml b/deployment/docker-compose/subfolderWithSSL/docker-compose.yml similarity index 100% rename from docker-compose/subfolderWithSSL/docker-compose.yml rename to deployment/docker-compose/subfolderWithSSL/docker-compose.yml diff --git a/docker-compose/withPostgres/README.md b/deployment/docker-compose/withPostgres/README.md similarity index 100% rename from docker-compose/withPostgres/README.md rename to deployment/docker-compose/withPostgres/README.md diff --git a/docker-compose/withPostgres/docker-compose.yml b/deployment/docker-compose/withPostgres/docker-compose.yml similarity index 100% rename from docker-compose/withPostgres/docker-compose.yml rename to deployment/docker-compose/withPostgres/docker-compose.yml diff --git a/docker-compose/withPostgres/init-data.sh b/deployment/docker-compose/withPostgres/init-data.sh old mode 100755 new mode 100644 similarity index 100% rename from docker-compose/withPostgres/init-data.sh rename to deployment/docker-compose/withPostgres/init-data.sh diff --git a/docker-compose/withPostgresAndWorker/README.md b/deployment/docker-compose/withPostgresAndWorker/README.md similarity index 100% rename from docker-compose/withPostgresAndWorker/README.md rename to deployment/docker-compose/withPostgresAndWorker/README.md diff --git a/docker-compose/withPostgresAndWorker/docker-compose.yml b/deployment/docker-compose/withPostgresAndWorker/docker-compose.yml similarity index 100% rename from docker-compose/withPostgresAndWorker/docker-compose.yml rename to deployment/docker-compose/withPostgresAndWorker/docker-compose.yml diff --git a/docker-compose/withPostgresAndWorker/init-data.sh b/deployment/docker-compose/withPostgresAndWorker/init-data.sh old mode 100755 new mode 100644 similarity index 100% rename from docker-compose/withPostgresAndWorker/init-data.sh rename to deployment/docker-compose/withPostgresAndWorker/init-data.sh diff --git a/kubernetes/LICENSE b/deployment/kubernetes/LICENSE similarity index 100% rename from kubernetes/LICENSE rename to deployment/kubernetes/LICENSE diff --git a/kubernetes/README.md b/deployment/kubernetes/README.md similarity index 100% rename from kubernetes/README.md rename to deployment/kubernetes/README.md diff --git a/kubernetes/n8n-claim0-persistentvolumeclaim.yaml b/deployment/kubernetes/n8n-claim0-persistentvolumeclaim.yaml similarity index 100% rename from kubernetes/n8n-claim0-persistentvolumeclaim.yaml rename to deployment/kubernetes/n8n-claim0-persistentvolumeclaim.yaml diff --git a/kubernetes/n8n-deployment.yaml b/deployment/kubernetes/n8n-deployment.yaml similarity index 100% rename from kubernetes/n8n-deployment.yaml rename to deployment/kubernetes/n8n-deployment.yaml diff --git a/kubernetes/n8n-service.yaml b/deployment/kubernetes/n8n-service.yaml similarity index 100% rename from kubernetes/n8n-service.yaml rename to deployment/kubernetes/n8n-service.yaml diff --git a/kubernetes/namespace.yaml b/deployment/kubernetes/namespace.yaml similarity index 100% rename from kubernetes/namespace.yaml rename to deployment/kubernetes/namespace.yaml diff --git a/kubernetes/postgres-claim0-persistentvolumeclaim.yaml b/deployment/kubernetes/postgres-claim0-persistentvolumeclaim.yaml similarity index 100% rename from kubernetes/postgres-claim0-persistentvolumeclaim.yaml rename to deployment/kubernetes/postgres-claim0-persistentvolumeclaim.yaml diff --git a/kubernetes/postgres-configmap.yaml b/deployment/kubernetes/postgres-configmap.yaml similarity index 100% rename from kubernetes/postgres-configmap.yaml rename to deployment/kubernetes/postgres-configmap.yaml diff --git a/kubernetes/postgres-deployment.yaml b/deployment/kubernetes/postgres-deployment.yaml similarity index 100% rename from kubernetes/postgres-deployment.yaml rename to deployment/kubernetes/postgres-deployment.yaml diff --git a/kubernetes/postgres-service.yaml b/deployment/kubernetes/postgres-service.yaml similarity index 100% rename from kubernetes/postgres-service.yaml rename to deployment/kubernetes/postgres-service.yaml diff --git a/docker-caddy/.env b/docker-caddy/.env deleted file mode 100644 index 4dbb543b..00000000 --- a/docker-caddy/.env +++ /dev/null @@ -1,18 +0,0 @@ -# Replace with the path where you created folders earlier -DATA_FOLDER=//n8n-docker-caddy - -# The top level domain to serve from, this should be the same as the subdomain you created above -DOMAIN_NAME=example.com - -# The subdomain to serve from -SUBDOMAIN=n8n - -# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from -# above example would result in: https://n8n.example.com - -# Optional timezone to set which gets used by Cron-Node by default -# If not set New York time will be used -GENERIC_TIMEZONE=Europe/Berlin - -# The email address to use for the SSL certificate creation -SSL_EMAIL=example@example.com diff --git a/docker-compose/subfolderWithSSL/.env b/docker-compose/subfolderWithSSL/.env deleted file mode 100644 index 20212744..00000000 --- a/docker-compose/subfolderWithSSL/.env +++ /dev/null @@ -1,19 +0,0 @@ -# Folder where data should be saved -DATA_FOLDER=/root/n8n/ - -# The top level domain to serve from -DOMAIN_NAME=example.com - -# The subfolder to serve from -SUBFOLDER=app1 -N8N_PATH=/app1/ - -# DOMAIN_NAME and SUBDOMAIN combined decide where n8n will be reachable from -# above example would result in: https://example.com/app1/ - -# Optional timezone to set which gets used by Cron-Node by default -# If not set New York time will be used -GENERIC_TIMEZONE=Europe/Berlin - -# The email address to use for the SSL certificate creation -SSL_EMAIL=user@example.com diff --git a/docker-compose/withPostgres/.env b/docker-compose/withPostgres/.env deleted file mode 100644 index 90b6726e..00000000 --- a/docker-compose/withPostgres/.env +++ /dev/null @@ -1,6 +0,0 @@ -POSTGRES_USER=changeUser -POSTGRES_PASSWORD=changePassword -POSTGRES_DB=n8n - -POSTGRES_NON_ROOT_USER=changeUser -POSTGRES_NON_ROOT_PASSWORD=changePassword diff --git a/docker-compose/withPostgresAndWorker/.env b/docker-compose/withPostgresAndWorker/.env deleted file mode 100644 index d4f69207..00000000 --- a/docker-compose/withPostgresAndWorker/.env +++ /dev/null @@ -1,8 +0,0 @@ -POSTGRES_USER=changeUser -POSTGRES_PASSWORD=changePassword -POSTGRES_DB=n8n - -POSTGRES_NON_ROOT_USER=changeUser -POSTGRES_NON_ROOT_PASSWORD=changePassword - -ENCRYPTION_KEY=changeEncryptionKey diff --git a/kubernetes/postgres-secret.yaml b/kubernetes/postgres-secret.yaml deleted file mode 100644 index 29d006c8..00000000 --- a/kubernetes/postgres-secret.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - namespace: n8n - name: postgres-secret -type: Opaque -stringData: - POSTGRES_USER: changeUser - POSTGRES_PASSWORD: changePassword - POSTGRES_DB: n8n - POSTGRES_NON_ROOT_USER: changeUser - POSTGRES_NON_ROOT_PASSWORD: changePassword \ No newline at end of file diff --git a/schemas/Actor.ts b/schemas/Actor.ts new file mode 100644 index 00000000..85d31b7d --- /dev/null +++ b/schemas/Actor.ts @@ -0,0 +1,22 @@ +/** + * Actor Schema + * + * Type definitions for the Actors table + */ + +import { AirtableRecord, AirtableAttachment } from './AirtableBase'; + +/** + * Actor record fields + */ +export interface ActorFields { + 'Name': string; + 'Demographics'?: string; + 'Description'?: string; + 'Reference Images'?: AirtableAttachment[]; +} + +/** + * Actor record type + */ +export type ActorRecord = AirtableRecord; diff --git a/schemas/AdCopyAnalysis.ts b/schemas/AdCopyAnalysis.ts new file mode 100644 index 00000000..ed3cfea4 --- /dev/null +++ b/schemas/AdCopyAnalysis.ts @@ -0,0 +1,151 @@ +/** + * Ad Copy Analysis Schema + * + * Type definitions for the Ad Copy table and related workflows + */ + +import { AirtableRecord, AirtableAttachment } from './AirtableBase'; + +/** + * Ad Copy record fields + */ +export interface AdCopyFields { + // Core identification + 'Record ID'?: string; + + // Ad concept fields + 'Full Concept': string; + 'Avatar Target'?: string; + 'Angle'?: string; + 'Headline'?: string; + 'CTA'?: string; + + // Linked records + 'Product'?: string[]; // Link to Products table + + // Image prompt generation + 'Generate Image Prompts'?: 'Not Started' | 'In Progress' | 'Done'; + 'Prompt 1'?: string; + 'Prompt 2'?: string; + 'Prompt 3'?: string; + 'Image Prompts'?: string; // Alternative multiline format + + // Image generation status + 'Image Generated'?: boolean; + 'Image Gen Timestamp'?: string; + 'Image Seed'?: number; + 'Image Gen Error'?: string; + 'Image Gen Failed At'?: string; + 'Image Gen Attempts'?: number; + + // Image URLs + 'Base Image URL'?: string; + 'Final Image URL'?: string; + + // Overall status + 'Status'?: 'Pending' | 'Generated' | 'Failed'; + + // Image metadata + 'Model'?: string; + 'Width'?: number; + 'Height'?: number; + 'Generated At'?: string; +} + +/** + * Ad Copy record type + */ +export type AdCopyRecord = AirtableRecord; + +/** + * Pipeline start workflow input + */ +export interface PipelineStartInput { + recordId: string; + skipOverlay?: boolean; + format?: 'square' | 'story' | 'landscape' | 'landscape_16_9'; + highQuality?: boolean; + promptIndex?: number; // Which prompt to use (1-3) +} + +/** + * Pipeline start workflow output + */ +export interface PipelineStartOutput { + pipelineId: string; + status: 'queued' | 'processing' | 'completed' | 'failed'; + recordId: string; + baseImageUrl?: string; + finalImageUrl?: string; + estimatedTime?: number; + error?: string; + timestamp: string; +} + +/** + * Batch processing input + */ +export interface PipelineBatchInput { + recordIds: string[]; + skipOverlay?: boolean; + format?: 'square' | 'story' | 'landscape' | 'landscape_16_9'; + highQuality?: boolean; +} + +/** + * Batch processing output + */ +export interface PipelineBatchOutput { + batchId: string; + jobCount: number; + estimatedTime: number; + status: 'processing' | 'completed' | 'failed'; + progress: number; // 0-100 + results?: PipelineStartOutput[]; + failedCount?: number; + successCount?: number; +} + +/** + * Pipeline status check input + */ +export interface PipelineStatusInput { + pipelineId: string; +} + +/** + * Pipeline status check output + */ +export interface PipelineStatusOutput { + pipelineId: string; + status: 'queued' | 'processing' | 'completed' | 'failed'; + progress: number; // 0-100 + startedAt?: string; + completedAt?: string; + error?: string; + result?: any; +} + +/** + * Webhook payload for image generation trigger + */ +export interface ImageGenerationWebhookPayload { + recordId: string; + action?: 'generate' | 'regenerate'; + promptIndex?: number; + options?: { + imageSize?: string; + numInferenceSteps?: number; + guidanceScale?: number; + seed?: number; + }; +} + +/** + * Airtable automation trigger payload + */ +export interface AirtableAutomationPayload { + recordId: string; + fields: Partial; + timestamp: string; +} diff --git a/schemas/AirtableBase.ts b/schemas/AirtableBase.ts new file mode 100644 index 00000000..2fdda664 --- /dev/null +++ b/schemas/AirtableBase.ts @@ -0,0 +1,116 @@ +/** + * Base Airtable Type Definitions + * + * Foundational types used across all Airtable table schemas + */ + +/** + * Generic Airtable record structure + */ +export interface AirtableRecord { + id: string; + fields: T; + createdTime: string; +} + +/** + * Airtable API response for list queries + */ +export interface AirtableResponse { + records: AirtableRecord[]; + offset?: string; +} + +/** + * Airtable attachment (image, file, etc.) + */ +export interface AirtableAttachment { + id: string; + url: string; + filename: string; + size: number; + type: string; + width?: number; + height?: number; + thumbnails?: { + small: AirtableThumbnail; + large: AirtableThumbnail; + full: AirtableThumbnail; + }; +} + +/** + * Thumbnail metadata within attachment + */ +export interface AirtableThumbnail { + url: string; + width: number; + height: number; +} + +/** + * Possible Airtable field value types + */ +export type AirtableFieldValue = + | string + | number + | boolean + | string[] + | AirtableAttachment[] + | AirtableRecord[] + | null + | undefined; + +/** + * Airtable filter formula helper type + */ +export type AirtableFormula = string; + +/** + * Airtable sort configuration + */ +export interface AirtableSort { + field: string; + direction: 'asc' | 'desc'; +} + +/** + * Airtable query options + */ +export interface AirtableQueryOptions { + filterByFormula?: AirtableFormula; + maxRecords?: number; + pageSize?: number; + sort?: AirtableSort[]; + view?: string; + fields?: string[]; +} + +/** + * Airtable error response + */ +export interface AirtableError { + error: { + type: string; + message: string; + }; +} + +/** + * Airtable create/update request body + */ +export interface AirtableUpdateRequest { + fields: Partial; + typecast?: boolean; +} + +/** + * Airtable batch operation request + */ +export interface AirtableBatchRequest { + records: Array<{ + id?: string; + fields: Partial; + }>; + typecast?: boolean; +} diff --git a/schemas/ImageRecord.ts b/schemas/ImageRecord.ts new file mode 100644 index 00000000..e704aa47 --- /dev/null +++ b/schemas/ImageRecord.ts @@ -0,0 +1,112 @@ +/** + * Image Record Schema + * + * Type definitions for the Images table and API responses + */ + +import { AirtableRecord } from './AirtableBase'; + +/** + * Images table record fields + */ +export interface ImageFields { + 'Name': string; + 'Image URL': string; + 'Source Record'?: string[]; // Link to Ad Copy table + 'Prompt Index'?: number; + 'Seed'?: number; + 'Model'?: string; + 'Generated At'?: string; + 'Width'?: number; + 'Height'?: number; +} + +/** + * Image record type + */ +export type ImageRecord = AirtableRecord; + +/** + * fal.ai API response structure + */ +export interface FalAIResponse { + images: FalAIImage[]; + seed: number; + has_nsfw_concepts?: boolean[]; + prompt?: string; + timings?: { + inference: number; + }; +} + +/** + * Individual image in fal.ai response + */ +export interface FalAIImage { + url: string; + width: number; + height: number; + content_type: string; +} + +/** + * fal.ai API request parameters + */ +export interface FalAIRequest { + prompt: string; + negative_prompt?: string; + image_size?: 'square' | 'square_hd' | 'portrait_4_3' | 'portrait_16_9' | 'landscape_4_3' | 'landscape_16_9'; + num_inference_steps?: number; + guidance_scale?: number; + num_images?: number; + enable_safety_checker?: boolean; + output_format?: 'jpeg' | 'png'; + seed?: number; +} + +/** + * Bannerbear API response structure + */ +export interface BannerbearResponse { + uid: string; + status: 'pending' | 'completed' | 'failed'; + image_url: string | null; + template: string; + modifications: BannerbearModification[]; + webhook_url?: string; + created_at: string; + updated_at?: string; +} + +/** + * Bannerbear modification object + */ +export interface BannerbearModification { + name: string; + text?: string; + image_url?: string; + color?: string; +} + +/** + * Bannerbear API request + */ +export interface BannerbearRequest { + template: string; + modifications: BannerbearModification[]; + webhook_url?: string; + metadata?: Record; +} + +/** + * Image generation metadata + */ +export interface ImageGenerationMetadata { + recordId: string; + prompt: string; + seed: number; + model: string; + imageSize: string; + timestamp: string; + processingTime?: number; +} diff --git a/schemas/Product.ts b/schemas/Product.ts new file mode 100644 index 00000000..e69da84f --- /dev/null +++ b/schemas/Product.ts @@ -0,0 +1,22 @@ +/** + * Product Schema + * + * Type definitions for the Products table + */ + +import { AirtableRecord, AirtableAttachment } from './AirtableBase'; + +/** + * Product record fields + */ +export interface ProductFields { + 'Name': string; + 'Description'?: string; + 'Category'?: 'Electronics' | 'Fashion' | 'Home' | 'Beauty' | 'Food' | 'Other'; + 'Product Images'?: AirtableAttachment[]; +} + +/** + * Product record type + */ +export type ProductRecord = AirtableRecord; diff --git a/schemas/Scene.ts b/schemas/Scene.ts new file mode 100644 index 00000000..179cd41a --- /dev/null +++ b/schemas/Scene.ts @@ -0,0 +1,22 @@ +/** + * Scene Schema + * + * Type definitions for the Scenes table + */ + +import { AirtableRecord } from './AirtableBase'; + +/** + * Scene record fields + */ +export interface SceneFields { + 'Name': string; + 'Description'?: string; + 'Lighting'?: string; + 'Mood'?: string; +} + +/** + * Scene record type + */ +export type SceneRecord = AirtableRecord; diff --git a/scripts/backup-workflows.sh b/scripts/backup-workflows.sh new file mode 100644 index 00000000..e140932d --- /dev/null +++ b/scripts/backup-workflows.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# +# Backup Workflows from n8n Instance +# +# Usage: +# ./backup-workflows.sh +# +# Environment variables required: +# N8N_URL - URL of your n8n instance +# N8N_API_KEY - API key for n8n authentication +# +# Example: +# export N8N_URL="https://n8n.example.com" +# export N8N_API_KEY="your_api_key" +# ./backup-workflows.sh + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKFLOWS_DIR="${SCRIPT_DIR}/../workflows" +BACKUP_DIR="${WORKFLOWS_DIR}/backups" +N8N_URL="${N8N_URL:-}" +N8N_API_KEY="${N8N_API_KEY:-}" + +# Check if required environment variables are set +if [ -z "$N8N_URL" ]; then + echo -e "${RED}ERROR: N8N_URL environment variable is not set${NC}" + echo "Please set it with: export N8N_URL=\"https://your-n8n-instance.com\"" + exit 1 +fi + +if [ -z "$N8N_API_KEY" ]; then + echo -e "${RED}ERROR: N8N_API_KEY environment variable is not set${NC}" + echo "Please set it with: export N8N_API_KEY=\"your_api_key\"" + exit 1 +fi + +# Create directories if they don't exist +mkdir -p "$WORKFLOWS_DIR" +mkdir -p "$BACKUP_DIR" + +# Check if jq is available (optional, for pretty printing) +if command -v jq &> /dev/null; then + HAS_JQ=true +else + HAS_JQ=false + echo -e "${YELLOW}Note: jq not installed. Workflows will be saved without pretty-printing.${NC}" + echo "Install jq for better formatting: sudo apt-get install jq" + echo "" +fi + +echo "================================================" +echo "Cyclone-S5 Workflow Backup" +echo "================================================" +echo "Source: ${N8N_URL}" +echo "Target: ${WORKFLOWS_DIR}" +echo "Backup: ${BACKUP_DIR}" +echo "" + +# Fetch all workflows from n8n +echo -e "${YELLOW}Fetching workflows from n8n...${NC}" +workflows=$(curl -s \ + -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ + "${N8N_URL}/api/v1/workflows") + +# Check if request was successful +if [ $? -ne 0 ]; then + echo -e "${RED}ERROR: Failed to fetch workflows from n8n${NC}" + exit 1 +fi + +# Check if workflows data is valid JSON +if ! echo "$workflows" | jq . > /dev/null 2>&1; then + echo -e "${RED}ERROR: Invalid response from n8n API${NC}" + echo "Response: $workflows" + exit 1 +fi + +# Count workflows +workflow_count=$(echo "$workflows" | jq '.data | length' 2>/dev/null || echo "0") + +if [ "$workflow_count" -eq 0 ]; then + echo -e "${YELLOW}No workflows found on n8n instance${NC}" + exit 0 +fi + +echo -e "${BLUE}Found $workflow_count workflows${NC}" +echo "" + +# Process each workflow +# Use a pre-parsed list to avoid subshell issues and catch jq failures +saved_count=0 +error_count=0 +timestamp=$(date +%Y%m%d_%H%M%S) + +if ! workflows_list=$(echo "$workflows" | jq -c '.data[]' 2>/dev/null); then + echo -e "${RED}ERROR: Failed to parse workflow data${NC}" + exit 1 +fi + +while IFS= read -r workflow; do + workflow_id=$(echo "$workflow" | jq -r '.id') + workflow_name=$(echo "$workflow" | jq -r '.name') + + # Sanitize filename (replace spaces and special chars with dashes) + safe_name=$(echo "$workflow_name" | tr ' ' '-' | tr -cd '[:alnum:]-_') + + # If safe_name is empty, use workflow ID + if [ -z "$safe_name" ]; then + safe_name="workflow-${workflow_id}" + fi + + echo -e "${YELLOW}Backing up: ${workflow_name} (${workflow_id})${NC}" + + # Save to workflows directory + save_success=true + if [ "$HAS_JQ" = true ]; then + if ! echo "$workflow" | jq '.' > "${WORKFLOWS_DIR}/${safe_name}.json"; then + save_success=false + fi + else + if ! echo "$workflow" > "${WORKFLOWS_DIR}/${safe_name}.json"; then + save_success=false + fi + fi + + if [ "$save_success" = true ]; then + # Also save timestamped backup + if [ "$HAS_JQ" = true ]; then + echo "$workflow" | jq '.' > "${BACKUP_DIR}/${safe_name}_${timestamp}.json" + else + echo "$workflow" > "${BACKUP_DIR}/${safe_name}_${timestamp}.json" + fi + + echo -e "${GREEN}βœ“ Saved: ${safe_name}.json${NC}" + saved_count=$((saved_count + 1)) + else + echo -e "${RED}βœ— Failed to save: ${safe_name}.json${NC}" + error_count=$((error_count + 1)) + fi +done <<< "$workflows_list" + +echo "" +echo "================================================" +echo "Backup Summary" +echo "================================================" +echo -e "${GREEN}Workflows backed up: $saved_count${NC}" +if [ $error_count -gt 0 ]; then + echo -e "${RED}Failed to backup: $error_count${NC}" +fi +echo "" +echo "Main copies saved to: ${WORKFLOWS_DIR}" +echo "Timestamped backups saved to: ${BACKUP_DIR}" +echo "" + +# Exit with error if any backups failed +if [ $error_count -gt 0 ]; then + echo -e "${RED}Backup completed with errors!${NC}" + exit 1 +fi + +echo -e "${GREEN}Backup complete!${NC}" +echo "" +echo "To deploy these workflows to another n8n instance:" +echo " export N8N_URL=\"https://other-n8n-instance.com\"" +echo " export N8N_API_KEY=\"other_api_key\"" +echo " ./deploy-workflow.sh" diff --git a/scripts/deploy-workflow.sh b/scripts/deploy-workflow.sh new file mode 100644 index 00000000..cfde92c4 --- /dev/null +++ b/scripts/deploy-workflow.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# +# Deploy Workflows to n8n Instance +# +# Usage: +# ./deploy-workflow.sh [workflow-name] +# +# Environment variables required: +# N8N_URL - URL of your n8n instance (e.g., https://n8n.example.com) +# N8N_API_KEY - API key for n8n authentication +# +# Examples: +# export N8N_URL="https://n8n.example.com" +# export N8N_API_KEY="your_api_key" +# ./deploy-workflow.sh # Deploy all workflows +# ./deploy-workflow.sh palmaura-fal-image-generation # Deploy specific workflow + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKFLOWS_DIR="${SCRIPT_DIR}/../workflows" +N8N_URL="${N8N_URL:-}" +N8N_API_KEY="${N8N_API_KEY:-}" + +# Check if required environment variables are set +if [ -z "$N8N_URL" ]; then + echo -e "${RED}ERROR: N8N_URL environment variable is not set${NC}" + echo "Please set it with: export N8N_URL=\"https://your-n8n-instance.com\"" + exit 1 +fi + +if [ -z "$N8N_API_KEY" ]; then + echo -e "${RED}ERROR: N8N_API_KEY environment variable is not set${NC}" + echo "Please set it with: export N8N_API_KEY=\"your_api_key\"" + exit 1 +fi + +# Function to deploy a single workflow +deploy_workflow() { + local workflow_file="$1" + local workflow_name=$(basename "$workflow_file" .json) + + echo -e "${YELLOW}Deploying: ${workflow_name}${NC}" + + # Check if file exists + if [ ! -f "$workflow_file" ]; then + echo -e "${RED}ERROR: Workflow file not found: ${workflow_file}${NC}" + return 1 + fi + + # Deploy to n8n using API + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "X-N8N-API-KEY: ${N8N_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "@${workflow_file}" \ + "${N8N_URL}/api/v1/workflows" 2>&1) + + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" -eq 200 ] || [ "$http_code" -eq 201 ]; then + echo -e "${GREEN}βœ“ Successfully deployed: ${workflow_name}${NC}" + + # Extract workflow ID if available + workflow_id=$(echo "$body" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) + if [ -n "$workflow_id" ]; then + echo -e "${BLUE} Workflow ID: ${workflow_id}${NC}" + fi + + return 0 + else + echo -e "${RED}βœ— Failed to deploy: ${workflow_name} (HTTP ${http_code})${NC}" + echo -e "${RED}Response: ${body}${NC}" + return 1 + fi +} + +# Main execution +echo "================================================" +echo "Cyclone-S5 Workflow Deployment" +echo "================================================" +echo "Target: ${N8N_URL}" +echo "" + +# Check if workflows directory exists +if [ ! -d "$WORKFLOWS_DIR" ]; then + echo -e "${RED}ERROR: Workflows directory not found: ${WORKFLOWS_DIR}${NC}" + exit 1 +fi + +# Deploy specific workflow or all workflows +if [ -n "$1" ]; then + # Deploy specific workflow + workflow_path="${WORKFLOWS_DIR}/$1" + + # Add .json extension if not present + if [[ "$1" != *.json ]]; then + workflow_path="${workflow_path}.json" + fi + + if [ -f "$workflow_path" ]; then + deploy_workflow "$workflow_path" + else + echo -e "${RED}ERROR: Workflow not found: $1${NC}" + echo "" + echo "Available workflows:" + ls -1 "${WORKFLOWS_DIR}"/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' || echo " (none found)" + exit 1 + fi +else + # Deploy all workflows + echo "Deploying all workflows from: ${WORKFLOWS_DIR}" + echo "" + + workflow_count=0 + success_count=0 + fail_count=0 + + # Find all JSON files except README + for workflow_file in "${WORKFLOWS_DIR}"/*.json; do + # Skip if no files found + if [ ! -f "$workflow_file" ]; then + echo -e "${YELLOW}No workflow files found in ${WORKFLOWS_DIR}${NC}" + break + fi + + # Skip README and backup files + filename=$(basename "$workflow_file") + if [[ "$filename" == "README.json" ]] || [[ "$filename" == *"backup"* ]]; then + continue + fi + + workflow_count=$((workflow_count + 1)) + + if deploy_workflow "$workflow_file"; then + success_count=$((success_count + 1)) + else + fail_count=$((fail_count + 1)) + fi + + echo "" # Blank line between workflows + done + + # Summary + echo "================================================" + echo "Deployment Summary" + echo "================================================" + echo "Total workflows: $workflow_count" + echo -e "${GREEN}Successful: $success_count${NC}" + if [ $fail_count -gt 0 ]; then + echo -e "${RED}Failed: $fail_count${NC}" + else + echo -e "Failed: $fail_count" + fi + + # Exit with error code if any deployments failed + if [ $fail_count -gt 0 ]; then + echo "" + echo -e "${RED}Deployment completed with errors!${NC}" + exit 1 + fi +fi + +echo "" +echo -e "${GREEN}Deployment complete!${NC}" diff --git a/scripts/sync-env.sh b/scripts/sync-env.sh new file mode 100644 index 00000000..f2ca18f4 --- /dev/null +++ b/scripts/sync-env.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# Sync .env.example to Deployment Folders +# +# Copies the centralized config/.env.example to all deployment directories +# Ensures all deployment configurations have the latest environment template +# +# Usage: +# ./sync-env.sh + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_ENV="${SCRIPT_DIR}/../config/.env.example" +DEPLOYMENT_DIR="${SCRIPT_DIR}/../deployment" + +echo "================================================" +echo "Sync .env.example to Deployment Folders" +echo "================================================" +echo "Source: ${SOURCE_ENV}" +echo "" + +# Check if source file exists +if [ ! -f "$SOURCE_ENV" ]; then + echo -e "${RED}ERROR: Source .env.example not found at ${SOURCE_ENV}${NC}" + exit 1 +fi + +# Deployment directories to sync +DEPLOY_TARGETS=( + "docker-caddy" + "docker-compose/withPostgres" + "docker-compose/withPostgresAndWorker" + "docker-compose/subfolderWithSSL" +) + +sync_count=0 +skip_count=0 + +# Copy to each deployment folder +for target in "${DEPLOY_TARGETS[@]}"; do + target_path="${DEPLOYMENT_DIR}/${target}/.env.example" + target_dir=$(dirname "$target_path") + + if [ -d "$target_dir" ]; then + cp "$SOURCE_ENV" "$target_path" + echo -e "${GREEN}βœ“ Synced to: ${target}/.env.example${NC}" + sync_count=$((sync_count + 1)) + else + echo -e "${YELLOW}⚠ Skipped (directory not found): ${target}${NC}" + skip_count=$((skip_count + 1)) + fi +done + +echo "" +echo "================================================" +echo "Sync Summary" +echo "================================================" +echo -e "${GREEN}Synced: $sync_count${NC}" +if [ $skip_count -gt 0 ]; then + echo -e "${YELLOW}Skipped: $skip_count${NC}" +fi +echo "" +echo -e "${GREEN}Sync complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Navigate to each deployment directory" +echo " 2. Copy .env.example to .env: cp .env.example .env" +echo " 3. Edit .env with your actual credentials" diff --git a/scripts/validate-config.js b/scripts/validate-config.js new file mode 100644 index 00000000..2299e190 --- /dev/null +++ b/scripts/validate-config.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node +/** + * Validate Configuration Files for Cyclone-S5 + * + * Checks all configuration files for validity and completeness + * Run before deployment to catch configuration errors early + * + * Usage: + * node validate-config.js + */ + +const fs = require('fs'); +const path = require('path'); + +// ANSI color codes +const colors = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m' +}; + +// Configuration paths +const SCRIPT_DIR = __dirname; +const PROJECT_ROOT = path.join(SCRIPT_DIR, '..'); +const CONFIG_DIR = path.join(PROJECT_ROOT, 'config'); +const SCHEMAS_DIR = path.join(PROJECT_ROOT, 'schemas'); +const WORKFLOWS_DIR = path.join(PROJECT_ROOT, 'workflows'); + +let errors = 0; +let warnings = 0; + +/** + * Log error message + */ +function logError(message) { + console.log(`${colors.red}βœ— ERROR: ${message}${colors.reset}`); + errors++; +} + +/** + * Log warning message + */ +function logWarning(message) { + console.log(`${colors.yellow}⚠ WARNING: ${message}${colors.reset}`); + warnings++; +} + +/** + * Log success message + */ +function logSuccess(message) { + console.log(`${colors.green}βœ“ ${message}${colors.reset}`); +} + +/** + * Log info message + */ +function logInfo(message) { + console.log(`${colors.blue}β„Ή ${message}${colors.reset}`); +} + +/** + * Validate JSON file + */ +function validateJSONFile(filePath, schema = null) { + const fileName = path.basename(filePath); + + if (!fs.existsSync(filePath)) { + logError(`File not found: ${fileName}`); + return false; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(content); + + // Optional schema validation + if (schema && typeof schema === 'function') { + schema(data, fileName); + } + + logSuccess(`Valid JSON: ${fileName}`); + return true; + } catch (e) { + logError(`Invalid JSON in ${fileName}: ${e.message}`); + return false; + } +} + +/** + * Validate .env.example file + */ +function validateEnvExample() { + const envPath = path.join(CONFIG_DIR, '.env.example'); + + logInfo('Validating .env.example...'); + + if (!fs.existsSync(envPath)) { + logError('.env.example file not found in config/'); + return false; + } + + const content = fs.readFileSync(envPath, 'utf8'); + + // Required environment variables + const requiredKeys = [ + 'N8N_ENCRYPTION_KEY', + 'AIRTABLE_BASE_ID', + 'AIRTABLE_PAT', + 'FAL_API_KEY', + 'BANNERBEAR_API_KEY', + 'POSTGRES_PASSWORD' + ]; + + const missingKeys = []; + requiredKeys.forEach(key => { + if (!content.includes(key)) { + missingKeys.push(key); + } + }); + + if (missingKeys.length > 0) { + logError(`.env.example missing required keys: ${missingKeys.join(', ')}`); + return false; + } + + logSuccess('.env.example contains all required keys'); + return true; +} + +/** + * Validate Airtable schemas + */ +function validateAirtableSchemas() { + const schemasPath = path.join(CONFIG_DIR, 'airtable-schemas.json'); + + logInfo('Validating airtable-schemas.json...'); + + const isValid = validateJSONFile(schemasPath, (schemas, fileName) => { + // Check required tables + const requiredTables = ['adCopy', 'images', 'actors', 'products', 'scenes']; + const missingTables = requiredTables.filter(table => !schemas.tables || !schemas.tables[table]); + + if (missingTables.length > 0) { + logError(`Missing required tables in ${fileName}: ${missingTables.join(', ')}`); + return false; + } + + // Validate each table has required properties + Object.keys(schemas.tables).forEach(tableKey => { + const table = schemas.tables[tableKey]; + if (!table.name) { + logWarning(`Table '${tableKey}' missing 'name' property`); + } + if (!table.fields || Object.keys(table.fields).length === 0) { + logWarning(`Table '${tableKey}' has no fields defined`); + } + }); + }); + + return isValid; +} + +/** + * Validate API endpoints configuration + */ +function validateApiEndpoints() { + const endpointsPath = path.join(CONFIG_DIR, 'api-endpoints.json'); + + logInfo('Validating api-endpoints.json...'); + + const isValid = validateJSONFile(endpointsPath, (config, fileName) => { + // Check required APIs + const requiredApis = ['fal', 'bannerbear', 'airtable']; + const missingApis = requiredApis.filter(api => !config.apis || !config.apis[api]); + + if (missingApis.length > 0) { + logError(`Missing required APIs in ${fileName}: ${missingApis.join(', ')}`); + return false; + } + + // Validate each API has required properties + Object.keys(config.apis).forEach(apiKey => { + const api = config.apis[apiKey]; + if (!api.baseUrl) { + logWarning(`API '${apiKey}' missing 'baseUrl' property`); + } + if (!api.endpoints || Object.keys(api.endpoints).length === 0) { + logWarning(`API '${apiKey}' has no endpoints defined`); + } + }); + }); + + return isValid; +} + +/** + * Validate prompt templates + */ +function validatePromptTemplates() { + const templatesPath = path.join(CONFIG_DIR, 'prompt-templates.json'); + + logInfo('Validating prompt-templates.json...'); + + const isValid = validateJSONFile(templatesPath, (config, fileName) => { + if (!config.templates) { + logError(`${fileName} missing 'templates' object`); + return false; + } + + // Check for key templates + const expectedTemplates = ['imageGeneration', 'promptGeneration']; + expectedTemplates.forEach(template => { + if (!config.templates[template]) { + logWarning(`Missing template: ${template}`); + } + }); + }); + + return isValid; +} + +/** + * Validate workflow files + */ +function validateWorkflows() { + logInfo('Validating workflow files...'); + + if (!fs.existsSync(WORKFLOWS_DIR)) { + logError(`Workflows directory not found: ${WORKFLOWS_DIR}`); + return false; + } + + const files = fs.readdirSync(WORKFLOWS_DIR); + const jsonFiles = files.filter(f => f.endsWith('.json') && f !== 'README.json'); + + if (jsonFiles.length === 0) { + logWarning('No workflow JSON files found in workflows/'); + return true; + } + + let allValid = true; + jsonFiles.forEach(file => { + const filePath = path.join(WORKFLOWS_DIR, file); + if (!validateJSONFile(filePath)) { + allValid = false; + } + }); + + return allValid; +} + +/** + * Check if TypeScript schemas exist + */ +function checkTypeScriptSchemas() { + logInfo('Checking TypeScript schemas...'); + + if (!fs.existsSync(SCHEMAS_DIR)) { + logWarning('Schemas directory not found (TypeScript schemas optional)'); + return true; + } + + const expectedSchemas = [ + 'AirtableBase.ts', + 'AdCopyAnalysis.ts', + 'ImageRecord.ts', + 'Actor.ts', + 'Product.ts', + 'Scene.ts' + ]; + + let found = 0; + expectedSchemas.forEach(schema => { + const schemaPath = path.join(SCHEMAS_DIR, schema); + if (fs.existsSync(schemaPath)) { + found++; + } + }); + + logInfo(`Found ${found}/${expectedSchemas.length} TypeScript schema files`); + + return true; +} + +/** + * Check helper scripts exist + */ +function checkHelperScripts() { + logInfo('Checking helper scripts...'); + + const helpersDir = path.join(PROJECT_ROOT, 'utils', 'n8n-helpers'); + + if (!fs.existsSync(helpersDir)) { + logWarning('Helper scripts directory not found: utils/n8n-helpers/'); + return true; + } + + const expectedHelpers = [ + 'airtable-ops.js', + 'image-generation.js', + 'prompt-builder.js', + 'error-handler.js' + ]; + + let found = 0; + expectedHelpers.forEach(helper => { + const helperPath = path.join(helpersDir, helper); + if (fs.existsSync(helperPath)) { + found++; + } else { + logWarning(`Helper script not found: ${helper}`); + } + }); + + logInfo(`Found ${found}/${expectedHelpers.length} helper scripts`); + + return true; +} + +/** + * Main validation + */ +function main() { + console.log('================================================'); + console.log('Cyclone-S5 Configuration Validation'); + console.log('================================================\n'); + + // Validate configuration files + console.log('Validating Configuration Files...\n'); + + validateEnvExample(); + validateAirtableSchemas(); + validateApiEndpoints(); + validatePromptTemplates(); + + console.log(''); + + // Validate workflows + validateWorkflows(); + + console.log(''); + + // Check additional resources + checkTypeScriptSchemas(); + checkHelperScripts(); + + // Summary + console.log('\n================================================'); + console.log('Validation Summary'); + console.log('================================================'); + console.log(`Errors: ${errors}`); + console.log(`Warnings: ${warnings}`); + + if (errors === 0 && warnings === 0) { + console.log(`${colors.green}βœ“ All validations passed!${colors.reset}`); + process.exit(0); + } else if (errors === 0) { + console.log(`${colors.yellow}⚠ Validations passed with warnings${colors.reset}`); + process.exit(0); + } else { + console.log(`${colors.red}βœ— Validation failed${colors.reset}`); + process.exit(1); + } +} + +// Run validation +main(); diff --git a/statscaler/.gitignore b/statscaler/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/statscaler/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/statscaler/README.md b/statscaler/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/statscaler/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/statscaler/app/favicon.ico b/statscaler/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/statscaler/app/favicon.ico differ diff --git a/statscaler/app/globals.css b/statscaler/app/globals.css new file mode 100644 index 00000000..a2dc41ec --- /dev/null +++ b/statscaler/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/statscaler/app/layout.tsx b/statscaler/app/layout.tsx new file mode 100644 index 00000000..f7fa87eb --- /dev/null +++ b/statscaler/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/statscaler/app/page.tsx b/statscaler/app/page.tsx new file mode 100644 index 00000000..295f8fdf --- /dev/null +++ b/statscaler/app/page.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/statscaler/eslint.config.mjs b/statscaler/eslint.config.mjs new file mode 100644 index 00000000..05e726d1 --- /dev/null +++ b/statscaler/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/statscaler/next.config.ts b/statscaler/next.config.ts new file mode 100644 index 00000000..e9ffa308 --- /dev/null +++ b/statscaler/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/statscaler/package.json b/statscaler/package.json new file mode 100644 index 00000000..72156ec0 --- /dev/null +++ b/statscaler/package.json @@ -0,0 +1,26 @@ +{ + "name": "statscaler", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/statscaler/postcss.config.mjs b/statscaler/postcss.config.mjs new file mode 100644 index 00000000..61e36849 --- /dev/null +++ b/statscaler/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/statscaler/public/file.svg b/statscaler/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/statscaler/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/statscaler/public/globe.svg b/statscaler/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/statscaler/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/statscaler/public/next.svg b/statscaler/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/statscaler/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/statscaler/public/vercel.svg b/statscaler/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/statscaler/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/statscaler/public/window.svg b/statscaler/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/statscaler/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/statscaler/tsconfig.json b/statscaler/tsconfig.json new file mode 100644 index 00000000..3a13f90a --- /dev/null +++ b/statscaler/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/utils/n8n-helpers/airtable-ops.js b/utils/n8n-helpers/airtable-ops.js new file mode 100644 index 00000000..860152dd --- /dev/null +++ b/utils/n8n-helpers/airtable-ops.js @@ -0,0 +1,267 @@ +/** + * Airtable Operations Helper for n8n + * + * Provides reusable functions for common Airtable operations + * Use these in n8n Code nodes for consistent API interactions + * + * Usage in n8n Code node: + * const { fetchRecord, updateRecord } = require('./utils/n8n-helpers/airtable-ops.js'); + * const record = fetchRecord('recXXXXXX', $env.AIRTABLE_TABLE_AD_COPY); + */ + +/** + * Fetch a record by ID + * @param {string} recordId - Airtable record ID + * @param {string} tableId - Airtable table ID + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {object} Record data with fields + */ +function fetchRecord(recordId, tableId, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!recordId || !tableId) { + throw new Error('Missing required parameters: recordId and tableId'); + } + + try { + const response = $http.request({ + method: 'GET', + url: `https://api.airtable.com/v0/${base}/${tableId}/${recordId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + } + }); + + return response.body; + } catch (error) { + throw new Error(`Failed to fetch record ${recordId}: ${error.message}`); + } +} + +/** + * Update a record + * @param {string} recordId - Airtable record ID + * @param {string} tableId - Airtable table ID + * @param {object} fields - Fields to update (key-value pairs) + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {object} Updated record + */ +function updateRecord(recordId, tableId, fields, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!recordId || !tableId || !fields) { + throw new Error('Missing required parameters: recordId, tableId, and fields'); + } + + try { + const response = $http.request({ + method: 'PATCH', + url: `https://api.airtable.com/v0/${base}/${tableId}/${recordId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + }, + body: { + fields: fields + } + }); + + return response.body; + } catch (error) { + throw new Error(`Failed to update record ${recordId}: ${error.message}`); + } +} + +/** + * Create a new record + * @param {string} tableId - Airtable table ID + * @param {object} fields - Fields to set (key-value pairs) + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {object} Created record + */ +function createRecord(tableId, fields, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!tableId || !fields) { + throw new Error('Missing required parameters: tableId and fields'); + } + + try { + const response = $http.request({ + method: 'POST', + url: `https://api.airtable.com/v0/${base}/${tableId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + }, + body: { + fields: fields + } + }); + + return response.body; + } catch (error) { + throw new Error(`Failed to create record: ${error.message}`); + } +} + +/** + * Search records with filter formula + * @param {string} tableId - Airtable table ID + * @param {string} filterFormula - Airtable filter formula + * @param {object} options - Optional parameters (maxRecords, sort, view, fields) + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {array} Matching records + */ +function searchRecords(tableId, filterFormula, options = {}, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!tableId) { + throw new Error('Missing required parameter: tableId'); + } + + // Build query string + const queryParams = {}; + if (filterFormula) { + queryParams.filterByFormula = filterFormula; + } + if (options.maxRecords) { + queryParams.maxRecords = options.maxRecords; + } + if (options.view) { + queryParams.view = options.view; + } + if (options.sort) { + queryParams.sort = JSON.stringify(options.sort); + } + if (options.fields && Array.isArray(options.fields)) { + options.fields.forEach((field, index) => { + queryParams[`fields[${index}]`] = field; + }); + } + + try { + const response = $http.request({ + method: 'GET', + url: `https://api.airtable.com/v0/${base}/${tableId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + }, + qs: queryParams + }); + + return response.body.records || []; + } catch (error) { + throw new Error(`Failed to search records: ${error.message}`); + } +} + +/** + * Delete a record + * @param {string} recordId - Airtable record ID + * @param {string} tableId - Airtable table ID + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {object} Deleted record info + */ +function deleteRecord(recordId, tableId, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!recordId || !tableId) { + throw new Error('Missing required parameters: recordId and tableId'); + } + + try { + const response = $http.request({ + method: 'DELETE', + url: `https://api.airtable.com/v0/${base}/${tableId}/${recordId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + } + }); + + return response.body; + } catch (error) { + throw new Error(`Failed to delete record ${recordId}: ${error.message}`); + } +} + +/** + * Batch update multiple records + * @param {string} tableId - Airtable table ID + * @param {array} updates - Array of {id, fields} objects + * @param {string} baseId - (Optional) Airtable base ID, defaults to env var + * @returns {array} Updated records + */ +function batchUpdate(tableId, updates, baseId = null) { + const base = baseId || $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!base || !pat) { + throw new Error('Missing Airtable configuration: AIRTABLE_BASE_ID or AIRTABLE_PAT'); + } + + if (!tableId || !updates || !Array.isArray(updates)) { + throw new Error('Missing or invalid parameters: tableId and updates array required'); + } + + // Airtable limits batch operations to 10 records + if (updates.length > 10) { + throw new Error('Batch update limited to 10 records at a time'); + } + + try { + const response = $http.request({ + method: 'PATCH', + url: `https://api.airtable.com/v0/${base}/${tableId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + }, + body: { + records: updates + } + }); + + return response.body.records || []; + } catch (error) { + throw new Error(`Failed to batch update records: ${error.message}`); + } +} + +// Export for use in n8n Code nodes +module.exports = { + fetchRecord, + updateRecord, + createRecord, + searchRecords, + deleteRecord, + batchUpdate +}; diff --git a/utils/n8n-helpers/error-handler.js b/utils/n8n-helpers/error-handler.js new file mode 100644 index 00000000..99bfcae0 --- /dev/null +++ b/utils/n8n-helpers/error-handler.js @@ -0,0 +1,251 @@ +/** + * Error Handling and Retry Logic for n8n Workflows + * + * Provides utilities for handling API errors, implementing retry logic, + * and logging failures to Airtable + * + * Usage in n8n Code node: + * const { isRetryable, getRetryDelay } = require('./utils/n8n-helpers/error-handler.js'); + * if (isRetryable(response.statusCode)) { ... } + */ + +/** + * Determine if an error is retryable based on status code or error type + * @param {number} statusCode - HTTP status code + * @param {object} error - Optional error object + * @returns {boolean} Whether the error should be retried + */ +function isRetryable(statusCode, error = {}) { + // HTTP status codes that warrant retry + const retryableCodes = [ + 429, // Too Many Requests (rate limit) + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504 // Gateway Timeout + ]; + + // Check if status code is retryable + if (retryableCodes.includes(statusCode)) { + return true; + } + + // Check for network-level errors + if (error && error.code) { + const retryableErrorCodes = [ + 'ECONNRESET', // Connection reset + 'ETIMEDOUT', // Connection timeout + 'ECONNREFUSED', // Connection refused + 'EHOSTUNREACH', // Host unreachable + 'ENETUNREACH', // Network unreachable + 'EAI_AGAIN' // DNS lookup timeout + ]; + + if (retryableErrorCodes.includes(error.code)) { + return true; + } + } + + return false; +} + +/** + * Parse error details from API response + * @param {object} response - HTTP response or error object + * @returns {object} Parsed error details + */ +function parseError(response) { + let errorMessage = 'Unknown error occurred'; + let errorCode = 500; + let errorDetails = null; + + try { + // Handle different response formats + if (response.statusCode) { + errorCode = response.statusCode; + } else if (response.code) { + errorCode = response.code; + } + + // Extract error message + const body = response.body || response; + + if (typeof body === 'string') { + errorMessage = body; + } else if (body && typeof body === 'object') { + // Try different common error message fields + errorMessage = body.detail || body.error || body.message || body.error_message || JSON.stringify(body); + errorDetails = body; + } else if (response.message) { + errorMessage = response.message; + } + } catch (parseErr) { + errorMessage = response.message || String(response); + } + + return { + code: errorCode, + message: errorMessage, + details: errorDetails, + isRetryable: isRetryable(errorCode, response), + timestamp: new Date().toISOString() + }; +} + +/** + * Calculate retry delay using exponential backoff + * @param {number} attemptNumber - Current attempt number (1-based) + * @param {number} baseDelay - Base delay in milliseconds (default 1000) + * @param {number} maxDelay - Maximum delay in milliseconds (default 60000) + * @returns {number} Delay in milliseconds + */ +function getRetryDelay(attemptNumber, baseDelay = 1000, maxDelay = 60000) { + if (attemptNumber < 1) { + return 0; + } + + // Exponential backoff: baseDelay * 2^(attemptNumber - 1) + const delay = baseDelay * Math.pow(2, attemptNumber - 1); + + // Apply jitter (random factor to prevent thundering herd) + const jitter = Math.random() * 0.3 * delay; // 0-30% jitter + + // Cap at max delay + return Math.min(delay + jitter, maxDelay); +} + +/** + * Log error to Airtable for tracking and debugging + * @param {string} recordId - Airtable record ID + * @param {string} tableId - Airtable table ID + * @param {object} error - Error details + * @param {number} attemptNumber - Current attempt number + */ +function logErrorToAirtable(recordId, tableId, error, attemptNumber = 1) { + const baseId = $env.AIRTABLE_BASE_ID; + const pat = $env.AIRTABLE_PAT; + + if (!baseId || !pat) { + console.error('Cannot log error to Airtable: missing credentials'); + return; + } + + try { + const errorMessage = typeof error === 'string' ? error : error.message || JSON.stringify(error); + + $http.request({ + method: 'PATCH', + url: `https://api.airtable.com/v0/${baseId}/${tableId}/${recordId}`, + headers: { + 'Authorization': `Bearer ${pat}`, + 'Content-Type': 'application/json' + }, + body: { + fields: { + 'Image Gen Error': errorMessage.substring(0, 1000), // Limit to 1000 chars + 'Image Gen Failed At': new Date().toISOString(), + 'Image Gen Attempts': attemptNumber + } + } + }); + } catch (logError) { + console.error('Failed to log error to Airtable:', logError); + } +} + +/** + * Check if maximum retries have been exceeded + * @param {number} currentAttempt - Current attempt number + * @param {number} maxAttempts - Maximum allowed attempts (default 3) + * @returns {boolean} True if max retries exceeded + */ +function maxRetriesExceeded(currentAttempt, maxAttempts = 3) { + return currentAttempt >= maxAttempts; +} + +/** + * Wait for specified milliseconds (for use in retry logic) + * @param {number} ms - Milliseconds to wait + * @returns {Promise} Promise that resolves after delay + */ +async function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +/** + * Execute function with retry logic + * @param {function} fn - Function to execute + * @param {object} options - Retry options (maxAttempts, baseDelay, onRetry) + * @returns {Promise} Result of function execution + */ +async function withRetry(fn, options = {}) { + const { + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 60000, + onRetry = null + } = options; + + let lastError; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + const parsedError = parseError(error); + + // Don't retry if error is not retryable or max attempts reached + if (!parsedError.isRetryable || attempt >= maxAttempts) { + throw error; + } + + // Calculate delay and wait + const delay = getRetryDelay(attempt, baseDelay, maxDelay); + + // Call onRetry callback if provided + if (onRetry) { + onRetry(attempt, parsedError, delay); + } + + await wait(delay); + } + } + + throw lastError; +} + +/** + * Create error response object for n8n + * @param {object} error - Error object + * @param {string} context - Context where error occurred + * @returns {object} Formatted error object + */ +function createErrorResponse(error, context = '') { + const parsedError = parseError(error); + + return { + error: true, + errorCode: parsedError.code, + errorMessage: parsedError.message, + context: context, + timestamp: parsedError.timestamp, + isRetryable: parsedError.isRetryable, + details: parsedError.details + }; +} + +// Export for use in n8n Code nodes +module.exports = { + isRetryable, + parseError, + getRetryDelay, + logErrorToAirtable, + maxRetriesExceeded, + wait, + withRetry, + createErrorResponse +}; diff --git a/utils/n8n-helpers/image-generation.js b/utils/n8n-helpers/image-generation.js new file mode 100644 index 00000000..3ef6dfd4 --- /dev/null +++ b/utils/n8n-helpers/image-generation.js @@ -0,0 +1,243 @@ +/** + * Image Generation Helper for fal.ai + * + * Provides reusable functions for image generation workflows + * Optimized for Flux models via fal.ai API + * + * Usage in n8n Code node: + * const { generateImage, validatePrompt } = require('./utils/n8n-helpers/image-generation.js'); + * const result = generateImage(prompt, { image_size: 'square_hd' }); + */ + +/** + * Generate image with fal.ai Flux model + * @param {string} prompt - Image generation prompt + * @param {object} options - Generation options (image_size, num_inference_steps, etc.) + * @returns {object} Generated image data from fal.ai + */ +function generateImage(prompt, options = {}) { + const apiKey = $env.FAL_API_KEY; + + if (!apiKey) { + throw new Error('Missing FAL_API_KEY environment variable'); + } + + if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { + throw new Error('Invalid prompt: must be a non-empty string'); + } + + // Default parameters (can be overridden by options) + const defaults = { + image_size: 'landscape_16_9', + num_inference_steps: 28, + guidance_scale: 3.5, + num_images: 1, + enable_safety_checker: false, + output_format: 'jpeg' + }; + + const params = { ...defaults, ...options }; + + // Clean and enhance prompt + const cleanPrompt = cleanPromptText(prompt); + const enhancedPrompt = `${cleanPrompt}, clean composition, no text, no watermark, no logo, professional photography`; + + // Generate random seed if not provided + if (!params.seed) { + params.seed = Math.floor(Math.random() * 2147483647); + } + + const negativePrompt = 'text, words, letters, watermark, logo, signature, writing, captions, subtitles, blurry, low quality, distorted, deformed'; + + try { + const response = $http.request({ + method: 'POST', + url: 'https://fal.run/fal-ai/flux/dev', + headers: { + 'Authorization': `Key ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: { + prompt: enhancedPrompt, + negative_prompt: negativePrompt, + ...params + }, + timeout: 120000 // 2 minute timeout + }); + + return response.body; + } catch (error) { + throw new Error(`Image generation failed: ${error.message}`); + } +} + +/** + * Clean prompt text by removing problematic characters + * @param {string} prompt - Raw prompt text + * @returns {string} Cleaned prompt + */ +function cleanPromptText(prompt) { + if (!prompt) { + return ''; + } + + return prompt + .replace(/\n+/g, ' ') // Remove newlines + .replace(/\s+/g, ' ') // Normalize whitespace + .replace(/["']/g, '') // Remove quotes + .replace(/[^\w\s,.-]/g, '') // Remove special characters except common punctuation + .trim(); +} + +/** + * Validate prompt before generation + * @param {string} prompt - Prompt to validate + * @returns {object} Validation result with isValid, errors, and cleanedPrompt + */ +function validatePrompt(prompt) { + const errors = []; + + if (!prompt || typeof prompt !== 'string') { + errors.push('Prompt must be a string'); + return { + isValid: false, + errors: errors, + cleanedPrompt: '' + }; + } + + const cleaned = cleanPromptText(prompt); + + if (cleaned === '') { + errors.push('Prompt is empty after cleaning'); + } + + if (cleaned.length > 2000) { + errors.push('Prompt exceeds 2000 character limit'); + } + + if (cleaned.length < 10) { + errors.push('Prompt is too short (minimum 10 characters)'); + } + + // Check for forbidden terms + const forbidden = ['nsfw', 'explicit', 'nude', 'naked']; + const lowerPrompt = cleaned.toLowerCase(); + forbidden.forEach(term => { + if (lowerPrompt.includes(term)) { + errors.push(`Prompt contains forbidden term: ${term}`); + } + }); + + return { + isValid: errors.length === 0, + errors: errors, + cleanedPrompt: cleaned + }; +} + +/** + * Get image dimensions for a format preset + * @param {string} format - Format name (square, story, landscape, etc.) + * @returns {object} Width and height in pixels + */ +function getDimensionsForFormat(format) { + const dimensions = { + square: { width: 1024, height: 1024 }, + square_hd: { width: 1024, height: 1024 }, + story: { width: 1080, height: 1920 }, + landscape: { width: 1200, height: 628 }, + landscape_4_3: { width: 1024, height: 768 }, + landscape_16_9: { width: 1344, height: 768 }, + portrait_4_3: { width: 768, height: 1024 }, + portrait_16_9: { width: 768, height: 1344 } + }; + + return dimensions[format] || dimensions.square; +} + +/** + * Build fal.ai API URL for specific model + * @param {string} model - Model name (fluxDev, fluxSchnell, fluxPro) + * @returns {string} Full API endpoint URL + */ +function getModelUrl(model = 'fluxDev') { + const models = { + fluxDev: 'https://fal.run/fal-ai/flux/dev', + fluxSchnell: 'https://fal.run/fal-ai/flux/schnell', + fluxPro: 'https://fal.run/fal-ai/flux-pro' + }; + + return models[model] || models.fluxDev; +} + +/** + * Parse fal.ai error response + * @param {object} error - Error object from API call + * @returns {object} Parsed error details + */ +function parseError(error) { + let errorMessage = 'Unknown error'; + let errorCode = 500; + let isRetryable = false; + + try { + if (error.response) { + errorCode = error.response.statusCode || 500; + const body = error.response.body; + + if (typeof body === 'string') { + errorMessage = body; + } else if (body && body.detail) { + errorMessage = body.detail; + } else if (body && body.error) { + errorMessage = body.error; + } else if (body) { + errorMessage = JSON.stringify(body); + } + } else { + errorMessage = error.message || String(error); + } + + // Determine if error is retryable + const retryableCodes = [429, 500, 502, 503, 504]; + isRetryable = retryableCodes.includes(errorCode); + + } catch (parseError) { + errorMessage = error.message || String(error); + } + + return { + code: errorCode, + message: errorMessage, + isRetryable: isRetryable, + timestamp: new Date().toISOString() + }; +} + +/** + * Extract image URL from fal.ai response + * @param {object} response - fal.ai API response + * @returns {string} Image URL or null + */ +function extractImageUrl(response) { + try { + if (response && response.images && Array.isArray(response.images) && response.images.length > 0) { + return response.images[0].url; + } + return null; + } catch (error) { + return null; + } +} + +// Export for use in n8n Code nodes +module.exports = { + generateImage, + cleanPromptText, + validatePrompt, + getDimensionsForFormat, + getModelUrl, + parseError, + extractImageUrl +}; diff --git a/utils/n8n-helpers/prompt-builder.js b/utils/n8n-helpers/prompt-builder.js new file mode 100644 index 00000000..80277754 --- /dev/null +++ b/utils/n8n-helpers/prompt-builder.js @@ -0,0 +1,244 @@ +/** + * Prompt Builder for AI Image Generation + * + * Constructs optimized prompts from Airtable data + * Uses templates from config/prompt-templates.json + * + * Usage in n8n Code node: + * const { buildImagePrompt, extractPrompts } = require('./utils/n8n-helpers/prompt-builder.js'); + * const prompts = extractPrompts(record.fields); + */ + +/** + * Build image prompt from ad copy data + * @param {object} adCopyData - Airtable Ad Copy record fields + * @param {object} options - Build options + * @returns {string} Optimized image prompt + */ +function buildImagePrompt(adCopyData, options = {}) { + const { + fullConcept, + avatarTarget, + angle, + product, + scene + } = adCopyData; + + // Use existing prompt if available and requested + if (options.useExisting && adCopyData['Prompt 1']) { + return adCopyData['Prompt 1']; + } + + // Build prompt from components + let prompt = fullConcept || ''; + + // Add context + if (avatarTarget) { + prompt += `, targeted at ${avatarTarget}`; + } + + if (angle) { + prompt += `, ${angle} marketing angle`; + } + + // Add product details if available + if (product && typeof product === 'object' && product.description) { + prompt += `, featuring ${product.description}`; + } else if (typeof product === 'string') { + prompt += `, featuring ${product}`; + } + + // Add scene details if available + if (scene) { + if (typeof scene === 'object') { + if (scene.lighting) { + prompt += `, ${scene.lighting} lighting`; + } + if (scene.mood) { + prompt += `, ${scene.mood} mood`; + } + } else if (typeof scene === 'string') { + prompt += `, ${scene}`; + } + } + + return prompt.trim(); +} + +/** + * Extract prompts from Ad Copy record + * @param {object} fields - Airtable record fields + * @returns {array} Array of non-empty prompts + */ +function extractPrompts(fields) { + const prompts = []; + + // Check if using Image Prompts array field + if (fields['Image Prompts'] && typeof fields['Image Prompts'] === 'string') { + // Split by newlines if multiline text + const lines = fields['Image Prompts'].split('\n'); + lines.forEach(line => { + const trimmed = line.trim(); + if (trimmed) { + prompts.push(trimmed); + } + }); + } + + // Check individual prompt fields + const promptFields = ['Prompt 1', 'Prompt 2', 'Prompt 3']; + for (const field of promptFields) { + if (fields[field] && typeof fields[field] === 'string') { + const trimmed = fields[field].trim(); + if (trimmed && !prompts.includes(trimmed)) { + prompts.push(trimmed); + } + } + } + + // Fallback to Full Concept if no prompts found + if (prompts.length === 0 && fields['Full Concept']) { + prompts.push(fields['Full Concept']); + } + + return prompts; +} + +/** + * Add style modifiers to prompt + * @param {string} prompt - Base prompt + * @param {string} style - Style type (photorealistic, artistic, minimal, cinematic, lifestyle) + * @returns {string} Enhanced prompt with style modifiers + */ +function addStyleModifiers(prompt, style = 'photorealistic') { + const styleModifiers = { + photorealistic: 'photorealistic, highly detailed, 8k resolution, studio lighting, professional photography', + artistic: 'artistic rendering, creative composition, vibrant colors, aesthetic', + minimal: 'minimal design, clean aesthetic, simple composition, negative space', + cinematic: 'cinematic composition, dramatic lighting, film grain, bokeh, depth of field', + lifestyle: 'lifestyle photography, natural lighting, authentic moment, candid' + }; + + const modifier = styleModifiers[style] || styleModifiers.photorealistic; + return `${prompt}, ${modifier}`; +} + +/** + * Add lighting preset to prompt + * @param {string} prompt - Base prompt + * @param {string} lighting - Lighting preset (goldenHour, studio, natural, dramatic, softDiffused) + * @returns {string} Prompt with lighting description + */ +function addLightingPreset(prompt, lighting = 'natural') { + const lightingPresets = { + goldenHour: 'golden hour lighting, warm tones, soft shadows, sunset glow', + studio: 'studio lighting, soft box, even illumination, professional setup', + natural: 'natural daylight, window light, soft ambient', + dramatic: 'dramatic lighting, strong shadows, high contrast, moody', + softDiffused: 'soft diffused lighting, gentle shadows, flattering' + }; + + const preset = lightingPresets[lighting] || lightingPresets.natural; + return `${prompt}, ${preset}`; +} + +/** + * Create prompt variations using different strategies + * @param {string} basePrompt - Base prompt text + * @param {number} count - Number of variations to create (1-5) + * @returns {array} Array of prompt variations + */ +function createVariations(basePrompt, count = 3) { + if (count < 1 || count > 5) { + throw new Error('Variation count must be between 1 and 5'); + } + + const variations = []; + + // Strategy 1: Different angles + const angles = ['close-up shot', 'wide angle view', 'eye level perspective', 'aerial view', 'low angle shot']; + + // Strategy 2: Different lighting + const lightings = ['golden hour lighting', 'studio lighting', 'natural daylight', 'dramatic lighting', 'soft diffused light']; + + // Strategy 3: Different moods + const moods = ['energetic and vibrant', 'calm and peaceful', 'professional and clean', 'playful and fun', 'elegant and sophisticated']; + + for (let i = 0; i < count; i++) { + let variation = basePrompt; + + // Add angle variation + if (angles[i]) { + variation += `, ${angles[i]}`; + } + + // Add lighting variation + if (lightings[i]) { + variation += `, ${lightings[i]}`; + } + + // Add mood variation + if (moods[i]) { + variation += `, ${moods[i]} atmosphere`; + } + + variations.push(variation); + } + + return variations; +} + +/** + * Clean and normalize prompt text + * @param {string} prompt - Raw prompt + * @returns {string} Cleaned prompt + */ +function cleanPrompt(prompt) { + if (!prompt) { + return ''; + } + + return prompt + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .replace(/["']/g, '') + .trim(); +} + +/** + * Combine multiple prompt components + * @param {object} components - Prompt components (subject, environment, lighting, mood, camera) + * @returns {string} Combined prompt + */ +function combineComponents(components) { + const { + subject, + environment, + lighting, + mood, + camera, + style + } = components; + + const parts = []; + + if (subject) parts.push(subject); + if (environment) parts.push(`in ${environment}`); + if (camera) parts.push(camera); + if (lighting) parts.push(lighting); + if (mood) parts.push(`${mood} atmosphere`); + if (style) parts.push(style); + + return parts.join(', '); +} + +// Export for use in n8n Code nodes +module.exports = { + buildImagePrompt, + extractPrompts, + addStyleModifiers, + addLightingPreset, + createVariations, + cleanPrompt, + combineComponents +}; diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 00000000..41cd418f --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,566 @@ +# n8n Workflow Implementation Guide + +This guide provides step-by-step instructions for building the three critical workflows needed for the mass production pipeline. + +## Prerequisites + +- n8n Cloud account (or self-hosted n8n) +- Airtable Personal Access Token configured in n8n credentials +- fal.ai API key configured in n8n credentials +- Bannerbear API key configured in n8n credentials + +## Workflow 1: pipeline-start (Single Record Pipeline) + +**Purpose:** Process a single Airtable record through the full creative pipeline. + +**Webhook URL:** `https://williamsforeal.app.n8n.cloud/webhook/pipeline-start` + +**Expected Input:** +```json +{ + "recordId": "recXXXXXXXXXXXXXX", + "skipOverlay": false, + "format": "square", + "highQuality": false +} +``` + +**Expected Output:** +```json +{ + "pipelineId": "pipeline-{timestamp}-{recordId}", + "status": "queued", + "estimatedTime": 45 +} +``` + +### Step-by-Step Workflow Construction + +#### Node 1: Webhook (Trigger) +- **Type:** Webhook +- **Method:** POST +- **Path:** `pipeline-start` +- **Response Mode:** Respond to Webhook +- **Output:** `{{ $json }}` + +#### Node 2: Extract Parameters +- **Type:** Code +- **Code:** +```javascript +const recordId = $input.item.json.body.recordId; +const skipOverlay = $input.item.json.body.skipOverlay || false; +const format = $input.item.json.body.format || 'square'; +const highQuality = $input.item.json.body.highQuality || false; +const pipelineId = `pipeline-${Date.now()}-${recordId}`; + +return { + recordId, + skipOverlay, + format, + highQuality, + pipelineId, + startTime: Date.now() +}; +``` + +#### Node 3: Fetch Airtable Record +- **Type:** HTTP Request +- **Method:** GET +- **URL:** `https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ $env.AIRTABLE_TABLE_ID }}/{{ $json.recordId }}` +- **Authentication:** Generic Credential Type + - **Type:** Header Auth + - **Name:** Authorization + - **Value:** `Bearer {{ $env.AIRTABLE_PAT }}` +- **Headers:** + - `Content-Type: application/json` + +**Expected Response Structure:** +```json +{ + "id": "rec...", + "fields": { + "Full Concept": "...", + "Headline": "...", + "CTA": "...", + "Generate Image Prompts": "Done", + "Image Prompts": ["prompt1", "prompt2", ...] + } +} +``` + +#### Node 4: Extract Prompt and Copy +- **Type:** Code +- **Code:** +```javascript +const record = $input.item.json; +const fields = record.fields; + +// Get the first prompt from Image Prompts array, or use Full Concept +const prompt = (fields['Image Prompts'] && fields['Image Prompts'].length > 0) + ? fields['Image Prompts'][0] + : fields['Full Concept'] || ''; + +const headline = fields['Headline'] || fields['Full Concept'] || ''; +const cta = fields['CTA'] || 'Shop Now'; +const format = $('Extract Parameters').item.json.format; + +// Determine dimensions based on format +const dimensions = { + square: { width: 1080, height: 1080 }, + story: { width: 1080, height: 1920 }, + landscape: { width: 1200, height: 628 } +}[format] || { width: 1080, height: 1080 }; + +return { + ...$('Extract Parameters').item.json, + prompt: prompt, + headline: headline, + cta: cta, + width: dimensions.width, + height: dimensions.height, + recordFields: fields +}; +``` + +#### Node 5: Generate Image with fal.ai +- **Type:** HTTP Request +- **Method:** POST +- **URL:** `https://fal.run/fal-ai/flux/schnell` +- **Authentication:** Generic Credential Type + - **Type:** Header Auth + - **Name:** Authorization + - **Value:** `Key {{ $env.FAL_API_KEY }}` +- **Body (JSON):** +```json +{ + "prompt": "{{ $json.prompt }}, clean composition, no text, no watermark, no logo, professional photography", + "negative_prompt": "text, words, letters, watermark, logo, signature, writing, captions, subtitles, blurry, low quality", + "image_size": { + "width": {{ $json.width }}, + "height": {{ $json.height }} + }, + "num_images": 1, + "num_inference_steps": 28, + "guidance_scale": 7.5 +} +``` + +**Expected Response:** +```json +{ + "images": [ + { + "url": "https://fal.media/files/...", + "width": 1080, + "height": 1080 + } + ], + "seed": 12345 +} +``` + +#### Node 6: Check if Overlay Needed +- **Type:** IF +- **Condition:** `{{ $json.skipOverlay }}` equals `false` + +**True Branch (Apply Bannerbear Overlay):** + +#### Node 7: Prepare Bannerbear Request +- **Type:** Code +- **Code:** +```javascript +const falResult = $('Generate Image with fal.ai').item.json; +const baseImageUrl = falResult.images[0].url; +const params = $('Extract Prompt and Copy').item.json; + +// Get template ID based on format (these should match your Bannerbear templates) +const templateIds = { + square: '{{ $env.BANNERBEAR_TEMPLATE_SQUARE }}', + story: '{{ $env.BANNERBEAR_TEMPLATE_STORY }}', + landscape: '{{ $env.BANNERBEAR_TEMPLATE_LANDSCAPE }}' +}; + +const templateId = templateIds[params.format] || templateIds.square; + +const modifications = [ + { + name: 'background', + image_url: baseImageUrl + }, + { + name: 'headline_text', + text: params.headline + }, + { + name: 'cta_button', + text: params.cta + } +]; + +return { + template: templateId, + modifications: modifications, + synchronous: false +}; +``` + +#### Node 8: Create Bannerbear Image +- **Type:** HTTP Request +- **Method:** POST +- **URL:** `https://api.bannerbear.com/v2/images` +- **Authentication:** Generic Credential Type + - **Type:** Header Auth + - **Name:** Authorization + - **Value:** `Bearer {{ $env.BANNERBEAR_API_KEY }}` +- **Body (JSON):** `{{ $json }}` + +**Expected Response:** +```json +{ + "uid": "bb_...", + "status": "pending", + "image_url": null +} +``` + +#### Node 9: Poll Bannerbear Completion +- **Type:** HTTP Request (Loop) +- **Method:** GET +- **URL:** `https://api.bannerbear.com/v2/images/{{ $json.uid }}` +- **Authentication:** Same as Node 8 +- **Loop:** Continue until `status === "completed"` (max 30 attempts, 2s interval) + +#### Node 10: Merge Results (True Branch) +- **Type:** Code +- **Code:** +```javascript +const falResult = $('Generate Image with fal.ai').item.json; +const bannerbearResult = $('Poll Bannerbear Completion').item.json; +const params = $('Extract Prompt and Copy').item.json; + +return { + ...params, + baseImageUrl: falResult.images[0].url, + finalImageUrl: bannerbearResult.image_url, + bannerbearUid: bannerbearResult.uid, + seed: falResult.seed +}; +``` + +**False Branch (Skip Overlay):** + +#### Node 11: Merge Results (False Branch) +- **Type:** Code +- **Code:** +```javascript +const falResult = $('Generate Image with fal.ai').item.json; +const params = $('Extract Prompt and Copy').item.json; + +return { + ...params, + baseImageUrl: falResult.images[0].url, + finalImageUrl: null, + seed: falResult.seed +}; +``` + +#### Node 12: Update Airtable Record +- **Type:** HTTP Request +- **Method:** PATCH +- **URL:** `https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ $env.AIRTABLE_TABLE_ID }}/{{ $json.recordId }}` +- **Authentication:** Same as Node 3 +- **Body (JSON):** +```json +{ + "fields": { + "Base Image URL": "{{ $json.baseImageUrl }}", + "Final Image URL": "{{ $json.finalImageUrl || '' }}", + "Status": "Generated", + "Generated At": "{{ $now.toISO() }}", + "Seed": {{ $json.seed }}, + "Model": "fal-ai/flux/schnell", + "Width": {{ $json.width }}, + "Height": {{ $json.height }} + } +} +``` + +#### Node 13: Return Success Response +- **Type:** Code +- **Code:** +```javascript +const params = $('Extract Parameters').item.json; +const result = $input.item.json; + +return { + pipelineId: params.pipelineId, + status: 'completed', + recordId: params.recordId, + baseImageUrl: result.baseImageUrl, + finalImageUrl: result.finalImageUrl, + estimatedTime: Math.round((Date.now() - params.startTime) / 1000) +}; +``` + +#### Node 14: Error Handler +- **Type:** Code (On Error) +- **Code:** +```javascript +const error = $input.item.json.error; +const params = $('Extract Parameters').item.json; + +// Update Airtable with error +// (Add HTTP Request node here to update Airtable Status = "Failed", Error = error.message) + +return { + pipelineId: params.pipelineId, + status: 'failed', + error: error.message +}; +``` + +--- + +## Workflow 2: pipeline-batch (Batch Processing) + +**Purpose:** Process multiple records in parallel with progress tracking. + +**Webhook URL:** `https://williamsforeal.app.n8n.cloud/webhook/pipeline-batch` + +**Expected Input:** +```json +{ + "recordIds": ["rec1", "rec2", "rec3"], + "skipOverlay": false, + "highQuality": false +} +``` + +**Expected Output:** +```json +{ + "batchId": "batch-{timestamp}", + "jobCount": 3, + "estimatedTime": 135 +} +``` + +### Step-by-Step Workflow Construction + +#### Node 1: Webhook (Trigger) +- **Type:** Webhook +- **Method:** POST +- **Path:** `pipeline-batch` + +#### Node 2: Create Batch Job +- **Type:** Code +- **Code:** +```javascript +const recordIds = $input.item.json.body.recordIds || []; +const batchId = `batch-${Date.now()}`; +const skipOverlay = $input.item.json.body.skipOverlay || false; +const highQuality = $input.item.json.body.highQuality || false; + +// Store batch job in n8n database or Airtable +// For now, we'll track in memory (in production, use n8n database) + +return { + batchId, + recordIds, + skipOverlay, + highQuality, + total: recordIds.length, + completed: 0, + failed: 0, + results: [], + startTime: Date.now() +}; +``` + +#### Node 3: Split into Items +- **Type:** Split In Batches +- **Batch Size:** 5 (process 5 records at a time) + +#### Node 4: Process Each Record (Sub-workflow or Loop) +- **Type:** Execute Workflow (or HTTP Request to pipeline-start) +- **For each recordId:** + - Call `pipeline-start` workflow + - Or inline the pipeline logic + +**Option A: Call pipeline-start workflow** +- **Type:** HTTP Request +- **Method:** POST +- **URL:** `https://williamsforeal.app.n8n.cloud/webhook/pipeline-start` +- **Body:** +```json +{ + "recordId": "{{ $json.recordId }}", + "skipOverlay": "{{ $json.skipOverlay }}", + "highQuality": "{{ $json.highQuality }}" +} +``` + +#### Node 5: Aggregate Results +- **Type:** Code +- **Code:** +```javascript +const batchData = $('Create Batch Job').item.json; +const results = $input.all(); + +const completed = results.filter(r => r.json.status === 'completed').length; +const failed = results.filter(r => r.json.status === 'failed').length; + +return { + batchId: batchData.batchId, + status: completed + failed === batchData.total ? 'completed' : 'processing', + progress: Math.round(((completed + failed) / batchData.total) * 100), + completed: completed, + total: batchData.total, + results: results.map(r => ({ + recordId: r.json.recordId, + status: r.json.status, + baseImageUrl: r.json.baseImageUrl, + finalImageUrl: r.json.finalImageUrl, + error: r.json.error + })) +}; +``` + +#### Node 6: Return Batch Response +- **Type:** Code +- **Code:** +```javascript +const aggregated = $input.item.json; +const batchData = $('Create Batch Job').item.json; + +return { + batchId: aggregated.batchId, + jobCount: aggregated.total, + estimatedTime: Math.round((Date.now() - batchData.startTime) / 1000), + status: aggregated.status, + progress: aggregated.progress +}; +``` + +--- + +## Workflow 3: pipeline-status (Status Check) + +**Purpose:** Check the status of a batch or single pipeline job. + +**Webhook URL:** `https://williamsforeal.app.n8n.cloud/webhook/pipeline-status` + +**Expected Input (Query Params):** +- `id`: Batch ID or Pipeline ID +- `type`: `"batch"` or `"pipeline"` + +**Expected Output:** +```json +{ + "id": "batch-1234567890", + "status": "processing", + "progress": 60, + "completed": 3, + "total": 5, + "results": [...] +} +``` + +### Step-by-Step Workflow Construction + +#### Node 1: Webhook (Trigger) +- **Type:** Webhook +- **Method:** GET +- **Path:** `pipeline-status` +- **Query Parameters:** `id`, `type` + +#### Node 2: Extract Parameters +- **Type:** Code +- **Code:** +```javascript +const id = $input.item.json.query.id; +const type = $input.item.json.query.type || 'pipeline'; + +return { id, type }; +``` + +#### Node 3: Check Type and Fetch Status +- **Type:** IF +- **Condition:** `{{ $json.type }}` equals `"batch"` + +**True Branch (Batch Status):** +- Fetch batch job from n8n database or Airtable +- Return aggregated status + +**False Branch (Pipeline Status):** +- Check if pipeline is complete +- Return single record status + +#### Node 4: Return Status Response +- **Type:** Code +- **Code:** +```javascript +// This would fetch from your storage (n8n database, Airtable, or Redis) +// For now, return structure: + +return { + id: $('Extract Parameters').item.json.id, + status: 'processing', // or 'completed', 'failed', 'queued' + progress: 60, + completed: 3, + total: 5, + results: [ + { + recordId: 'rec1', + status: 'success', + baseImageUrl: 'https://...', + finalImageUrl: 'https://...' + }, + // ... + ] +}; +``` + +--- + +## Environment Variables in n8n + +Configure these in your n8n Cloud account settings: + +- `AIRTABLE_BASE_ID`: Your Airtable base ID +- `AIRTABLE_TABLE_ID`: Your Airtable table ID (Images table) +- `AIRTABLE_PAT`: Your Airtable Personal Access Token +- `FAL_API_KEY`: Your fal.ai API key +- `BANNERBEAR_API_KEY`: Your Bannerbear API key +- `BANNERBEAR_TEMPLATE_SQUARE`: Your square template UID +- `BANNERBEAR_TEMPLATE_STORY`: Your story template UID +- `BANNERBEAR_TEMPLATE_LANDSCAPE`: Your landscape template UID + +## Testing Workflows + +1. **Test pipeline-start:** + ```bash + curl -X POST https://williamsforeal.app.n8n.cloud/webhook/pipeline-start \ + -H "Content-Type: application/json" \ + -d '{"recordId": "recYOURRECORDID", "skipOverlay": false, "format": "square"}' + ``` + +2. **Test pipeline-batch:** + ```bash + curl -X POST https://williamsforeal.app.n8n.cloud/webhook/pipeline-batch \ + -H "Content-Type: application/json" \ + -d '{"recordIds": ["rec1", "rec2"], "skipOverlay": false}' + ``` + +3. **Test pipeline-status:** + ```bash + curl "https://williamsforeal.app.n8n.cloud/webhook/pipeline-status?id=batch-123&type=batch" + ``` + +## Important Notes + +1. **Error Handling:** Always wrap API calls in error handlers +2. **Rate Limiting:** Add delays between API calls to respect rate limits +3. **Storage:** For batch status tracking, consider using n8n's database or Airtable itself +4. **Webhook URLs:** Make sure webhook paths match exactly what's in `src/lib/n8n.ts` +5. **Testing:** Test each workflow individually before connecting them diff --git a/workflows/backups/.gitkeep b/workflows/backups/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/workflows/claude-n8n-http.json b/workflows/claude-n8n-http.json new file mode 100644 index 00000000..cc8069cb --- /dev/null +++ b/workflows/claude-n8n-http.json @@ -0,0 +1,26 @@ +{ + "parameters": { + "method": "POST", + "url": "https://queue.fal.run/fal-ai/flux/dev", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "Key {{ $credentials.falApiKey.apiKey }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"prompt\": $json['Image Prompt'],\n \"image_size\": \"square_hd\",\n \"num_inference_steps\": 28,\n \"guidance_scale\": 3.5,\n \"num_images\": 1,\n \"enable_safety_checker\": false,\n \"seed\": Math.floor(Math.random() * 2147483647)\n}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [368, 1360], + "id": "6b878b1c-713d-4dc5-95e2-976a4d70583f", + "name": "HTTP Request" + } \ No newline at end of file diff --git a/workflows/gumloop-airtable-mcp.json b/workflows/gumloop-airtable-mcp.json new file mode 100644 index 00000000..8a774432 --- /dev/null +++ b/workflows/gumloop-airtable-mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "airtable-guMCP-server": { + "command": "npx", + "args": [ + "mcp-remote@0.1.12", + "https://mcp.gumloop.com/airtable/Po4tsiByVANmPe33wJygwM6qNJG3:d965ea51425a4c7e9c15f5d3b21088c3:eyJleHRlcm5hbF9jbGllbnQiOnRydWV9/mcp" + ] + } + } +} \ No newline at end of file diff --git a/workflows/palmaura-fal-image-generation.json b/workflows/palmaura-fal-image-generation.json new file mode 100644 index 00000000..26c3b76d --- /dev/null +++ b/workflows/palmaura-fal-image-generation.json @@ -0,0 +1,852 @@ +{ + "name": "PalmAura - fal.ai Image Generation at Scale", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 1 + } + ] + } + }, + "id": "schedule-trigger", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, 0], + "notes": "Runs hourly. Adjust interval based on your generation volume." + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.N8N_WEBHOOK_URL }}/webhook/palmaura-generate-images", + "options": {} + }, + "id": "webhook-trigger", + "name": "Webhook Trigger", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [0, 200], + "webhookId": "palmaura-generate-images", + "notes": "Alternative trigger via Airtable automation or manual call" + }, + { + "parameters": { + "operation": "search", + "base": { + "__rl": true, + "value": "={{ $env.AIRTABLE_BASE_ID }}", + "mode": "id" + }, + "table": { + "__rl": true, + "value": "Ad Copy", + "mode": "list" + }, + "filterByFormula": "AND({Generate Image Prompts} = 'Done', {Image Generated} != TRUE(), {Prompt 1} != '')", + "options": { + "fields": [ + "recordId", + "Full Concept", + "Avatar Target", + "Angle", + "Prompt 1", + "Prompt 2", + "Prompt 3", + "headline", + "CTA", + "Product" + ] + } + }, + "id": "airtable-search", + "name": "Search Records Ready for Image Gen", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2.1, + "position": [250, 100], + "credentials": { + "airtableTokenApi": { + "id": "airtable-credentials", + "name": "Airtable Personal Access Token" + } + }, + "notes": "Fetches Ad Copy records that have prompts but no generated images" + }, + { + "parameters": { + "jsCode": "// ============================================\n// PROMPT VALIDATION & TRANSFORMATION\n// ============================================\n// Purpose: Validate prompts, flatten for batch processing,\n// add metadata for tracking and retry logic\n\nconst items = $input.all();\nconst outputItems = [];\n\nfor (const item of items) {\n const record = item.json;\n const recordId = record.id || record.recordId;\n \n // Extract all prompt fields (Prompt 1, Prompt 2, Prompt 3)\n const promptFields = ['Prompt 1', 'Prompt 2', 'Prompt 3'];\n \n for (let i = 0; i < promptFields.length; i++) {\n const promptField = promptFields[i];\n const prompt = record[promptField];\n \n // Skip empty prompts\n if (!prompt || prompt.trim() === '') continue;\n \n // Validate prompt length (fal.ai has limits)\n if (prompt.length > 2000) {\n console.log(`WARNING: Prompt too long for ${recordId}, ${promptField}`);\n continue;\n }\n \n // Clean the prompt\n const cleanPrompt = prompt\n .replace(/\\n+/g, ' ') // Remove newlines\n .replace(/\\s+/g, ' ') // Normalize whitespace\n .replace(/[\"']/g, '') // Remove quotes that might break JSON\n .trim();\n \n if (cleanPrompt.length < 10) {\n console.log(`WARNING: Prompt too short for ${recordId}, ${promptField}`);\n continue;\n }\n \n outputItems.push({\n json: {\n // Record tracking\n recordId: recordId,\n promptIndex: i + 1,\n promptField: promptField,\n \n // Content\n cleanPrompt: cleanPrompt,\n fullConcept: record['Full Concept'] || '',\n avatarTarget: record['Avatar Target'] || '',\n angle: record['Angle'] || '',\n headline: record['headline'] || '',\n \n // Metadata for Airtable update\n outputField: `Image ${i + 1}`,\n \n // Retry tracking\n attemptNumber: 1,\n maxAttempts: 3,\n \n // Timestamp\n queuedAt: new Date().toISOString()\n }\n });\n }\n}\n\nconsole.log(`Prepared ${outputItems.length} prompts for generation`);\n\n// Return empty array handler\nif (outputItems.length === 0) {\n return [{ json: { _noItems: true, message: 'No valid prompts to process' } }];\n}\n\nreturn outputItems;" + }, + "id": "js-validate-prompts", + "name": "Validate & Flatten Prompts", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [500, 100], + "notes": "Validates prompts, flattens multi-prompt records into individual items for parallel processing" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "check-has-items", + "leftValue": "={{ $json._noItems }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-has-items", + "name": "Has Items to Process?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [750, 100], + "notes": "Guards against empty batches" + }, + { + "parameters": { + "batchSize": 5, + "options": {} + }, + "id": "split-batches", + "name": "Split In Batches", + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 3, + "position": [1000, 0], + "notes": "Process 5 images at a time to avoid rate limits. Adjust based on your fal.ai plan." + }, + { + "parameters": { + "amount": 1, + "unit": "seconds" + }, + "id": "rate-limit-delay", + "name": "Rate Limit Delay", + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [1250, 0], + "notes": "1 second delay between batches. Increase if hitting rate limits." + }, + { + "parameters": { + "method": "POST", + "url": "https://fal.run/fal-ai/flux/dev", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"prompt\": {{ JSON.stringify($json.cleanPrompt) }},\n \"image_size\": \"landscape_16_9\",\n \"num_inference_steps\": 28,\n \"guidance_scale\": 3.5,\n \"num_images\": 1,\n \"enable_safety_checker\": false,\n \"output_format\": \"jpeg\",\n \"seed\": {{ Math.floor(Math.random() * 2147483647) }}\n}", + "options": { + "response": { + "response": { + "fullResponse": true + } + }, + "timeout": 120000 + } + }, + "id": "fal-ai-request", + "name": "fal.ai Flux Dev", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1500, 0], + "credentials": { + "httpHeaderAuth": { + "id": "fal-api-key", + "name": "fal.ai API Key" + } + }, + "continueOnFail": true, + "notes": "Main image generation call. Using Flux Dev for photorealistic quality." + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "check-success", + "leftValue": "={{ $json.statusCode }}", + "rightValue": 200, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-success", + "name": "Generation Success?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1750, 0], + "notes": "Routes successful vs failed generations" + }, + { + "parameters": { + "jsCode": "// ============================================\n// EXTRACT IMAGE DATA FROM FAL.AI RESPONSE\n// ============================================\n\nconst response = $json;\nconst inputData = $('Split In Batches').item.json;\n\n// Parse the response body\nlet body;\ntry {\n body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n} catch (e) {\n body = response.body;\n}\n\n// Extract image URL\nconst imageUrl = body?.images?.[0]?.url || null;\nconst seed = body?.seed || null;\nconst width = body?.images?.[0]?.width || null;\nconst height = body?.images?.[0]?.height || null;\n\nif (!imageUrl) {\n throw new Error('No image URL in response');\n}\n\nreturn {\n json: {\n // Original tracking data\n recordId: inputData.recordId,\n promptIndex: inputData.promptIndex,\n promptField: inputData.promptField,\n outputField: inputData.outputField,\n \n // Generated image data\n imageUrl: imageUrl,\n seed: seed,\n width: width,\n height: height,\n \n // Metadata\n generatedAt: new Date().toISOString(),\n model: 'fal-ai/flux/dev',\n \n // For Airtable attachment format\n attachmentArray: [\n {\n url: imageUrl,\n filename: `${inputData.recordId}_prompt${inputData.promptIndex}.jpg`\n }\n ]\n }\n};" + }, + "id": "js-extract-image", + "name": "Extract Image Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2000, -100], + "notes": "Parses fal.ai response and formats for Airtable" + }, + { + "parameters": { + "operation": "update", + "base": { + "__rl": true, + "value": "={{ $env.AIRTABLE_BASE_ID }}", + "mode": "id" + }, + "table": { + "__rl": true, + "value": "Ad Copy", + "mode": "list" + }, + "id": "={{ $json.recordId }}", + "columns": { + "mappingMode": "defineBelow", + "value": { + "Image Generated": true, + "Image Gen Timestamp": "={{ $json.generatedAt }}", + "Image Seed": "={{ $json.seed }}" + }, + "matchingColumns": [], + "schema": [ + { + "id": "Image Generated", + "displayName": "Image Generated", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "boolean" + }, + { + "id": "Image Gen Timestamp", + "displayName": "Image Gen Timestamp", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Image Seed", + "displayName": "Image Seed", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + } + ] + }, + "options": {} + }, + "id": "airtable-update-success", + "name": "Update Ad Copy Record", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2.1, + "position": [2250, -200], + "credentials": { + "airtableTokenApi": { + "id": "airtable-credentials", + "name": "Airtable Personal Access Token" + } + }, + "notes": "Marks record as processed and stores seed for reproducibility" + }, + { + "parameters": { + "operation": "create", + "base": { + "__rl": true, + "value": "={{ $env.AIRTABLE_BASE_ID }}", + "mode": "id" + }, + "table": { + "__rl": true, + "value": "Images", + "mode": "list" + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "Name": "={{ $('Extract Image Data').item.json.recordId }}_{{ $('Extract Image Data').item.json.outputField }}", + "Image URL": "={{ $('Extract Image Data').item.json.imageUrl }}", + "Source Record": "={{ $('Extract Image Data').item.json.recordId }}", + "Prompt Index": "={{ $('Extract Image Data').item.json.promptIndex }}", + "Seed": "={{ $('Extract Image Data').item.json.seed }}", + "Model": "={{ $('Extract Image Data').item.json.model }}", + "Generated At": "={{ $('Extract Image Data').item.json.generatedAt }}", + "Width": "={{ $('Extract Image Data').item.json.width }}", + "Height": "={{ $('Extract Image Data').item.json.height }}" + }, + "matchingColumns": [], + "schema": [ + { + "id": "Name", + "displayName": "Name", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Image URL", + "displayName": "Image URL", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Source Record", + "displayName": "Source Record", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Prompt Index", + "displayName": "Prompt Index", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + }, + { + "id": "Seed", + "displayName": "Seed", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + }, + { + "id": "Model", + "displayName": "Model", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Generated At", + "displayName": "Generated At", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Width", + "displayName": "Width", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + }, + { + "id": "Height", + "displayName": "Height", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + } + ] + }, + "options": {} + }, + "id": "airtable-create-image", + "name": "Create Image Record", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2.1, + "position": [2250, 0], + "credentials": { + "airtableTokenApi": { + "id": "airtable-credentials", + "name": "Airtable Personal Access Token" + } + }, + "notes": "Creates new record in Images table with all metadata" + }, + { + "parameters": { + "jsCode": "// ============================================\n// ERROR HANDLING & RETRY LOGIC\n// ============================================\n\nconst response = $json;\nconst inputData = $('Split In Batches').item.json;\n\n// Parse error details\nlet errorMessage = 'Unknown error';\nlet errorCode = response.statusCode || 500;\n\ntry {\n const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n errorMessage = body?.detail || body?.error || body?.message || JSON.stringify(body);\n} catch (e) {\n errorMessage = response.body || e.message;\n}\n\n// Determine if retryable\nconst retryableCodes = [429, 500, 502, 503, 504];\nconst isRetryable = retryableCodes.includes(errorCode);\nconst currentAttempt = inputData.attemptNumber || 1;\nconst maxAttempts = inputData.maxAttempts || 3;\nconst shouldRetry = isRetryable && currentAttempt < maxAttempts;\n\nconsole.log(`ERROR: ${errorCode} - ${errorMessage}`);\nconsole.log(`Record: ${inputData.recordId}, Prompt: ${inputData.promptIndex}`);\nconsole.log(`Attempt ${currentAttempt}/${maxAttempts}, Will retry: ${shouldRetry}`);\n\nreturn {\n json: {\n // Original data\n recordId: inputData.recordId,\n promptIndex: inputData.promptIndex,\n promptField: inputData.promptField,\n cleanPrompt: inputData.cleanPrompt,\n \n // Error details\n errorCode: errorCode,\n errorMessage: errorMessage,\n failedAt: new Date().toISOString(),\n \n // Retry logic\n isRetryable: isRetryable,\n shouldRetry: shouldRetry,\n attemptNumber: currentAttempt + 1,\n maxAttempts: maxAttempts\n }\n};" + }, + "id": "js-handle-error", + "name": "Handle Error", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2000, 100], + "notes": "Parses errors and determines retry eligibility" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "check-retry", + "leftValue": "={{ $json.shouldRetry }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-should-retry", + "name": "Should Retry?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [2250, 100], + "notes": "Routes to retry queue or permanent failure logging" + }, + { + "parameters": { + "amount": 5, + "unit": "seconds" + }, + "id": "retry-delay", + "name": "Retry Delay", + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [2500, 50], + "notes": "5 second backoff before retry" + }, + { + "parameters": { + "operation": "update", + "base": { + "__rl": true, + "value": "={{ $env.AIRTABLE_BASE_ID }}", + "mode": "id" + }, + "table": { + "__rl": true, + "value": "Ad Copy", + "mode": "list" + }, + "id": "={{ $json.recordId }}", + "columns": { + "mappingMode": "defineBelow", + "value": { + "Image Gen Error": "={{ $json.errorMessage }}", + "Image Gen Failed At": "={{ $json.failedAt }}", + "Image Gen Attempts": "={{ $json.attemptNumber - 1 }}" + }, + "matchingColumns": [], + "schema": [ + { + "id": "Image Gen Error", + "displayName": "Image Gen Error", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Image Gen Failed At", + "displayName": "Image Gen Failed At", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "string" + }, + { + "id": "Image Gen Attempts", + "displayName": "Image Gen Attempts", + "required": false, + "defaultMatch": false, + "canBeUsedToMatch": true, + "display": true, + "type": "number" + } + ] + }, + "options": {} + }, + "id": "airtable-log-failure", + "name": "Log Permanent Failure", + "type": "n8n-nodes-base.airtable", + "typeVersion": 2.1, + "position": [2500, 200], + "credentials": { + "airtableTokenApi": { + "id": "airtable-credentials", + "name": "Airtable Personal Access Token" + } + }, + "notes": "Records permanent failure for manual review" + }, + { + "parameters": {}, + "id": "no-op-end", + "name": "No Items - End", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1000, 200], + "notes": "Clean exit when no items to process" + }, + { + "parameters": {}, + "id": "continue-batch", + "name": "Continue Batch", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [2500, 200], + "notes": "Triggers the next batch without re-feeding items" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "merge-back", + "name": "cleanPrompt", + "value": "={{ $json.cleanPrompt }}", + "type": "string" + }, + { + "id": "record-id", + "name": "recordId", + "value": "={{ $json.recordId }}", + "type": "string" + }, + { + "id": "prompt-index", + "name": "promptIndex", + "value": "={{ $json.promptIndex }}", + "type": "number" + }, + { + "id": "attempt", + "name": "attemptNumber", + "value": "={{ $json.attemptNumber }}", + "type": "number" + }, + { + "id": "max-attempts", + "name": "maxAttempts", + "value": "={{ $json.maxAttempts }}", + "type": "number" + }, + { + "id": "prompt-field", + "name": "promptField", + "value": "={{ $json.promptField }}", + "type": "string" + }, + { + "id": "output-field", + "name": "outputField", + "value": "={{ $json.outputField }}", + "type": "string" + } + ] + }, + "options": {} + }, + "id": "set-retry-data", + "name": "Prepare Retry Data", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [2750, 50], + "notes": "Formats data for retry attempt" + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "Search Records Ready for Image Gen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Webhook Trigger": { + "main": [ + [ + { + "node": "Search Records Ready for Image Gen", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search Records Ready for Image Gen": { + "main": [ + [ + { + "node": "Validate & Flatten Prompts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate & Flatten Prompts": { + "main": [ + [ + { + "node": "Has Items to Process?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has Items to Process?": { + "main": [ + [ + { + "node": "Split In Batches", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Items - End", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split In Batches": { + "main": [ + [ + { + "node": "Rate Limit Delay", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Items - End", + "type": "main", + "index": 0 + } + ] + ] + }, + "Rate Limit Delay": { + "main": [ + [ + { + "node": "fal.ai Flux Dev", + "type": "main", + "index": 0 + } + ] + ] + }, + "fal.ai Flux Dev": { + "main": [ + [ + { + "node": "Generation Success?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generation Success?": { + "main": [ + [ + { + "node": "Extract Image Data", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Handle Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Image Data": { + "main": [ + [ + { + "node": "Update Ad Copy Record", + "type": "main", + "index": 0 + }, + { + "node": "Create Image Record", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update Ad Copy Record": { + "main": [ + [ + { + "node": "Continue Batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Image Record": { + "main": [ + [] + ] + }, + "Handle Error": { + "main": [ + [ + { + "node": "Should Retry?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Should Retry?": { + "main": [ + [ + { + "node": "Retry Delay", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Permanent Failure", + "type": "main", + "index": 0 + } + ] + ] + }, + "Retry Delay": { + "main": [ + [ + { + "node": "Prepare Retry Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Retry Data": { + "main": [ + [ + { + "node": "fal.ai Flux Dev", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Permanent Failure": { + "main": [ + [ + { + "node": "Continue Batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Continue Batch": { + "main": [ + [ + { + "node": "Split In Batches", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveManualExecutions": true, + "callerPolicy": "workflowsFromSameOwner", + "errorWorkflow": "" + }, + "staticData": null, + "tags": [ + { + "name": "PalmAura", + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" + }, + { + "name": "Image Generation", + "createdAt": "2024-01-01T00:00:00.000Z", + "updatedAt": "2024-01-01T00:00:00.000Z" + } + ], + "pinData": {} +}