diff --git a/README.md b/README.md index 61e2299..4cb1b35 100644 --- a/README.md +++ b/README.md @@ -1,361 +1,997 @@ +
+ # Rio — AI-Powered Figma Plugin -**Rio** is a full-stack Figma plugin that supercharges the design workflow with conversational AI generation, JSON-based design import/export, a personal UI component library, and persistent design versioning. It is backed by a production-grade Node.js API with Stripe billing, Google OAuth, and multi-provider AI support. +**Conversational AI design generation · JSON import/export · Personal UI library · Stripe billing** + +[![Node.js](https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org) +[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)](https://react.dev) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-14%2B-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org) +[![Express](https://img.shields.io/badge/Express-5-000000?logo=express&logoColor=white)](https://expressjs.com) +[![Stripe](https://img.shields.io/badge/Stripe-v20-635BFF?logo=stripe&logoColor=white)](https://stripe.com) +[![AWS S3](https://img.shields.io/badge/AWS%20S3-SDK%20v3-FF9900?logo=amazons3&logoColor=white)](https://aws.amazon.com/s3) +[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) + +
--- ## Table of Contents -1. [Features](#features) -2. [Architecture](#architecture) -3. [Tech Stack](#tech-stack) -4. [Project Structure](#project-structure) -5. [Database Schema](#database-schema) -6. [API Reference](#api-reference) -7. [Authentication](#authentication) -8. [Environment Variables](#environment-variables) -9. [Development Setup](#development-setup) -10. [Scripts](#scripts) -11. [Deployment](#deployment) -12. [Diagrams](#diagrams) +1. [Overview](#1-overview) +2. [Features](#2-features) +3. [System Architecture](#3-system-architecture) +4. [Clean Architecture Layers](#4-clean-architecture-layers) +5. [Tech Stack](#5-tech-stack) +6. [Project Structure](#6-project-structure) +7. [Database Schema & ERD](#7-database-schema--erd) +8. [API Reference](#8-api-reference) +9. [Workflow Diagrams](#9-workflow-diagrams) + - 9.1 [Authentication Flow](#91-authentication-flow) + - 9.2 [AI Design Generation Flow](#92-ai-design-generation-flow) + - 9.3 [Payment & Points Flow](#93-payment--points-flow) + - 9.4 [Subscription Flow](#94-subscription-flow) + - 9.5 [UI Library Component Flow](#95-ui-library-component-flow) + - 9.6 [Plugin Build Pipeline](#96-plugin-build-pipeline) + - 9.7 [Request Middleware Pipeline](#97-request-middleware-pipeline) +10. [UML Class Diagram](#10-uml-class-diagram) +11. [AI Models & Pricing](#11-ai-models--pricing) +12. [Monetization Model](#12-monetization-model) +13. [Security & Rate Limiting](#13-security--rate-limiting) +14. [Environment Variables](#14-environment-variables) +15. [Development Setup](#15-development-setup) +16. [Scripts Reference](#16-scripts-reference) +17. [Deployment](#17-deployment) +18. [Points Lifecycle & Deduction Logic](#18-points-lifecycle--deduction-logic) +19. [Node Creation Pipeline](#19-node-creation-pipeline-figma-canvas) +20. [Export Pipeline](#20-export-pipeline-figma--json) +21. [Error Handling Architecture](#21-error-handling-architecture) +22. [Full System State Machine](#22-full-system-state-machine) --- -## Features +## 1. Overview -| Feature | Description | -|---|---| -| **AI Generate** | Chat with an AI to generate new designs or modify an existing selection | -| **Paste JSON** | Convert any structured JSON object into a live Figma design | -| **Export to JSON** | Export a Figma selection to a clean, structured JSON for handoff | -| **Design Versions** | Save and restore design snapshots to a personal cloud database | -| **UI Library** | Organise reusable design components in projects with S3-hosted preview images | -| **Prototype** | Generate Figma prototype connections with AI | -| **Points & Subscriptions** | Stripe-powered pay-per-use points and monthly subscription plans | +**Rio** is a production-grade full-stack Figma plugin that bridges the gap between natural language and pixel-perfect design. It combines a **conversational AI engine** (supporting 7+ model providers), a **personal cloud UI component library**, and a **Stripe-powered billing system** into a single Figma plugin. ---- +The system comprises two independent sub-projects that communicate over HTTP: + +| Sub-project | Role | +| --------------- | ----------------------------------------------------------- | +| `backend/` | Node.js REST API — auth, AI orchestration, billing, storage | +| `figma-plugin/` | Figma sandbox + React iframe — design creation and UI | -## Architecture +--- -The system is composed of three layers: +## 2. Features + +| Feature | Description | API Endpoint | +| ------------------------ | ------------------------------------------------------------------- | ---------------------------------------------- | +| **AI Generate** | Chat with AI to generate new Figma designs from natural language | `POST /api/designs/generate-from-conversation` | +| **AI Edit** | Modify a selected Figma node with a follow-up prompt | `POST /api/designs/edit-with-ai` | +| **AI Variant** | Generate a design variant from an existing Figma selection | `POST /api/designs/generate-based-on-existing` | +| **AI Prototype** | Auto-generate Figma prototype connections | `POST /api/designs/generate-prototype` | +| **Paste JSON** | Convert any structured JSON blob into live Figma nodes | Plugin-side only | +| **Export JSON** | Export any Figma selection to a clean, portable JSON | Plugin-side only | +| **Design Versions** | Save & restore design snapshots to the cloud | `GET/POST /api/design-generations` | +| **UI Library** | Manage reusable components in named projects with S3 image previews | `CRUD /api/ui-library` | +| **Points (Pay-per-use)** | Purchase AI generation credits with Stripe one-time checkout | `POST /api/payments/create-checkout` | +| **Subscriptions** | Monthly subscription plans with daily point allocations | `POST /api/subscriptions/create-checkout` | +| **Google OAuth** | One-click login via Google — JWT issued on completion | `GET /auth/google` | -``` -┌─────────────────────────────────────────────┐ -│ Figma Plugin (UI) │ -│ React 19 · Clean Architecture · esbuild │ -├─────────────────────────────────────────────┤ -│ Backend API │ -│ Express.js · TypeScript · TypeORM · Postgres│ -├─────────────────────────────────────────────┤ -│ External Services │ -│ OpenAI · Claude · Gemini · Stripe · S3 │ -└─────────────────────────────────────────────┘ -``` +--- -### Plugin Clean Architecture Layers +## 3. System Architecture ``` -Presentation → Application → Domain → Infrastructure -(React UI) (Use Cases) (Entities) (Figma API, HTTP) +┌──────────────────────────────────────────────────────────────────────┐ +│ FIGMA DESKTOP APP │ +│ ┌─────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ Plugin Sandbox (main.ts) │◄──│ Plugin UI (React iframe) │ │ +│ │ TypeScript · Figma API │ │ React 19 · esbuild │ │ +│ │ Node creators/exporters │ │ Zustand · react-toastify │ │ +│ └──────────────┬───────────────┘ └────────────┬───────────────┘ │ +│ │ postMessage() │ HTTP fetch() │ +└─────────────────┼───────────────────────────────-┼───────────────────┘ + │ │ + │ ┌────────────▼──────────────────┐ + │ │ BACKEND API (Render) │ + │ │ Express 5 · TypeORM · Node 18 │ + │ │ Clean Architecture (DDD) │ + │ └────────────┬──────────────────┘ + │ │ + ┌─────────▼─────────────────────────────────▼─────────────────┐ + │ EXTERNAL SERVICES │ + │ ┌───────────┐ ┌─────────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ + │ │ PostgreSQL │ │ AWS S3 │ │Stripe│ │Google│ │ AI APIs │ │ + │ │ (TypeORM) │ │(images) │ │(pay) │ │OAuth │ │7 Providers│ │ + │ └───────────┘ └─────────┘ └──────┘ └──────┘ └──────────┘ │ + └─────────────────────────────────────────────────────────────┘ ``` -- **Domain** — pure entities, value objects, repository interfaces (no framework dependencies) -- **Application** — use cases that orchestrate the domain (GenerateDesign, ImportDesign, ExportDesign, etc.) -- **Infrastructure** — Figma API adapters, node creators, exporters, mappers -- **Presentation** — React UI, message handlers, contexts, hooks - -### Data Flows +--- -**AI Generation** -``` -User prompt → ChatInterface → POST /api/designs/generate-from-conversation -→ Claude/OpenAI/Gemini → structured JSON + HTML preview -→ Plugin main thread → Figma canvas +## 4. Clean Architecture Layers + +Both the **backend** and the **Figma plugin** follow Clean Architecture with strict layer separation. + +### 4.1 Backend Layers + +```mermaid +flowchart TB + subgraph Presentation["Presentation Layer"] + direction LR + R[Routes] --> C[Controllers] + M[Middleware] --> R + end + + subgraph Application["Application Layer"] + direction LR + UC[Use Cases] --> DTO[DTOs] + end + + subgraph Domain["Domain Layer (Pure)"] + direction LR + E[Entities] --- RI[Repository\nInterfaces] + RI --- SI[Service\nInterfaces] + end + + subgraph Infrastructure["Infrastructure Layer"] + direction LR + DB[(TypeORM\nRepositories)] --- SV[Services\nAI · Stripe · S3 · JWT] + SV --- CFG[Config\nenv · AI models · Plans] + end + + Presentation -->|calls| Application + Application -->|depends on| Domain + Infrastructure -->|implements| Domain + + style Domain fill:#1e3a5f,color:#fff + style Application fill:#1a4a2e,color:#fff + style Presentation fill:#3d1a1a,color:#fff + style Infrastructure fill:#2d2d00,color:#fff ``` -**Export** -``` -Figma selection → NodeExporter → structured JSON -→ Copy / Download / Save to DB +### 4.2 Plugin Layers + +```mermaid +flowchart TB + subgraph P["Presentation (React UI)"] + App["App.tsx"] --> Screens["LoginScreen\nHomeScreen"] + Screens --> Sections["AiSection · ExportSection\nPasteJsonSection · ProjectsSection"] + Sections --> Hooks["useApiClient\nusePluginMessage\nuseDropdown"] + end + + subgraph A["Application (Use Cases)"] + UC1["ExportSelectedUseCase"] + UC2["ImportDesignUseCase"] + UC3["ImportAiDesignUseCase"] + SVC["DesignDataParser\nNodeCounter\nAccessControl"] + end + + subgraph D["Domain (Pure Entities)"] + EN["DesignNode\nFill · Effect\nConstraints"] + IF["NodeRepository\nUIPort\nNotificationPort"] + VO["Color\nTypography"] + end + + subgraph I["Infrastructure (Figma API)"] + CR["Creators\nframe · rect · text\nshape · component"] + EX["NodeExporter"] + MP["Mappers\nfill · effect · nodeType"] + end + + P --> A --> D + I -->|implements| D + + style D fill:#1e3a5f,color:#fff + style A fill:#1a4a2e,color:#fff + style P fill:#3d1a1a,color:#fff + style I fill:#2d2d00,color:#fff ``` -**Version Restore** -``` -User clicks version → GET /api/designs/:id → JSON -→ Plugin main thread → Figma canvas +### 4.3 Dependency Injection + +```mermaid +flowchart LR + Bootstrap["index.ts\n(Composition Root)"] -->|instantiates| DB["DataSource\n(TypeORM)"] + Bootstrap -->|creates| Repos["Repositories\nUser · Design\nPayment · Subscription\nUILibrary · ClientError"] + Bootstrap -->|creates| Services["Services\nAI · JWT · Auth\nStripe · S3 · Points"] + Bootstrap -->|wires into| UseCases["Use Cases"] + UseCases -->|injected into| Controllers["Controllers"] + Controllers -->|mounted on| Routes["Express Routes"] ``` --- -## Tech Stack +## 5. Tech Stack ### Backend -| Category | Technology | -|---|---| -| Runtime | Node.js | -| Framework | Express.js v5 | -| Language | TypeScript 5.8 | -| ORM | TypeORM v0.3 | -| Database | PostgreSQL | -| Auth | Google OAuth 2 + JWT (30-day expiry) | -| Payment | Stripe SDK v20 | -| Storage | AWS S3 (`@aws-sdk/client-s3`) | -| AI Providers | OpenAI, Claude, Gemini, Mistral, Hugging Face, POE, Open Router | -| Validation | Joi · express-validator · Zod | -| Docs | Swagger / OpenAPI (`swagger-ui-express`) | +| Category | Technology | Version | Notes | +| -------------- | -------------------------------------------------------------------- | --------------------- | ------------------------ | +| Runtime | Node.js | 18+ LTS | | +| Framework | Express.js | v5.1 | | +| Language | TypeScript | 5.8 | Strict mode | +| ORM | TypeORM | 0.3.28 | Decorators | +| Database | PostgreSQL | 14+ | | +| Authentication | Google OAuth 2.0 + JWT | 30-day expiry | figma.clientStorage | +| Payments | Stripe SDK | v20.3.1 | One-time + subscriptions | +| Storage | AWS S3 | SDK v3.999 | Component previews | +| Validation | Joi · express-validator · Zod | Latest | Layered checks | +| Rate Limiting | express-rate-limit | 8.3.1 | Per-route limits | +| Docs | Swagger / OpenAPI | swagger-ui-express v5 | `/api-docs` | +| AI Providers | OpenAI · Claude · Gemini · Mistral · HuggingFace · POE · Open Router | Multiple | 7+ providers | +| Compression | compression | Latest | gzip level 6 | +| Additional | axios · bcryptjs · jsonwebtoken · dayjs · cors | Latest | | ### Figma Plugin -| Category | Technology | -|---|---| -| Language | TypeScript 5.8 | -| UI | React 19 + React-DOM 19 | -| Build | esbuild v0.27 | -| Icons | lucide-react | -| Notifications | react-toastify | -| Figma typings | `@figma/plugin-typings` v1.119 | +| Category | Technology | Version | Notes | +| ------------- | --------------------- | ------- | ------------------ | +| Language | TypeScript | 5.8 | Path aliases | +| UI Framework | React + React-DOM | 19.2.4 | | +| Bundler | esbuild | 0.27.1 | Single HTML output | +| Icons | lucide-react | 0.563.0 | | +| Notifications | react-toastify | Latest | | +| Figma Typings | @figma/plugin-typings | 1.119 | | --- -## Project Structure +## 6. Project Structure ``` task-creator/ -├── backend/ +│ +├── backend/ # Node.js REST API │ ├── public/ -│ │ ├── prompt/ # AI system prompt templates -│ │ └── pages/ # Static HTML pages +│ │ ├── pages/home.html # Landing page +│ │ └── prompt/design/ # AI system prompt templates │ └── src/ -│ ├── index.ts # Bootstrap & DI composition root -│ ├── application/ -│ │ ├── dto/ # Request / response DTOs -│ │ └── use-cases/ # All business use cases -│ ├── domain/ -│ │ ├── entities/ # Core domain entities -│ │ ├── repositories/ # Repository interfaces -│ │ └── services/ # Service interfaces +│ ├── index.ts # Bootstrap & DI composition root +│ │ +│ ├── application/ # Business logic (no framework) +│ │ ├── dto/ # Request / response DTOs +│ │ ├── errors/ # Custom HTTP error classes +│ │ └── use-cases/ +│ │ ├── auth/ # GoogleSignIn +│ │ ├── design/ # GenerateDesign · EditDesign · Prototype +│ │ ├── design-generation/ # ListDesigns · GetDesign · DeleteDesign +│ │ ├── payment/ # CreateCheckout · HandleWebhook · GetBalance +│ │ ├── subscription/ # CreateSub · CancelSub · GetStatus +│ │ ├── ui-library/ # CRUD projects & components +│ │ └── client-error/ # LogClientError +│ │ +│ ├── domain/ # Pure domain (no deps) +│ │ ├── entities/ # User · DesignGeneration · PaymentTransaction +│ │ │ # Subscription · UILibraryProject · UILibraryComponent +│ │ ├── repositories/ # Repository interfaces (contracts) +│ │ └── services/ # IAiDesignService · IPointsService · IStripeService +│ │ # IJwtService · IS3Service +│ │ │ └── infrastructure/ -│ ├── config/ # env, CORS, Swagger, data-source +│ ├── config/ +│ │ ├── env.config.ts # All env vars with defaults +│ │ ├── ai-models.config.ts # Model registry + pricing +│ │ ├── points-packages.config.ts # Stripe one-time packages +│ │ └── subscription-plans.config.ts # Monthly plan tiers │ ├── database/ -│ │ ├── entities/ # TypeORM entity classes -│ │ └── migrations/ # Database migrations -│ ├── repository/ # TypeORM implementations -│ ├── services/ # AI, Stripe, S3, JWT, Auth services +│ │ ├── data-source.ts # TypeORM connection +│ │ ├── entities/ # UserEntity · DesignGenerationEntity · … +│ │ └── migrations/ # Sequential SQL migrations +│ ├── repository/ # TypeORM implementations of domain interfaces +│ ├── services/ +│ │ ├── ai/ # AiGenerateDesignService · IconExtractor · CostCalculator +│ │ ├── auth/ # GoogleAuthService · JwtService · TokenStore +│ │ ├── payment/ # StripeService · PointsService +│ │ └── storage/ # S3Service │ └── web/ -│ ├── controllers/ # Route handlers -│ ├── middleware/ # auth, validation, error -│ ├── routes/ # Express routers -│ └── server.ts # Express app factory +│ ├── controllers/ # Route handlers (inject services) +│ ├── middleware/ # auth · validation · rateLimiter · concurrencyLimiter · logger +│ ├── routes/ # Express routers per domain +│ ├── validation/ # Joi & express-validator schemas +│ ├── docs/ # Swagger YAML / JSON +│ └── server.ts # Express app factory │ -├── figma-plugin/ -│ ├── manifest.json # Figma plugin manifest -│ ├── build.js # esbuild configuration -│ ├── .env # Local backend URL -│ ├── .env.production # Production backend URL +├── figma-plugin/ # Figma plugin +│ ├── manifest.json # Plugin manifest (id · api · permissions) +│ ├── build.js # esbuild config (UI + code bundles) +│ ├── .env # BACKEND_URL=http://localhost:5000 +│ ├── .env.production # BACKEND_URL=https://...onrender.com │ └── src/ -│ ├── main.ts # Plugin entry point +│ ├── main.ts # Plugin entry & composition root │ ├── application/ -│ │ ├── services/ # design-data-parser, node-counter -│ │ └── use-cases/ # export-all, export-selected, -│ │ # import-design, import-ai-design +│ │ ├── services/ # DesignDataParser · NodeCounter · AccessControl +│ │ └── use-cases/ # ExportAll · ExportSelected · ImportDesign · ImportAiDesign │ ├── domain/ -│ │ ├── entities/ # DesignNode, Fill, Effect, Constraints -│ │ ├── interfaces/ # NodeRepository, UIPort, NotificationPort -│ │ └── value-objects/ # Color, Typography +│ │ ├── entities/ # DesignNode · Fill · Effect · Constraints +│ │ ├── interfaces/ # NodeRepository · UIPort · NotificationPort +│ │ └── value-objects/ # Color · Typography │ ├── infrastructure/ │ │ ├── figma/ -│ │ │ ├── creators/ # frame, rect, text, shape, component nodes -│ │ │ ├── exporters/ # node.exporter.ts -│ │ │ └── figma-node.repository.ts -│ │ ├── mappers/ # fill, effect, node-type mappers -│ │ └── services/ # error-reporter, image-optimizer +│ │ │ ├── creators/ # FrameCreator · RectCreator · TextCreator +│ │ │ │ # ShapeCreator · ComponentCreator +│ │ │ ├── exporters/ # NodeExporter (Figma → JSON) +│ │ │ ├── figma-node.repository.ts +│ │ │ ├── figma-ui.port.ts +│ │ │ ├── figma-notification.port.ts +│ │ │ └── selection-change.handler.ts +│ │ ├── mappers/ # EffectMapper · FillMapper · NodeTypeMapper +│ │ └── services/ # ErrorReporter · ImageOptimizer │ ├── presentation/ -│ │ ├── handlers/ # plugin-message.handler.ts -│ │ └── ui/ # React components, contexts, hooks -│ │ ├── App.tsx -│ │ ├── contexts/ # AppContext, AuthContext -│ │ ├── hooks/ # useApiClient, usePluginMessage, -│ │ │ # useUILibrary, useDropdown +│ │ ├── handlers/ # PluginMessageHandler +│ │ └── ui/ +│ │ ├── App.tsx # Root component +│ │ ├── screens/ # LoginScreen · HomeScreen +│ │ ├── sections/ # AiSection · ExportSection · PasteJsonSection +│ │ │ # ProjectsSection · PrototypePanel │ │ ├── components/ -│ │ │ ├── tabs/ # AiTab, ExportTab, PasteJsonTab, -│ │ │ │ # UILibraryTab, PrototypePanel -│ │ │ ├── shared/ # Modal, DeleteConfirmModal, -│ │ │ │ # LoadingState, CreateProjectModal -│ │ │ └── modals/ # SaveModal, BuyPointsModal, -│ │ │ # ProfileDropdown -│ │ └── styles/ # base.css, component CSS files +│ │ │ ├── shared/ # Modal · DeleteConfirmModal · LoadingState +│ │ │ └── modals/ # SaveModal · BuyPointsModal · ProfileDropdown +│ │ ├── hooks/ # useApiClient · usePluginMessage +│ │ │ # useUILibrary · useDropdown · useNotify +│ │ └── styles/ # base.css · ProjectsSection.css · … │ └── shared/ -│ ├── constants/ # plugin-config.ts (API URL, dimensions) -│ └── types/ # node-types.ts, shared interfaces +│ ├── constants/ # plugin-config.ts +│ └── types/ # node-types.ts · shared interfaces │ -└── public/ # Architecture diagrams & assets +└── public/ # Architecture diagrams (PNG) + ├── Clean Architecture UML.png + ├── Data Flow Sequence Diagram.png + └── Design Version ERD.png ``` --- -## Database Schema - -### Users -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | auto-generated | -| figmaUserId | varchar | unique Figma identity | -| userName | varchar | display name | -| email | varchar | unique | -| googleId | varchar | Google OAuth subject | -| profilePicture | varchar | avatar URL | -| pointsBalance | int | purchased points | -| stripeCustomerId | varchar | Stripe customer | -| hasPurchased | boolean | lifetime flag | -| createdAt / updatedAt | timestamp | | - -### Design Versions -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | | -| version | int | incrementing counter | -| description | text | user-provided label | -| designJson | JSONB | full Figma node tree | -| userId | UUID FK → users | | - -### UI Library Projects -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | | -| name | varchar | project name | -| userId | UUID FK → users | | - -### UI Library Components -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | | -| name | varchar | | -| description | text | | -| designJson | JSONB | component node tree | -| previewImage | text | S3 image URL | -| projectId | UUID FK → projects | | -| userId | UUID FK → users | | - -### Subscriptions -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | | -| planId | varchar | starter / pro / basic / premium | -| status | enum | active · canceled · past_due · expired | -| stripeSubscriptionId | varchar | | -| currentPeriodStart / End | timestamp | billing window | -| dailyPointsLimit | int | plan allocation | -| dailyPointsUsed | int | resets daily | -| cancelAtPeriodEnd | boolean | | -| userId | UUID FK → users | | - -### Payment Transactions -| Column | Type | Notes | -|---|---|---| -| id | UUID PK | | -| stripeSessionId | varchar | checkout session | -| stripePaymentIntentId | varchar | | -| packageName | varchar | | -| pointsPurchased | int | | -| amountPaid | decimal | | -| currency | varchar | default USD | -| status | enum | pending · completed · failed | -| userId | UUID FK → users | | +## 7. Database Schema & ERD + +### Entity Relationship Diagram + +```mermaid +erDiagram + USERS { + uuid id PK + varchar figmaUserId UK + varchar userName + varchar email UK + varchar googleId + varchar profilePicture + int pointsBalance + varchar stripeCustomerId UK + boolean hasPurchased + timestamp createdAt + timestamp updatedAt + } + + DESIGN_GENERATIONS { + uuid id PK + uuid userId FK + varchar operationType + varchar modelId + text prompt + varchar designSystemId + jsonb conversationHistory + jsonb currentDesign + jsonb referenceDesign + jsonb resultDesign + jsonb resultConnections + varchar status + text errorMessage + text aiMessage + int inputTokens + int outputTokens + decimal totalCost + int pointsDeducted + timestamp createdAt + timestamp updatedAt + } + + PAYMENT_TRANSACTIONS { + uuid id PK + uuid userId FK + varchar stripeSessionId + varchar stripePaymentIntentId + varchar packageName + int pointsPurchased + decimal amountPaid + varchar currency + varchar status + timestamp createdAt + timestamp completedAt + } + + SUBSCRIPTIONS { + uuid id PK + uuid userId FK + varchar planId + varchar stripeSubscriptionId + varchar stripeCustomerId + varchar status + timestamp currentPeriodStart + timestamp currentPeriodEnd + int dailyPointsLimit + int dailyPointsUsed + varchar lastUsageResetDate + boolean cancelAtPeriodEnd + timestamp createdAt + timestamp updatedAt + } + + UI_LIBRARY_PROJECTS { + uuid id PK + uuid userId FK + varchar name + timestamp createdAt + timestamp updatedAt + } + + UI_LIBRARY_COMPONENTS { + uuid id PK + uuid projectId FK + uuid userId FK + varchar name + varchar description + jsonb designJson + text previewImage + timestamp createdAt + timestamp updatedAt + } + + CLIENT_ERRORS { + uuid id PK + uuid userId FK + varchar errorType + text errorMessage + text stackTrace + varchar url + varchar userAgent + timestamp createdAt + } + + USERS ||--o{ DESIGN_GENERATIONS : "has" + USERS ||--o{ PAYMENT_TRANSACTIONS : "has" + USERS ||--o{ SUBSCRIPTIONS : "has" + USERS ||--o{ UI_LIBRARY_PROJECTS : "owns" + USERS ||--o{ UI_LIBRARY_COMPONENTS : "owns" + USERS ||--o{ CLIENT_ERRORS : "logs" + UI_LIBRARY_PROJECTS ||--o{ UI_LIBRARY_COMPONENTS : "contains" +``` + +### Schema Details + +#### `design_generations` + +| Column | Type | Description | +| ---------------------------------------------- | ------- | ------------------------------------------------------- | +| operationType | `enum` | `create` · `create_by_reference` · `edit` · `prototype` | +| conversationHistory | `JSONB` | Multi-turn chat history array | +| currentDesign / referenceDesign / resultDesign | `JSONB` | Full Figma node trees | +| status | `enum` | `success` · `failed` | +| Indexes | — | `[userId]`, `[userId, operationType]`, `[createdAt]` | + +#### `subscriptions` + +| Column | Type | Description | +| ------------------ | --------- | ---------------------------------------------- | +| status | `enum` | `active` · `canceled` · `past_due` · `expired` | +| dailyPointsUsed | `int` | Resets daily at midnight UTC | +| lastUsageResetDate | `varchar` | Date string `YYYY-MM-DD` for reset tracking | --- -## API Reference +## 8. API Reference -> Base URL (dev): `http://localhost:5000` -> Base URL (prod): `https://task-creator-api.onrender.com` +> **Base URL (dev):** `http://localhost:5000` +> **Base URL (prod):** `https://task-creator-api.onrender.com` +> **Interactive docs:** `GET /api-docs` +> **Protected routes** require `Authorization: Bearer ` ### Authentication — `/auth` -| Method | Path | Description | -|---|---|---| -| GET | `/auth/google` | Initiate Google OAuth | -| GET | `/auth/google/callback` | OAuth redirect callback | -| GET | `/auth/poll` | Plugin polls for JWT after browser auth | -| GET | `/auth/me` | Return current user profile | +| Method | Path | Auth | Description | +| ------ | ----------------------- | ---- | ------------------------------------- | +| `GET` | `/auth/google` | — | Initiate Google OAuth redirect | +| `GET` | `/auth/google/callback` | — | OAuth redirect handler — issues JWT | +| `GET` | `/auth/poll` | — | Plugin polls until JWT is ready | +| `GET` | `/auth/me` | ✅ | Return current user profile + balance | -### AI Design — `/api/designs` +### AI Design Generation — `/api/designs` -| Method | Path | Description | -|---|---|---| -| POST | `/api/designs/generate-from-conversation` | Generate design from chat prompt | -| POST | `/api/designs/edit-with-ai` | Modify selected design with AI | -| POST | `/api/designs/generate-based-on-existing` | Generate variant of existing selection | -| POST | `/api/designs/generate-prototype` | Generate prototype connections | +| Method | Path | Auth | Body | +| ------ | ----------------------------------------- | ---- | ------------------------------------------------------------ | +| `POST` | `/api/designs/generate-from-conversation` | ✅ | `{ prompt, modelId, conversationHistory?, designSystemId? }` | +| `POST` | `/api/designs/edit-with-ai` | ✅ | `{ prompt, modelId, currentDesign, conversationHistory? }` | +| `POST` | `/api/designs/generate-based-on-existing` | ✅ | `{ prompt, modelId, referenceDesign }` | +| `POST` | `/api/designs/generate-prototype` | ✅ | `{ modelId, currentDesign }` | -### Design Versions — `/api/designs` +### Design History — `/api/design-generations` -| Method | Path | Description | -|---|---|---| -| POST | `/api/designs` | Save current design as version | -| GET | `/api/designs` | List all versions for user | -| GET | `/api/designs/:id` | Get a specific version | -| DELETE | `/api/designs/:id` | Delete a version | +| Method | Path | Auth | Description | +| -------- | ----------------------------- | ---- | -------------------------------------- | +| `GET` | `/api/design-generations` | ✅ | List user's design history (paginated) | +| `GET` | `/api/design-generations/:id` | ✅ | Fetch one generation record | +| `DELETE` | `/api/design-generations/:id` | ✅ | Delete a generation record | ### UI Library — `/api/ui-library` -| Method | Path | Description | -|---|---|---| -| POST | `/api/ui-library/projects` | Create project | -| GET | `/api/ui-library/projects` | List projects | -| DELETE | `/api/ui-library/projects/:id` | Delete project | -| GET | `/api/ui-library/projects/:id/components` | List components in project | -| POST | `/api/ui-library/components` | Create component | -| POST | `/api/ui-library/components/upload-image` | Upload S3 preview image | -| DELETE | `/api/ui-library/components/:id` | Delete component | +| Method | Path | Auth | Description | +| -------- | ----------------------------------------- | ---- | ----------------------------------- | +| `POST` | `/api/ui-library/projects` | ✅ | Create project | +| `GET` | `/api/ui-library/projects` | ✅ | List all user's projects | +| `DELETE` | `/api/ui-library/projects/:id` | ✅ | Delete project (cascade components) | +| `GET` | `/api/ui-library/projects/:id/components` | ✅ | List components in project | +| `POST` | `/api/ui-library/components` | ✅ | Create component with JSON | +| `POST` | `/api/ui-library/components/upload-image` | ✅ | Upload S3 preview image | +| `DELETE` | `/api/ui-library/components/:id` | ✅ | Delete component + S3 image | ### Payments — `/api/payments` -| Method | Path | Description | -|---|---|---| -| POST | `/api/payments/create-checkout` | Stripe one-time checkout session | -| POST | `/api/payments/webhook` | Stripe webhook handler | -| GET | `/api/payments/balance` | User's current points balance | -| GET | `/api/payments/history` | Payment transaction history | -| GET | `/api/payments/packages` | Available point packages | -| GET | `/api/payments/poll/:sessionId` | Poll payment completion | +| Method | Path | Auth | Description | +| ------ | ------------------------------- | ---- | ----------------------------------- | +| `POST` | `/api/payments/create-checkout` | ✅ | Create Stripe one-time checkout | +| `POST` | `/api/payments/webhook` | — | Stripe webhook (signature verified) | +| `GET` | `/api/payments/balance` | ✅ | Current points balance | +| `GET` | `/api/payments/history` | ✅ | Transaction history | +| `GET` | `/api/payments/packages` | — | Available point packages | +| `GET` | `/api/payments/poll/:sessionId` | ✅ | Poll checkout completion | ### Subscriptions — `/api/subscriptions` -| Method | Path | Description | -|---|---|---| -| POST | `/api/subscriptions/create-checkout` | Subscription checkout session | -| POST | `/api/subscriptions/cancel` | Cancel active subscription | -| GET | `/api/subscriptions/status` | Current subscription & usage | -| GET | `/api/subscriptions/plans` | Available plans and pricing | +| Method | Path | Auth | Description | +| ------ | ------------------------------------ | ---- | ---------------------------- | +| `POST` | `/api/subscriptions/create-checkout` | ✅ | Create subscription checkout | +| `POST` | `/api/subscriptions/cancel` | ✅ | Cancel at period end | +| `GET` | `/api/subscriptions/status` | ✅ | Active plan + daily usage | +| `GET` | `/api/subscriptions/plans` | — | Available plans and pricing | + +### Utility + +| Method | Path | Auth | Description | +| ------ | --------------------- | ---- | ----------------------------- | +| `GET` | `/api/ai-models` | ✅ | List all available AI models | +| `GET` | `/api/design-systems` | ✅ | List available design systems | +| `POST` | `/api/errors` | ✅ | Log a client-side error | + +--- + +## 9. Workflow Diagrams + +### 9.1 Authentication Flow + +```mermaid +sequenceDiagram + autonumber + actor User + participant Plugin as Figma Plugin\n(main.ts) + participant UI as Plugin UI\n(React) + participant Browser as Browser Tab + participant API as Backend API + participant Google as Google OAuth + + User->>UI: Clicks "Sign in with Google" + UI->>Plugin: postMessage(OPEN_AUTH) + Plugin->>Browser: figma.openExternal(authURL) + Browser->>API: GET /auth/google + API->>Google: Redirect to Google consent screen + Google-->>API: GET /auth/google/callback?code=... + API->>Google: Exchange code for user info + Google-->>API: { email, name, picture, googleId } + API->>API: Upsert user in DB + API->>API: Generate JWT (30-day expiry) + API->>API: Store token in memory (TokenStore) + API-->>Browser: Redirect → success page + Browser-->>User: "Login successful, return to Figma" + + loop Poll every 2 seconds (max 60s) + Plugin->>API: GET /auth/poll + alt Token ready + API-->>Plugin: { token: "eyJ..." } + Plugin->>Plugin: figma.clientStorage.set('token') + Plugin->>UI: postMessage(AUTH_SUCCESS, user) + else Not ready yet + API-->>Plugin: { token: null } + end + end + + UI->>UI: Render HomeScreen +``` + +--- + +### 9.2 AI Design Generation Flow + +```mermaid +sequenceDiagram + autonumber + actor Designer + participant UI as Plugin UI (React) + participant Plugin as Plugin Sandbox + participant API as Backend API + participant AIOrch as AI Orchestrator\n(AiGenerateDesignService) + participant AIProvider as AI Provider\n(OpenAI/Claude/Gemini/...) + participant DB as PostgreSQL + participant Canvas as Figma Canvas + + Designer->>UI: Types prompt, selects model, hits Generate + UI->>API: POST /api/designs/generate-from-conversation\n{ prompt, modelId, conversationHistory } + + API->>API: Validate JWT + deduct points check + API->>AIOrch: execute(prompt, model, history) + AIOrch->>AIOrch: Build system prompt\n(load design system template) + AIOrch->>AIProvider: chat.completions(messages) + + alt Success + AIProvider-->>AIOrch: { designJson, aiMessage, tokens } + AIOrch->>AIOrch: Extract icons from JSON + AIOrch->>AIOrch: Calculate cost & points + AIOrch-->>API: { resultDesign, inputTokens, outputTokens, cost } + API->>DB: INSERT design_generations (status=success) + API->>DB: UPDATE users SET pointsBalance -= deducted + API-->>UI: 200 { design, aiMessage, pointsBalance } + else AI Error + AIProvider-->>AIOrch: Error / malformed JSON + AIOrch-->>API: throw AiGenerationError + API->>DB: INSERT design_generations (status=failed) + API-->>UI: 422 { error: "Generation failed" } + end + + UI->>Plugin: postMessage(IMPORT_DESIGN, { design }) + Plugin->>Canvas: Create Figma nodes\n(FrameCreator, TextCreator, ...) + Canvas-->>Plugin: Nodes created + Plugin-->>UI: postMessage(IMPORT_COMPLETE) + UI->>Designer: Show success toast + updated points balance +``` + +--- + +### 9.3 Payment & Points Flow + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Plugin UI + participant API as Backend API + participant Stripe as Stripe API + participant Webhook as Stripe Webhook + participant DB as PostgreSQL + + User->>UI: Clicks "Buy Points" → selects package + UI->>API: POST /api/payments/create-checkout\n{ packageId } + API->>Stripe: sessions.create({ price, metadata }) + Stripe-->>API: { sessionId, url } + API-->>UI: { checkoutUrl, sessionId } + UI->>UI: Open Stripe checkout in browser + + User->>Stripe: Completes payment + Stripe->>Webhook: POST /api/payments/webhook\n(checkout.session.completed) + Webhook->>Webhook: Verify Stripe signature + Webhook->>DB: UPDATE payment_transactions SET status=completed + Webhook->>DB: UPDATE users SET pointsBalance += purchased + Webhook-->>Stripe: 200 OK + + loop Poll every 2s (max 60s) + UI->>API: GET /api/payments/poll/:sessionId + alt Payment confirmed + API-->>UI: { status: 'completed', newBalance: 3750 } + UI->>UI: Update displayed points balance + UI->>User: "Points added successfully!" + else Pending + API-->>UI: { status: 'pending' } + end + end +``` + +--- + +### 9.4 Subscription Flow + +```mermaid +flowchart TD + A([User selects plan]) --> B[POST /api/subscriptions/create-checkout] + B --> C[Stripe subscription checkout] + C --> D{User completes\npayment?} + D -->|Yes| E[Stripe webhook fires\nsubscription.created] + D -->|No| F([Checkout abandoned]) + E --> G[Insert subscription record\nstatus=active] + G --> H[Set dailyPointsLimit\nper plan tier] + + H --> I([Daily AI usage]) + I --> J{dailyPointsUsed\n< dailyPointsLimit?} + J -->|Yes| K[Allow AI generation\nIncrement dailyPointsUsed] + J -->|No| L[Return 429 Daily limit reached] + + K --> M{Next calendar day?} + M -->|Yes| N[Reset dailyPointsUsed = 0\nUpdate lastUsageResetDate] + N --> I + + O([User cancels]) --> P[POST /api/subscriptions/cancel] + P --> Q[Stripe: cancelAtPeriodEnd = true] + Q --> R[Subscription expires at\ncurrentPeriodEnd] + R --> S[status = expired] +``` + +--- + +### 9.5 UI Library Component Flow + +```mermaid +flowchart LR + subgraph Figma["Figma Canvas"] + SEL["User selects\nnodes"] + end + + subgraph Plugin["Plugin Sandbox"] + EXP["NodeExporter\n(Figma → JSON)"] + IMG["ImageOptimizer\n(rasterize preview)"] + end + + subgraph API["Backend API"] + direction TB + UP["POST /components\n(save JSON)"] + S3U["POST /upload-image\n(S3 upload)"] + LIST["GET /projects/:id/components"] + DEL["DELETE /components/:id\n+ S3 delete"] + end + + subgraph Storage["External Storage"] + S3[("AWS S3\nbucket")] + PG[("PostgreSQL\nui_library_components")] + end + + SEL --> EXP --> Plugin + Plugin -->|exportJson| UP --> PG + Plugin -->|previewPng| S3U --> S3 + PG -->|previewImage URL| LIST + LIST --> SEL2["User views\ncomponent grid"] + DEL --> PG & S3 +``` + +--- -### AI Models — `/api/ai-models` +### 9.6 Plugin Build Pipeline -| Method | Path | Description | -|---|---|---| -| GET | `/api/ai-models` | List all available AI models | +```mermaid +flowchart TD + A([npm run build]) --> B{Mode?} + B -->|--prod| C[minify: true\ndrop console logs] + B -->|default| D[minify: false\nkeep logs] -### Client Errors — `/api/errors` + C & D --> E[esbuild: bundle UI\nentryPoint: index.jsx\nbundle: true\nloader: jsx,tsx,css,js,ts] -| Method | Path | Description | -|---|---|---| -| POST | `/api/errors` | Log a client-side error | + E --> F[Inline CSS into\n<style> tag] + F --> G[Escape </script>\ninjection sequences] + G --> H["Write dist/ui.html\n(self-contained)"] -All protected routes require `Authorization: Bearer ` header. -Interactive API docs available at `/api-docs` when the server is running. + C & D --> I[esbuild: bundle Plugin\nentryPoint: main.ts\nplatform: browser\ntarget: es2017] + I --> J[Write dist/code.js] + + H & J --> K([Load in Figma\nDesktop App]) +``` --- -## Authentication +### 9.7 Request Middleware Pipeline + +```mermaid +flowchart LR + REQ([Incoming\nHTTP Request]) + --> COMP[compression\ngzip level 6] + --> LOG[Logger\nreq/res logging] + --> CORS[cors\nallowedDomains] + --> COOKIE[cookieParser] + --> BODY[bodyParser\nJSON 50MB] + --> AUTH{Protected\nroute?} + + AUTH -->|Yes| JWT[authMiddleware\nverify JWT\nattach user] + AUTH -->|No| RATE + + JWT --> RATE[Rate Limiter\nper-route limits] + RATE --> CONC{AI route?} + CONC -->|Yes| AICONC[concurrencyLimiter\nmax parallel AI] + CONC -->|No| VALID + + AICONC --> VALID[Joi / Zod\nvalidation] + VALID --> CTRL[Controller\n→ Use Case] + CTRL --> RES([HTTP Response]) +``` + +--- -1. **Google OAuth** — user opens the plugin, clicks "Sign in with Google", browser tab opens and completes OAuth flow. -2. **JWT** — after OAuth the server issues a 30-day JWT. -3. **Plugin polling** — the plugin's main thread calls `GET /auth/poll` repeatedly until a token is returned, then stores it in `figma.clientStorage`. -4. **API requests** — the React UI's `useApiClient` hook reads the stored token and injects it as a `Bearer` header on every request. +## 10. UML Class Diagram + +```mermaid +classDiagram + class User { + +UUID id + +string figmaUserId + +string email + +int pointsBalance + +string stripeCustomerId + +boolean hasPurchased + } + + class DesignGeneration { + +UUID id + +string operationType + +string modelId + +string prompt + +object resultDesign + +string status + +int pointsDeducted + } + + class PaymentTransaction { + +UUID id + +string stripeSessionId + +string packageName + +int pointsPurchased + +decimal amountPaid + +string status + } + + class Subscription { + +UUID id + +string planId + +string status + +int dailyPointsLimit + +int dailyPointsUsed + +Date currentPeriodEnd + +boolean cancelAtPeriodEnd + } + + class UILibraryProject { + +UUID id + +string name + } + + class UILibraryComponent { + +UUID id + +string name + +object designJson + +string previewImage + } + + class AiGenerateDesignService { + +generateFromConversation(prompt, model, history) + +editWithAi(prompt, model, currentDesign) + +generateVariant(prompt, model, refDesign) + +generatePrototype(model, design) + -buildSystemPrompt(designSystemId) + -extractIcons(json) + -calculateCost(inputTokens, outputTokens, model) + } + + class StripeService { + +createCheckoutSession(package) + +createSubscriptionSession(plan) + +handleWebhook(event) + +cancelSubscription(subId) + } + + class PointsService { + +deductPoints(userId, amount) + +addPoints(userId, amount) + +getBalance(userId) + +checkDailyLimit(userId) + } + + class JwtService { + +sign(payload) string + +verify(token) payload + } + + class S3Service { + +uploadImage(buffer, key) string + +deleteImage(key) + } + + User "1" --> "0..*" DesignGeneration : has + User "1" --> "0..*" PaymentTransaction : has + User "1" --> "0..1" Subscription : has + User "1" --> "0..*" UILibraryProject : owns + UILibraryProject "1" --> "0..*" UILibraryComponent : contains + + AiGenerateDesignService ..> PointsService : uses + AiGenerateDesignService ..> DesignGeneration : creates + StripeService ..> PaymentTransaction : creates + StripeService ..> Subscription : manages + PointsService ..> User : updates +``` --- -## Environment Variables +## 11. AI Models & Pricing + +| Model | Provider | Input ($/1M tokens) | Output ($/1M tokens) | Max Tokens | Vision | +| ---------------- | --------- | ------------------- | -------------------- | ---------- | ------ | +| Devstral Latest | Mistral | $0.40 | $2.00 | 262,144 | No | +| Gemini 2.5 Flash | Google | $0.30 | $2.50 | 1,000,000 | Yes | +| Claude Opus 4.6 | Anthropic | $5.00 | $25.00 | 131,072 | Yes | +| GPT-5.2 | OpenAI | $1.75 | $14.00 | 128,000 | Yes | +| Gemini 3.1 Pro | Google | $2.00 | $12.00 | 2,000,000 | Yes | + +> Points are calculated as: `cost_in_USD × 500 points_per_dollar` + +--- + +## 12. Monetization Model + +### One-Time Point Packages + +| Package | Points | Price | Margin | +| ------- | ---------- | ------ | ------ | +| Starter | 3,750 pts | $10.00 | 25% | +| Pro | 10,000 pts | $25.00 | 20% | + +### Monthly Subscriptions + +| Plan | Price/month | Daily Points | Total/month | Margin | +| ------- | ----------- | ------------- | ----------- | ------ | +| Basic | $50 | 708 pts/day | ~21,250 pts | 15% | +| Premium | $80 | 1,200 pts/day | ~36,000 pts | 10% | + +```mermaid +flowchart LR + subgraph Billing["Billing Options"] + PP["Pay Per Use\n(Points Packages)"] + SUB["Subscription\n(Daily Allowance)"] + end + + PP -->|One-time payment| S["Stripe Checkout\n(one-time)"] + SUB -->|Monthly recurring| SS["Stripe Checkout\n(subscription)"] + + S --> W["Webhook\ncheckout.session.completed"] + SS --> W2["Webhook\nsubscription.created\n+ invoice.paid"] + + W --> DB1[("Add points\nto balance")] + W2 --> DB2[("Create subscription\nSet dailyPointsLimit")] + + DB1 --> AI[AI Generation\nDeduct from balance] + DB2 --> AI2[AI Generation\nDeduct from dailyUsed] +``` + +--- + +## 13. Security & Rate Limiting + +### Rate Limits + +| Endpoint Group | Limit | Window | Key | +| --------------------------------------- | ------------ | ---------- | ------------ | +| Auth (`/auth/*`) | 50 requests | per minute | IP | +| AI Generation (`/api/designs/*`) | 5 requests | per minute | user or IP | +| UI Library (`/api/ui-library/*`) | 5 requests | per minute | user or IP | +| Payments (`/api/payments/*`) | 5 requests | per minute | user or IP | +| Subscriptions (`/api/subscriptions/*`) | 5 requests | per minute | user or IP | +| Webhooks (`/api/payments/webhook`) | 100 requests | per minute | IP | + +### Concurrency Limit + +- Maximum **2 concurrent AI requests per user** enforced via a counting semaphore middleware (`aiConcurrencyLimiter`) +- Returns `429` immediately if the user already has 2 in-flight AI requests +- Slot released on response `finish` or `close` event +- Prevents API key quota exhaustion and runaway costs + +### Payload Limits + +| Check | Limit | +| -------------------- | --------------------------------------- | +| JSON body parser | 50 MB | +| Uploaded image (S3) | 5 MB | +| MIME type validation | `image/png`, `image/jpeg`, `image/webp` | + +### Authentication Security + +- JWT verified on every protected request +- Tokens stored only in `figma.clientStorage` (sandboxed per plugin) +- Stripe webhook signature verified via `stripe.webhooks.constructEvent()` +- Google OAuth state parameter for CSRF protection + +--- + +## 14. Environment Variables ### Backend — `backend/.env` @@ -374,6 +1010,7 @@ GOOGLE_CALLBACK_URL=http://localhost:5000/auth/google/callback # JWT JWT_SECRET=your_jwt_secret_change_in_production +JWT_EXPIRY=30d # Stripe STRIPE_SECRET_KEY=sk_test_... @@ -392,10 +1029,11 @@ AWS_S3_BUCKET=your_bucket_name # AI Providers OPENAI_API_KEY=sk-... CLAUDE_API_KEY=sk-ant-... -GEMINI_API_KEY=... +GEMINI_API_KEY=AI... MISTRAL_API_KEY=... -HAMGINGFACE_API_KEY=... +HAMGINGFACE_API_KEY=hf_... POE_API_KEY=... +OPEN_ROUTER_API_KEY=... # Trello (optional) TRELLO_API_KEY=... @@ -406,30 +1044,36 @@ TRELLO_BOARD_ID=... ### Figma Plugin — `figma-plugin/.env` ```env +# Local development BACKEND_URL=http://localhost:5000 ``` -> For production, set `BACKEND_URL` in `figma-plugin/.env.production`. +```env +# figma-plugin/.env.production +BACKEND_URL=https://task-creator-api.onrender.com +``` --- -## Development Setup +## 15. Development Setup ### Prerequisites -- Node.js 18+ (LTS) -- PostgreSQL 14+ -- Figma Desktop App -- Stripe CLI (for webhook testing) +| Tool | Version | Purpose | +| ------------- | ------- | ---------------------- | +| Node.js | 18+ LTS | Backend & plugin build | +| PostgreSQL | 14+ | Primary database | +| Figma Desktop | Latest | Plugin development | +| Stripe CLI | Latest | Webhook testing | -### 1. Clone the repository +### Step 1 — Clone ```bash git clone https://github.com/Rezkaudi/task-creator.git cd task-creator ``` -### 2. Backend +### Step 2 — Backend ```bash cd backend @@ -437,95 +1081,272 @@ npm install # Copy and fill in environment variables cp .env.example .env +# Edit .env with your credentials # Run database migrations npm run migration:run -# Start development server +# Start development server (nodemon + ts-node) npm run dev +# → API running at http://localhost:5000 +# → Swagger UI at http://localhost:5000/api-docs ``` -The API will be available at `http://localhost:5000`. -Swagger docs: `http://localhost:5000/api-docs`. - -### 3. Figma Plugin +### Step 3 — Figma Plugin ```bash -cd figma-plugin +cd ../figma-plugin npm install -# Development build (watches for changes) +# Development build with watch mode npm run dev - -# OR production build -npm run build +# → Outputs dist/code.js and dist/ui.html ``` -### 4. Load the plugin in Figma +### Step 4 — Load Plugin in Figma -1. Open the **Figma Desktop App**. -2. Go to **Plugins → Development → Import plugin from manifest…** -3. Select `figma-plugin/manifest.json`. -4. Run the plugin — it will connect to your local backend. +1. Open **Figma Desktop** +2. Menu → **Plugins** → **Development** → **Import plugin from manifest…** +3. Select `figma-plugin/manifest.json` +4. Run the plugin — it will connect to `http://localhost:5000` -### 5. Stripe webhooks (local) +### Step 5 — Stripe Webhooks (local) ```bash +# Forward Stripe events to local server stripe listen --forward-to http://localhost:5000/api/payments/webhook + +# Test a payment event +stripe trigger checkout.session.completed ``` --- -## Scripts +## 16. Scripts Reference ### Backend (`backend/`) -| Command | Description | -|---|---| -| `npm run dev` | Development server with nodemon + ts-node | -| `npm run build` | Compile TypeScript and copy public assets | -| `npm start` | Run compiled production server | -| `npm run migration:generate` | Generate a new migration from entity changes | -| `npm run migration:run` | Apply pending migrations | -| `npm run migration:revert` | Revert last migration | -| `npm run migration:show` | Show migration status | +| Command | Description | +| ---------------------------- | --------------------------------------- | +| `npm run dev` | Development server — nodemon + ts-node | +| `npm run build` | Compile TypeScript + copy public assets | +| `npm start` | Run compiled production server | +| `npm run migration:generate` | Generate migration from entity changes | +| `npm run migration:run` | Apply all pending migrations | +| `npm run migration:revert` | Revert most recent migration | +| `npm run migration:show` | Show migration status table | ### Figma Plugin (`figma-plugin/`) -| Command | Description | -|---|---| -| `npm run dev` | esbuild watch mode (local development) | -| `npm run build` | Minified production build | +| Command | Description | +| --------------------- | ---------------------------------- | +| `npm run dev` | esbuild watch mode (local dev) | +| `npm run build` | Minified production build | | `npm run build:local` | Unminified build for local testing | -| `npm run clean` | Remove `dist/` folder | -| `npm run rebuild` | Clean then build | +| `npm run clean` | Remove `dist/` folder | +| `npm run rebuild` | `clean` + `build` | --- -## Deployment +## 17. Deployment -| Service | URL | -|---|---| -| Backend (Render) | `https://task-creator-api.onrender.com` | -| Web redirect | `https://rio-app.design` | +### Backend (Render.com) + +| Setting | Value | +| ------------- | --------------------------------------------------- | +| Build Command | `npm run build` | +| Start Command | `npm start` | +| Environment | All variables from [§14](#14-environment-variables) | +| Live URL | `https://task-creator-api.onrender.com` | **Production checklist:** -- Set all env vars in Render dashboard (never commit secrets) -- Set `NODE_ENV=production` -- Use a strong random `JWT_SECRET` -- Add production `STRIPE_SECRET_KEY` and re-register the Stripe webhook -- Set `BACKEND_URL` in `figma-plugin/.env.production` and run `npm run build` -- Submit the built plugin to the Figma Community or distribute `manifest.json` + `dist/` + +- [ ] Set all env vars in Render dashboard (never commit secrets) +- [ ] `NODE_ENV=production` +- [ ] Use a strong random `JWT_SECRET` (≥ 64 chars) +- [ ] Register Stripe production webhook pointing to `https://...onrender.com/api/payments/webhook` +- [ ] Verify AWS S3 bucket CORS policy allows Figma plugin origin + +### Figma Plugin + +```bash +# Build for production (minified, points to production API) +cd figma-plugin +npm run build + +# Distribute via Figma Community +# Or share manifest.json + dist/ folder +``` + +**Plugin checklist:** + +- [ ] Set `BACKEND_URL` in `.env.production` +- [ ] Run `npm run build` (not `build:local`) +- [ ] Update `manifest.json` version number +- [ ] Submit to Figma Community or share `dist/` + `manifest.json` --- -## Diagrams +## 18. Points Lifecycle & Deduction Logic -### Clean Architecture UML -![Clean Architecture UML](./public/Clean%20Architecture%20UML.png) +```mermaid +flowchart TD + START([User triggers AI generation]) --> CHECK_SUB{Active\nsubscription?} -### Data Flow Sequence Diagram -![Data Flow Sequence Diagram](./public/Data%20Flow%20Sequence%20Diagram.png) + CHECK_SUB -->|Yes| CHECK_DAILY{dailyPointsUsed\n< dailyPointsLimit?} + CHECK_SUB -->|No| CHECK_BAL{pointsBalance\n> required?} -### Entity Relationship Diagram -![Entity Relationship Diagram](./public/Design%20Version%20ERD.png) + CHECK_DAILY -->|No| ERR1([429 — Daily limit reached\nUpgrade plan or wait for reset]) + CHECK_DAILY -->|Yes| CALL_AI + + CHECK_BAL -->|No| ERR2([402 — Insufficient points\nBuy more points]) + CHECK_BAL -->|Yes| CALL_AI + + CALL_AI[Send prompt to AI provider] --> AI_RESP{AI response\nsuccessful?} + + AI_RESP -->|No| FAIL[Log failed generation\nDo NOT deduct points] + FAIL --> ERR3([422 — Generation failed]) + + AI_RESP -->|Yes| CALC[Calculate tokens used\ninput × inputRate + output × outputRate] + CALC --> CONV[Convert cost → points\ncost × 500 pts/dollar] + CONV --> DEDUCT{Source?} + + DEDUCT -->|Subscription| UPD1[UPDATE subscriptions\nSET dailyPointsUsed += deducted] + DEDUCT -->|Balance| UPD2[UPDATE users\nSET pointsBalance -= deducted] + + UPD1 & UPD2 --> SAVE[INSERT design_generations\nstatus=success, pointsDeducted] + SAVE --> RETURN([Return design JSON\n+ updated balance]) +``` + +--- + +## 19. Node Creation Pipeline (Figma Canvas) + +```mermaid +flowchart TD + INPUT([Receive JSON from API]) --> PARSE[DesignDataParser\nvalidate & normalize] + PARSE --> ROOT{Root node\ntype?} + + ROOT -->|FRAME| FC[FrameCreator\napply layout, padding, constraints] + ROOT -->|RECTANGLE| RC[RectCreator\napply fills, strokes, radius] + ROOT -->|TEXT| TC[TextCreator\napply font, size, weight, color] + ROOT -->|ELLIPSE / POLYGON| SC[ShapeCreator] + ROOT -->|COMPONENT| CC[ComponentCreator\nresolve from library] + ROOT -->|GROUP| GC[GroupCreator\nwrap children] + + FC & RC & TC & SC & CC & GC --> FILL[FillMapper\nsolid · gradient · image fills] + FILL --> EFFECT[EffectMapper\ndrop shadow · blur · inner shadow] + EFFECT --> CONSTRAINT[Apply Constraints\nfixed · fill · hug parent] + CONSTRAINT --> CHILDREN{Has\nchildren?} + + CHILDREN -->|Yes| RECURSE[Recurse into children\n← same pipeline] + CHILDREN -->|No| APPEND[Append node to parent\nor canvas root] + RECURSE --> APPEND + + APPEND --> NOTIFY[figma-notification.port\nShow success toast] + NOTIFY --> DONE([Design rendered on canvas]) +``` + +--- + +## 20. Export Pipeline (Figma → JSON) + +```mermaid +flowchart LR + SEL([User selects nodes\nin Figma]) --> HANDLER[selection-change.handler\ndetect selection] + HANDLER --> UC[ExportSelectedUseCase\nor ExportAllUseCase] + UC --> REPO[FigmaNodeRepository\nread node tree] + + REPO --> EXP[NodeExporter\ntraverse node tree] + + EXP --> MAP1[NodeTypeMapper\nFigma type → domain type] + MAP1 --> MAP2[FillMapper\npaints → Fill value objects] + MAP2 --> MAP3[EffectMapper\neffects → Effect value objects] + MAP3 --> BUILD[Build DesignNode\ndomain entity] + + BUILD --> CHILDREN2{Children?} + CHILDREN2 -->|Yes| RECURSE2[Recurse subtree] + RECURSE2 --> BUILD + CHILDREN2 -->|No| JSON[Serialize to JSON\nclean portable schema] + + JSON --> OUT{Output\nchoice} + OUT -->|Copy| CLIP[Copy to clipboard] + OUT -->|Download| FILE[Download .json file] + OUT -->|Save version| API[POST /api/design-generations] +``` + +--- + +## 21. Error Handling Architecture + +```mermaid +flowchart TD + subgraph Plugin["Plugin (Client Side)"] + ERR_UI[React error boundary\nor try/catch in hook] + ERR_UI --> REPORT["POST /api/errors\n{ errorType, message, stack }"] + ERR_UI --> TOAST[react-toastify\nshow user-friendly message] + end + + subgraph Backend["Backend (Server Side)"] + direction LR + CTRL_ERR[Controller catches\nunhandled exception] + CTRL_ERR --> CUSTOM{Custom\nHTTP error?} + CUSTOM -->|Yes| TYPED[AppError subclass\nstatus + message] + CUSTOM -->|No| GENERIC[500 Internal Server Error] + TYPED & GENERIC --> MWARE[Error middleware\nformat JSON response] + MWARE --> LOG2[Logger middleware\nlog to stdout] + end + + REPORT --> DB_ERR[("INSERT client_errors\ntable")] + MWARE --> RES(["HTTP error response\n{ error, message, status }"]) +``` + +--- + +## 22. Full System State Machine + +```mermaid +stateDiagram-v2 + [*] --> Unauthenticated : Plugin opens + + Unauthenticated --> Authenticating : Click "Sign in with Google" + Authenticating --> Authenticated : JWT received via polling + Authenticating --> Unauthenticated : Timeout / error + + Authenticated --> Idle : Home screen loaded + + Idle --> GeneratingDesign : Submit AI prompt + Idle --> ExportingDesign : Click Export + Idle --> ImportingJSON : Paste JSON + Idle --> ViewingLibrary : Open UI Library + Idle --> ViewingHistory : Open Design Versions + Idle --> Purchasing : Click Buy Points + + GeneratingDesign --> Idle : Success — nodes created + GeneratingDesign --> Idle : Failed — toast shown + + ExportingDesign --> Idle : JSON copied / downloaded + + ImportingJSON --> Idle : Nodes created on canvas + + ViewingLibrary --> CreatingComponent : Save selection + CreatingComponent --> ViewingLibrary : Saved + ViewingLibrary --> Idle : Close + + ViewingHistory --> RestoringVersion : Click version + RestoringVersion --> Idle : Nodes restored + + Purchasing --> AwaitingPayment : Stripe checkout opened + AwaitingPayment --> Idle : Payment confirmed + points added + AwaitingPayment --> Idle : Checkout abandoned + + Authenticated --> Unauthenticated : Sign out / token expired +``` + +--- + +
+ +**Rio** — Built with Clean Architecture · Powered by 7+ AI providers · Secured by Stripe & Google OAuth + +
diff --git a/backend/package-lock.json b/backend/package-lock.json index 98bdb25..9facba4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,6 +24,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.3.1", "express-validator": "^7.3.1", "google-auth-library": "^10.5.0", "joi": "^18.0.2", @@ -2864,6 +2865,23 @@ "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==", "license": "MIT" }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", @@ -3448,6 +3466,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/backend/package.json b/backend/package.json index 12cc353..bc1e80f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "dotenv": "^16.4.7", "express": "^5.1.0", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.3.1", "express-validator": "^7.3.1", "google-auth-library": "^10.5.0", "joi": "^18.0.2", diff --git a/backend/public/prompt/design/instructions/icon-instructions.txt b/backend/public/prompt/design/instructions/icon-instructions.txt index fa72352..1992ffc 100644 --- a/backend/public/prompt/design/instructions/icon-instructions.txt +++ b/backend/public/prompt/design/instructions/icon-instructions.txt @@ -12,11 +12,14 @@ When you need icons NOT in the reference list (or when no list is provided): Before generating the design, scan the entire layout and identify every icon you will need. Then call searchIcons for ALL of them simultaneously in a SINGLE response — do NOT search incrementally across multiple rounds. -Each searchIcons call returns objects with 'id' and 'url' fields. -Use the 'url' value directly as the imageUrl — no additional tool calls needed. +Each searchIcons call returns an array of icon ID strings (e.g. "mdi:home", "lucide:arrow-right"). +Construct the icon URL from the ID using this pattern: +https://api.iconify.design/{prefix}/{name}.svg +where prefix and name are the two parts split by ':'. +Example: "mdi:home" → https://api.iconify.design/mdi/home.svg Example result from searchIcons: - { "icons": [{ "id": "mdi:home", "url": "https://api.iconify.design/mdi/home.svg" }, ...] } + { "icons": ["mdi:home", "mdi:home-outline", "mdi:home-variant"] } Then use RECTANGLE with IMAGE fill: diff --git a/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts b/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts index 09c0986..78752c9 100644 --- a/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts +++ b/backend/src/application/use-cases/ui-library/delete-ui-library-component.use-case.ts @@ -9,6 +9,9 @@ export class DeleteUILibraryComponentUseCase { async execute(componentId: string, userId: string): Promise { const component = await this.uiLibraryRepository.findComponentById(componentId, userId); + if (!component) { + throw new Error('Component not found'); + } await this.uiLibraryRepository.deleteComponent(componentId, userId); diff --git a/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts b/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts index b155371..4848a81 100644 --- a/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts +++ b/backend/src/application/use-cases/ui-library/delete-ui-library-project.use-case.ts @@ -4,6 +4,10 @@ export class DeleteUILibraryProjectUseCase { constructor(private readonly uiLibraryRepository: IUILibraryRepository) {} async execute(projectId: string, userId: string): Promise { + const project = await this.uiLibraryRepository.findProjectById(projectId, userId); + if (!project) { + throw new Error('Project not found'); + } await this.uiLibraryRepository.deleteProject(projectId, userId); } } diff --git a/backend/src/infrastructure/config/env.config.ts b/backend/src/infrastructure/config/env.config.ts index ae54717..456fd35 100644 --- a/backend/src/infrastructure/config/env.config.ts +++ b/backend/src/infrastructure/config/env.config.ts @@ -52,4 +52,11 @@ export const ENV_CONFIG = { AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY!, AWS_REGION: process.env.AWS_REGION || 'us-east-1', AWS_S3_BUCKET: process.env.AWS_S3_BUCKET!, + + // Icon & Tool Call Configuration + ICON_SEARCH_TIMEOUT_MS: Number(process.env.ICON_SEARCH_TIMEOUT_MS || 5000), + ICON_SEARCH_LIMIT: Number(process.env.ICON_SEARCH_LIMIT || 10), + ICON_CACHE_TTL_MS: Number(process.env.ICON_CACHE_TTL_MS || 600000), // 10 minutes + MAX_CONCURRENT_TOOL_CALLS: Number(process.env.MAX_CONCURRENT_TOOL_CALLS || 5), + MAX_TOOL_CALL_ROUNDS: Number(process.env.MAX_TOOL_CALL_ROUNDS || 3), }; diff --git a/backend/src/infrastructure/services/ai/ai-generate-design.service.ts b/backend/src/infrastructure/services/ai/ai-generate-design.service.ts index 5272070..4aa9f9b 100644 --- a/backend/src/infrastructure/services/ai/ai-generate-design.service.ts +++ b/backend/src/infrastructure/services/ai/ai-generate-design.service.ts @@ -9,6 +9,7 @@ import { ConversationMessage, DesignGenerationResult, IAiDesignService } from '. import { iconTools } from '../../config/ai-tools.config'; import { AIModelConfig, getModelById } from '../../config/ai-models.config'; +import { ENV_CONFIG } from '../../config/env.config'; import { ToolCallHandlerService, FunctionToolCall } from './tool-call-handler.service'; import { ResponseParserService } from './response-parser.service'; @@ -297,10 +298,13 @@ export class AiGenerateDesignService implements IAiDesignService { totalInputTokens += completion.usage?.prompt_tokens ?? 0; totalOutputTokens += completion.usage?.completion_tokens ?? 0; - // Handle tool calls loop - while (completion.choices[0]?.message?.tool_calls) { + // Handle tool calls loop (capped to prevent runaway token costs) + const maxRounds = ENV_CONFIG.MAX_TOOL_CALL_ROUNDS; + let round = 0; + while (completion.choices[0]?.message?.tool_calls && round < maxRounds) { + round++; const toolCalls = completion.choices[0].message.tool_calls as FunctionToolCall[]; - console.log(`--- Processing ${toolCalls.length} tool calls ---`); + console.log(`--- Processing ${toolCalls.length} tool calls (round ${round}/${maxRounds}) ---`); const toolResults = await this.toolCallHandler.handleToolCalls(toolCalls); // Add assistant message with tool calls @@ -324,6 +328,17 @@ export class AiGenerateDesignService implements IAiDesignService { totalOutputTokens += completion.usage?.completion_tokens ?? 0; } + if (round >= maxRounds && completion.choices[0]?.message?.tool_calls) { + console.warn(`⚠️ Tool call loop hit max rounds (${maxRounds}). Forcing final completion without tools.`); + // Request one final completion without tools to get the text response + completion = await openai.chat.completions.create({ + model: aiModel.id, + messages: messages as any, + }); + totalInputTokens += completion.usage?.prompt_tokens ?? 0; + totalOutputTokens += completion.usage?.completion_tokens ?? 0; + } + const responseText = completion.choices[0]?.message?.content; if (!responseText) { diff --git a/backend/src/infrastructure/services/ai/icon-extractor.service.ts b/backend/src/infrastructure/services/ai/icon-extractor.service.ts index 0c2c8dd..5132032 100644 --- a/backend/src/infrastructure/services/ai/icon-extractor.service.ts +++ b/backend/src/infrastructure/services/ai/icon-extractor.service.ts @@ -39,11 +39,11 @@ export class IconExtractorService { */ normalizeName(name: string): string { return (name || '') - // .toLowerCase() - // .replace(/^(icon[s]?[\s/\-_]+|logo[s]?[\s/\-_]+|ic[\s/\-_]+)/i, '') - // .replace(/([\s/\-_]+icon[s]?$|[\s/\-_]+logo[s]?$)/i, '') - // .replace(/[\s\-_/]+/g, '') - // .trim(); + .toLowerCase() + .replace(/^(icon[s]?[\s/\-_]+|logo[s]?[\s/\-_]+|ic[\s/\-_]+)/i, '') + .replace(/([\s/\-_]+icon[s]?$|[\s/\-_]+logo[s]?$)/i, '') + .replace(/[\s\-_/]+/g, '') + .trim(); } private walk(node: any, map: Map): void { diff --git a/backend/src/infrastructure/services/ai/icon.service.ts b/backend/src/infrastructure/services/ai/icon.service.ts index aa47f81..61352a8 100644 --- a/backend/src/infrastructure/services/ai/icon.service.ts +++ b/backend/src/infrastructure/services/ai/icon.service.ts @@ -1,21 +1,35 @@ // src/infrastructure/services/icon.service.ts import { IIconService, IconSearchResult } from '../../../domain/services/IIconService'; +import { ENV_CONFIG } from '../../config/env.config'; -const ICON_SEARCH_TIMEOUT_MS = 5000; -const ICON_SEARCH_LIMIT = 10; +interface CacheEntry { + result: IconSearchResult; + expiry: number; +} export class IconService implements IIconService { + private cache = new Map(); async searchIcons(query: string): Promise { - console.log(`🔍 Searching icons for: ${query}`); + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return { icons: [] }; + + // Check cache first + const cached = this.cache.get(normalizedQuery); + if (cached && Date.now() < cached.expiry) { + console.log(`⚡ Icon cache hit for: "${normalizedQuery}"`); + return cached.result; + } + + console.log(`🔍 Searching icons for: "${normalizedQuery}"`); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ICON_SEARCH_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), ENV_CONFIG.ICON_SEARCH_TIMEOUT_MS); try { const response = await fetch( - `https://api.iconify.design/search?query=${encodeURIComponent(query)}&limit=${ICON_SEARCH_LIMIT}`, + `https://api.iconify.design/search?query=${encodeURIComponent(normalizedQuery)}&limit=${ENV_CONFIG.ICON_SEARCH_LIMIT}`, { signal: controller.signal } ); @@ -25,12 +39,24 @@ export class IconService implements IIconService { const data = await response.json() as { icons?: string[] }; const icons: string[] = data.icons ?? []; + const result: IconSearchResult = { icons }; + + // Store in cache + this.cache.set(normalizedQuery, { + result, + expiry: Date.now() + ENV_CONFIG.ICON_CACHE_TTL_MS, + }); + + // Evict expired entries periodically (keep cache bounded) + if (this.cache.size > 200) { + this.evictExpired(); + } - return { icons }; + return result; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - console.warn(`⚠️ Icon search timed out for: ${query}`); + console.warn(`⚠️ Icon search timed out for: "${normalizedQuery}"`); return { icons: [] }; } @@ -39,4 +65,13 @@ export class IconService implements IIconService { clearTimeout(timeout); } } + + private evictExpired(): void { + const now = Date.now(); + for (const [key, entry] of this.cache) { + if (now >= entry.expiry) { + this.cache.delete(key); + } + } + } } \ No newline at end of file diff --git a/backend/src/infrastructure/services/ai/tool-call-handler.service.ts b/backend/src/infrastructure/services/ai/tool-call-handler.service.ts index 45cd306..1c6ab7c 100644 --- a/backend/src/infrastructure/services/ai/tool-call-handler.service.ts +++ b/backend/src/infrastructure/services/ai/tool-call-handler.service.ts @@ -1,6 +1,7 @@ // src/infrastructure/services/tool-call-handler.service.ts import { IIconService } from '../../../domain/services/IIconService'; +import { ENV_CONFIG } from '../../config/env.config'; export interface FunctionToolCall { id: string; @@ -24,8 +25,11 @@ export class ToolCallHandlerService { console.log(`🛠️ [tools] start — ${toolCalls.length} tool call(s)`); const start = Date.now(); - const results = await Promise.all( - toolCalls.map(toolCall => this.handleSingleToolCall(toolCall)) + // Run with concurrency throttle to avoid overwhelming external APIs + const results = await this.runWithConcurrency( + toolCalls, + tc => this.handleSingleToolCall(tc), + ENV_CONFIG.MAX_CONCURRENT_TOOL_CALLS ); console.log(`✅ [tools] done — ${toolCalls.length} tool call(s) in ${Date.now() - start}ms`); @@ -62,4 +66,32 @@ export class ToolCallHandlerService { content: result, }; } + + /** + * Runs async tasks with a concurrency limit. + * Avoids blasting external APIs with unbounded parallel requests. + */ + private async runWithConcurrency( + items: T[], + fn: (item: T) => Promise, + limit: number + ): Promise { + if (items.length <= limit) { + return Promise.all(items.map(fn)); + } + + const results: R[] = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const idx = nextIndex++; + results[idx] = await fn(items[idx]); + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); + await Promise.all(workers); + return results; + } } diff --git a/backend/src/infrastructure/services/storage/s3.service.ts b/backend/src/infrastructure/services/storage/s3.service.ts index ea35bc6..451b640 100644 --- a/backend/src/infrastructure/services/storage/s3.service.ts +++ b/backend/src/infrastructure/services/storage/s3.service.ts @@ -2,6 +2,47 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client import { ENV_CONFIG } from '../../config/env.config'; import { randomUUID } from 'crypto'; +const ALLOWED_MIME_TYPES: Record = { + 'image/png': { + extension: 'png', + magic: [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + }, + 'image/jpeg': { + extension: 'jpg', + magic: [Buffer.from([0xff, 0xd8, 0xff])], + }, + 'image/webp': { + extension: 'webp', + // RIFF????WEBP — bytes 0-3 are RIFF, bytes 8-11 are WEBP + magic: [Buffer.from([0x52, 0x49, 0x46, 0x46])], + }, +}; + +const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB + +function validateImageBuffer(buffer: Buffer, mimeType: string): void { + if (buffer.length > MAX_SIZE_BYTES) { + throw new Error(`File size ${buffer.length} exceeds the 5 MB limit`); + } + + const spec = ALLOWED_MIME_TYPES[mimeType]; + if (!spec) { + throw new Error(`MIME type "${mimeType}" is not allowed. Allowed types: ${Object.keys(ALLOWED_MIME_TYPES).join(', ')}`); + } + + const matchesMagic = spec.magic.some(magic => buffer.slice(0, magic.length).equals(magic)); + + // WebP requires an additional check on bytes 8-11 + if (mimeType === 'image/webp') { + const webpMarker = buffer.slice(8, 12).toString('ascii'); + if (!matchesMagic || webpMarker !== 'WEBP') { + throw new Error('File content does not match declared MIME type image/webp'); + } + } else if (!matchesMagic) { + throw new Error(`File content does not match declared MIME type ${mimeType}`); + } +} + export class S3Service { private readonly client: S3Client; private readonly bucket: string; @@ -19,14 +60,17 @@ export class S3Service { } async uploadBase64Image(base64DataUrl: string, folder = 'component-previews'): Promise { - const matches = base64DataUrl.match(/^data:(.+);base64,(.+)$/); + const matches = base64DataUrl.match(/^data:([a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]+\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]+);base64,([A-Za-z0-9+/]+=*)$/); if (!matches) { throw new Error('Invalid base64 image format. Expected: data:;base64,'); } const mimeType = matches[1]; const buffer = Buffer.from(matches[2], 'base64'); - const extension = mimeType.split('/')[1] || 'png'; + + validateImageBuffer(buffer, mimeType); + + const extension = ALLOWED_MIME_TYPES[mimeType].extension; const key = `${folder}/${randomUUID()}.${extension}`; await this.client.send(new PutObjectCommand({ diff --git a/backend/src/infrastructure/web/controllers/ui-library.controller.ts b/backend/src/infrastructure/web/controllers/ui-library.controller.ts index 9386329..076bb73 100644 --- a/backend/src/infrastructure/web/controllers/ui-library.controller.ts +++ b/backend/src/infrastructure/web/controllers/ui-library.controller.ts @@ -62,7 +62,9 @@ export class UILibraryController { message: 'Project deleted successfully', }); } catch (error) { - next(error); + const message = error instanceof Error ? error.message : 'Failed to delete project'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ success: false, message }); } } @@ -119,7 +121,9 @@ export class UILibraryController { message: 'Component deleted successfully', }); } catch (error) { - next(error); + const message = error instanceof Error ? error.message : 'Failed to delete component'; + const statusCode = message.toLowerCase().includes('not found') ? 404 : 500; + res.status(statusCode).json({ success: false, message }); } } diff --git a/backend/src/infrastructure/web/middleware/concurrency-limit.middleware.ts b/backend/src/infrastructure/web/middleware/concurrency-limit.middleware.ts new file mode 100644 index 0000000..4329740 --- /dev/null +++ b/backend/src/infrastructure/web/middleware/concurrency-limit.middleware.ts @@ -0,0 +1,68 @@ +import { Request, Response, NextFunction } from 'express'; + +const MAX_CONCURRENT = 2; +// const SLOT_TTL_MS = 5 * 60 * 1000; // 5 minutes — auto-release stuck slots + +interface Slot { + count: number; + lastUpdated: number; +} + +const activeRequests = new Map(); + +// // Cleanup stale slots every minute +// setInterval(() => { +// const now = Date.now(); +// for (const [userId, slot] of activeRequests) { +// if (now - slot.lastUpdated > SLOT_TTL_MS) { +// activeRequests.delete(userId); +// } +// } +// }, 60 * 1000); + +function getSlot(userId: string): Slot { + let slot = activeRequests.get(userId); + if (!slot) { + slot = { count: 0, lastUpdated: Date.now() }; + activeRequests.set(userId, slot); + } + return slot; +} + +function release(userId: string): void { + const slot = activeRequests.get(userId); + if (!slot) return; + slot.count = Math.max(0, slot.count - 1); + slot.lastUpdated = Date.now(); + if (slot.count === 0) { + activeRequests.delete(userId); + } +} + +export function aiConcurrencyLimiter(req: Request, res: Response, next: NextFunction): void { + const userId: string | undefined = (req as any).user?.id?.toString(); + + if (!userId) { + next(); + return; + } + + const slot = getSlot(userId); + + if (slot.count >= MAX_CONCURRENT) { + res.status(429).json({ + success: false, + message: `You already have ${MAX_CONCURRENT} AI requests in progress. Please wait for one to finish.`, + }); + return; + } + + slot.count++; + slot.lastUpdated = Date.now(); + + // Release the slot when the response finishes (success, error, or client disconnect) + res.on('finish', () => release(userId)); + res.on('close', () => release(userId)); + + next(); +} diff --git a/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts new file mode 100644 index 0000000..4783eff --- /dev/null +++ b/backend/src/infrastructure/web/middleware/rate-limit.middleware.ts @@ -0,0 +1,58 @@ +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; +import { Request } from 'express'; + +const userOrIpKey = (req: Request): string => + (req as any).user?.id?.toString() ?? ipKeyGenerator(req.ip ?? ''); + +const rateLimitResponse = (message: string) => ({ + success: false, + message, +}); + +// Auth endpoints: 50 req/min per IP (brute-force protection) +export const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 50, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Too many auth requests. Please try again in a minute.'), +}); + +// AI generation: 5 req/min per user (cost protection) +export const aiLimiterPerMinute = rateLimit({ + windowMs: 60 * 1000, + max: 5, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('AI generation limit reached. Max 5 requests per minute.'), +}); + +// File upload: 5 req/min per user +export const uploadLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Upload limit reached. Max 5 uploads per minute.'), +}); + +// Payment/subscription checkout: 5 req/min per user +export const paymentLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + keyGenerator: userOrIpKey, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Payment request limit reached. Max 5 requests per minute.'), +}); + +// Stripe webhook: 100 req/min (generous, Stripe retries legitimately) +export const webhookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: rateLimitResponse('Webhook rate limit exceeded.'), +}); diff --git a/backend/src/infrastructure/web/server.ts b/backend/src/infrastructure/web/server.ts index a4ffdcd..e570d6f 100644 --- a/backend/src/infrastructure/web/server.ts +++ b/backend/src/infrastructure/web/server.ts @@ -25,6 +25,14 @@ import designGenerationRoutes from './routes/design-generation.routes'; import { setupDependencies } from './dependencies'; import { logger } from './middleware/logger.middleware'; +import { + authLimiter, + aiLimiterPerMinute, + uploadLimiter, + paymentLimiter, + webhookLimiter, +} from './middleware/rate-limit.middleware'; +import { aiConcurrencyLimiter } from './middleware/concurrency-limit.middleware'; export class Server { private app: Application; @@ -75,14 +83,16 @@ export class Server { this.container.authMiddleware.requireAuthForApi(req, res, next); }); - this.app.use('/auth', authRoutes(this.container.authController)); - this.app.use('/api/designs', designRoutes(this.container.designController)); + this.app.use('/auth', authLimiter, authRoutes(this.container.authController)); + this.app.use('/api/designs', aiLimiterPerMinute, aiConcurrencyLimiter, designRoutes(this.container.designController)); this.app.use('/api/ai-models', aiModelsRoutes(this.container.aiModelsController)); this.app.use('/api/design-systems', designSystemsRoutes(this.container.designSystemsController)); this.app.use('/api/errors', clientErrorRoutes(this.container.clientErrorController)); + this.app.use('/api/ui-library/components/upload-image', uploadLimiter); this.app.use('/api/ui-library', uiLibraryRoutes(this.container.uiLibraryController)); - this.app.use('/api/payments', paymentRoutes(this.container.paymentController)); - this.app.use('/api/subscriptions', subscriptionRoutes(this.container.subscriptionController)); + this.app.use('/api/payments/webhook', webhookLimiter); + this.app.use('/api/payments', paymentLimiter, paymentRoutes(this.container.paymentController)); + this.app.use('/api/subscriptions', paymentLimiter, subscriptionRoutes(this.container.subscriptionController)); this.app.use('/api/design-generations', designGenerationRoutes(this.container.designGenerationController)); this.app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); } diff --git a/backend/src/infrastructure/web/validation/index.ts b/backend/src/infrastructure/web/validation/index.ts index b1aba77..d1281ca 100644 --- a/backend/src/infrastructure/web/validation/index.ts +++ b/backend/src/infrastructure/web/validation/index.ts @@ -140,6 +140,7 @@ export const uploadComponentImageValidation = [ body('image') .notEmpty().withMessage('Image is required') .isString().withMessage('Image must be a string') + .isLength({ max: 5 * 1024 * 1024 }).withMessage('Image must be less than 5MB') .matches(/^data:image\/(png|jpeg|jpg|webp);base64,/).withMessage('Image must be a valid base64 data URL'), ]; diff --git a/figma-plugin/src/domain/entities/design-node.ts b/figma-plugin/src/domain/entities/design-node.ts index 3ff3647..a21c5fc 100644 --- a/figma-plugin/src/domain/entities/design-node.ts +++ b/figma-plugin/src/domain/entities/design-node.ts @@ -315,8 +315,8 @@ export interface DesignNode { // Children children?: DesignNode[]; - // Image data (for embedded images) - imageData?: string; + // Image data (for embedded images) (disabled) + // imageData?: string; // SVG URL for icon nodes (uses figma.createNodeFromSvg instead of image fill) svgUrl?: string; diff --git a/figma-plugin/src/domain/entities/fill.ts b/figma-plugin/src/domain/entities/fill.ts index b6eea85..172b122 100644 --- a/figma-plugin/src/domain/entities/fill.ts +++ b/figma-plugin/src/domain/entities/fill.ts @@ -63,7 +63,7 @@ export interface Fill { // Image fill scaleMode?: 'FILL' | 'FIT' | 'CROP' | 'TILE'; imageHash?: string; - imageData?: string; // Base64 encoded image data for export/import + // imageData?: string; // Base64 encoded image data for export/import (disabled) imageUrl?: string; // URL to fetch image from (for import) imageTransform?: [[number, number, number], [number, number, number]]; scalingFactor?: number; diff --git a/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts b/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts index 7533e2b..d809248 100644 --- a/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts +++ b/figma-plugin/src/infrastructure/figma/exporters/node.exporter.ts @@ -961,23 +961,23 @@ export class NodeExporter { if (imagePaint.imageHash) { fill.imageHash = imagePaint.imageHash; - // Try to get base64 image data - if (!this.imageCache.has(imagePaint.imageHash)) { - try { - const image = figma.getImageByHash(imagePaint.imageHash); - if (image) { - const bytes = await image.getBytesAsync(); - const base64 = this.bytesToBase64(bytes); - this.imageCache.set(imagePaint.imageHash, base64); - } - } catch (e) { - console.warn('Failed to export image:', e); - } - } - - if (this.imageCache.has(imagePaint.imageHash)) { - fill.imageData = this.imageCache.get(imagePaint.imageHash); - } + // imageData export disabled — not included in exported JSON + // if (!this.imageCache.has(imagePaint.imageHash)) { + // try { + // const image = figma.getImageByHash(imagePaint.imageHash); + // if (image) { + // const bytes = await image.getBytesAsync(); + // const base64 = this.bytesToBase64(bytes); + // this.imageCache.set(imagePaint.imageHash, base64); + // } + // } catch (e) { + // console.warn('Failed to export image:', e); + // } + // } + + // if (this.imageCache.has(imagePaint.imageHash)) { + // fill.imageData = this.imageCache.get(imagePaint.imageHash); + // } } if (imagePaint.imageTransform) { diff --git a/figma-plugin/src/infrastructure/mappers/fill.mapper.ts b/figma-plugin/src/infrastructure/mappers/fill.mapper.ts index a4f94fd..e4e6758 100644 --- a/figma-plugin/src/infrastructure/mappers/fill.mapper.ts +++ b/figma-plugin/src/infrastructure/mappers/fill.mapper.ts @@ -189,11 +189,11 @@ export class FillMapper { if (fill.imageUrl) { image = await FillMapper.fetchImageFromUrl(fill.imageUrl); } - // Priority 2: If we have base64 image data, create the image - else if (fill.imageData) { - const bytes = FillMapper.base64ToBytes(fill.imageData); - image = await figma.createImage(bytes); - } + // Priority 2: imageData disabled + // else if (fill.imageData) { + // const bytes = FillMapper.base64ToBytes(fill.imageData); + // image = await figma.createImage(bytes); + // } // Priority 3: Try to get existing image by hash else if (fill.imageHash) { image = figma.getImageByHash(fill.imageHash); diff --git a/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts b/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts index edd654d..32d8185 100644 --- a/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts +++ b/figma-plugin/src/infrastructure/services/plugin-image-optimizer.service.ts @@ -12,7 +12,7 @@ export interface ImageReference { path: string[]; imageHash: string; - imageData: string; + // imageData: string; // disabled scaleMode?: string; } @@ -89,18 +89,16 @@ export class ImageOptimizerService { if (node.fills && Array.isArray(node.fills)) { node.fills.forEach((fill: any, fillIndex: number) => { if (fill.type === 'IMAGE' && fill.imageData) { - const imagePath = [...currentPath, 'fills', fillIndex]; - - imageReferences.push({ - path: imagePath, - imageHash: fill.imageHash || '', - imageData: fill.imageData, - scaleMode: fill.scaleMode - }); - - // Remove imageData - delete fill.imageData; - fill._imageStripped = true; + // imageData disabled — skipping strip/restore cycle + // const imagePath = [...currentPath, 'fills', fillIndex]; + // imageReferences.push({ + // path: imagePath, + // imageHash: fill.imageHash || '', + // imageData: fill.imageData, + // scaleMode: fill.scaleMode + // }); + // delete fill.imageData; + // fill._imageStripped = true; } }); } @@ -142,14 +140,16 @@ export class ImageOptimizerService { // Verify this is still an IMAGE fill if (fill.type === 'IMAGE' && fill._imageStripped === true) { - fill.imageData = imageRef.imageData; + // imageData disabled + // fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { fill.scaleMode = imageRef.scaleMode; } delete fill._imageStripped; return true; } else if (fill.type === 'IMAGE' && fill.imageHash === imageRef.imageHash) { - fill.imageData = imageRef.imageData; + // imageData disabled + // fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { fill.scaleMode = imageRef.scaleMode; } @@ -167,7 +167,8 @@ export class ImageOptimizerService { const found = this.findImageFillByHash(design, imageRef.imageHash); if (found && found.fill) { - found.fill.imageData = imageRef.imageData; + // imageData disabled + // found.fill.imageData = imageRef.imageData; if (imageRef.scaleMode) { found.fill.scaleMode = imageRef.scaleMode; } @@ -219,11 +220,8 @@ export class ImageOptimizerService { return null; } - private estimateTokenSavings(imageReferences: ImageReference[]): number { - let totalChars = 0; - for (const ref of imageReferences) { - totalChars += ref.imageData.length; - } - return Math.floor(totalChars / 4); + private estimateTokenSavings(_imageReferences: ImageReference[]): number { + // imageData disabled — always 0 savings + return 0; } } \ No newline at end of file diff --git a/figma-plugin/src/presentation/handlers/plugin-message.handler.ts b/figma-plugin/src/presentation/handlers/plugin-message.handler.ts index e60c494..45c8338 100644 --- a/figma-plugin/src/presentation/handlers/plugin-message.handler.ts +++ b/figma-plugin/src/presentation/handlers/plugin-message.handler.ts @@ -7,7 +7,7 @@ import { ExportSelectedUseCase, ExportAllUseCase, } from '../../application/use-cases'; -import { ApiConfig, defaultModel } from '../../shared/constants/plugin-config.js'; +import { ApiConfig, defaultModel, MAX_PAYLOAD_BYTES } from '../../shared/constants/plugin-config.js'; import { NodeExporter } from '../../infrastructure/figma/exporters/node.exporter'; import { GetUserInfoUseCase } from '@application/use-cases/getUserInfoUseCase'; import { errorReporter } from '../../infrastructure/services/error-reporter.service'; @@ -596,16 +596,22 @@ export class PluginMessageHandler { const requestKey = `edit_request_${Date.now()}`; this.imageReferencesStore.set(requestKey, imageReferences); + const editBody = JSON.stringify({ + message: userMessage, + history: this.conversationHistory, + currentDesign: cleanedDesign, + modelId: selectedModel, + designSystemId: designSystemId + }); + + if (editBody.length > MAX_PAYLOAD_BYTES) { + throw new Error('Selected layer is too large to edit (exceeds 5MB). Please select a smaller layer.'); + } + const response = await fetch(`${ApiConfig.BASE_URL}/api/designs/edit-with-ai`, { method: 'POST', headers: await this.getUserInfoUseCase.execute(), - body: JSON.stringify({ - message: userMessage, - history: this.conversationHistory, - currentDesign: cleanedDesign, - modelId: selectedModel, - designSystemId: designSystemId - }) + body: editBody, }); if (!response.ok) { @@ -701,17 +707,23 @@ export class PluginMessageHandler { console.log(`Plugin: sending ${cleanedReferences.length} references to backend`); + const basedOnExistingBody = JSON.stringify({ + message: userMessage, + history: conversationHistory, + referenceDesigns: cleanedReferences, + modelId: selectedModel, + pinnedComponentNames: pinnedComponentNames ?? [], + ...(imageDataUrl ? { imageDataUrl } : {}), + }); + + if (basedOnExistingBody.length > MAX_PAYLOAD_BYTES) { + throw new Error('References are too large to send (exceeds 5MB). Please attach fewer layers or components.'); + } + const response = await fetch(`${ApiConfig.BASE_URL}/api/designs/generate-based-on-existing`, { method: 'POST', headers: await this.getUserInfoUseCase.execute(), - body: JSON.stringify({ - message: userMessage, - history: conversationHistory, - referenceDesigns: cleanedReferences, - modelId: selectedModel, - pinnedComponentNames: pinnedComponentNames ?? [], - ...(imageDataUrl ? { imageDataUrl } : {}), - }) + body: basedOnExistingBody, }); if (!response.ok) { diff --git a/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx b/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx index e52c66c..91b1562 100644 --- a/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx +++ b/figma-plugin/src/presentation/ui/components/modals/SaveModal.tsx @@ -17,6 +17,7 @@ export default function SaveModal(): React.JSX.Element | null { const [projects, setProjects] = useState([]); const [isLoadingProjects, setIsLoadingProjects] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const componentName = useMemo(() => getComponentNameFromExportData(currentExportData), [currentExportData]); const componentNames = useMemo(() => getComponentNamesFromExportData(currentExportData), [currentExportData]); @@ -65,6 +66,7 @@ export default function SaveModal(): React.JSX.Element | null { try { setIsSaving(true); + setSaveError(null); let previewImageUrl: string | null = null; try { @@ -103,7 +105,7 @@ export default function SaveModal(): React.JSX.Element | null { setDescription(''); setSelectedProjectId(''); } catch (error) { - notify(`❌ ${(error as Error).message}`, 'error'); + setSaveError((error as Error).message); reportErrorAsync(error, { actionType: 'saveComponent', }); @@ -116,6 +118,7 @@ export default function SaveModal(): React.JSX.Element | null { dispatch({ type: 'CLOSE_SAVE_MODAL' }); setDescription(''); setSelectedProjectId(''); + setSaveError(null); }; return ( @@ -210,6 +213,12 @@ export default function SaveModal(): React.JSX.Element | null { disabled={isSaving} /> + {saveError && ( +
+ {saveError} +
+ )} +