diff --git a/.cursor/rules/00-core.mdc b/.cursor/rules/00-core.mdc new file mode 100644 index 00000000..e7524b7a --- /dev/null +++ b/.cursor/rules/00-core.mdc @@ -0,0 +1,21 @@ +--- +description: Core context & output contract for AiM +globs: + - "**/*" +alwaysApply: true +--- + +# Core Project Context (AiM) +STACK: Next.js 15, React 19, TS, Prisma + Postgres, NextAuth, shadcn/ui, Zod. +DOMAINS: Organization, Campaign, Content, Asset, Schedule, AnalyticsEvent. + +## OUTPUT CONTRACT (include in every response) +1) CHANGES — file list + brief diff +2) COMMANDS — install/migrate/dev/test +3) TESTS/RESULTS — build/typecheck/lint/health logs +4) GIT — branch name, commit SHAs, PR link (if cloud) + +MUST READ (if present): docs/SPEC.md, docs/data-model.md, docs/api/*.md +DO NOT: +- Touch real `.env`; only update `.env.example` +- Add dependencies without explicit approval diff --git a/.cursor/rules/01-guardrails.mdc b/.cursor/rules/01-guardrails.mdc new file mode 100644 index 00000000..a4be43c0 --- /dev/null +++ b/.cursor/rules/01-guardrails.mdc @@ -0,0 +1,22 @@ +--- +description: Guardrails — permissions, limits, safety +globs: + - "app/**" + - "features/**" + - "prisma/**" + - "app/api/**" + - "lib/**" +alwaysApply: false +--- + +# Guardrails +WRITE ALLOWED: app/**, features/**, lib/**, prisma/**, app/api/** +ASK FIRST: package.json, pnpm-lock.yaml, next.config.ts, .github/workflows/** + +SECURITY: +- Mọi route phải check session + RBAC +- Validate input bằng Zod; trả JSON { ok, data | error } + +LIMITS: +- ≤ 20 files / ≤ 800 lines diff / PR; vượt ngưỡng → dừng và hỏi +- Không ghi logs nhạy cảm (PII) diff --git a/.cursor/rules/02-repo-map.mdc b/.cursor/rules/02-repo-map.mdc new file mode 100644 index 00000000..abd6ca44 --- /dev/null +++ b/.cursor/rules/02-repo-map.mdc @@ -0,0 +1,16 @@ +--- +description: Repo map & module boundaries for AiM +globs: + - "**/*" +alwaysApply: false +--- + +# Repo Map & Boundaries +FEATURES: features/{auth,orgs,campaigns,content,assets,calendar,analytics,settings} +API: app/api/[orgId]/{campaigns,content,assets,schedules,analytics}/... +DATA: prisma/schema.prisma (source of truth) +UI: components/layout/*, components/ui/*, components/dashboards/* + +RULE: +- Tôn trọng boundaries: service/repo/UI tách lớp +- Không gọi Prisma trực tiếp từ UI client \ No newline at end of file diff --git a/.cursor/rules/03-sop-ai-sse.mdc b/.cursor/rules/03-sop-ai-sse.mdc new file mode 100644 index 00000000..be465e56 --- /dev/null +++ b/.cursor/rules/03-sop-ai-sse.mdc @@ -0,0 +1,20 @@ +--- +description: SOP for AI-SSE — how to deliver a PR +globs: + - "docs/tasks/**" + - "app/**" + - "features/**" + - "app/api/**" + - "prisma/**" +alwaysApply: false +--- + +# SOP for AI-SSE (per PR) +ACCEPTANCE (check all): +- Feature chạy sau khi `pnpm dev` +- Inputs validated (Zod); Auth + RBAC: 401/403 đúng chỗ +- Có migration + seed + rollback note (dev) +- CI pass (build, typecheck, lint, /api/health) + +RESPONSE FORMAT = theo OUTPUT CONTRACT. +Nếu chạm schema/kiến trúc → tạo docs/adr/####-*.md diff --git a/.cursor/rules/04-api-style.mdc b/.cursor/rules/04-api-style.mdc new file mode 100644 index 00000000..4041fd2a --- /dev/null +++ b/.cursor/rules/04-api-style.mdc @@ -0,0 +1,16 @@ +--- +description: API style & contracts for route handlers +globs: + - "app/api/**" + - "lib/**" +alwaysApply: false +--- + +# API Style (Next.js Route Handlers) +RESPONSE SHAPE: +{ "ok": true, "data": ... } | { "ok": false, "error": { "code", "message", "details?" } } + +VALIDATION: Zod per route → 400 invalid; 404 not found. +ORG SCOPING: Lấy orgId từ path; cấm cross-org access. +PAGINATION: ?page=&limit= → { items, total, page, limit } +ERRORS: Dùng mã ngắn (e.g., E_VALIDATION, E_FORBIDDEN) diff --git a/.cursor/rules/05-rbac-security.mdc b/.cursor/rules/05-rbac-security.mdc new file mode 100644 index 00000000..6a1b5f21 --- /dev/null +++ b/.cursor/rules/05-rbac-security.mdc @@ -0,0 +1,17 @@ +--- +description: RBAC roles & security checklist +globs: + - "app/api/**" + - "lib/**" + - "middleware.ts" +alwaysApply: false +--- + +# RBAC & Security +ROLES: ADMIN, BRAND_OWNER, CREATOR (mặc định deny-by-default). +HELPERS: lib/rbac.ts — kiểm tra quyền theo orgId + vai trò. + +CHECKLIST: +- [ ] Session bắt buộc cho routes không public +- [ ] Sanitize input/output; strip secrets khỏi logs +- [ ] Rate limit (nếu có hành vi ghi/nhạy cảm) diff --git a/.cursor/rules/06-ci-quality.mdc b/.cursor/rules/06-ci-quality.mdc new file mode 100644 index 00000000..ec959043 --- /dev/null +++ b/.cursor/rules/06-ci-quality.mdc @@ -0,0 +1,18 @@ +--- +description: CI pipeline & quality gates +globs: + - ".github/workflows/**" + - "**/*" +alwaysApply: false +--- + +# CI & Quality Gates +CI MUST: +- build, typecheck, lint +- curl /api/health trả 200 + +PR CHECKLIST (đi vào PR template): +- [ ] Zod validation +- [ ] Auth + RBAC checks +- [ ] Migration/seed + rollback note +- [ ] CI xanh diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..54aed853 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Database (Postgres) +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/circle_dev?schema=public" + +# NextAuth +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="changeme-in-local" + +# Dev-only credentials login +DEV_LOGIN_PASSWORD="dev" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3bf624f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + - name: Run unit tests + run: pnpm run test + + - name: Build + run: pnpm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run E2E tests + run: pnpm run test:e2e + + - name: Health check + run: | + pnpm run start & + sleep 10 + curl -f http://localhost:3000/api/health || exit 1 + pkill -f "next start" || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f9a9e88..2e91c277 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,10 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env +.env.local +.env.production +# .env.example is allowed to be committed # vercel .vercel diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1 @@ +{} diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1 @@ +{} diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 00000000..aa7bd982 --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,33 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "footnotes": false, + "properties": false, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "bases": true, + "webviewer": false +} diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 00000000..ec1b1390 --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,211 @@ +{ + "main": { + "id": "893e4335666bb73c", + "type": "split", + "children": [ + { + "id": "11c40342203930f7", + "type": "tabs", + "children": [ + { + "id": "b1d29e7c72ab1df3", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "docs/ui/campaigns.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "campaigns" + } + } + ] + } + ], + "direction": "vertical" + }, + "left": { + "id": "72e3f419536dccb2", + "type": "split", + "children": [ + { + "id": "15caff196190004c", + "type": "tabs", + "children": [ + { + "id": "5c458420e26c3b03", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": false + }, + "icon": "lucide-folder-closed", + "title": "Files" + } + }, + { + "id": "53b843ad39ed6c0f", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "Search" + } + }, + { + "id": "96501c8e7e5e3dce", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "Bookmarks" + } + } + ] + } + ], + "direction": "horizontal", + "width": 242.5 + }, + "right": { + "id": "291dec6b9bbe827e", + "type": "split", + "children": [ + { + "id": "c54902261a13dcf7", + "type": "tabs", + "children": [ + { + "id": "650593ff38043448", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "file": "docs/ui/campaigns.md", + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "Backlinks for campaigns" + } + }, + { + "id": "2d9a526162c43ded", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "file": "docs/ui/campaigns.md", + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "Outgoing links from campaigns" + } + }, + { + "id": "5092e30ea33d8ccd", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "Tags" + } + }, + { + "id": "35aaf7d0cc18ec7f", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "file": "docs/ui/campaigns.md", + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "Outline of campaigns" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300, + "collapsed": true + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false, + "bases:Create new base": false + } + }, + "active": "5c458420e26c3b03", + "lastOpenFiles": [ + "node_modules/@testing-library", + "node_modules/@playwright", + "node_modules/@eslint", + "node_modules/@tailwindcss", + "node_modules/@kayron013", + "components/campaigns/content/index.ts", + "components/campaigns/analytics/index.ts", + "components/campaigns/members/index.ts", + "app/api/[orgId]/campaigns/[id]/tasks/[taskId]/route.ts", + "app/api/[orgId]/campaigns/[id]/tasks/[taskId]", + "app/api/[orgId]/campaigns/[id]/contents/route.ts", + "docs/tasks/PR-002-campaigns-content-crud.md", + "docs/tasks/PR-001-auth-prisma.md", + "docs/ui/campaigns.md", + "docs/ui/README.md", + "docs/ui/dashboards.md", + "docs/ui/content-editor.md", + "docs/ui/auth.md", + "docs/ui/dashboard-layout.md", + "docs/ui/content-layout.md", + "docs/ui/campaigns-layout.md", + "docs/ui/schedule.md", + "docs/adr/0002-schedule-system-architecture.md", + "docs/design/schedule/README.md", + "docs/tasks/PR-012-documentation-deployment.md", + "docs/tasks/PR-011-testing-quality.md", + "docs/tasks/PR-010-performance-optimization.md", + "docs/tasks/PR-009-settings-management.md", + "docs/tasks/PR-008-analytics-reporting.md", + "docs/tasks/PR-007-scheduling-calendar.md", + "docs/tasks/README.md", + "docs/tasks/PR-006-ai-integration.md", + "docs/tasks/PR-005-dashboards.md", + "docs/tasks/PR-004-assets-upload.md", + "docs/tasks/PR-003-rbac-navigation.md", + "docs/playbooks/rollback.md" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..1b525590 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..eb66b02f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "chatgpt.openOnStartup": true +} diff --git a/AI_INTEGRATION_README.md b/AI_INTEGRATION_README.md new file mode 100644 index 00000000..3026adfc --- /dev/null +++ b/AI_INTEGRATION_README.md @@ -0,0 +1,246 @@ +# AI Services Integration for AiM + +This document describes the AI services integration implemented in the AiM (AI Marketing) platform. + +## Overview + +The AI integration provides the following services: + +- **Content Generation**: Generate new content based on prompts +- **Content Summarization**: Summarize long-form content +- **Content Translation**: Translate content to different languages +- **Idea Generation**: Generate creative ideas for content topics + +## Setup + +### Environment Variables + +Add the following to your `.env` file: + +```env +# OpenAI +OPENAI_API_KEY="your-openai-api-key-here" +``` + +### Dependencies + +The following packages are required: + +- `openai`: OpenAI SDK for API integration + +Install with: + +```bash +pnpm add openai +``` + +## API Endpoints + +### Content Generation + +**POST** `/api/[orgId]/content/generate` + +Generates new content using AI based on a prompt. + +**Request Body:** + +```json +{ + "prompt": "Write a social media post about summer fashion", + "campaignId": "campaign-uuid" +} +``` + +**Response:** + +```json +{ + "id": "content-uuid", + "title": "AI Generated: Write a social media post about summer fashion...", + "body": "Generated content here...", + "campaignId": "campaign-uuid", + "createdAt": "2024-01-01T00:00:00.000Z" +} +``` + +### Content Summarization + +**POST** `/api/[orgId]/content/summarize` + +Summarizes provided content. + +**Request Body:** + +```json +{ + "content": "Long text to summarize...", + "length": "brief" | "detailed" +} +``` + +**Response:** + +```json +{ + "summary": "Summarized content here..." +} +``` + +### Content Translation + +**POST** `/api/[orgId]/content/translate` + +Translates content to a target language. + +**Request Body:** + +```json +{ + "content": "Text to translate", + "targetLanguage": "Spanish", + "sourceLanguage": "English" // optional +} +``` + +**Response:** + +```json +{ + "translatedContent": "Texto traducido" +} +``` + +### Idea Generation + +**POST** `/api/[orgId]/content/ideas` + +Generates creative ideas for content topics. + +**Request Body:** + +```json +{ + "topic": "social media marketing", + "count": 5, + "type": "general" | "titles" | "hashtags" | "campaigns" +} +``` + +**Response:** + +```json +{ + "ideas": ["Idea 1", "Idea 2", "Idea 3"] +} +``` + +## UI Integration + +### Creator Dashboard + +The AI features are integrated into the Creator Dashboard with three main sections: + +1. **AI Assistant Tab**: Contains forms for all AI services +2. **Content Studio**: AI-powered content creation dialog +3. **Idea Generation**: Generate content ideas based on topics + +### Features + +- **Real-time AI Generation**: Generate content instantly +- **Campaign Integration**: Associate generated content with campaigns +- **Multi-language Support**: Translate content to various languages +- **Idea Brainstorming**: Generate multiple ideas for content topics +- **Content Optimization**: Summarize and improve existing content + +## Usage Examples + +### Generating Content + +```typescript +import { generateContent } from '@/lib/openai'; + +const content = await generateContent({ + prompt: 'Write a blog post about AI in marketing', + type: 'blog', + tone: 'professional', + length: 'medium', +}); +``` + +### Summarizing Content + +```typescript +import { summarizeContent } from '@/lib/openai'; + +const summary = await summarizeContent({ + content: 'Long article text...', + length: 'brief', +}); +``` + +### Translating Content + +```typescript +import { translateContent } from '@/lib/openai'; + +const translation = await translateContent({ + content: 'Hello world', + targetLanguage: 'Spanish', +}); +``` + +### Generating Ideas + +```typescript +import { generateIdeas } from '@/lib/openai'; + +const ideas = await generateIdeas({ + topic: 'content marketing', + count: 5, + type: 'general', +}); +``` + +## Error Handling + +All AI functions include proper error handling: + +- API key validation +- Network error handling +- Rate limiting considerations +- Fallback responses for failed requests + +## Security + +- API keys are stored as environment variables +- All endpoints require authentication +- RBAC permissions are enforced +- Input validation using Zod schemas + +## Testing + +Run the AI integration tests: + +```typescript +import { runAITests } from '@/lib/ai-test'; + +// Run all tests +const results = await runAITests(); +``` + +## Future Enhancements + +- Support for additional AI models +- Batch processing for multiple content items +- Advanced content optimization features +- Integration with other AI services +- Custom AI model training + +## Support + +For issues or questions about the AI integration: + +1. Check the API key is valid and has sufficient credits +2. Verify network connectivity +3. Review error logs in the console +4. Ensure proper permissions are set for the user diff --git a/AiM_Migration_Plan_vi.md b/AiM_Migration_Plan_vi.md new file mode 100644 index 00000000..a209ae15 --- /dev/null +++ b/AiM_Migration_Plan_vi.md @@ -0,0 +1,586 @@ +--- +title: 'Kế hoạch Phân tích & Migration Toàn Diện cho **AiM Platform**' +tags: [AiM, Migration, Architecture, Next.js, Prisma, NextAuth, Postgres, RBAC, Obsidian] +date: 2025-09-02 +--- + +# Kế hoạch Phân tích & Migration Toàn Diện cho **AiM Platform** + +## 1) Tóm tắt điều hành (**Executive Summary**) + +Repository hiện tại (**ioqwfoihas**, tên **Circle**) là một ứng dụng **Next.js** lấy cảm hứng từ công cụ quản lý dự án **Linear**. Ứng dụng theo dõi _issues, projects, teams_ với UI hiện đại, _responsive_. `package.json` cho thấy đây là **client-only stack** với các dependencies như **Next.js 15**, **React 19**, **Tailwind CSS**, **Radix UI**, **Zustand**, **Recharts** và **Zod**. Chưa có **database** hay **API layer**; dữ liệu đang nằm ở **local state**. + +**AiM** hướng tới chuyển hoá codebase này thành một **AI‑powered content platform** phục vụ **Creators** và **Brands**. Hệ thống tương lai cần hỗ trợ **role‑based dashboards**, **campaign planning**, **AI‑assisted content creation**, **scheduling**, **asset management**, **analytics** và **administrative controls**. Báo cáo này phân tích repo, _map_ năng lực hiện tại sang yêu cầu **AiM**, xác định **gaps/risks**, đề xuất **target architecture** và **data model**, đồng thời cung cấp **migration roadmap** với **150+** nhiệm vụ khả dụng ngay. + +--- + +## 2) Kiểm kê Repo & Tech Stack (**Repo Inventory & Tech Stack**) + +### Cây thư mục (ước lượng, depth ≤ 3) + +> Ghi chú: do không có listing đầy đủ, một số đường dẫn được suy luận theo pattern **Linear/Next.js** phổ biến. + +```text +ioqwfoihas/ +├─ README.md +├─ package.json +├─ tsconfig.json +├─ next.config.ts +├─ app/ +│ ├─ page.tsx +│ ├─ layout.tsx +│ ├─ lndev-ui/ +│ │ ├─ team/[team]/[view]/ +│ │ │ ├─ page.tsx +│ │ │ └─ layout.tsx +│ │ ├─ settings/… +│ │ └─ … +│ └─ (auth)/ +├─ components/ +│ ├─ ui/ +│ ├─ board/ +│ ├─ modal/ +│ ├─ chart/ +│ └─ navigation/ +├─ lib/ +│ ├─ store/ +│ ├─ hooks/ +│ └─ utils.ts +├─ public/ +├─ styles/ +├─ .husky/ +└─ .eslintrc, .prettierrc +``` + +### Công nghệ & phiên bản (**Tech & Versions**) + +| Khu vực | Chi tiết | +| ---------------------- | ---------------------------------------------------- | +| **Framework** | **Next.js 15** (App Router, React Server Components) | +| **Ngôn ngữ** | **TypeScript** (strict) | +| **UI Library** | **shadcn/ui** (trên nền **Radix UI**) | +| **Styling** | **Tailwind CSS v4** + `tailwindcss-animate` | +| **State Mgmt** | **Zustand** cho client state | +| **Forms & Validation** | **React Hook Form** + **Zod** | +| **Charts** | **Recharts** | +| **Utilities** | `clsx`, `date-fns`, `uuid`, … | +| **Testing** | Chưa có **Jest/Vitest/Cypress** | +| **CI & Scripts** | **Husky**, **lint-staged**, **ESLint**, **Prettier** | +| **Database/ORM** | Chưa có (no **Prisma/ORM**) | + +--- + +## 3) Đánh giá kiến trúc (**Architecture Assessment**) + +- **Single-app**: cấu trúc **Next.js** thuần, không **monorepo/workspaces**. +- **Client‑only**: không có API routes/server logic; dữ liệu nằm ở **Zustand** → _không phù hợp cho AiM_ (cần persistence). +- **UI/UX**: responsive, dark/light, sidebars, modals; dùng **shadcn/ui** + **Radix**; có command palette (**cmdk**) và **Recharts** có thể tái sử dụng. +- **Routing**: **App Router**; root page redirect sang board; dynamic routes theo `team/view`. +- **State**: **Zustand** cho local data; để lên **AiM** cần **React Query** (data fetching/cache) + backend. +- **Auth**: Chưa có **authentication/roles**. +- **Config/Env**: Tối thiểu; chưa có `.env.example` hay secret management. +- **Quality/DX**: Có lint/format cơ bản; chưa có tests/CI đầy đủ. +- **Security**: Không có ranh giới bảo mật (no auth, no server validation). + +--- + +## 4) Mapping sang **AiM** (**Reuse / Extend / Replace / Remove**) + +| Module/Screen hiện có | Mapping sang AiM | Action | Lý do | +| ------------------------------ | ----------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| UI Shell (layout, navigation) | Base dashboard cho mọi role | **Reuse & Extend** | Giữ responsive & theme; mở rộng navigation: **Campaigns, Content Studio, Calendar, Assets, Analytics, Settings** | +| Team/Project Boards | **Campaign Kanban** | **Extend** | Chuyển issue card → campaign card (**Draft/Active/Completed**), drag‑drop persist | +| List/Issue Views | **Content lists** trong campaign | **Replace** | Thay issue list → content items; thêm trạng thái AI (**Draft/AI‑generated/Approved**) | +| **Zustand Stores** (mock data) | Server data qua **React Query** | **Replace** | **Zustand** chỉ giữ UI state; data persist qua **Postgres/API** | +| Modals & Forms | Wizards tạo **Campaign/Content** | **Reuse & Extend** | Reuse modal patterns + multi‑step, **zod** validation, AI suggestions | +| Charts (**Recharts**) | **Campaign/Content analytics** | **Reuse & Extend** | Nối analytics API, hiển thị metrics (impressions/CTR/ROI) | +| Command palette | **AI assistant** & global search | **Extend** | Palette nhận prompt AI, thực thi lệnh (“Generate LinkedIn post for X”) | +| Auth/RBAC (missing) | **Auth** & role‑based access | **Add** | Thêm **NextAuth** + **Prisma**, roles: **Creator/Brand/Admin** (+**Reviewer/Publisher** nếu cần) | +| API & ORM (missing) | API endpoints/**tRPC** + **Prisma** | **Add** | CRUD cho campaigns/contents/schedules/assets/analytics | +| Tests/CI (missing) | Testing & CI pipelines | **Add** | Unit/integration/e2e; **GitHub Actions**: lint, test, build, health check | +| Assets (missing) | Asset storage | **Add** | Upload → **S3/UploadThing**; Asset Library UI/API | +| Analytics (missing) | Event tracking & dashboards | **Add** | Thu thập events (view/click/conv), tổng hợp hiển thị | +| AI (missing) | AI content generation | **Add** | Server route gọi AI provider; UI để generate/refine/translate | +| i18n (missing) | Đa ngôn ngữ | **Extend** | Thêm **next-intl** (English/Vietnamese) | +| Settings (missing) | Settings (profile/org/billing/AI) | **Add** | Trang cấu hình user/org, API keys, preferences, … | + +--- + +## 5) Gaps, Risks & Mitigations + +### Gaps + +- **Persistence**: chưa có DB → **Prisma + Postgres** (schema cho **Users/Orgs/Campaign/Content/Asset/Schedule/Analytics**). +- **Auth & RBAC**: thiếu → **NextAuth** + role enum/middleware. +- **API layer**: thiếu CRUD → API routes hoặc **tRPC** + **zod** validation. +- **Testing/CI**: thiếu → thêm **Jest/Vitest**, **Playwright**, **GitHub Actions**. +- **Security**: chưa có validation, rate limit, secrets → thêm server validation, rate limiting, secret mgmt. +- **Observability**: thiếu logs/monitoring → **pino/winston**, **Sentry**. +- **i18n**: English‑only → **next-intl**. +- **Scalability**: dễ thành monolith → module boundaries, cân nhắc packages sau. + +### Risks & Mitigations + +| Risk | Tác động | Xác suất | Giảm thiểu | +| --------------------------------------- | ---------------- | -------- | ------------------------------------------------------- | +| Migration phức tạp | Dễ phát sinh bug | Cao | Chia nhỏ PR, giữ trạng thái chạy được, test tự động | +| Dependencies chưa ổn định (**Next 15**) | Compatibility | TB | Pin version, thử nghiệm trước khi merge | +| Security (auth/upload) | Lỗ hổng | TB | Lib an toàn, OWASP, rate limit, file‑type validation | +| Hiệu năng (board nhiều items) | Lag | TB | Pagination/virtualization, lazy load, React Query cache | +| Adoption/training | Chậm triển khai | TB | Tài liệu, code samples, workshop | +| Compliance (GDPR, …) | Rủi ro pháp lý | Thấp | Privacy by design, retention policy, consent | + +--- + +## 6) Kiến trúc mục tiêu (**Target Architecture for AiM**) + +```mermaid +flowchart TD + subgraph Client + U[Creators/Brands/Admins] -->|Browser| FE[Next.js 15 App] + FE -->|Fetch| API + FE -->|Auth Hooks| AuthClient + end + subgraph Server + API --> DB[(PostgreSQL via Prisma)] + API --> AIService[(External AI API)] + API --> Storage[(Asset Storage: S3/UploadThing)] + API --> Analytics[(Analytics Service)] + API --> RBAC[(RBAC Middleware)] + AuthService[(NextAuth.js)] --> DB + RBAC --> AuthService + end + Analytics --> DB + FE -.->|WebSockets| Notifications[(Real-time Notifications)] +``` + +**Mô tả ngắn** + +- **Frontend (Next.js)**: server/client components; **Zustand** cho UI state; **React Query** để fetch/cache. +- **API layer (Next API routes/tRPC)**: endpoints cho campaign/content/asset/schedule/analytics/ai; **zod** + RBAC middleware. +- **Auth**: **NextAuth.js**, session lưu DB. +- **DB**: **PostgreSQL** qua **Prisma** (migrations). +- **AI Service**: proxy tới provider (**OpenAI/…**). +- **Asset Storage**: **S3/UploadThing**, metadata trong DB. +- **Analytics**: thu thập & tổng hợp events. +- **Real‑time**: **WebSockets/Pusher** (tuỳ chọn). + +### Data model đề xuất (**Proposed Data Model**) + +| Entity | Fields chính | Quan hệ | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| **User** | `id, name, email (unique), passwordHash, role (creator/brand/admin/reviewer/publisher), organizationId, timestamps` | Thuộc **Organization**; tạo **Campaign/Content/Schedule/Asset/AnalyticsEvent** | +| **Organization** | `id, name, slug, timestamps` | Có nhiều **Users/Campaigns/Assets** | +| **Campaign** | `id, organizationId, createdById, name, description, status (draft/active/completed), budget, startDate, endDate, timestamps` | Thuộc **Organization/User**; có **Contents/Schedules/AnalyticsEvents** | +| **Content** | `id, campaignId, authorId, assetId?, title, body, type, aiGenerated, status, metadata (JSON), timestamps` | Thuộc **Campaign**; tham chiếu **Asset**; có **Schedules/AnalyticsEvents** | +| **Asset** | `id, organizationId, uploadedById, fileName, fileType, size, url, timestamps` | Thuộc **Organization**; liên kết nhiều **Contents** | +| **Schedule** | `id, contentId, campaignId, scheduledAt, platform, status, createdById` | Lịch đăng cho **Content** trong **Campaign** | +| **AnalyticsEvent** | `id, contentId, userId?, eventType, timestamp, metadata` | Log tương tác, dùng để tổng hợp | +| **Role** _(tuỳ chọn)_ | `id, name, permissions (JSON)` | Many‑to‑many với **User** nếu cần RBAC chi tiết | + +### UI shell & điều hướng theo role (**Role‑Based Navigation**) + +- **Creator**: Dashboard nhanh vào _Campaigns, Content Studio, Calendar, Assets, Analytics_; có AI assistant; hiển thị deadlines/assignments. +- **Brand**: Tổng quan campaigns, approval queue, budget/ROI; duyệt nội dung, review assets, giám sát lịch. +- **Admin**: Quản trị organizations/users/roles/settings; cấu hình billing/AI quotas. + +--- + +## 7) Kế hoạch Delta theo file (**Delta Plan – File‑level**) + +> Bảng **ADD/MODIFY/REMOVE** chi tiết — xem đầy đủ trong bản gốc; giữ nguyên danh sách đường dẫn và mô tả như đã đề xuất (**Prisma schema**, **API routes**, **pages**, **components**, **workflows CI**, **Docker**, **scripts**…). + +**ADD**: +`prisma/schema.prisma`, `prisma/migrations/*`, `lib/prisma.ts`, `app/api/auth/[...nextauth]/route.ts`, `app/api/auth/register/route.ts`, +`app/api/campaigns/*`, `app/api/contents/*`, `app/api/assets/*`, `app/api/schedules/*`, `app/api/analytics/*`, +`app/api/ai/generate/route.ts`, `app/middleware.ts`, `lib/rbac.ts`, `lib/hooks/useCurrentUser.ts`, +`(auth)/login`, `(auth)/register`, `(dashboard)/creator`, `(dashboard)/brand`, `(dashboard)/admin`, +`campaigns/*`, `content/*`, `calendar/page.tsx`, `assets/page.tsx`, `settings/page.tsx`, +các components `forms/ai/navigation/dashboard…`, `.env.example`, `.github/workflows/*.yml`, `Dockerfile`, `docker-compose.yml`, `scripts/publish-worker.ts`… + +**MODIFY**: +`package.json`, `tsconfig.json`, `next.config.ts`, `app/layout.tsx`, `app/page.tsx`, `components/board/*`, `components/navigation/*`, `styles/globals.css`… + +**REMOVE**: +mock data trong `lib/store`, routes cũ dưới `lndev-ui` không dùng, assets placeholder. + +--- + +## 8) Lộ trình 30/60/90 ngày (**Roadmap**) + +- **0–30 ngày – Nền tảng**: **Prisma/Postgres**, **Auth + RBAC**, **API** cơ bản, **Dashboard shell**, CI tối thiểu, Docs. +- **31–60 ngày – Tính năng chính**: **Campaign Kanban + Wizard**, **Content Studio + AI**, **Calendar & Scheduling**, **Asset Library**, **Analytics** cơ bản, **i18n**, **Testing**. +- **61–90 ngày – Nâng cao & polishing**: RBAC chi tiết, **Realtime notifications**, **Observability**, **Performance**, **Collaboration**, **Deployment**, **Extensibility**. + +> Chi tiết từng hạng mục đã được giữ nguyên và diễn giải tiếng Việt ở phần **Backlog**. + +--- + +## 9) Backlog – To‑Do chi tiết (≥ 150 tasks) + +Giữ nguyên cấu trúc **Epic → Story → Task** như bản gốc. Nội dung dưới đây được dịch phần diễn giải, giữ thuật ngữ gốc trong ngoặc cho dễ đọc. +Mỗi task có: **Acceptance Criteria**, **Files touched**, **Effort (S/M/L)**, **Priority (P0–P3)**. + +### Epic: **Authentication & RBAC** + +**Story: Cài đặt & cấu hình NextAuth.js** + +- Cài dependencies Auth… (như bản gốc; acceptance/files/effort/priority giữ nguyên). _Gợi ý_: dùng **NextAuth** (credentials provider), **bcrypt**, **JWT**. +- Tạo route `app/api/auth/[...nextauth]/route.ts` (credentials) với callbacks (session/JWT)… +- Hash password (`lib/auth.ts`) bằng **bcrypt**… +- Endpoint **register** tạo **User + Organization**… +- Bọc ``… +- Trang **login** (zod validation, `signIn()`)… +- Trang **register** (tạo org, role mặc định)… +- **Logout** (`signOut()`)… +- `/api/auth/session` trả user hiện tại… + +**Story: Quản lý vai trò (**Role Management & RBAC**)** + +- `lib/roles.ts` khai báo enum roles… +- Thêm field **role** vào **User** (**Prisma enum**)… +- `lib/rbac.ts` helpers `hasRole/hasPermission` + tests… +- `middleware.ts` bảo vệ route theo session/role… +- Gán role khi register (first user = **admin**)… +- Role switcher (dev only)… +- **Role‑based navigation** (Sidebar)… +- **Role‑based API checks**… + +### Epic: **Database & Prisma** + +**Story: Định nghĩa schema** + +- `npx prisma init`… +- Models **User/Organization**… +- **Campaign**… +- **Content**… +- **Asset**… +- **Schedule**… +- **AnalyticsEvent**… +- _(Tuỳ chọn)_ **Role/UserRole**… + +**Story: Migrations & Seeding** + +- `prisma migrate dev --name init`… +- `scripts/seed.ts` (roles, admin, org mẫu, campaigns, content)… +- `pnpm seed`… +- Document DB setup trong **README**… + +### Epic: **API Layer** + +**Story: Campaign Endpoints** + +- `GET /campaigns` (pagination/search, RBAC)… +- `POST /campaigns` (zod, `createdBy`, status `draft`)… +- `GET /campaigns/[id]` (details + relations)… +- `PUT /campaigns/[id]`… +- `DELETE /campaigns/[id]` (cascade theo rule)… +- `GET /campaigns/[id]/analytics` (summary)… +- `POST /campaigns/[id]/duplicate`… + +**Story: Content Endpoints** + +- `GET /contents` (filter status/type)… +- `POST /contents` (create)… +- `GET /contents/[id]` (asset/author/schedules)… +- `PUT /contents/[id]` (restrictions by role/status)… +- `DELETE /contents/[id]`… +- `POST /contents/[id]/submit` (submit for approval)… +- `POST /contents/[id]/approve|reject` (**Brand** role)… +- `POST /ai/generate` (**OpenAI**; cache; audit)… +- `POST /ai/translate`… +- `POST /ai/summarise`… + +**Story: Asset Endpoints** + +- `POST /assets` (**UploadThing/S3**; validate type/size; metadata)… +- `GET /assets` (org scope; pagination/search)… +- `DELETE /assets/[id]` (prevent delete if in‑use)… +- `GET /assets/upload-url` (pre‑signed **S3**)… + +**Story: Schedule Endpoints** + +- `GET /schedules` (filters)… +- `POST /schedules` (validate future; content must be approved)… +- `PUT /schedules/[id]`… +- `POST /schedules/[id]/cancel`… +- `scripts/publish-worker.ts` (cron publish + analytics + notifications)… + +**Story: Analytics Endpoints** + +- `POST /analytics` (record event)… +- `GET /analytics` (filters)… +- `GET /analytics/summary` (aggregate)… +- `GET /analytics/export` (CSV/Excel; admin/brand only)… + +### Epic: **Frontend – Global Layout & Navigation** + +**Story: Providers & Setup** + +- **React Query** provider (`QueryClientProvider`, `Hydrate`)… +- **next-intl** (en/vi) + ``… +- **Theme provider** (dark/light)… + +**Story: Sidebar & Topbar** + +- Sidebar (role‑specific menus)… +- Topbar (search, notifications, user menu)… +- **Command palette** (global search + AI commands)… +- **Notifications panel** (**Radix UI** + realtime)… + +### Epic: **Dashboards** + +**Story: Creator Dashboard** + +- Summary cards (active campaigns, drafts, scheduled, impressions)… +- Campaign list… +- Quick actions… +- Draft reminders… +- AI suggestions widget… + +**Story: Brand Dashboard** + +- Brand metrics cards (budget/ROI)… +- Approval queue… +- Budget vs ROI chart… +- Creator leaderboard… +- Campaign health summary… + +**Story: Admin Dashboard** + +- `UserTable` (enable/disable)… +- `OrgTable` (rename/delete cascade)… +- Role manager (nếu dùng Role model)… +- Audit logs… +- Feature flags… + +### Epic: **Campaign Module** + +**Story: Campaign List & Board** + +- Campaign table (sort/filter/search)… +- Pagination/infinite scroll… +- Board view (**Draft/Active/Completed**; drag‑drop persist)… +- Campaign detail (**Overview/Content/Schedule/Analytics/Settings**)… +- Create wizard (multi‑step)… +- Duplicate… +- Delete confirm… +- Search debounce… +- Permissions UI… + +### Epic: **Content Module** + +**Story: Content Editor** + +- Rich text editor (**TipTap**)… +- **AI assistant** panel (prompt, rewrite, tone)… +- Auto‑save drafts… +- Asset picker… +- Preview… +- **Translate & summarise**… +- Status management (**Draft/Submitted/Approved/Published/Rejected**)… +- Comments & versioning… + +**Story: Content List & Cards** + +- Content list (trong **Campaign**)… +- Content card (badge, quick actions)… +- Filtering & search… +- Bulk actions… + +### Epic: **Calendar & Scheduling** + +**Story: Calendar View** + +- Calendar page (**react-day-picker**) — color‑code theo platform… +- Schedule form modal (platform/date/time/timezone)… +- Timezone support (user preference, store **UTC**)… +- Recurring schedules (**RRULE** model)… + +### Epic: **Assets Module** + +**Story: Asset Library UI** + +- Assets page (search/filter/thumbnail)… +- Upload dialog (multi files, progress)… +- Asset detail drawer (preview, metadata, usage)… +- Asset usage indicator… + +### Epic: **Analytics Module** + +**Story: Analytics Dashboards** + +- Overview chart (7/30/90)… +- Campaign analytics tab (line/bar/pie)… +- Content analytics tab… +- Creator performance… +- Export analytics UI… + +### Epic: **Settings Module** + +**Story: User & Organization Settings** + +- Profile settings (name/email/password; AI usage)… +- Organization settings (slug/logo/default language)… +- Notification preferences… +- Billing & quotas (**Stripe**)… +- API keys management… + +### Epic: **Internationalisation** + +**Story: i18n** + +- Cài **next-intl** (en/vi)… +- Tạo translation files… +- Refactor components dùng `t('key')`… +- Language switcher… + +### Epic: **Testing & Quality** + +**Story: Unit & Integration Tests** + +- **Jest/Vitest** setup… +- Tests cho utilities (auth, rbac, utils)… +- Tests forms & components… +- Tests API routes… + +**Story: End‑to‑End Tests** + +- **Playwright** setup… +- e2e login… +- e2e create campaign… +- e2e content workflow… +- e2e schedule & publish… + +### Epic: **CI/CD & Observability** + +**Story: Continuous Integration** + +- `ci.yml` (lint/build/test)… +- `e2e.yml` (**Playwright** artifacts)… +- Health check (`/api/health`)… + +**Story: Delivery & Deployment** + +- `Dockerfile` (**Node 20**; `prisma migrate deploy`)… +- `docker-compose.yml` (app + postgres + mailhog)… +- `deploy.yml` (push image, deploy staging/prod)… + +**Story: Observability & Monitoring** + +- Logging (**pino/winston**)… +- Error tracking (**Sentry**)… +- `/api/health`… +- `/api/metrics` (**Prometheus prom-client**)… + +### Epic: **AI Integration** + +**Story: AI Services** + +- Chọn AI provider (đánh giá cost/perf/language)… +- AI proxy endpoint (`/api/ai/generate`) — prompt engineering, rate limit, cache… +- Prompt library (`lib/prompts.ts`)… +- AI usage tracking (model **AIUsage** hoặc gắn vào **Analytics**)… +- AI tone tuning (formal/friendly/professional)… +- Content idea generator (`/api/ai/ideas`)… + +### Epic: **Performance & Scalability** + +**Story: Optimisations** + +- Bundle analysis (`next build --profile`, dynamic imports)… +- Image optimization (``, lazy)… +- Virtualization (lists > 100 rows)… +- Caching & SWR (**React Query** strategies)… + +> ⚠️ **Tổng số mục checklist vượt 150** (đủ chuẩn để tạo **GitHub Issues** ngay). + +--- + +## 10) CI/CD & Quality Gates + +- **Static Analysis**: **ESLint/Prettier**, **Husky** pre‑commit. +- **Type Checking**: `tsc --noEmit` trong CI. +- **Unit & Integration Tests**: ≥ 80% cho core logic. +- **E2E Tests**: **Playwright** trên PR. +- **Build & Health Check**: ping `/api/health`. +- **Code Review**: branch protection + **Conventional Commits**. +- **Secrets Management**: **GitHub Secrets**, `.env.example`. +- **CD Pipeline**: deploy **staging** tự động; smoke test; promote to **prod**. + +--- + +## 11) `.env` & xử lý secrets (**Secrets Handling**) + +Tạo **.env.example** với các biến: + +```env +DATABASE_URL="postgresql://aim_user:password@localhost:5432/aim_db" +NEXTAUTH_SECRET="set-a-random-secret-here" +NEXTAUTH_URL="http://localhost:3000" +S3_BUCKET="aim-assets" +S3_REGION="ap-southeast-1" +S3_ACCESS_KEY_ID="" +S3_SECRET_ACCESS_KEY="" +AI_API_KEY="" +PUBLIC_SITE_URL="http://localhost:3000" +DEFAULT_LANGUAGE="en" + +# Optional +SMTP_HOST="" +SMTP_PORT="" +SMTP_USER="" +SMTP_PASS="" +``` + +- Dev sao chép `.env.example` → `.env` (được `.gitignore`). +- Prod dùng **platform secrets** (**Vercel/AWS/GitHub Actions**). +- Tránh log secrets; **rotate** định kỳ; **least privilege**. + +--- + +## 12) Câu hỏi mở (**Open Questions**) + +- **Stack Upgrade Tolerance**: Giữ **Next.js 15** hay cân nhắc **Next.js 14 LTS** để ổn định? +- **Roles bổ sung**: Có cần **Reviewer/Publisher** không? +- **Compliance**: Có yêu cầu **GDPR/ISO** cụ thể? (ảnh hưởng đến retention, consent, audit). + +--- + +## 13) Phụ lục (**Appendix**) + +### Scripts/Commands đề xuất + +```bash +pnpm prisma generate +pnpm prisma migrate dev --name +pnpm seed +pnpm dev +pnpm build +pnpm test +pnpm e2e +node scripts/publish-worker.js +``` + +### Quy ước thư mục (**Folder Conventions**) + +``` +app/ # App Router; groups theo role +components/ # UI tái sử dụng theo domain +lib/ # Prisma/RBAC/logger/metrics/prompts/hooks +prisma/ # schema + migrations +public/ # assets +scripts/ # seed/cron/test data +tests/ # unit/integration/e2e +``` + +### Coding Guidelines + +- **TypeScript** nghiêm ngặt, tránh `any`. +- **Conventional Commits**. +- **ESLint + Prettier** (Next preset). +- Ưu tiên **Server Components**; **Client Components** khi cần interactivity. +- **React Query** cho data fetching/cache. +- **zod** validate ở boundary (API/forms). +- **A11y** với **Radix/ARIA**. +- Viết tests cho components/APIs (ưu tiên critical paths). diff --git a/DEPLOYMENT_README.md b/DEPLOYMENT_README.md new file mode 100644 index 00000000..21a09a46 --- /dev/null +++ b/DEPLOYMENT_README.md @@ -0,0 +1,57 @@ +# Deployment and Monitoring Guide + +## Containerization + +### Docker + +- Build: `docker build -t aim-platform .` +- Run: `docker run -p 3000:3000 aim-platform` + +### Docker Compose (Development) + +- Start: `docker-compose up` +- Stop: `docker-compose down` + +## Deployment + +### Vercel + +1. Connect repository to Vercel +2. Set environment variables in Vercel dashboard: + - `DATABASE_URL` + - `NEXTAUTH_SECRET` + - `NEXTAUTH_URL` + - `OPENAI_API_KEY` +3. Deploy + +### AWS Amplify + +1. Connect repository to Amplify +2. Configure build settings (amplify.yml provided) +3. Set environment variables +4. Deploy + +## Environment Variables + +### Production (.env.production) + +- Update with production values +- Use secure secrets for sensitive data + +## Monitoring + +### Health Check + +- Endpoint: `/api/health` +- Returns status of services (database, OpenAI) +- Use for load balancer health checks + +### Logs + +- Application logs available in deployment platform +- Database logs via PostgreSQL + +### Performance + +- Next.js analytics in production +- Monitor API response times diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4da00ea1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Use the official Node.js 20 image as the base image +FROM node:20-alpine + +# Set the working directory inside the container +WORKDIR /app + +# Copy package.json and package-lock.json (or pnpm-lock.yaml) +COPY package.json pnpm-lock.yaml ./ + +# Install dependencies +RUN npm install -g pnpm && pnpm install --frozen-lockfile + +# Copy the rest of the application code +COPY . . + +# Generate Prisma client +RUN pnpm run db:generate + +# Build the Next.js application +RUN pnpm run build + +# Expose the port the app runs on +EXPOSE 3000 + +# Set environment variables +ENV NODE_ENV=production + +# Start the application +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index 5e731633..df216969 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,166 @@ -# Circle +# AiM Platform -
- - Vercel OSS Program - +**AI-powered Marketing Platform** - Hệ thống quản lý nội dung và chiến dịch marketing được hỗ trợ bởi AI. -
-
+## 🚀 Features -Project management interface inspired by Linear. Built with Next.js and shadcn/ui, this application allows tracking of issues, projects and teams with a modern, responsive UI. +- **🎨 Content Creation**: AI-assisted content generation với approval workflow +- **📅 Smart Scheduling**: Multi-view calendar (Day/Week/Month) với drag & drop +- **📊 Campaign Management**: End-to-end campaign lifecycle management +- **🔐 Role-based Access**: Creator, Brand Owner, Admin roles với permissions +- **📱 Multi-platform**: Support Facebook, Instagram, Twitter, YouTube, LinkedIn, TikTok +- **📈 Analytics**: Event tracking và performance monitoring -## 🛠️ Technologies +## 🛠️ Tech Stack -- **Framework**: [Next.js](https://nextjs.org/) -- **Langage**: [TypeScript](https://www.typescriptlang.org/) -- **UI Components**: [shadcn/ui](https://ui.shadcn.com/) -- **Styling**: [Tailwind CSS](https://tailwindcss.com/) +- **Frontend**: Next.js 15 (App Router), React 19, TypeScript 5 +- **UI Components**: shadcn/ui, Radix UI, Tailwind CSS 4 +- **Backend**: Next.js API Routes, Prisma 6 ORM +- **Database**: PostgreSQL +- **Authentication**: NextAuth.js 5 +- **State Management**: Zustand, React Query +- **AI Integration**: OpenAI API +- **File Storage**: UploadThing/S3 -### 📦 Installation +## 📚 Documentation -```shell -git clone https://github.com/ln-dev7/circle.git -cd circle -``` +### 🏗️ Architecture & Design + +- [**AiM Architecture**](./AiM_Architecture.md) - Overall system architecture và design decisions +- [**Product Specification**](./docs/SPEC.md) - Product features, user roles, và MVP scope +- [**Data Model**](./docs/data-model.md) - Database schema, entities, và relationships + +### 🔌 API Reference + +- [**Campaigns API**](./docs/api/campaigns.md) - Campaign management endpoints +- [**Content API**](./docs/api/content.md) - Content creation và management +- [**Schedules API**](./docs/api/schedules.md) - Content scheduling với timezone support +- [**Assets API**](./docs/api/assets.md) - File upload và management +- [**Analytics API**](./docs/api/analytics.md) - Event tracking và metrics + +### 🎨 User Interface + +- [**Schedule UI Guide**](./docs/ui/schedule.md) - Calendar interface, drag & drop, draft panel +- [**Design System**](./docs/ui/design-system.md) - Component library và design tokens + +### 🔒 Security & Operations + +- [**Security Guide**](./docs/SECURITY.md) - Authentication, authorization, data protection +- [**Contributing Guide**](./docs/CONTRIBUTING.md) - Development setup và contribution guidelines +- [**Rollback Playbook**](./docs/playbooks/rollback.md) - Emergency procedures và rollback steps +- [**Observability Guide**](./docs/playbooks/observability.md) - Monitoring, logging, alerting + +### 📋 Development Tasks + +- [**Task Templates**](./docs/tasks/) - PR templates và development guidelines +- [**Migration Plan**](./AiM_Migration_Plan_vi.md) - Technical migration roadmap -### Install dependencies +## 🚀 Quick Start -```shell +### Prerequisites + +- Node.js 18+ +- PostgreSQL 15+ +- pnpm (recommended) + +### Installation + +```bash +# Clone repository +git clone +cd aim-platform + +# Install dependencies pnpm install -``` -### Start the development server +# Setup environment +cp .env.example .env +# Edit .env with your configuration -```shell +# Setup database +pnpm db:generate +pnpm db:push +pnpm db:seed + +# Start development server pnpm dev ``` -## Star History +### Environment Variables + +```bash +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/aim_db" + +# Authentication +NEXTAUTH_SECRET="your-secret-key" +NEXTAUTH_URL="http://localhost:3000" + +# AI Integration +OPENAI_API_KEY="your-openai-key" + +# File Storage +UPLOADTHING_SECRET="your-uploadthing-secret" +UPLOADTHING_APP_ID="your-uploadthing-app-id" +``` + +## 🧪 Testing + +```bash +# Unit tests +pnpm test + +# E2E tests +pnpm test:e2e + +# Test coverage +pnpm test:coverage +``` + +## 📁 Project Structure + +``` +aim-platform/ +├── app/ # Next.js App Router +│ ├── [orgId]/ # Organization-scoped routes +│ │ ├── schedule/ # Schedule management +│ │ ├── campaigns/ # Campaign management +│ │ ├── content/ # Content management +│ │ └── analytics/ # Analytics dashboard +│ ├── api/ # API routes +│ └── auth/ # Authentication pages +├── components/ # Reusable UI components +├── features/ # Feature-specific components +│ └── calendar/ # Schedule calendar components +├── lib/ # Utility functions và configurations +├── prisma/ # Database schema và migrations +├── docs/ # Documentation +└── tests/ # Test files +``` + +## 🤝 Contributing + +Please read our [Contributing Guide](./docs/CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. + +### Development Workflow + +1. Create feature branch từ `main` +2. Implement changes với tests +3. Update documentation nếu cần +4. Submit PR với detailed description +5. Code review và approval +6. Merge vào `main` + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE.md) file for details. + +## 🆘 Support + +- **Documentation**: Check the [docs/](./docs/) directory +- **Issues**: Create GitHub issue với detailed description +- **Discussions**: Use GitHub Discussions cho questions và ideas + +--- - - - - - Star History Chart - - +_Built with ❤️ by the AiM Team_ diff --git a/__mocks__/next-intl.ts b/__mocks__/next-intl.ts new file mode 100644 index 00000000..c09b69e1 --- /dev/null +++ b/__mocks__/next-intl.ts @@ -0,0 +1,26 @@ +export const useTranslations = () => (key: string) => { + const translations: Record = { + admin_title: 'Admin Dashboard', + admin_description: 'Manage your organization and users', + total_users: 'Total Users', + active_users: 'active', + active_campaigns: 'Active Campaigns', + total_campaigns: 'of', + total: 'total', + content_pieces: 'Content Pieces', + pending_approvals: 'Pending Approvals', + user_management: 'User Management', + organization: 'Organization', + system: 'System', + system_settings: 'System Settings', + }; + + return translations[key] || key; +}; + +export const useFormatter = () => ({ + dateTime: (date: Date) => date.toISOString(), + number: (num: number) => num.toString(), +}); + +export const NextIntlClientProvider = ({ children }: { children: React.ReactNode }) => children; diff --git a/amplify.yml b/amplify.yml new file mode 100644 index 00000000..a5d35f30 --- /dev/null +++ b/amplify.yml @@ -0,0 +1,19 @@ +version: 1 +frontend: + phases: + preBuild: + commands: + - npm install -g pnpm + - pnpm install + - pnpm run db:generate + build: + commands: + - pnpm run build + artifacts: + baseDirectory: .next + files: + - '**/*' + cache: + paths: + - node_modules/**/* + - .next/cache/**/* \ No newline at end of file diff --git a/app/[orgId]/assets/page.tsx b/app/[orgId]/assets/page.tsx new file mode 100644 index 00000000..f4d5bad0 --- /dev/null +++ b/app/[orgId]/assets/page.tsx @@ -0,0 +1,17 @@ +import { AssetLibrary } from '@/components/assets/asset-library'; + +interface AssetsPageProps { + params: { + orgId: string; + }; +} + +export default function AssetsPage({ params }: AssetsPageProps) { + const { orgId } = params; + + return ( +
+ +
+ ); +} diff --git a/app/[orgId]/campaigns/[id]/analytics/page.tsx b/app/[orgId]/campaigns/[id]/analytics/page.tsx new file mode 100644 index 00000000..1eeabe35 --- /dev/null +++ b/app/[orgId]/campaigns/[id]/analytics/page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { getCampaign } from '@/lib/campaigns'; +import { CampaignAnalyticsPage } from '@/components/campaigns/analytics/campaign-analytics-page'; + +interface CampaignAnalyticsPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function CampaignAnalyticsPage({ params }: CampaignAnalyticsPageProps) { + const campaign = await getCampaign(params.orgId, params.id); + + if (!campaign) { + notFound(); + } + + return ( +
+ Loading analytics...
}> + + + + ); +} diff --git a/app/[orgId]/campaigns/[id]/content/page.tsx b/app/[orgId]/campaigns/[id]/content/page.tsx new file mode 100644 index 00000000..bdbf96eb --- /dev/null +++ b/app/[orgId]/campaigns/[id]/content/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { getCampaign } from '@/lib/campaigns'; +import { CampaignContentPage } from '@/components/campaigns/content/campaign-content-page'; + +interface CampaignContentPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function CampaignContentPage({ params }: CampaignContentPageProps) { + const campaign = await getCampaign(params.orgId, params.id); + + if (!campaign) { + notFound(); + } + + return ( +
+ Loading content management...
}> + + + + ); +} diff --git a/app/[orgId]/campaigns/[id]/members/page.tsx b/app/[orgId]/campaigns/[id]/members/page.tsx new file mode 100644 index 00000000..f9006074 --- /dev/null +++ b/app/[orgId]/campaigns/[id]/members/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { getCampaign } from '@/lib/campaigns'; +import { CampaignMembersPage } from '@/components/campaigns/members/campaign-members-page'; + +interface CampaignMembersPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function CampaignMembersPage({ params }: CampaignMembersPageProps) { + const campaign = await getCampaign(params.orgId, params.id); + + if (!campaign) { + notFound(); + } + + return ( +
+ Loading member management...
}> + + + + ); +} diff --git a/app/[orgId]/campaigns/[id]/page.tsx b/app/[orgId]/campaigns/[id]/page.tsx new file mode 100644 index 00000000..7ca23e58 --- /dev/null +++ b/app/[orgId]/campaigns/[id]/page.tsx @@ -0,0 +1,28 @@ +import { Suspense } from 'react'; +import { CampaignDetail } from '@/components/campaigns/campaign-detail'; +import { notFound } from 'next/navigation'; +import { getCampaign } from '@/lib/campaigns'; +import { CampaignDetailWrapper } from '@/components/campaigns/campaign-detail-wrapper'; + +interface CampaignPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function CampaignPage({ params }: CampaignPageProps) { + const campaign = await getCampaign(params.orgId, params.id); + + if (!campaign) { + notFound(); + } + + return ( +
+ Loading campaign...
}> + + + + ); +} diff --git a/app/[orgId]/campaigns/[id]/tasks/page.tsx b/app/[orgId]/campaigns/[id]/tasks/page.tsx new file mode 100644 index 00000000..1585e42f --- /dev/null +++ b/app/[orgId]/campaigns/[id]/tasks/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import { getCampaign } from '@/lib/campaigns'; +import { TaskManagementPage } from '@/components/campaigns/task-management/task-management-page'; + +interface CampaignTasksPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function CampaignTasksPage({ params }: CampaignTasksPageProps) { + const campaign = await getCampaign(params.orgId, params.id); + + if (!campaign) { + notFound(); + } + + return ( +
+ Loading task management...
}> + + + + ); +} diff --git a/app/[orgId]/campaigns/new/page.tsx b/app/[orgId]/campaigns/new/page.tsx new file mode 100644 index 00000000..cefe084b --- /dev/null +++ b/app/[orgId]/campaigns/new/page.tsx @@ -0,0 +1,14 @@ +import { CampaignForm } from '@/components/campaigns/campaign-form'; +import { PageHeader } from '@/components/ui/page-header'; + +export default function CreateCampaignPage() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/app/[orgId]/campaigns/page.tsx b/app/[orgId]/campaigns/page.tsx new file mode 100644 index 00000000..9fda5fa5 --- /dev/null +++ b/app/[orgId]/campaigns/page.tsx @@ -0,0 +1,11 @@ +import MainLayout from '@/components/layout/main-layout'; +import Header from '@/components/layout/headers/campaigns/header'; +import Campaigns from '@/components/common/campaigns/campaigns'; + +export default function CampaignsPage() { + return ( + }> + + + ); +} diff --git a/app/[orgId]/content/[id]/page.tsx b/app/[orgId]/content/[id]/page.tsx new file mode 100644 index 00000000..bcdc094c --- /dev/null +++ b/app/[orgId]/content/[id]/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { ContentDetail } from '@/components/content/content-detail'; +import { notFound } from 'next/navigation'; +import { getContent } from '@/lib/content'; + +interface ContentPageProps { + params: { + orgId: string; + id: string; + }; +} + +export default async function ContentPage({ params }: ContentPageProps) { + const content = await getContent(params.orgId, params.id); + + if (!content) { + notFound(); + } + + return ( +
+ Loading content...
}> + + + + ); +} diff --git a/app/[orgId]/content/new/page.tsx b/app/[orgId]/content/new/page.tsx new file mode 100644 index 00000000..0d4a36b2 --- /dev/null +++ b/app/[orgId]/content/new/page.tsx @@ -0,0 +1,22 @@ +import { ContentEditor } from '@/components/content/content-editor'; +import { PageHeader } from '@/components/ui/page-header'; + +interface CreateContentPageProps { + params: { + orgId: string; + }; +} + +export default function CreateContentPage({ params }: CreateContentPageProps) { + const { orgId } = params; + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/app/[orgId]/content/page.tsx b/app/[orgId]/content/page.tsx new file mode 100644 index 00000000..213d7d1a --- /dev/null +++ b/app/[orgId]/content/page.tsx @@ -0,0 +1,28 @@ +import { Suspense } from 'react'; +import { ContentList } from '@/components/content/content-list'; +import { CreateContentButton } from '@/components/content/create-content-button'; +import { PageHeader } from '@/components/ui/page-header'; + +interface ContentPageProps { + params: { + orgId: string; + }; +} + +export default function ContentPage({ params }: ContentPageProps) { + const { orgId } = params; + + return ( +
+ } + /> + + Loading content...
}> + + + + ); +} diff --git a/app/[orgId]/page.tsx b/app/[orgId]/page.tsx index afd7d1bf..e6841772 100644 --- a/app/[orgId]/page.tsx +++ b/app/[orgId]/page.tsx @@ -1,5 +1,55 @@ +import { Suspense } from 'react'; +import { getUserRole } from '@/lib/rbac'; +import { CreatorDashboard } from '@/components/dashboards/creator-dashboard'; +import { BrandDashboard } from '@/components/dashboards/brand-dashboard'; +import { AdminDashboard } from '@/components/dashboards/admin-dashboard'; import { redirect } from 'next/navigation'; +import { OrgRole } from '@prisma/client'; -export default function OrgIdPage() { - redirect('lndev-ui/team/CORE/all'); +interface OrgIdPageProps { + params: Promise<{ + orgId: string; + }>; +} + +async function DashboardContent({ orgId }: { orgId: string }) { + const userRole = await getUserRole(orgId); + + if (!userRole) { + // User doesn't have access to this organization + redirect('/auth/signin'); + } + + switch (userRole) { + case OrgRole.CREATOR: + return ; + case OrgRole.BRAND_OWNER: + return ; + case OrgRole.ADMIN: + return ; + default: + redirect('/auth/signin'); + } +} + +export default async function OrgIdPage({ params }: OrgIdPageProps) { + const resolvedParams = await params; + + return ( + +
+
+
Loading dashboard...
+
+ Please wait while we set up your workspace +
+
+ + } + > + +
+ ); } diff --git a/app/[orgId]/schedule/page.tsx b/app/[orgId]/schedule/page.tsx new file mode 100644 index 00000000..d87d77fc --- /dev/null +++ b/app/[orgId]/schedule/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { ScheduleShell } from '@/features/calendar/ui/ScheduleShell'; + +interface SchedulePageProps { + params: { + orgId: string; + }; +} + +export default function SchedulePage({ params }: SchedulePageProps) { + const { orgId } = params; + + return ( +
+
+

Content Scheduling

+

+ Schedule your content for publication and manage your posting calendar. +

+
+ + Loading calendar...
}> + + + + ); +} diff --git a/app/[orgId]/schedules/page.tsx b/app/[orgId]/schedules/page.tsx new file mode 100644 index 00000000..2972be9b --- /dev/null +++ b/app/[orgId]/schedules/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react'; +import { ScheduleCalendar } from '@/components/schedules/schedule-calendar'; + +interface SchedulesPageProps { + params: { + orgId: string; + }; +} + +export default function SchedulesPage({ params }: SchedulesPageProps) { + const { orgId } = params; + + return ( +
+
+

