From 16dfbcfadfc27e36fe97c19460fa19bf22dcbbfe Mon Sep 17 00:00:00 2001 From: rymleon23 Date: Tue, 2 Sep 2025 13:07:21 +0700 Subject: [PATCH 1/2] =?UTF-8?q?ADD:=20prisma/schema.prisma,=20prisma/migra?= =?UTF-8?q?tions/*,=20lib/prisma.ts,=20app/api/auth/[...nextauth]/route.ts?= =?UTF-8?q?,=20app/api/auth/register/route.ts,=20app/api/campaigns/*,=20ap?= =?UTF-8?q?p/api/contents/*,=20app/api/assets/*,=20app/api/schedules/*,=20?= =?UTF-8?q?app/api/analytics/*,=20app/api/ai/generate/route.ts,=20app/midd?= =?UTF-8?q?leware.ts,=20lib/rbac.ts,=20lib/hooks/useCurrentUser.ts,=20(aut?= =?UTF-8?q?h)/login,=20(auth)/register,=20(dashboard)/creator,=20(dashboar?= =?UTF-8?q?d)/brand,=20(dashboard)/admin,=20campaigns/*,=20content/*,=20ca?= =?UTF-8?q?lendar/page.tsx,=20assets/page.tsx,=20settings/page.tsx,=20c?= =?UTF-8?q?=C3=A1c=20components=20forms/ai/navigation/dashboard=E2=80=A6,?= =?UTF-8?q?=20.env.example,=20.github/workflows/*.yml,=20Dockerfile,=20doc?= =?UTF-8?q?ker-compose.yml,=20scripts/publish-worker.ts=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 49 + .vscode/launch.json | 15 + .vscode/settings.json | 3 + AI_INTEGRATION_README.md | 246 + DEPLOYMENT_README.md | 57 + Dockerfile | 29 + README.md | 33 + Repo analysis and plan.docx | Bin 0 -> 48334 bytes __mocks__/next-intl.ts | 26 + amplify.yml | 19 + app/[orgId]/assets/page.tsx | 17 + app/[orgId]/content/page.tsx | 17 + app/[orgId]/page.tsx | 54 +- app/[orgId]/schedules/page.tsx | 27 + app/api/[orgId]/analytics/events/route.ts | 66 + app/api/[orgId]/analytics/metrics/route.ts | 88 + app/api/[orgId]/analytics/track/route.ts | 73 + app/api/[orgId]/assets/[id]/route.ts | 136 + app/api/[orgId]/assets/route.ts | 38 + app/api/[orgId]/assets/upload/route.ts | 69 + app/api/[orgId]/campaigns/[id]/route.ts | 110 + app/api/[orgId]/campaigns/route.ts | 70 + app/api/[orgId]/content/[id]/route.ts | 111 + app/api/[orgId]/content/generate/route.ts | 61 + app/api/[orgId]/content/ideas/route.ts | 41 + app/api/[orgId]/content/route.ts | 83 + app/api/[orgId]/content/summarize/route.ts | 40 + app/api/[orgId]/content/translate/route.ts | 44 + app/api/[orgId]/schedules/[id]/route.ts | 110 + app/api/[orgId]/schedules/route.ts | 82 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/cron/publish/route.ts | 32 + app/api/health/route.ts | 38 + app/api/me/role/route.ts | 36 + app/api/me/route.ts | 13 + app/api/onboarding/brand/route.ts | 88 + app/api/onboarding/creator/route.ts | 77 + app/auth/signin/page.tsx | 110 + app/layout.tsx | 24 +- app/onboarding/page.tsx | 127 + app/page.tsx | 76 +- app/test/page.tsx | 33 + components/__tests__/admin-dashboard.test.tsx | 45 + components/analytics/analytics-charts.tsx | 149 + components/analytics/analytics-example.tsx | 119 + components/assets/asset-library.tsx | 312 ++ components/assets/asset-preview.tsx | 129 + components/assets/asset-upload.tsx | 121 + .../common/settings/language-switcher.tsx | 64 + components/common/settings/settings.tsx | 8 + components/content/content-editor.tsx | 322 ++ components/dashboards/admin-dashboard.tsx | 417 ++ components/dashboards/brand-dashboard.tsx | 303 ++ components/dashboards/creator-dashboard.tsx | 706 +++ components/layout/sidebar/app-sidebar.tsx | 17 +- components/layout/sidebar/nav-workspace.tsx | 122 +- components/layout/sidebar/org-switcher.tsx | 4 +- components/layout/theme-provider.tsx | 7 +- components/schedules/schedule-calendar.tsx | 184 + .../schedules/schedule-content-modal.tsx | 296 ++ db/seed.ts | 99 + docker-compose.yml | 33 + hooks/use-analytics.ts | 91 + i18n.ts | 15 + jest.config.js | 23 + jest.setup.js | 1 + lib/__tests__/schemas.test.ts | 155 + lib/__tests__/utils.test.ts | 25 + lib/ai-test.ts | 94 + lib/auth.ts | 59 + lib/cron-worker.ts | 76 + lib/i18n.ts | 16 + lib/openai.ts | 148 + lib/prisma.ts | 14 + lib/rbac.ts | 117 + lib/schemas.ts | 75 + lib/uploadthing.ts | 29 + messages/en.json | 80 + messages/vi.json | 80 + middleware.ts | 100 + next.config.ts | 11 +- package.json | 27 +- playwright-report/index.html | 76 + playwright.config.ts | 71 + pnpm-lock.yaml | 4040 ++++++++++++++++- .../migration.sql | 178 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 157 + spec.md | 688 +++ test-results/.last-run.json | 4 + tests/e2e/basic.spec.ts | 21 + types/next-auth.d.ts | 12 + vercel.json | 14 + 93 files changed, 12363 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 AI_INTEGRATION_README.md create mode 100644 DEPLOYMENT_README.md create mode 100644 Dockerfile create mode 100644 Repo analysis and plan.docx create mode 100644 __mocks__/next-intl.ts create mode 100644 amplify.yml create mode 100644 app/[orgId]/assets/page.tsx create mode 100644 app/[orgId]/content/page.tsx create mode 100644 app/[orgId]/schedules/page.tsx create mode 100644 app/api/[orgId]/analytics/events/route.ts create mode 100644 app/api/[orgId]/analytics/metrics/route.ts create mode 100644 app/api/[orgId]/analytics/track/route.ts create mode 100644 app/api/[orgId]/assets/[id]/route.ts create mode 100644 app/api/[orgId]/assets/route.ts create mode 100644 app/api/[orgId]/assets/upload/route.ts create mode 100644 app/api/[orgId]/campaigns/[id]/route.ts create mode 100644 app/api/[orgId]/campaigns/route.ts create mode 100644 app/api/[orgId]/content/[id]/route.ts create mode 100644 app/api/[orgId]/content/generate/route.ts create mode 100644 app/api/[orgId]/content/ideas/route.ts create mode 100644 app/api/[orgId]/content/route.ts create mode 100644 app/api/[orgId]/content/summarize/route.ts create mode 100644 app/api/[orgId]/content/translate/route.ts create mode 100644 app/api/[orgId]/schedules/[id]/route.ts create mode 100644 app/api/[orgId]/schedules/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/cron/publish/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/me/role/route.ts create mode 100644 app/api/me/route.ts create mode 100644 app/api/onboarding/brand/route.ts create mode 100644 app/api/onboarding/creator/route.ts create mode 100644 app/auth/signin/page.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 app/test/page.tsx create mode 100644 components/__tests__/admin-dashboard.test.tsx create mode 100644 components/analytics/analytics-charts.tsx create mode 100644 components/analytics/analytics-example.tsx create mode 100644 components/assets/asset-library.tsx create mode 100644 components/assets/asset-preview.tsx create mode 100644 components/assets/asset-upload.tsx create mode 100644 components/common/settings/language-switcher.tsx create mode 100644 components/content/content-editor.tsx create mode 100644 components/dashboards/admin-dashboard.tsx create mode 100644 components/dashboards/brand-dashboard.tsx create mode 100644 components/dashboards/creator-dashboard.tsx create mode 100644 components/schedules/schedule-calendar.tsx create mode 100644 components/schedules/schedule-content-modal.tsx create mode 100644 db/seed.ts create mode 100644 docker-compose.yml create mode 100644 hooks/use-analytics.ts create mode 100644 i18n.ts create mode 100644 jest.config.js create mode 100644 jest.setup.js create mode 100644 lib/__tests__/schemas.test.ts create mode 100644 lib/__tests__/utils.test.ts create mode 100644 lib/ai-test.ts create mode 100644 lib/auth.ts create mode 100644 lib/cron-worker.ts create mode 100644 lib/i18n.ts create mode 100644 lib/openai.ts create mode 100644 lib/prisma.ts create mode 100644 lib/rbac.ts create mode 100644 lib/schemas.ts create mode 100644 lib/uploadthing.ts create mode 100644 messages/en.json create mode 100644 messages/vi.json create mode 100644 middleware.ts create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 prisma/migrations/20250901064406_update_schema/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 spec.md create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/basic.spec.ts create mode 100644 types/next-auth.d.ts create mode 100644 vercel.json 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/.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/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..aa75f357 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,36 @@ pnpm dev Star History Chart + +## Auth + DB + Onboarding + +- Credentials login for development only (NextAuth v5) +- Prisma + Postgres schema for User/Organization/Brand/Membership/CreatorProfile +- Health check at /api/health +- Onboarding flow to choose Creator or Brand and set internal role +- Seed script with demo org, brand, and users + +### Environment + +Copy .env.example to .env.local and adjust values: + +`DATABASE_URL="postgresql://postgres:postgres@localhost:5432/circle_dev?schema=public" +NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_SECRET="changeme-in-local" +DEV_LOGIN_PASSWORD="dev"` + +Ensure Postgres is running and accessible by DATABASE_URL. + +### Database + +Generate and push schema, then seed (optional): + +`pnpm db:generate +pnpm db:push +pnpm db:seed` + +### Dev Login + +- Visit /auth/signin +- Use any email and password equal to DEV_LOGIN_PASSWORD +- After first login, go to /onboarding to set role diff --git a/Repo analysis and plan.docx b/Repo analysis and plan.docx new file mode 100644 index 0000000000000000000000000000000000000000..d4097e6a5945ecdf9bcf38c1bef915bee8259be6 GIT binary patch literal 48334 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 (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]/content/page.tsx b/app/[orgId]/content/page.tsx new file mode 100644 index 00000000..ed1b5801 --- /dev/null +++ b/app/[orgId]/content/page.tsx @@ -0,0 +1,17 @@ +import { ContentEditor } from '@/components/content/content-editor'; + +interface ContentPageProps { + params: { + orgId: string; + }; +} + +export default function ContentPage({ params }: ContentPageProps) { + const { orgId } = params; + + return ( +
+ +
+ ); +} 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]/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]/route.ts b/app/api/[orgId]/campaigns/[id]/route.ts new file mode 100644 index 00000000..291c2fb7 --- /dev/null +++ b/app/api/[orgId]/campaigns/[id]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { updateCampaignSchema } 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; + + // Check access + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const campaign = await prisma.campaign.findFirst({ + where: { id, organizationId: orgId }, + include: { + 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: 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; + + // Require permission + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CAMPAIGNS); + + const body = await request.json(); + const validatedData = updateCampaignSchema.parse(body); + + const campaign = await prisma.campaign.update({ + where: { id }, + data: validatedData, + }); + + return NextResponse.json(campaign); + } catch (error) { + console.error('Error updating campaign:', 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; + + // Require permission + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CAMPAIGNS); + + await prisma.campaign.delete({ + where: { id }, + }); + + return NextResponse.json({ message: 'Campaign deleted' }); + } catch (error) { + console.error('Error deleting campaign:', 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/route.ts b/app/api/[orgId]/campaigns/route.ts new file mode 100644 index 00000000..f88462d7 --- /dev/null +++ b/app/api/[orgId]/campaigns/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/lib/auth'; +import prisma from '@/lib/prisma'; +import { createCampaignSchema } 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; + + // Check if user has access to the org (at least CREATOR) + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const campaigns = await prisma.campaign.findMany({ + where: { organizationId: orgId }, + include: { + contents: true, + schedules: true, + }, + }); + + return NextResponse.json(campaigns); + } catch (error) { + console.error('Error fetching campaigns:', 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; + + // Require BRAND_OWNER or ADMIN to create campaigns + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CAMPAIGNS); + + const body = await request.json(); + const validatedData = createCampaignSchema.parse(body); + + const campaign = await prisma.campaign.create({ + data: { + ...validatedData, + organizationId: orgId, + }, + }); + + return NextResponse.json(campaign, { status: 201 }); + } catch (error) { + console.error('Error creating campaign:', 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/[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..a912ddc8 --- /dev/null +++ b/app/api/[orgId]/content/route.ts @@ -0,0 +1,83 @@ +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({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + const { searchParams } = new URL(request.url); + const campaignId = searchParams.get('campaignId'); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_CONTENT); + + const where = campaignId + ? { campaign: { organizationId: orgId }, campaignId } + : { campaign: { organizationId: orgId } }; + + const contents = await prisma.content.findMany({ + where, + include: { + campaign: true, + assets: true, + }, + }); + + return NextResponse.json(contents); + } catch (error) { + console.error('Error fetching contents:', 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.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({ error: 'Campaign not found' }, { status: 404 }); + } + + const content = await prisma.content.create({ + data: validatedData, + include: { + campaign: true, + assets: true, + }, + }); + + return NextResponse.json(content, { status: 201 }); + } catch (error) { + console.error('Error creating 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/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..af9afec9 --- /dev/null +++ b/app/api/[orgId]/schedules/route.ts @@ -0,0 +1,82 @@ +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({ error: 'Unauthorized' }, { status: 401 }); + } + + const { orgId } = params; + const { searchParams } = new URL(request.url); + const campaignId = searchParams.get('campaignId'); + + await requirePermission(session.user.id, orgId, PERMISSIONS.MANAGE_SCHEDULES); + + const where = campaignId + ? { campaign: { organizationId: orgId }, campaignId } + : { campaign: { organizationId: orgId } }; + + const schedules = await prisma.schedule.findMany({ + where, + include: { + campaign: true, + content: true, + }, + }); + + return NextResponse.json(schedules); + } catch (error) { + console.error('Error fetching schedules:', 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.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({ error: 'Campaign not found' }, { status: 404 }); + } + + const schedule = await prisma.schedule.create({ + data: validatedData, + include: { + campaign: true, + }, + }); + + return NextResponse.json(schedule, { status: 201 }); + } catch (error) { + console.error('Error creating 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/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/api/onboarding/brand/route.ts b/app/api/onboarding/brand/route.ts new file mode 100644 index 00000000..e5ebb9ab --- /dev/null +++ b/app/api/onboarding/brand/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import type { InternalRole } from '@prisma/client'; +import { auth } from '@/lib/auth'; + +const bodySchema = z.object({ + brandName: z.string().min(2).max(100), + inviteCreatorEmail: z.string().email().optional(), + internalRole: z + .union([ + z.literal('DESIGNER'), + z.literal('CONTENT'), + z.literal('ACCOUNT'), + z.literal('MANAGER'), + ]) + .nullish(), +}); + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const userId = session.user.id; + + const json = await req.json().catch(() => ({})); + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + const { brandName, inviteCreatorEmail, internalRole } = parsed.data; + + // Resolve or create default organization + const existingMembership = await prisma.membership.findFirst({ where: { userId } }); + let organizationId: string; + if (existingMembership) { + organizationId = existingMembership.organizationId; + } else { + const user = await prisma.user.findUnique({ where: { id: userId } }); + const org = await prisma.organization.create({ + data: { name: `Workspace for ${user?.name || user?.email || 'user'}` }, + }); + organizationId = org.id; + } + + const brand = await prisma.brand.create({ data: { name: brandName, organizationId } }); + + // Ensure membership for current user as BRAND_OWNER + await prisma.membership.upsert({ + where: { userId_organizationId: { userId, organizationId } }, + update: { role: 'BRAND_OWNER', internalRole: (internalRole as InternalRole | null) ?? null }, + create: { + userId, + organizationId, + role: 'BRAND_OWNER', + internalRole: (internalRole as InternalRole | null) ?? null, + }, + }); + + if (inviteCreatorEmail) { + // Create or find creator user + let invited = await prisma.user.findUnique({ where: { email: inviteCreatorEmail } }); + if (!invited) { + invited = await prisma.user.create({ + data: { email: inviteCreatorEmail, name: inviteCreatorEmail.split('@')[0] }, + }); + } + // Ensure creator profile + const creator = await prisma.creatorProfile.upsert({ + where: { userId: invited.id }, + update: {}, + create: { userId: invited.id }, + }); + // Ensure membership of invited creator to org + await prisma.membership.upsert({ + where: { userId_organizationId: { userId: invited.id, organizationId } }, + update: { role: 'CREATOR' }, + create: { userId: invited.id, organizationId, role: 'CREATOR' }, + }); + // Link creator to brand + await prisma.creatorBrand.upsert({ + where: { brandId_creatorProfileId: { brandId: brand.id, creatorProfileId: creator.id } }, + update: {}, + create: { brandId: brand.id, creatorProfileId: creator.id }, + }); + } + + return NextResponse.json({ ok: true, organizationId }); +} diff --git a/app/api/onboarding/creator/route.ts b/app/api/onboarding/creator/route.ts new file mode 100644 index 00000000..456e849c --- /dev/null +++ b/app/api/onboarding/creator/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import prisma from '@/lib/prisma'; +import type { InternalRole } from '@prisma/client'; +import { auth } from '@/lib/auth'; + +const bodySchema = z.object({ + createBrand: z + .object({ + name: z.string().min(2).max(100), + }) + .optional(), + internalRole: z + .union([ + z.literal('DESIGNER'), + z.literal('CONTENT'), + z.literal('ACCOUNT'), + z.literal('MANAGER'), + ]) + .nullish(), +}); + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const userId = session.user.id; + + const json = await req.json().catch(() => ({})); + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + const { createBrand, internalRole } = parsed.data; + + // Resolve or create default organization + const existingMembership = await prisma.membership.findFirst({ where: { userId } }); + let organizationId: string; + if (existingMembership) { + organizationId = existingMembership.organizationId; + } else { + const user = await prisma.user.findUnique({ where: { id: userId } }); + const org = await prisma.organization.create({ + data: { name: `Workspace for ${user?.name || user?.email || 'user'}` }, + }); + organizationId = org.id; + } + + const creator = await prisma.creatorProfile.upsert({ + where: { userId }, + update: {}, + create: { userId }, + }); + + // Ensure membership with CREATOR role + await prisma.membership.upsert({ + where: { userId_organizationId: { userId, organizationId } }, + update: { role: 'CREATOR', internalRole: (internalRole as InternalRole | null) ?? null }, + create: { + userId, + organizationId, + role: 'CREATOR', + internalRole: (internalRole as InternalRole | null) ?? null, + }, + }); + + if (createBrand) { + const brand = await prisma.brand.create({ data: { name: createBrand.name, organizationId } }); + // Link creator to brand + await prisma.creatorBrand.upsert({ + where: { brandId_creatorProfileId: { brandId: brand.id, creatorProfileId: creator.id } }, + update: {}, + create: { brandId: brand.id, creatorProfileId: creator.id }, + }); + } + + return NextResponse.json({ ok: true, 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/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 ( +
+ ); + } +} 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