Content Scheduling

+

+ Schedule your content for publication and manage your posting calendar. +

+
+ + Loading calendar...
}> + + + + ); +} diff --git a/app/api/[orgId]/analytics/events/route.ts b/app/api/[orgId]/analytics/events/route.ts new file mode 100644 index 00000000..79d1b24a --- /dev/null +++ b/app/api/[orgId]/analytics/events/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { createAnalyticsEventSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.VIEW_ANALYTICS); + + const events = await (prisma as any).analyticsEvent.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json(events); + } catch (error) { + console.error('Error fetching analytics events:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.VIEW_ANALYTICS); + + const body = await request.json(); + const validatedData = createAnalyticsEventSchema.parse(body); + + const event = await (prisma as any).analyticsEvent.create({ + data: { + ...validatedData, + userId: session.user.id, + organizationId: params.orgId, + }, + }); + + return NextResponse.json(event, { status: 201 }); + } catch (error) { + console.error('Error creating analytics event:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/analytics/metrics/route.ts b/app/api/[orgId]/analytics/metrics/route.ts new file mode 100644 index 00000000..03f0e45f --- /dev/null +++ b/app/api/[orgId]/analytics/metrics/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.VIEW_ANALYTICS); + + // Get metrics for the org - count events by type + const eventCounts = await prisma.analyticsEvent.groupBy({ + by: ['event'], + where: { organizationId: params.orgId }, + _count: { + event: true, + }, + }); + + // Get campaign-specific metrics + const campaignMetrics = await prisma.campaign.findMany({ + where: { organizationId: params.orgId }, + include: { + contents: { + include: { + analyticsEvents: true, + }, + }, + analyticsEvents: true, + }, + }); + + // Calculate aggregated metrics + const totalImpressions = eventCounts.find((e) => e.event === 'impression')?._count.event || 0; + const totalClicks = eventCounts.find((e) => e.event === 'click')?._count.event || 0; + const totalViews = eventCounts.find((e) => e.event === 'view')?._count.event || 0; + + const ctr = totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0; + + // Calculate ROI (simplified - assuming some conversion value) + const conversions = eventCounts.find((e) => e.event === 'conversion')?._count.event || 0; + const roi = conversions > 0 ? ((conversions * 100) / totalClicks) * 100 : 0; // Assuming $100 per conversion + + const metrics = { + totalEvents: eventCounts.reduce((sum, item) => sum + item._count.event, 0), + eventsByType: eventCounts.reduce( + (acc, item) => { + acc[item.event] = item._count.event; + return acc; + }, + {} as Record + ), + impressions: totalImpressions, + clicks: totalClicks, + views: totalViews, + ctr: ctr, + roi: roi, + campaignMetrics: campaignMetrics.map((campaign) => ({ + id: campaign.id, + name: campaign.name, + totalEvents: campaign.analyticsEvents.length, + contentCount: campaign.contents.length, + contentMetrics: campaign.contents.map((content) => ({ + id: content.id, + title: content.title, + events: content.analyticsEvents.length, + impressions: content.analyticsEvents.filter((e) => e.event === 'impression').length, + clicks: content.analyticsEvents.filter((e) => e.event === 'click').length, + views: content.analyticsEvents.filter((e) => e.event === 'view').length, + })), + })), + }; + + return NextResponse.json(metrics); + } catch (error) { + console.error('Error fetching analytics metrics:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/analytics/track/route.ts b/app/api/[orgId]/analytics/track/route.ts new file mode 100644 index 00000000..e4c2fd2d --- /dev/null +++ b/app/api/[orgId]/analytics/track/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.VIEW_ANALYTICS); + + const body = await request.json(); + const { event, campaignId, contentId, data } = body; + + if (!event) { + return NextResponse.json({ error: 'Event type is required' }, { status: 400 }); + } + + // Validate that campaign and content belong to the organization + if (campaignId) { + const campaign = await prisma.campaign.findFirst({ + where: { + id: campaignId, + organizationId: orgId, + }, + }); + if (!campaign) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + } + + if (contentId) { + const content = await prisma.content.findFirst({ + where: { + id: contentId, + campaign: { + organizationId: orgId, + }, + }, + }); + if (!content) { + return NextResponse.json({ error: 'Content not found' }, { status: 404 }); + } + } + + const analyticsEvent = await prisma.analyticsEvent.create({ + data: { + event, + data, + userId: session.user.id, + organizationId: orgId, + campaignId: campaignId || null, + contentId: contentId || null, + }, + }); + + return NextResponse.json(analyticsEvent, { status: 201 }); + } catch (error) { + console.error('Error tracking analytics event:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/assets/[id]/route.ts b/app/api/[orgId]/assets/[id]/route.ts new file mode 100644 index 00000000..bdf47728 --- /dev/null +++ b/app/api/[orgId]/assets/[id]/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const asset = await prisma.asset.findFirst({ + where: { + id, + content: { campaign: { organizationId: orgId } }, + }, + include: { + content: true, + }, + }); + + if (!asset) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + + return NextResponse.json(asset); + } catch (error) { + console.error('Error fetching asset:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + const body = await request.json(); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + // Verify asset belongs to org + const existingAsset = await prisma.asset.findFirst({ + where: { + id, + content: { campaign: { organizationId: orgId } }, + }, + }); + + if (!existingAsset) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + + const updatedAsset = await prisma.asset.update({ + where: { id }, + data: { + url: body.url, + type: body.type, + }, + include: { + content: true, + }, + }); + + return NextResponse.json(updatedAsset); + } catch (error) { + console.error('Error updating asset:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + // Verify asset belongs to org + const existingAsset = await prisma.asset.findFirst({ + where: { + id, + content: { campaign: { organizationId: orgId } }, + }, + }); + + if (!existingAsset) { + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); + } + + await prisma.asset.delete({ + where: { id }, + }); + + return NextResponse.json({ message: 'Asset deleted successfully' }); + } catch (error) { + console.error('Error deleting asset:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/assets/route.ts b/app/api/[orgId]/assets/route.ts new file mode 100644 index 00000000..9a5d6c90 --- /dev/null +++ b/app/api/[orgId]/assets/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + const { searchParams } = new URL(request.url); + const contentId = searchParams.get('contentId'); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const where = contentId + ? { content: { campaign: { organizationId: orgId } }, contentId } + : { content: { campaign: { organizationId: orgId } } }; + + const assets = await prisma.asset.findMany({ + where, + include: { + content: true, + }, + }); + + return NextResponse.json(assets); + } catch (error) { + console.error('Error fetching assets:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/assets/upload/route.ts b/app/api/[orgId]/assets/upload/route.ts new file mode 100644 index 00000000..fec30c42 --- /dev/null +++ b/app/api/[orgId]/assets/upload/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; +import formidable from 'formidable'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + // Parse multipart form data + const form = formidable({ + uploadDir: path.join(process.cwd(), 'public/uploads'), + keepExtensions: true, + }); + + const [fields, files] = await form.parse(request as any); + + const contentId = fields.contentId?.[0]; + const file = files.file?.[0]; + + if (!contentId || !file) { + return NextResponse.json({ error: 'Missing contentId or file' }, { status: 400 }); + } + + // Verify content belongs to org + const content = await prisma.content.findFirst({ + where: { id: contentId, campaign: { organizationId: orgId } }, + }); + if (!content) { + return NextResponse.json({ error: 'Content not found' }, { status: 404 }); + } + + // Mock URL - in real implementation, upload to cloud storage + const mockUrl = `/uploads/${file.newFilename}`; + + const asset = await prisma.asset.create({ + data: { + url: mockUrl, + name: fields.name?.[0] || file.originalFilename || 'Untitled', + type: file.mimetype || 'unknown', + size: file.size, + description: fields.description?.[0], + tags: fields.tags?.[0] ? JSON.parse(fields.tags[0]) : [], + contentId, + }, + }); + + return NextResponse.json(asset, { status: 201 }); + } catch (error) { + console.error('Error uploading asset:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/contents/route.ts b/app/api/[orgId]/campaigns/[id]/contents/route.ts new file mode 100644 index 00000000..1b9b2765 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/contents/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Fetch contents for the specific campaign + const contents = await prisma.content.findMany({ + where: { + campaignId: params.id, + organizationId: params.orgId, + }, + include: { + campaign: { + select: { + id: true, + title: true, + }, + }, + creator: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return NextResponse.json({ contents }); + } catch (error) { + console.error('Error fetching campaign contents:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/labels/route.ts b/app/api/[orgId]/campaigns/[id]/labels/route.ts new file mode 100644 index 00000000..6cc87e53 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/labels/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +// Validation schemas +const createLabelSchema = z.object({ + name: z.string().min(1).max(50), + color: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .default('#3B82F6'), +}); + +// const updateLabelSchema = z.object({ +// name: z.string().min(1).max(50).optional(), +// color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(), +// }); + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get campaign labels + const labels = await prisma.campaignLabel.findMany({ + where: { + campaignId: params.id, + }, + orderBy: [{ name: 'asc' }], + }); + + return NextResponse.json(labels); + } catch (error) { + console.error('Error fetching campaign labels:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Check if user is campaign member with edit permissions + const campaignMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: session.user.id, + }, + }, + }); + + if (!campaignMember || !['OWNER', 'MANAGER'].includes(campaignMember.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const validatedData = createLabelSchema.parse(body); + + // Check if label name already exists for this campaign + const existingLabel = await prisma.campaignLabel.findUnique({ + where: { + campaignId_name: { + campaignId: params.id, + name: validatedData.name, + }, + }, + }); + + if (existingLabel) { + return NextResponse.json( + { error: 'Label with this name already exists' }, + { status: 400 } + ); + } + + // Create label + const label = await prisma.campaignLabel.create({ + data: { + ...validatedData, + campaignId: params.id, + }, + }); + + return NextResponse.json(label, { status: 201 }); + } catch (error) { + console.error('Error creating campaign label:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/members/route.ts b/app/api/[orgId]/campaigns/[id]/members/route.ts new file mode 100644 index 00000000..3848edd7 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/members/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +// Validation schemas +const addMemberSchema = z.object({ + userId: z.string().cuid(), + role: z.enum(['OWNER', 'MANAGER', 'MEMBER', 'VIEWER']).default('MEMBER'), +}); + +// const updateMemberSchema = z.object({ +// role: z.enum(['OWNER', 'MANAGER', 'MEMBER', 'VIEWER']), +// }); + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get campaign members + const members = await prisma.campaignMember.findMany({ + where: { + campaignId: params.id, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: [{ role: 'asc' }, { createdAt: 'asc' }], + }); + + return NextResponse.json(members); + } catch (error) { + console.error('Error fetching campaign members:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Check if user is campaign member with edit permissions + const campaignMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: session.user.id, + }, + }, + }); + + if (!campaignMember || !['OWNER', 'MANAGER'].includes(campaignMember.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const validatedData = addMemberSchema.parse(body); + + // Check if user exists in organization + const targetUserMembership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: validatedData.userId, + organizationId: params.orgId, + }, + }, + }); + + if (!targetUserMembership) { + return NextResponse.json( + { error: 'User is not a member of this organization' }, + { status: 400 } + ); + } + + // Check if user is already a campaign member + const existingMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: validatedData.userId, + }, + }, + }); + + if (existingMember) { + return NextResponse.json( + { error: 'User is already a member of this campaign' }, + { status: 400 } + ); + } + + // Add member to campaign + const newMember = await prisma.campaignMember.create({ + data: { + campaignId: params.id, + userId: validatedData.userId, + role: validatedData.role, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + + return NextResponse.json(newMember, { status: 201 }); + } catch (error) { + console.error('Error adding campaign member:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/route.ts b/app/api/[orgId]/campaigns/[id]/route.ts new file mode 100644 index 00000000..edf1ffc3 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/route.ts @@ -0,0 +1,307 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +// Validation schemas +const updateCampaignSchema = z.object({ + name: z.string().min(3).max(100).optional(), + summary: z.string().max(200).optional(), + description: z.string().max(2000).optional(), + status: z.enum(['DRAFT', 'PLANNING', 'READY', 'DONE', 'CANCELED']).optional(), + health: z.enum(['ON_TRACK', 'AT_RISK', 'OFF_TRACK']).optional(), + priority: z.enum(['NO_PRIORITY', 'LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(), + leadId: z.string().cuid().optional(), + startDate: z.string().datetime().optional(), + targetDate: z.string().datetime().optional(), +}); + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get campaign with all related data + const campaign = await prisma.campaign.findFirst({ + where: { + id: params.id, + organizationId: params.orgId, + }, + include: { + lead: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + tasks: { + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + subtasks: { + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], + }, + labels: true, + milestones: { + orderBy: { dueDate: 'asc' }, + }, + contents: { + select: { + id: true, + title: true, + status: true, + createdAt: true, + }, + }, + schedules: { + select: { + id: true, + name: true, + status: true, + runAt: true, + channel: true, + }, + }, + _count: { + select: { + tasks: true, + members: true, + labels: true, + milestones: true, + contents: true, + schedules: true, + }, + }, + }, + }); + + if (!campaign) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + return NextResponse.json(campaign); + } catch (error) { + console.error('Error fetching campaign:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Check if user is campaign member with edit permissions + const campaignMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: session.user.id, + }, + }, + }); + + if (!campaignMember || !['OWNER', 'MANAGER'].includes(campaignMember.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const validatedData = updateCampaignSchema.parse(body); + + // Validate dates if both are provided + if (validatedData.startDate && validatedData.targetDate) { + const startDate = new Date(validatedData.startDate); + const targetDate = new Date(validatedData.targetDate); + + if (startDate >= targetDate) { + return NextResponse.json( + { error: 'Target date must be after start date' }, + { status: 400 } + ); + } + } + + // Update campaign + const updatedCampaign = await prisma.campaign.update({ + where: { + id: params.id, + organizationId: params.orgId, + }, + data: { + ...validatedData, + startDate: validatedData.startDate ? new Date(validatedData.startDate) : undefined, + targetDate: validatedData.targetDate ? new Date(validatedData.targetDate) : undefined, + }, + include: { + lead: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + labels: true, + milestones: true, + _count: { + select: { + tasks: true, + members: true, + labels: true, + milestones: true, + }, + }, + }, + }); + + return NextResponse.json(updatedCampaign); + } catch (error) { + console.error('Error updating campaign:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Only campaign OWNER or organization ADMIN can delete campaigns + const campaignMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: session.user.id, + }, + }, + }); + + const canDelete = campaignMember?.role === 'OWNER' || membership.role === 'ADMIN'; + + if (!canDelete) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + // Delete campaign (cascades to related data) + await prisma.campaign.delete({ + where: { + id: params.id, + organizationId: params.orgId, + }, + }); + + return NextResponse.json({ message: 'Campaign deleted successfully' }); + } catch (error) { + console.error('Error deleting campaign:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/tasks/[taskId]/route.ts b/app/api/[orgId]/campaigns/[id]/tasks/[taskId]/route.ts new file mode 100644 index 00000000..039e5b66 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/tasks/[taskId]/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +export async function PUT( + request: NextRequest, + { params }: { params: { orgId: string; id: string; taskId: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + + // Update the task + const updatedTask = await prisma.campaignTask.update({ + where: { + id: params.taskId, + campaignId: params.id, + }, + data: body, + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + subtasks: { + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + }, + }); + + return NextResponse.json({ task: updatedTask }); + } catch (error) { + console.error('Error updating task:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { orgId: string; id: string; taskId: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Delete the task and all its subtasks + await prisma.campaignTask.deleteMany({ + where: { + OR: [{ id: params.taskId }, { parentTaskId: params.taskId }], + campaignId: params.id, + }, + }); + + return NextResponse.json({ message: 'Task deleted successfully' }); + } catch (error) { + console.error('Error deleting task:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/[id]/tasks/route.ts b/app/api/[orgId]/campaigns/[id]/tasks/route.ts new file mode 100644 index 00000000..85f4d7ba --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/tasks/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +// Validation schemas +const createTaskSchema = z.object({ + title: z.string().min(3).max(200), + description: z.string().max(2000).optional(), + status: z.enum(['TODO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELLED']).default('TODO'), + priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('MEDIUM'), + assigneeId: z.string().cuid().optional(), + dueDate: z.string().datetime().optional(), + parentTaskId: z.string().cuid().optional(), +}); + +// const updateTaskSchema = z.object({ +// title: z.string().min(3).max(200).optional(), +// description: z.string().max(3).max(2000).optional(), +// status: z.enum(['TODO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELLED']).optional(), +// priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(), +// assigneeId: z.string().cuid().optional(), +// dueDate: z.string().datetime().optional(), +// parentTaskId: z.string().cuid().optional(), +// }); + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get tasks for the campaign + const tasks = await prisma.campaignTask.findMany({ + where: { + campaignId: params.id, + parentTaskId: null, // Only get top-level tasks + }, + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + subtasks: { + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], + }, + }, + orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], + }); + + return NextResponse.json(tasks); + } catch (error) { + console.error('Error fetching campaign tasks:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Check if user is campaign member with edit permissions + const campaignMember = await prisma.campaignMember.findUnique({ + where: { + campaignId_userId: { + campaignId: params.id, + userId: session.user.id, + }, + }, + }); + + if (!campaignMember || !['OWNER', 'MANAGER'].includes(campaignMember.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const validatedData = createTaskSchema.parse(body); + + // Create task + const task = await prisma.campaignTask.create({ + data: { + ...validatedData, + campaignId: params.id, + dueDate: validatedData.dueDate ? new Date(validatedData.dueDate) : null, + }, + include: { + assignee: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + subtasks: true, + }, + }); + + return NextResponse.json(task, { status: 201 }); + } catch (error) { + console.error('Error creating campaign task:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/campaigns/route.ts b/app/api/[orgId]/campaigns/route.ts new file mode 100644 index 00000000..925f4c62 --- /dev/null +++ b/app/api/[orgId]/campaigns/route.ts @@ -0,0 +1,269 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +// Validation schemas +const createCampaignSchema = z.object({ + name: z.string().min(3).max(100), + summary: z.string().max(200).optional(), + description: z.string().max(2000).optional(), + status: z.enum(['DRAFT', 'PLANNING', 'READY', 'DONE', 'CANCELED']).default('DRAFT'), + health: z.enum(['ON_TRACK', 'AT_RISK', 'OFF_TRACK']).default('ON_TRACK'), + priority: z.enum(['NO_PRIORITY', 'LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('NO_PRIORITY'), + leadId: z.string().cuid().optional(), + startDate: z.string().datetime().optional(), + targetDate: z.string().datetime().optional(), +}); + +const getCampaignsSchema = z.object({ + status: z.enum(['DRAFT', 'PLANNING', 'READY', 'DONE', 'CANCELED']).optional(), + health: z.enum(['ON_TRACK', 'AT_RISK', 'OFF_TRACK']).optional(), + priority: z.enum(['NO_PRIORITY', 'LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(), + search: z.string().optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = getCampaignsSchema.parse({ + status: searchParams.get('status'), + health: searchParams.get('health'), + priority: searchParams.get('priority'), + search: searchParams.get('search'), + page: searchParams.get('page'), + limit: searchParams.get('limit'), + }); + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Build where clause + const where: any = { + organizationId: params.orgId, + }; + + if (query.status) { + where.status = query.status; + } + + if (query.health) { + where.health = query.health; + } + + if (query.priority) { + where.priority = query.priority; + } + + if (query.search) { + where.OR = [ + { name: { contains: query.search, mode: 'insensitive' } }, + { summary: { contains: query.search, mode: 'insensitive' } }, + { description: { contains: query.search, mode: 'insensitive' } }, + ]; + } + + // Get total count for pagination + const total = await prisma.campaign.count({ where }); + + // Get campaigns with pagination + const campaigns = await prisma.campaign.findMany({ + where, + include: { + lead: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + tasks: { + select: { + id: true, + status: true, + }, + }, + labels: true, + milestones: true, + _count: { + select: { + tasks: true, + members: true, + labels: true, + milestones: true, + }, + }, + }, + orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }], + skip: (query.page - 1) * query.limit, + take: query.limit, + }); + + // Calculate pagination info + const totalPages = Math.ceil(total / query.limit); + const hasNextPage = query.page < totalPages; + const hasPrevPage = query.page > 1; + + return NextResponse.json({ + campaigns, + pagination: { + page: query.page, + limit: query.limit, + total, + totalPages, + hasNextPage, + hasPrevPage, + }, + }); + } catch (error) { + console.error('Error fetching campaigns:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid query parameters', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Check if user has access to this organization + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId: session.user.id, + organizationId: params.orgId, + }, + }, + }); + + if (!membership) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Only ADMIN and BRAND_OWNER can create campaigns + if (!['ADMIN', 'BRAND_OWNER'].includes(membership.role)) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + const body = await request.json(); + const validatedData = createCampaignSchema.parse(body); + + // Validate dates + if (validatedData.startDate && validatedData.targetDate) { + const startDate = new Date(validatedData.startDate); + const targetDate = new Date(validatedData.targetDate); + + if (startDate >= targetDate) { + return NextResponse.json( + { error: 'Target date must be after start date' }, + { status: 400 } + ); + } + } + + // Create campaign + const campaign = await prisma.campaign.create({ + data: { + ...validatedData, + organizationId: params.orgId, + startDate: validatedData.startDate ? new Date(validatedData.startDate) : null, + targetDate: validatedData.targetDate ? new Date(validatedData.targetDate) : null, + }, + include: { + lead: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + labels: true, + milestones: true, + _count: { + select: { + tasks: true, + members: true, + labels: true, + milestones: true, + }, + }, + }, + }); + + // Add creator as campaign member with OWNER role + await prisma.campaignMember.create({ + data: { + campaignId: campaign.id, + userId: session.user.id, + role: 'OWNER', + }, + }); + + return NextResponse.json(campaign, { status: 201 }); + } catch (error) { + console.error('Error creating campaign:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/[orgId]/content/[id]/route.ts b/app/api/[orgId]/content/[id]/route.ts new file mode 100644 index 00000000..9c4ce172 --- /dev/null +++ b/app/api/[orgId]/content/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { updateContentSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const content = await prisma.content.findFirst({ + where: { id, campaign: { organizationId: orgId } }, + include: { + campaign: true, + assets: true, + }, + }); + + if (!content) { + return NextResponse.json({ error: 'Content not found' }, { status: 404 }); + } + + return NextResponse.json(content); + } catch (error) { + console.error('Error fetching content:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const validatedData = updateContentSchema.parse(body); + + const content = await prisma.content.update({ + where: { id }, + data: validatedData, + include: { + campaign: true, + assets: true, + }, + }); + + return NextResponse.json(content); + } catch (error) { + console.error('Error updating content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + await prisma.content.delete({ + where: { id }, + }); + + return NextResponse.json({ message: 'Content deleted' }); + } catch (error) { + console.error('Error deleting content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/content/generate/route.ts b/app/api/[orgId]/content/generate/route.ts new file mode 100644 index 00000000..0ccb6865 --- /dev/null +++ b/app/api/[orgId]/content/generate/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { generateContentSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; +import { generateContent } from '@/lib/openai'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const validatedData = generateContentSchema.parse(body); + + // Verify campaign belongs to org + const campaign = await prisma.campaign.findFirst({ + where: { id: validatedData.campaignId, organizationId: orgId }, + }); + if (!campaign) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + // Generate content using OpenAI + const generatedBody = await generateContent({ + prompt: validatedData.prompt, + type: 'general', + tone: 'professional', + length: 'medium', + }); + + const content = await prisma.content.create({ + data: { + title: `AI Generated: ${validatedData.prompt.slice(0, 50)}...`, + body: generatedBody, + campaignId: validatedData.campaignId, + }, + include: { + campaign: true, + assets: true, + }, + }); + + return NextResponse.json(content, { status: 201 }); + } catch (error) { + console.error('Error generating content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/content/ideas/route.ts b/app/api/[orgId]/content/ideas/route.ts new file mode 100644 index 00000000..18f83c18 --- /dev/null +++ b/app/api/[orgId]/content/ideas/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { generateIdeas } from '@/lib/openai'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const { topic, count = 5, type = 'general' } = body; + + if (!topic) { + return NextResponse.json({ error: 'Topic is required' }, { status: 400 }); + } + + const ideas = await generateIdeas({ + topic, + count, + type, + }); + + return NextResponse.json({ ideas }, { status: 200 }); + } catch (error) { + console.error('Error generating ideas:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/content/route.ts b/app/api/[orgId]/content/route.ts new file mode 100644 index 00000000..98139997 --- /dev/null +++ b/app/api/[orgId]/content/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { createContentSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { ok: false, error: { code: 'E_UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ); + } + + const { orgId } = params; + const { searchParams } = new URL(request.url); + const campaignId = searchParams.get('campaignId'); + const status = searchParams.get('status'); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const where: any = { + campaign: { organizationId: orgId }, + }; + + if (campaignId) { + where.campaignId = campaignId; + } + + if (status) { + where.status = status; + } + + const contents = await prisma.content.findMany({ + where, + include: { + campaign: true, + assets: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ ok: true, data: contents }); + } catch (error) { + console.error('Error fetching contents:', error); + return NextResponse.json( + { ok: false, error: { code: 'E_INTERNAL', message: 'Internal server error' } }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { ok: false, error: { code: 'E_UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const validatedData = createContentSchema.parse(body); + + // Verify campaign belongs to org + const campaign = await prisma.campaign.findFirst({ + where: { id: validatedData.campaignId, organizationId: orgId }, + }); + if (!campaign) { + return NextResponse.json( + { + ok: false, + error: { code: 'E_NOT_FOUND', message: 'Campaign not found' }, + }, + { status: 404 } + ); + } + + const content = await prisma.content.create({ + data: validatedData, + include: { + campaign: true, + assets: true, + }, + }); + + return NextResponse.json({ ok: true, data: content }, { status: 201 }); + } catch (error) { + console.error('Error creating content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json( + { + ok: false, + error: { code: 'E_FORBIDDEN', message: 'Forbidden' }, + }, + { status: 403 } + ); + } + return NextResponse.json( + { ok: false, error: { code: 'E_INTERNAL', message: 'Internal server error' } }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/content/summarize/route.ts b/app/api/[orgId]/content/summarize/route.ts new file mode 100644 index 00000000..e6de9068 --- /dev/null +++ b/app/api/[orgId]/content/summarize/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { summarizeContent } from '@/lib/openai'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const { content, length = 'brief' } = body; + + if (!content) { + return NextResponse.json({ error: 'Content is required' }, { status: 400 }); + } + + const summary = await summarizeContent({ + content, + length, + }); + + return NextResponse.json({ summary }, { status: 200 }); + } catch (error) { + console.error('Error summarizing content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/content/translate/route.ts b/app/api/[orgId]/content/translate/route.ts new file mode 100644 index 00000000..29c528a3 --- /dev/null +++ b/app/api/[orgId]/content/translate/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { translateContent } from '@/lib/openai'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const body = await request.json(); + const { content, targetLanguage, sourceLanguage } = body; + + if (!content || !targetLanguage) { + return NextResponse.json( + { error: 'Content and target language are required' }, + { status: 400 } + ); + } + + const translatedContent = await translateContent({ + content, + targetLanguage, + sourceLanguage, + }); + + return NextResponse.json({ translatedContent }, { status: 200 }); + } catch (error) { + console.error('Error translating content:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/schedules/[id]/route.ts b/app/api/[orgId]/schedules/[id]/route.ts new file mode 100644 index 00000000..ff8e6913 --- /dev/null +++ b/app/api/[orgId]/schedules/[id]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { updateScheduleSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + const schedule = await prisma.schedule.findFirst({ + where: { id, campaign: { organizationId: orgId } }, + include: { + campaign: true, + content: true, + }, + }); + + if (!schedule) { + return NextResponse.json({ error: 'Schedule not found' }, { status: 404 }); + } + + return NextResponse.json(schedule); + } catch (error) { + console.error('Error fetching schedule:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + const body = await request.json(); + const validatedData = updateScheduleSchema.parse(body); + + const schedule = await prisma.schedule.update({ + where: { id }, + data: validatedData, + include: { + campaign: true, + }, + }); + + return NextResponse.json(schedule); + } catch (error) { + console.error('Error updating schedule:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { orgId: string; id: string } } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId, id } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + await prisma.schedule.delete({ + where: { id }, + }); + + return NextResponse.json({ message: 'Schedule deleted' }); + } catch (error) { + console.error('Error deleting schedule:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/[orgId]/schedules/route.ts b/app/api/[orgId]/schedules/route.ts new file mode 100644 index 00000000..d956f514 --- /dev/null +++ b/app/api/[orgId]/schedules/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { createScheduleSchema } from '@/lib/schemas'; +import { requirePermission, PERMISSIONS } from '@/lib/rbac'; + +export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { ok: false, error: { code: 'E_UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ); + } + + const { orgId } = params; + const { searchParams } = new URL(request.url); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const channels = searchParams.get('channels')?.split(','); + const campaigns = searchParams.get('campaigns')?.split(','); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + const where: any = { + campaign: { organizationId: orgId }, + }; + + // Add date range filter + if (from && to) { + where.runAt = { + gte: new Date(from), + lte: new Date(to), + }; + } + + // Add channel filter + if (channels && channels.length > 0) { + where.channel = { in: channels }; + } + + // Add campaign filter + if (campaigns && campaigns.length > 0) { + where.campaignId = { in: campaigns }; + } + + const schedules = await prisma.schedule.findMany({ + where, + include: { + campaign: true, + content: true, + }, + orderBy: { runAt: 'asc' }, + }); + + return NextResponse.json({ ok: true, data: schedules }); + } catch (error) { + console.error('Error fetching schedules:', error); + return NextResponse.json( + { ok: false, error: { code: 'E_INTERNAL', message: 'Internal server error' } }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest, { params }: { params: { orgId: string } }) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { ok: false, error: { code: 'E_UNAUTHORIZED', message: 'Unauthorized' } }, + { status: 401 } + ); + } + + const { orgId } = params; + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + const body = await request.json(); + const validatedData = createScheduleSchema.parse(body); + + // Verify campaign belongs to org + const campaign = await prisma.campaign.findFirst({ + where: { id: validatedData.campaignId, organizationId: orgId }, + }); + if (!campaign) { + return NextResponse.json( + { + ok: false, + error: { code: 'E_NOT_FOUND', message: 'Campaign not found' }, + }, + { status: 404 } + ); + } + + // Verify content belongs to campaign + const content = await prisma.content.findFirst({ + where: { id: validatedData.contentId, campaignId: validatedData.campaignId }, + }); + if (!content) { + return NextResponse.json( + { + ok: false, + error: { code: 'E_NOT_FOUND', message: 'Content not found' }, + }, + { status: 404 } + ); + } + + // Check for potential conflicts (same channel at overlapping times) + const conflictingSchedule = await prisma.schedule.findFirst({ + where: { + channel: validatedData.channel, + runAt: { + gte: new Date(new Date(validatedData.runAt).getTime() - 15 * 60 * 1000), // 15 minutes before + lte: new Date(new Date(validatedData.runAt).getTime() + 15 * 60 * 1000), // 15 minutes after + }, + status: { not: 'CANCELLED' }, + }, + }); + + if (conflictingSchedule) { + return NextResponse.json( + { + ok: false, + error: { + code: 'E_CONFLICT_SLOT', + message: 'Potential scheduling conflict detected', + details: { conflictingScheduleId: conflictingSchedule.id }, + }, + }, + { status: 409 } + ); + } + + // Create schedule and update content status in a transaction + const result = await prisma.$transaction(async (tx) => { + const schedule = await tx.schedule.create({ + data: { + runAt: new Date(validatedData.runAt), + timezone: validatedData.timezone, + channel: validatedData.channel, + status: validatedData.status || 'PENDING', + campaignId: validatedData.campaignId, + contentId: validatedData.contentId, + }, + include: { + campaign: true, + content: true, + }, + }); + + // Update content status to SCHEDULED + await tx.content.update({ + where: { id: validatedData.contentId }, + data: { status: 'SCHEDULED' }, + }); + + return schedule; + }); + + return NextResponse.json({ ok: true, data: result }, { status: 201 }); + } catch (error) { + console.error('Error creating schedule:', error); + if (error instanceof Error && error.message === 'Insufficient permissions') { + return NextResponse.json( + { + ok: false, + error: { code: 'E_FORBIDDEN', message: 'Forbidden' }, + }, + { status: 403 } + ); + } + if (error instanceof Error && error.message.includes('Invalid date')) { + return NextResponse.json( + { + ok: false, + error: { code: 'E_VALIDATION', message: 'Invalid date format' }, + }, + { status: 400 } + ); + } + return NextResponse.json( + { ok: false, error: { code: 'E_INTERNAL', message: 'Internal server error' } }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..bf39bebd --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { authHandlers } from '@/lib/auth'; + +export const { GET, POST } = authHandlers; diff --git a/app/api/cron/publish/route.ts b/app/api/cron/publish/route.ts new file mode 100644 index 00000000..d4234386 --- /dev/null +++ b/app/api/cron/publish/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { runCronJob } from '@/lib/cron-worker'; + +export async function POST(request: NextRequest) { + try { + // You might want to add authentication/authorization here + // to prevent unauthorized access to the cron endpoint + + const result = await runCronJob(); + + if (result.success) { + return NextResponse.json({ + message: 'Cron job executed successfully', + published: result.published, + }); + } else { + return NextResponse.json({ error: result.error }, { status: 500 }); + } + } catch (error) { + console.error('Error running cron job:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// Optional: GET endpoint to check cron status +export async function GET() { + return NextResponse.json({ + message: 'Cron endpoint is available', + endpoint: '/api/cron/publish', + method: 'POST', + }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 00000000..ec945278 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET() { + try { + // Check database connection + await prisma.$queryRaw`SELECT 1`; + + // Check OpenAI API key (basic check) + const openaiKey = process.env.OPENAI_API_KEY; + const openaiOk = openaiKey && openaiKey.startsWith('sk-'); + + return NextResponse.json({ + ok: true, + status: 'healthy', + timestamp: new Date().toISOString(), + services: { + database: 'connected', + openai: openaiOk ? 'configured' : 'not configured', + }, + }); + } catch (error) { + console.error('Health check failed:', error); + return NextResponse.json( + { + ok: false, + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 503 } + ); + } finally { + await prisma.$disconnect(); + } +} diff --git a/app/api/me/role/route.ts b/app/api/me/role/route.ts new file mode 100644 index 00000000..39c390b6 --- /dev/null +++ b/app/api/me/role/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import { getUserRole } from '@/lib/rbac'; + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const orgId = searchParams.get('orgId'); + + if (!orgId) { + return NextResponse.json({ error: 'Organization ID required' }, { status: 400 }); + } + + const userRole = await getUserRole(orgId); + + if (!userRole) { + return NextResponse.json( + { error: 'No role found for this organization' }, + { status: 403 } + ); + } + + return NextResponse.json({ role: userRole }); + } catch (error) { + console.error('Error fetching user role:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 00000000..cef8894c --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; +import { auth } from '@/lib/auth'; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ hasMembership: false }); + const membership = await prisma.membership.findFirst({ where: { userId: session.user.id } }); + return NextResponse.json({ + hasMembership: !!membership, + organizationId: membership?.organizationId, + }); +} diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx new file mode 100644 index 00000000..47d4f1e0 --- /dev/null +++ b/app/auth/signin/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +export default function SignInPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [callbackUrl, setCallbackUrl] = useState('/'); + const router = useRouter(); + + useEffect(() => { + if (typeof window !== 'undefined') { + try { + const url = new URL(window.location.href); + const cb = url.searchParams.get('callbackUrl'); + if (cb) setCallbackUrl(cb); + } catch {} + } + }, []); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const res = await signIn('credentials', { + email, + password, + redirect: false, + callbackUrl, + }); + setLoading(false); + if (res?.ok) { + try { + const me = await fetch('/api/me').then((r) => + r.ok ? r.json() : { hasMembership: false } + ); + + if (!me.hasMembership) { + router.push('/onboarding'); + return; + } + + // If user has membership, check if callbackUrl is valid + // If callbackUrl is from an org route but user doesn't have access, use their actual org + let targetUrl = callbackUrl; + + if (callbackUrl) { + // Check if callbackUrl is an org route (contains orgId) + const urlParts = callbackUrl.split('/').filter(Boolean); + if ( + urlParts.length >= 1 && + urlParts[0] !== 'auth' && + urlParts[0] !== 'api' && + urlParts[0] !== '_next' + ) { + // This looks like an org route, verify it matches user's org + const callbackOrgId = urlParts[0]; + if (callbackOrgId !== me.organizationId) { + targetUrl = `/${me.organizationId}/projects`; + } + } + } + + targetUrl = targetUrl || `/${me.organizationId}/projects`; + router.push(targetUrl); + } catch (error) { + router.push('/onboarding'); + } + } else { + alert('Invalid credentials (dev: check DEV_LOGIN_PASSWORD)'); + } + } + + return ( +
+
+

Sign in

+ + setEmail(e.target.value)} + required + placeholder="you@aim.local" + /> + + setPassword(e.target.value)} + required + placeholder="DEV_LOGIN_PASSWORD" + /> + +
+
+ ); +} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx new file mode 100644 index 00000000..11705d18 --- /dev/null +++ b/app/auth/signup/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; + +export default function SignUpPage() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const router = useRouter(); + + const validateForm = () => { + if (!name.trim()) { + setError('Name is required'); + return false; + } + if (!email.trim()) { + setError('Email is required'); + return false; + } + if (!email.includes('@')) { + setError('Please enter a valid email'); + return false; + } + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return false; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return false; + } + return true; + }; + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + if (!validateForm()) { + return; + } + + setLoading(true); + + try { + // For now, we'll use the same logic as signin since this is dev-only + // In production, you'd want to create a proper signup API endpoint + const res = await signIn('credentials', { + email, + password, + redirect: false, + }); + + if (res?.ok) { + setSuccess(true); + setTimeout(() => { + router.push('/onboarding'); + }, 2000); + } else { + setError('Failed to create account. Please try again.'); + } + } catch (error) { + setError('An error occurred. Please try again.'); + } finally { + setLoading(false); + } + } + + return ( +
+ + + Create Account + + Join AiM Platform to start creating amazing campaigns + + + +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Account created successfully! Redirecting to onboarding... +
+ )} + +
+ + setName(e.target.value)} + required + placeholder="John Doe" + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + required + placeholder="john@example.com" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + placeholder="••••••••" + disabled={loading} + /> +
+ + + +
+ Already have an account?{' '} + +
+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 5572a576..e8c74411 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { Toaster } from '@/components/ui/sonner'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages } from 'next-intl/server'; +import { SessionProvider } from 'next-auth/react'; import './globals.css'; const geistSans = Geist({ @@ -55,21 +58,30 @@ export const metadata: Metadata = { import { ThemeProvider } from '@/components/layout/theme-provider'; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const messages = await getMessages(); + return ( - - - {children} - - + + + + + {children} + + + + ); diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx new file mode 100644 index 00000000..025460a3 --- /dev/null +++ b/app/onboarding/page.tsx @@ -0,0 +1,127 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; + +export default async function OnboardingPage() { + try { + const session = await auth(); + + if (!session?.user?.id) { + redirect('/auth/signin'); + } + + console.log('Onboarding - User ID:', session.user.id); + console.log('Onboarding - User email:', session.user.email); + + // First, check if user exists by ID + let user = await prisma.user.findUnique({ + where: { id: session.user.id }, + }); + + if (!user) { + // Check if user exists by email (might have different ID) + const existingUserByEmail = await prisma.user.findUnique({ + where: { email: session.user.email! }, + }); + + if (existingUserByEmail) { + console.log('Onboarding - User found by email with different ID:', existingUserByEmail); + + // Check if this user already has membership + const existingMembership = await prisma.membership.findFirst({ + where: { userId: existingUserByEmail.id }, + }); + + if (existingMembership) { + console.log('Onboarding - Existing user has membership, redirecting'); + redirect(`/${existingMembership.organizationId}/projects`); + } + + // Use the existing user ID for membership creation + user = existingUserByEmail; + } else { + console.log('Onboarding - User not found in DB, creating...'); + // Create user if it doesn't exist + user = await prisma.user.create({ + data: { + id: session.user.id, + email: session.user.email!, + name: session.user.name || 'Unknown User', + }, + }); + console.log('Onboarding - User created:', user); + } + } else { + console.log('Onboarding - User found in DB:', user); + } + + // Check if user already has membership + const membership = await prisma.membership.findFirst({ + where: { userId: user.id }, + }); + + if (membership) { + console.log('Onboarding - User already has membership, redirecting'); + // User already has membership, redirect to their org + redirect(`/${membership.organizationId}/projects`); + } + + console.log('Onboarding - Creating organization and membership...'); + + // Create default organization and membership for the user + const org = await prisma.organization.create({ + data: { + name: `${user.name || 'My'}'s Organization`, + }, + }); + + console.log('Onboarding - Organization created:', org); + + const newMembership = await prisma.membership.create({ + data: { + userId: user.id, + organizationId: org.id, + role: 'ADMIN', + }, + }); + + console.log('Onboarding - Membership created:', newMembership); + + // Redirect to the new organization + redirect(`/${org.id}/projects`); + } catch (error) { + console.error('Onboarding error:', error); + return ( +
+
+

🎯 Welcome to AiM!

+ +
+
+

❌ Setup Error

+

+ {error instanceof Error ? error.message : 'Unknown error occurred'} +

+
+ +
+

🔍 Debug Info

+

+ Check the server console for detailed error information. +

+
+
+ + +
+
+ ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 62fccc7e..9527b67b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,77 @@ import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; -export default function Home() { - redirect('lndev-ui/team/CORE/all'); +export default async function Home() { + try { + const session = await auth(); + console.log('Home page - Session:', session); + + if (!session?.user?.id) { + console.log('Home page - No session, redirecting to signin'); + redirect('/auth/signin'); + } + + // Check if user has membership by session user ID first + let membership = await prisma.membership.findFirst({ + where: { userId: session.user.id }, + }); + + // If no membership found by session ID, check by email + if (!membership && session.user.email) { + console.log('Home page - No membership by ID, checking by email...'); + const userByEmail = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (userByEmail) { + console.log('Home page - User found by email:', userByEmail); + membership = await prisma.membership.findFirst({ + where: { userId: userByEmail.id }, + }); + } + } + + console.log('Home page - Membership:', membership); + + if (!membership) { + console.log('Home page - No membership, redirecting to onboarding'); + redirect('/onboarding'); + } + + // User has membership, redirect to their organization + const redirectUrl = `/${membership.organizationId}/projects`; + console.log('Home page - Redirecting to:', redirectUrl); + redirect(redirectUrl); + } catch (error) { + // Don't log NEXT_REDIRECT errors as they are expected + if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { + // This is expected behavior, not an error + throw error; // Re-throw to let Next.js handle the redirect + } + + console.error('Home page - Actual error:', error); + return ( +
+
+

❌ Error Loading Page

+ +
+
+

Error Details

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

+ + Try Again + +
+
+
+
+ ); + } } diff --git a/app/test/page.tsx b/app/test/page.tsx new file mode 100644 index 00000000..f09d843b --- /dev/null +++ b/app/test/page.tsx @@ -0,0 +1,33 @@ +export default function TestPage() { + return ( +
+
+

Test Page

+

+ This page bypasses authentication to test if the app works. +

+
+
+

✅ App is Working

+

The basic Next.js app is functioning correctly.

+
+
+

🔐 Authentication Issue

+

+ The problem is likely in the authentication flow or redirects. +

+
+
+

📝 Next Steps

+

Check the browser console for error messages.

+
+
+ +
+
+ ); +} diff --git a/components/__tests__/admin-dashboard.test.tsx b/components/__tests__/admin-dashboard.test.tsx new file mode 100644 index 00000000..35439d83 --- /dev/null +++ b/components/__tests__/admin-dashboard.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AdminDashboard } from '../dashboards/admin-dashboard'; + +describe('AdminDashboard', () => { + const mockOrgId = 'test-org-123'; + + it('renders the dashboard title', () => { + render(); + expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); + }); + + it('renders organization statistics', () => { + render(); + + expect(screen.getByText('Total Users')).toBeInTheDocument(); + expect(screen.getByText('45')).toBeInTheDocument(); // totalUsers + expect(screen.getByText('38 active')).toBeInTheDocument(); // activeUsers + + expect(screen.getByText('Active Campaigns')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); // activeCampaigns + expect(screen.getByText('of 12 total')).toBeInTheDocument(); // totalCampaigns + + expect(screen.getByText('Content Pieces')).toBeInTheDocument(); + expect(screen.getByText('156')).toBeInTheDocument(); // totalContent + + expect(screen.getByText('Pending Approvals')).toBeInTheDocument(); + expect(screen.getByText('23')).toBeInTheDocument(); // pendingApprovals + }); + + it('renders tabs', () => { + render(); + + expect(screen.getByText('User Management')).toBeInTheDocument(); + expect(screen.getByText('Organization')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + }); + + it('renders system settings button', () => { + render(); + + expect(screen.getByText('System Settings')).toBeInTheDocument(); + }); +}); diff --git a/components/analytics/analytics-charts.tsx b/components/analytics/analytics-charts.tsx new file mode 100644 index 00000000..a98da55a --- /dev/null +++ b/components/analytics/analytics-charts.tsx @@ -0,0 +1,149 @@ +'use client'; + +import React from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + LineChart, + Line, +} from 'recharts'; + +interface AnalyticsChartsProps { + metrics: { + totalEvents: number; + eventsByType: Record; + impressions: number; + clicks: number; + views: number; + ctr: number; + roi: number; + campaignMetrics: Array<{ + id: string; + name: string; + totalEvents: number; + contentCount: number; + contentMetrics: Array<{ + id: string; + title: string; + events: number; + impressions: number; + clicks: number; + views: number; + }>; + }>; + }; +} + +const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8']; + +export function AnalyticsCharts({ metrics }: AnalyticsChartsProps) { + // Prepare data for event types pie chart + const eventTypesData = Object.entries(metrics.eventsByType).map(([type, count]) => ({ + name: type.charAt(0).toUpperCase() + type.slice(1), + value: count, + })); + + // Prepare data for campaign performance bar chart + const campaignData = metrics.campaignMetrics.map((campaign) => ({ + name: campaign.name.length > 15 ? campaign.name.substring(0, 15) + '...' : campaign.name, + events: campaign.totalEvents, + content: campaign.contentCount, + })); + + // Prepare data for performance metrics line chart + const performanceData = [ + { name: 'Impressions', value: metrics.impressions }, + { name: 'Clicks', value: metrics.clicks }, + { name: 'Views', value: metrics.views }, + ]; + + return ( +
+ {/* Event Types Distribution */} +
+

Event Types Distribution

+ + + `${name} ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {eventTypesData.map((entry, index) => ( + + ))} + + + + +
+ + {/* Campaign Performance */} +
+

Campaign Performance

+ + + + + + + + + +
+ + {/* Performance Metrics */} +
+

Key Performance Metrics

+
+
+
+ {metrics.impressions.toLocaleString()} +
+
Impressions
+
+
+
+ {metrics.clicks.toLocaleString()} +
+
Clicks
+
+
+
+ {metrics.ctr.toFixed(2)}% +
+
CTR
+
+
+ + + + + + + + + +
+
+ ); +} diff --git a/components/analytics/analytics-example.tsx b/components/analytics/analytics-example.tsx new file mode 100644 index 00000000..b38adfcf --- /dev/null +++ b/components/analytics/analytics-example.tsx @@ -0,0 +1,119 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { Eye, MousePointer, ThumbsUp, Share2 } from 'lucide-react'; + +interface AnalyticsExampleProps { + orgId: string; + campaignId?: string; + contentId?: string; +} + +export function AnalyticsExample({ orgId, campaignId, contentId }: AnalyticsExampleProps) { + const { trackEvent } = useAnalytics(orgId); + + const handleView = async () => { + try { + await trackEvent('view', campaignId, contentId, { + source: 'example_component', + timestamp: new Date().toISOString(), + }); + console.log('View event tracked'); + } catch (error) { + console.error('Failed to track view:', error); + } + }; + + const handleClick = async () => { + try { + await trackEvent('click', campaignId, contentId, { + element: 'example_button', + position: 'center', + }); + console.log('Click event tracked'); + } catch (error) { + console.error('Failed to track click:', error); + } + }; + + const handleLike = async () => { + try { + await trackEvent('like', campaignId, contentId, { + reaction_type: 'thumbs_up', + }); + console.log('Like event tracked'); + } catch (error) { + console.error('Failed to track like:', error); + } + }; + + const handleShare = async () => { + try { + await trackEvent('share', campaignId, contentId, { + platform: 'social_media', + }); + console.log('Share event tracked'); + } catch (error) { + console.error('Failed to track share:', error); + } + }; + + return ( + + + Analytics Event Tracking Example + + Click buttons to track different types of analytics events + + + +
+ + + + +
+ +
+

+ Campaign ID: {campaignId || 'Not set'} +

+

+ Content ID: {contentId || 'Not set'} +

+

+ Organization ID: {orgId} +

+
+ +
+

+ Usage: +

+
+                  {`const { trackEvent } = useAnalytics(orgId);
+
+await trackEvent('event_type', campaignId, contentId, {
+  custom_data: 'value'
+});`}
+               
+
+
+
+ ); +} diff --git a/components/assets/asset-library.tsx b/components/assets/asset-library.tsx new file mode 100644 index 00000000..e0d301b4 --- /dev/null +++ b/components/assets/asset-library.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Upload, Search, Image, Video, FileText, Music, Trash2, Eye } from 'lucide-react'; +import { AssetUpload } from './asset-upload'; +import { AssetPreview } from './asset-preview'; + +interface Asset { + id: string; + url: string; + name?: string; + type: string; + size?: number; + description?: string; + tags?: string[]; + createdAt: string; + content: { + id: string; + title: string; + }; +} + +interface AssetLibraryProps { + orgId: string; + contentId?: string; + onAssetSelect?: (asset: Asset) => void; +} + +export function AssetLibrary({ orgId, contentId, onAssetSelect }: AssetLibraryProps) { + const { data: session } = useSession(); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedType, setSelectedType] = useState('all'); + const [selectedTags, setSelectedTags] = useState([]); + const [sortBy, setSortBy] = useState('newest'); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [previewAsset, setPreviewAsset] = useState(null); + + const fetchAssets = async () => { + try { + const params = new URLSearchParams(); + if (contentId) params.append('contentId', contentId); + + const response = await fetch(`/api/${orgId}/assets?${params}`); + if (response.ok) { + const data = await response.json(); + setAssets(data); + } + } catch (error) { + console.error('Error fetching assets:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (session?.user) { + fetchAssets(); + } + }, [session, orgId, contentId]); + + const filteredAssets = assets + .filter((asset) => { + const matchesSearch = + !searchTerm || + asset.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + asset.content.title.toLowerCase().includes(searchTerm.toLowerCase()) || + asset.type.toLowerCase().includes(searchTerm.toLowerCase()) || + asset.description?.toLowerCase().includes(searchTerm.toLowerCase()) || + asset.tags?.some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase())); + + const matchesType = selectedType === 'all' || asset.type.startsWith(selectedType); + const matchesTags = + selectedTags.length === 0 || selectedTags.every((tag) => asset.tags?.includes(tag)); + + return matchesSearch && matchesType && matchesTags; + }) + .sort((a, b) => { + switch (sortBy) { + case 'newest': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + case 'name': + return (a.name || a.content.title).localeCompare(b.name || b.content.title); + case 'size': + return (b.size || 0) - (a.size || 0); + default: + return 0; + } + }); + + const getAssetIcon = (type: string) => { + if (type.startsWith('image/')) return ; + if (type.startsWith('video/')) return