diff --git a/.env.local b/.env.local deleted file mode 100644 index 34735c3..0000000 --- a/.env.local +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_SUPABASE_URL=https://fawhdeawrihomivctrnw.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZhd2hkZWF3cmlob21pdmN0cm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYyMzM4NzQsImV4cCI6MjA4MTgwOTg3NH0.ttuR5PkOYa1lwJCh_DJMHEO9LweAxyRUtoWNS8AlF_o \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5340dd9..76a9cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# env file +.env.local + # dependencies /node_modules /.pnp @@ -32,7 +35,24 @@ yarn-error.log* # vercel .vercel +.vscode # typescript *.tsbuildinfo next-env.d.ts + +# python +__pycache__/ +*.py[cod] +ML/env/ +.env +venv/ +env/ +ML/my_setfit_model/ +ML/skill_matcher_model/ +*.safetensors + +# Retrained ML Models +ML/my_setfit_model_critical/ + +/supabase \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..245955f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,183 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# TaskFlow + +TaskFlow is a Next.js project management workspace with Supabase-backed auth/data, guarded login via ALTCHA, and in-app ML features for prioritization, assignment, wellness, and bottleneck analysis. + +## Current App Surface + +- Public landing page at `/` +- Guided Supabase credential setup at `/setup` +- ALTCHA-gated auth at `/login` +- Authenticated home at `/dashboard` +- Team management at `/team` +- Workspace settings and local Env Vault at `/settings` +- Per-project workspace at `/projects/[id]` + +Inside a project, the current UI includes: + +- Kanban board +- Backlog +- Calendar +- Timeline +- Summary and reports +- Team chat and direct messages +- Time tracking +- Forms builder and responses +- Pages/documents +- Shortcuts +- Code/repository links +- Deployments +- Video room +- ML recommendations and bottleneck alerts + +## Tech Stack + +| Layer | Technology | +| --- | --- | +| Framework | Next.js 16 App Router | +| UI | React 19, TypeScript, Tailwind CSS | +| State/Contexts | Custom React contexts for auth, theme, and timers | +| Data/Auth | Supabase | +| CAPTCHA | ALTCHA | +| ML | In-app TypeScript engine plus `@xenova/transformers` | +| Charts | Recharts | +| Motion | Framer Motion | +| Drag and Drop | `@dnd-kit/*` | +| Icons | Lucide React | + +## Main Features + +### Authentication and Access + +- Email/password sign-up and sign-in +- Google and GitHub OAuth +- ALTCHA verification required before auth actions +- Shared authenticated layout that routes signed-in users into the workspace +- Role-aware user and project management flows + +### Project Operations + +- Project creation and membership management +- Task CRUD with comments, priorities, assignees, tags, due dates, and status updates +- Kanban, backlog, calendar, and timeline views +- Notifications, activity feed, and per-user history +- Direct messages, project room chat, replies, reactions, and attachments +- Time entry and time-tracking summaries + +### AI and ML + +- Browser-side draft assistance through [`lib/ml-browser.ts`](./lib/ml-browser.ts) +- Server-side scoring and analytics through [`lib/ml-engine.ts`](./lib/ml-engine.ts) +- Transformer-based assignment analysis through [`lib/ml-transformers.ts`](./lib/ml-transformers.ts) +- Priority prediction +- Assignment recommendations +- Bottleneck detection +- Workload and wellness analysis +- Task clustering and recommendation endpoints + +### Settings and Developer Workflow + +- Workspace settings for profile, theme, AI preferences, notifications, and security +- Frontend-only local Env Vault in settings, encrypted with Web Crypto and stored in device local storage +- Guided `/setup` page for validating Supabase credentials before login +- API routes for projects, tasks, comments, forms, documents, notifications, meetings, GitHub data, ML, and admin checks + +## Project Structure + +```text +task-flow/ +|- app/ # App Router pages and route handlers +| |- api/ # Backend endpoints used by the app UI +| |- dashboard/ # Authenticated dashboard +| |- login/ # ALTCHA-gated authentication +| |- projects/[id]/ # Per-project workspace +| |- settings/ # Workspace settings and local Env Vault +| |- setup/ # Supabase onboarding flow +| `- team/ # Team management +|- components/ # UI views, layout, modals, forms, charts +|- contexts/ # Auth, theme, and timer providers +|- lib/ # Supabase client, DB access, ML logic, utilities +|- public/ # Static assets +|- types/ # Shared TypeScript declarations +`- ML/ # Legacy Python experiments and archived model assets +``` + +## Environment Variables + +Create a `.env.local` file in the project root: + +```env +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key +ALTCHA_HMAC_SECRET=your_altcha_hmac_secret +ALTCHA_HMAC_KEY_SECRET=optional_altcha_key_secret +``` + +Optional variables may still be useful depending on the flows you use: + +- `NEXT_PUBLIC_SITE_URL` for explicit site URL resolution in auth redirects +- `NEXT_PUBLIC_VERCEL_URL` when running on Vercel +- SMTP-related variables if you wire up email delivery for OTP/member notifications ## Getting Started -First, run the development server: +### Prerequisites + +- Node.js 18+ +- npm +- A Supabase project with the tables/RPCs this app expects + +### Install + +```bash +npm install +``` + +### Run the app ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open `http://localhost:3000`. + +Recommended first-run flow: + +1. Add the required variables to `.env.local`. +2. Start the dev server. +3. Open `/setup` to validate the Supabase URL and anon key. +4. Apply the SQL you need in your Supabase project. +5. Continue to `/login` and sign in. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Database and Supabase Notes -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- Runtime Supabase access lives in [`lib/supabase.ts`](./lib/supabase.ts). +- The repository still contains a top-level `supabase/` folder for migration/history material when present locally, but the app runtime depends on the client/config in `lib/`, not on that folder itself. +- The checked-in `.gitignore` currently ignores `/supabase`, so migration files may exist locally without being committed. +- Auth redirect URL resolution is handled through [`lib/site-url.ts`](./lib/site-url.ts). -## Learn More +## ML Runtime Notes -To learn more about Next.js, take a look at the following resources: +- The live app does not require a separate Python ML server. +- Current runtime ML paths are TypeScript-based and live under `lib/`. +- The `ML/` directory is legacy research/training material, not part of the current Next.js runtime. +- `next.config.ts` explicitly allows external server packages needed by the transformer/ONNX path. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Scripts + +```bash +npm run dev +npm run build +npm run start +npm run lint +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Known Repo Notes -## Deploy on Vercel +- `npm run lint` may be noisy if local repo state references paths that are no longer present. +- The settings Env Vault is frontend-only and stores encrypted values in local browser storage on the current device. +- Some helper/setup copy in the UI still assumes a local open-source/self-hosted style workflow around Supabase credentials. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## License -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +MIT diff --git a/__tests__/gap-features.test.ts b/__tests__/gap-features.test.ts deleted file mode 100644 index 8870a8a..0000000 --- a/__tests__/gap-features.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Unit tests for gap features (run with Jest or Node test runner) - -// Burnout Detection Logic Tests -describe('Burnout Detection', () => { - const calculateBurnoutRisk = (taskCount: number, maxWorkload: number, wellnessScore: number) => { - if (taskCount >= maxWorkload || wellnessScore < 50) return 'High'; - if (taskCount >= maxWorkload - 1 || wellnessScore < 70) return 'Medium'; - return 'Low'; - }; - - it('should flag High risk when at max capacity', () => { - const risk = calculateBurnoutRisk(5, 5, 80); - expect(risk).toBe('High'); - }); - - it('should flag High risk when wellness < 50', () => { - const risk = calculateBurnoutRisk(2, 5, 40); - expect(risk).toBe('High'); - }); - - it('should flag Medium risk when near capacity', () => { - const risk = calculateBurnoutRisk(4, 5, 80); - expect(risk).toBe('Medium'); - }); - - it('should flag Medium risk when wellness < 70', () => { - const risk = calculateBurnoutRisk(2, 5, 65); - expect(risk).toBe('Medium'); - }); - - it('should flag Low risk when healthy capacity and wellness', () => { - const risk = calculateBurnoutRisk(2, 5, 85); - expect(risk).toBe('Low'); - }); -}); - -// Bottleneck Detection Logic Tests -describe('Bottleneck Detection', () => { - const COLUMN_OVERFLOW_THRESHOLD = 8; - const STALE_THRESHOLD_DAYS = 5; - - it('should identify process bottleneck when column overflows', () => { - const columnTaskCount = 10; - const isProcessBottleneck = columnTaskCount >= COLUMN_OVERFLOW_THRESHOLD; - expect(isProcessBottleneck).toBe(true); - }); - - it('should not flag process bottleneck below threshold', () => { - const columnTaskCount = 6; - const isProcessBottleneck = columnTaskCount >= COLUMN_OVERFLOW_THRESHOLD; - expect(isProcessBottleneck).toBe(false); - }); - - it('should identify person bottleneck when user has stale tasks', () => { - const staleTaskCount = 4; - const isPersonBottleneck = staleTaskCount >= 3; - expect(isPersonBottleneck).toBe(true); - }); - - it('should calculate severity correctly', () => { - const getSeverity = (count: number, threshold: number) => { - if (count >= threshold * 1.5) return 'high'; - if (count >= threshold) return 'medium'; - return 'low'; - }; - - expect(getSeverity(12, 8)).toBe('high'); - expect(getSeverity(8, 8)).toBe('medium'); - expect(getSeverity(5, 8)).toBe('low'); - }); -}); - -// ML Task Recommendation Logic Tests -describe('ML Task Recommendation', () => { - const PRIORITY_SCORE = { Critical: 100, High: 70, Medium: 40, Low: 20 }; - - const calculateTaskScore = ( - priority: string, - isOverdue: boolean, - dueSoon: boolean, - inProgress: boolean - ) => { - let score = PRIORITY_SCORE[priority as keyof typeof PRIORITY_SCORE] || 20; - if (isOverdue) score += 60; - if (dueSoon) score += 35; - if (inProgress) score += 30; - return score; - }; - - it('should prioritize overdue tasks highest', () => { - const overdueScore = calculateTaskScore('Medium', true, false, false); - const criticalScore = calculateTaskScore('Critical', false, false, false); - expect(overdueScore).toBeGreaterThan(criticalScore); - }); - - it('should add bonus for in-progress tasks', () => { - const inProgressScore = calculateTaskScore('Medium', false, false, true); - const todoScore = calculateTaskScore('Medium', false, false, false); - expect(inProgressScore).toBe(todoScore + 30); - }); - - it('should add bonus for due-soon tasks', () => { - const dueSoonScore = calculateTaskScore('Low', false, true, false); - const normalScore = calculateTaskScore('Low', false, false, false); - expect(dueSoonScore).toBe(normalScore + 35); - }); - - it('should select highest scoring task as recommendation', () => { - const tasks = [ - { id: 1, score: calculateTaskScore('Low', false, false, false) }, - { id: 2, score: calculateTaskScore('High', true, false, false) }, - { id: 3, score: calculateTaskScore('Critical', false, false, false) }, - ]; - const recommended = tasks.sort((a, b) => b.score - a.score)[0]; - expect(recommended.id).toBe(2); // High priority + overdue - }); -}); diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts index 3945cd6..25e2ffe 100644 --- a/app/api/activity/route.ts +++ b/app/api/activity/route.ts @@ -1,9 +1,18 @@ import { db } from '@/lib/db'; import { NextResponse } from 'next/server'; -export async function GET() { +export async function GET(request: Request) { try { - const logs = await db.getActivityLogs(); + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + let logs; + if (userId) { + logs = await db.getActivityLogsForUser(userId); + } else { + logs = await db.getActivityLogs(); + } + return NextResponse.json(logs); } catch (error) { console.error('Error fetching activity logs:', error); diff --git a/app/api/admin/rpc-check/route.ts b/app/api/admin/rpc-check/route.ts new file mode 100644 index 0000000..162f0c1 --- /dev/null +++ b/app/api/admin/rpc-check/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getSupabase } from '@/lib/supabase'; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : 'Unknown error'; + +export async function GET() { + try { + const { data, error } = await getSupabase().rpc('get_admin_create_user_v2_definition'); + + if (error) { + return NextResponse.json( + { ok: false, error: error.message }, + { status: 500 } + ); + } + + const definition = typeof data === 'string' ? data : ''; + const usesUsersTable = definition.includes('public.users'); + + return NextResponse.json({ + ok: true, + rpc: 'admin_create_user_v2', + usesUsersTable, + hasDefinition: definition.length > 0, + definitionSnippet: definition.slice(0, 240) + }); + } catch (error) { + return NextResponse.json( + { ok: false, error: getErrorMessage(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/ai/assign/route.ts b/app/api/ai/assign/route.ts index 3393b79..15cb8a0 100644 --- a/app/api/ai/assign/route.ts +++ b/app/api/ai/assign/route.ts @@ -1,241 +1,83 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { User, Task, Priority } from '@/types'; +import { User, Task } from '@/types'; +import { analyzeAndAssignTask, type CandidateInput } from '@/lib/ml-transformers'; -// Heuristic weights -const WEIGHTS = { - SKILL_MATCH: 40, // Base score per matching skill - SKILL_PARTIAL_MATCH: 15, // Partial/synonym match - WORKLOAD_PENALTY: 8, // Per weighted task - WELLNESS_FACTOR: 0.4, // Multiplier for wellness score - PRIORITY_BONUS: 25, // Bonus for critical tasks with skill match - AVAILABILITY_BONUS: 15, // Bonus for users under max workload -}; - -// Skill synonyms and related terms for better matching -const SKILL_SYNONYMS: Record = { - 'frontend': ['ui', 'ux', 'react', 'vue', 'angular', 'css', 'html', 'javascript', 'typescript', 'web', 'interface', 'design'], - 'backend': ['api', 'server', 'node', 'python', 'java', 'database', 'sql', 'rest', 'graphql', 'microservices'], - 'design': ['ui', 'ux', 'figma', 'sketch', 'prototype', 'wireframe', 'visual', 'graphics', 'creative'], - 'database': ['sql', 'postgres', 'mysql', 'mongodb', 'data', 'schema', 'query', 'storage'], - 'devops': ['ci', 'cd', 'docker', 'kubernetes', 'aws', 'azure', 'gcp', 'deployment', 'infrastructure'], - 'testing': ['qa', 'test', 'automation', 'selenium', 'jest', 'cypress', 'quality'], - 'ai': ['machine learning', 'ml', 'deep learning', 'neural', 'nlp', 'model', 'training', 'prediction'], - 'machine learning': ['ai', 'ml', 'deep learning', 'neural', 'model', 'training', 'data science'], - 'python': ['django', 'flask', 'pandas', 'numpy', 'scripting'], - 'react': ['frontend', 'javascript', 'typescript', 'hooks', 'components', 'redux', 'nextjs'], - 'product': ['planning', 'roadmap', 'requirements', 'stakeholder', 'strategy', 'management'], -}; - -// Priority weights for workload calculation -const PRIORITY_WEIGHT: Record = { - 'Critical': 3, - 'High': 2, - 'Medium': 1.5, - 'Low': 1, -}; +// Force Node.js runtime (required for @xenova/transformers ONNX) +export const runtime = 'nodejs'; interface AssignRequest { title: string; description: string; priority: string; + projectId?: string; } -// Extract keywords from task text -function extractKeywords(text: string): string[] { - const normalized = text.toLowerCase(); - // Split by common delimiters and filter out short words - const words = normalized.split(/[\s,.\-_:;!?()[\]{}]+/) - .filter(word => word.length > 2); - return [...new Set(words)]; -} - -// Check if a skill matches the task (exact, partial, or synonym) -function getSkillMatchScore(skill: string, taskKeywords: string[], taskText: string): number { - const skillLower = skill.toLowerCase(); - - // Exact match in keywords - if (taskKeywords.includes(skillLower)) { - return WEIGHTS.SKILL_MATCH; - } - - // Check if skill appears in full text (allows multi-word skills) - if (taskText.includes(skillLower)) { - return WEIGHTS.SKILL_MATCH; - } - - // Check synonyms - const synonyms = SKILL_SYNONYMS[skillLower] || []; - for (const synonym of synonyms) { - if (taskKeywords.includes(synonym) || taskText.includes(synonym)) { - return WEIGHTS.SKILL_PARTIAL_MATCH; - } - } - - // Check if this skill is a synonym of something in the task - for (const [key, syns] of Object.entries(SKILL_SYNONYMS)) { - if (syns.includes(skillLower) && (taskKeywords.includes(key) || taskText.includes(key))) { - return WEIGHTS.SKILL_PARTIAL_MATCH; - } - } - - // Partial match (skill is substring of keyword or vice versa) - for (const keyword of taskKeywords) { - if (skillLower.includes(keyword) || keyword.includes(skillLower)) { - return WEIGHTS.SKILL_PARTIAL_MATCH * 0.5; - } - } - - return 0; -} - -// Calculate weighted workload based on task priorities -function calculateWeightedWorkload(tasks: Task[]): number { - return tasks.reduce((total, task) => { - const weight = PRIORITY_WEIGHT[task.priority] || 1; - return total + weight; - }, 0); -} - +// API Route Handler export async function POST(request: Request) { try { const body: AssignRequest = await request.json(); - const { title, description, priority } = body; + const { title, description } = body; - // 1. Fetch Users and Tasks const users = await db.getUsers(); const allTasks = await db.getTasks(); - const taskText = (title + ' ' + (description || '')).toLowerCase(); - const taskKeywords = extractKeywords(title + ' ' + (description || '')); - - // 2. Calculate Stats per User - const candidates = users.map(user => { - // A. Workload Analysis with priority weighting - const activeTasks = allTasks.filter(t => - t.assigneeId === user.id && - (t.status === 'To Do' || t.status === 'In Progress') - ); - const taskCount = activeTasks.length; - const weightedWorkload = calculateWeightedWorkload(activeTasks); - - // B. Enhanced Skill Matching - const userSkills = user.skills || []; - let totalSkillScore = 0; - const matchingSkills: string[] = []; - const partialMatches: string[] = []; - - for (const skill of userSkills) { - const matchScore = getSkillMatchScore(skill, taskKeywords, taskText); - if (matchScore >= WEIGHTS.SKILL_MATCH) { - matchingSkills.push(skill); - totalSkillScore += matchScore; - } else if (matchScore > 0) { - partialMatches.push(skill); - totalSkillScore += matchScore; - } - } - - // C. Health & Burnout Check - const wellness = user.wellnessScore || 80; - const healthScore = wellness * WEIGHTS.WELLNESS_FACTOR; - - // D. Availability Check - const maxWorkload = user.maxWorkload || 5; - const isUnderCapacity = taskCount < maxWorkload; - const availabilityBonus = isUnderCapacity ? WEIGHTS.AVAILABILITY_BONUS : 0; + const taskText = `${title} ${description || ''}`.trim(); - // Calculate Burnout Risk - let burnoutRisk: 'Low' | 'Medium' | 'High' = 'Low'; - if (taskCount >= maxWorkload || wellness < 50) burnoutRisk = 'High'; - else if (taskCount >= maxWorkload - 1 || wellness < 70) burnoutRisk = 'Medium'; - - // E. Final Score Calculation - let score = totalSkillScore - - (weightedWorkload * WEIGHTS.WORKLOAD_PENALTY) - + healthScore - + availabilityBonus; - - // Critical Priority Logic: Boost skilled candidates for urgent tasks - if (priority === 'Critical' && matchingSkills.length > 0) { - score += WEIGHTS.PRIORITY_BONUS; - } - - // Preference for users with auto-assign enabled - if (user.autoAssign === true) { - score += 5; - } + // Build candidates with real wellness data (same as before) + const now = new Date(); + const candidates: CandidateInput[] = users.map(u => { + const userTasks = allTasks.filter(t => t.assigneeId === u.id && t.status !== 'Done'); + const highPriorityCount = userTasks.filter(t => t.priority === 'High' || t.priority === 'Critical').length; + const criticalUrgencyCount = userTasks.filter(t => t.dueDate && new Date(t.dueDate) <= now).length; return { - user, - score, - details: { - taskCount, - weightedWorkload: Math.round(weightedWorkload * 10) / 10, - matchingSkills, - partialMatches, - allSkills: userSkills, - wellness, - burnoutRisk, - isUnderCapacity, - maxWorkload - } + id: u.id, + name: u.name, + role: u.role, + skills: u.skills || [], + wellness_data: { + active_tasks: userTasks.length, + high_priority_count: highPriorityCount, + critical_urgency_count: criticalUrgencyCount, + }, }; }); - // 3. Sort Candidates by score - candidates.sort((a, b) => b.score - a.score); - - const bestMatch = candidates[0]; + try { + const result = await analyzeAndAssignTask(taskText, 'To Do', 7, 0, candidates); - if (!bestMatch) { - return NextResponse.json({ error: 'No candidates found' }, { status: 404 }); - } - - // 4. Generate Detailed Reasoning - const { user, details } = bestMatch; - let reason = ''; - - // Skill-based reasoning - if (details.matchingSkills.length > 0) { - reason = `**${user.name}** is recommended due to their expertise in **${details.matchingSkills.join(', ')}**. `; - } else if (details.partialMatches.length > 0) { - reason = `**${user.name}** has related skills (**${details.partialMatches.join(', ')}**) that align with this task. `; - } else { - reason = `**${user.name}** is the best available candidate based on current workload. `; - } - - // Workload and capacity info - if (details.isUnderCapacity) { - reason += `They have capacity (${details.taskCount}/${details.maxWorkload} active tasks). `; - } else { - reason += `Note: They are at capacity (${details.taskCount}/${details.maxWorkload} tasks). `; - } + const bestMatch = result.suggested_assignees[0]; + if (!bestMatch) { + throw new Error('No suggestions returned from ML engine'); + } - // Burnout warning - if (details.burnoutRisk === 'High') { - reason = `⚠️ **Warning**: ${user.name} has a **High Burnout Risk** (${details.taskCount} active tasks, wellness: ${details.wellness}%). Consider distributing workload. ` + reason; - } else if (details.burnoutRisk === 'Medium') { - reason += `⚡ Moderate workload - monitor capacity.`; - } else { - reason += `Wellness score: ${details.wellness}%.`; + const suggestedUser = users.find(u => u.id === bestMatch.id); + + return NextResponse.json({ + suggestedUser, + candidateId: bestMatch.id, + allCandidates: result.suggested_assignees.map(c => ({ + name: c.name, + id: c.id, + score: Math.min(100, Math.round(c.combined_ranking_score)), + match_percentage: c.match_percentage, + wellness_score: c.wellness_score, + wellness_status: c.wellness_status, + risk: c.wellness_score < 40 ? 'High' : (c.wellness_score < 70 ? 'Medium' : 'Low'), + matchingSkills: c.matching_skills || [], + partialMatches: [], + })), + analysis: result.analysis, + mlPowered: true, + }); + } catch (mlError) { + console.error('ML Assignment failed:', mlError); + return NextResponse.json( + { error: 'AI Assignment service is currently unavailable. Please try again later or assign manually.', status: 'unavailable' }, + { status: 503 }, + ); } - - return NextResponse.json({ - suggestedUser: user, - candidateId: user.id, - reasoning: reason, - allCandidates: candidates.map(c => ({ - name: c.user.name, - id: c.user.id, - score: Math.round(c.score), - risk: c.details.burnoutRisk, - taskCount: c.details.taskCount, - matchingSkills: c.details.matchingSkills, - partialMatches: c.details.partialMatches - })) - }); - } catch (error) { console.error('AI Assignment Error:', error); return NextResponse.json({ error: 'Failed to assign task' }, { status: 500 }); diff --git a/app/api/altcha/challenge/route.ts b/app/api/altcha/challenge/route.ts new file mode 100644 index 0000000..f714e21 --- /dev/null +++ b/app/api/altcha/challenge/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { createChallenge, randomInt } from 'altcha-lib'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export async function GET() { + try { + const hmacSecret = process.env.ALTCHA_HMAC_SECRET; + const hmacKeySecret = process.env.ALTCHA_HMAC_KEY_SECRET; + + if (!hmacSecret) { + return NextResponse.json( + { error: 'ALTCHA_HMAC_SECRET is not configured.' }, + { status: 500 } + ); + } + + const challenge = await createChallenge({ + algorithm: 'PBKDF2/SHA-256', + cost: 5_000, + counter: randomInt(10_000, 5_000), + deriveKey, + expiresAt: Math.floor(Date.now() / 1000) + 5 * 60, + hmacSignatureSecret: hmacSecret, + hmacKeySignatureSecret: hmacKeySecret, + }); + + return NextResponse.json(challenge, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + }); + } catch (error) { + console.error('ALTCHA challenge error:', error); + return NextResponse.json( + { error: 'Failed to create ALTCHA challenge.' }, + { status: 500 } + ); + } +} diff --git a/app/api/altcha/verify/route.ts b/app/api/altcha/verify/route.ts new file mode 100644 index 0000000..f86a9c8 --- /dev/null +++ b/app/api/altcha/verify/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server'; +import { verifySolution, type Payload, type PayloadV1 } from 'altcha-lib'; +import { verifySolution as verifySolutionV1 } from 'altcha-lib/v1'; +import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; + +function decodePayload(encodedPayload: string): Payload | PayloadV1 | null { + try { + const decoded = Buffer.from(encodedPayload, 'base64').toString('utf8'); + return JSON.parse(decoded) as Payload | PayloadV1; + } catch { + return null; + } +} + +function isPayloadV2(payload: Payload | PayloadV1): payload is Payload { + return typeof payload === 'object' && payload !== null && 'challenge' in payload && 'solution' in payload; +} + +export async function POST(request: Request) { + try { + const { payload } = await request.json(); + const hmacSecret = process.env.ALTCHA_HMAC_SECRET; + const hmacKeySecret = process.env.ALTCHA_HMAC_KEY_SECRET; + + if (!hmacSecret) { + return NextResponse.json( + { success: false, error: 'ALTCHA_HMAC_SECRET is not configured.' }, + { status: 500 } + ); + } + + if (!payload || typeof payload !== 'string') { + return NextResponse.json( + { success: false, error: 'Missing ALTCHA payload.' }, + { status: 400 } + ); + } + + const parsedPayload = decodePayload(payload); + if (!parsedPayload) { + return NextResponse.json( + { success: false, error: 'Invalid ALTCHA payload encoding.' }, + { status: 400 } + ); + } + + if (isPayloadV2(parsedPayload)) { + const result = await verifySolution({ + challenge: parsedPayload.challenge, + solution: parsedPayload.solution, + deriveKey, + hmacSignatureSecret: hmacSecret, + hmacKeySignatureSecret: hmacKeySecret, + }); + + if (!result.verified) { + return NextResponse.json( + { success: false, error: 'ALTCHA verification failed.' }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true }); + } + + const v1Verified = await verifySolutionV1(payload, hmacSecret, true); + if (!v1Verified) { + return NextResponse.json( + { success: false, error: 'ALTCHA verification failed.' }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('ALTCHA verification error:', error); + return NextResponse.json( + { success: false, error: 'Internal server error during ALTCHA verification.' }, + { status: 500 } + ); + } +} diff --git a/app/api/analytics/bottlenecks/route.ts b/app/api/analytics/bottlenecks/route.ts index a069329..8c14e22 100644 --- a/app/api/analytics/bottlenecks/route.ts +++ b/app/api/analytics/bottlenecks/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { Task } from '@/types'; +import { analyzeWellness, assignTaskWithEngine, getTaskLoadStats } from '@/lib/ml-engine'; + -const STALE_THRESHOLD_DAYS = 5; -const COLUMN_OVERFLOW_THRESHOLD = 8; interface BottleneckResult { type: 'person' | 'process'; @@ -12,8 +12,26 @@ interface BottleneckResult { avgDaysStuck: number; recommendation: string; severity: 'low' | 'medium' | 'high'; + taskIds?: string[]; +} + +interface RebalanceSuggestion { + taskId: string; + taskTitle: string; + taskPriority: string; + fromUser: { id: string; name: string; wellness: number }; + toUser: { + id: string; + name: string; + skillMatch: number; + wellness: number; + wellnessStatus: string; + matchingSkills: string[]; + }; + requiredSkills: string[]; } +// Helper function calculateAvgDays(tasks: Task[], now: Date): number { if (tasks.length === 0) return 0; const total = tasks.reduce((sum, t) => { @@ -24,80 +42,377 @@ function calculateAvgDays(tasks: Task[], now: Date): number { return Math.round(total / tasks.length); } -export async function GET() { - try { - const tasks = await db.getTasks(); - const users = await db.getUsers(); - const now = new Date(); - - const bottlenecks: BottleneckResult[] = []; - - // PROCESS BOTTLENECK: Column overflow detection - const columns: Array<{ status: string; label: string }> = [ - { status: 'To Do', label: 'To Do' }, - { status: 'In Progress', label: 'In Progress' }, - { status: 'Review', label: 'Review' } - ]; - - columns.forEach(({ status, label }) => { - const columnTasks = tasks.filter(t => t.status === status); - const avgDays = calculateAvgDays(columnTasks, now); - - if (columnTasks.length >= COLUMN_OVERFLOW_THRESHOLD) { - const severity = columnTasks.length >= COLUMN_OVERFLOW_THRESHOLD * 1.5 ? 'high' : - columnTasks.length >= COLUMN_OVERFLOW_THRESHOLD ? 'medium' : 'low'; - - bottlenecks.push({ - type: 'process', - location: label, - taskCount: columnTasks.length, - avgDaysStuck: avgDays, - severity, - recommendation: `"${label}" column has ${columnTasks.length} tasks (avg ${avgDays} days). Consider adding capacity, breaking down tasks, or refining the workflow.` - }); - } +// Rule-based Detection +function detectBottlenecks( + tasks: Task[], + users: { id: string; name: string }[], + thresholds: { stale: number, overflow: number } +) { + const now = new Date(); + const bottlenecks: BottleneckResult[] = []; + + // Process bottlenecks: Column overflow + const columns = [ + { status: 'To Do', label: 'To Do' }, + { status: 'In Progress', label: 'In Progress' }, + { status: 'Review', label: 'Review' } + ]; + + columns.forEach(({ status, label }) => { + const columnTasks = tasks.filter(t => t.status === status); + const avgDays = calculateAvgDays(columnTasks, now); + + if (columnTasks.length >= thresholds.overflow) { + const severity = columnTasks.length >= thresholds.overflow * 1.5 ? 'high' : + columnTasks.length >= thresholds.overflow ? 'medium' : 'low'; + + bottlenecks.push({ + type: 'process', + location: label, + taskCount: columnTasks.length, + avgDaysStuck: avgDays, + severity: severity as 'low' | 'medium' | 'high', + recommendation: `"${label}" column has ${columnTasks.length} tasks piling up.` + }); + } + }); + + // Overdue work detection + const overdueTasks = tasks.filter(t => { + if (!t.dueDate || t.status === 'Done') return false; + return new Date(t.dueDate) < now; + }); + + if (overdueTasks.length > 0) { + const avgOverdue = Math.max(1, Math.round( + overdueTasks.reduce((sum, t) => { + const due = new Date(t.dueDate!); + return sum + Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)); + }, 0) / overdueTasks.length + )); + + bottlenecks.push({ + type: 'process', + location: 'Overdue Work', + taskCount: overdueTasks.length, + avgDaysStuck: avgOverdue, + severity: overdueTasks.length >= 5 ? 'high' : 'medium', + recommendation: `${overdueTasks.length} tasks are overdue (avg ${avgOverdue}d).`, + taskIds: overdueTasks.map(t => t.id) + }); + } + + // Person bottlenecks: Stale tasks per user + users.forEach(user => { + const userTasks = tasks.filter(t => + t.assigneeId === user.id && + t.status !== 'Done' + ); + + const staleTasks = userTasks.filter(t => { + const updatedAt = new Date(t.updatedAt); + const days = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24)); + return days > thresholds.stale; }); - // PERSON BOTTLENECK: User with stale or blocked tasks - users.forEach(user => { - const userTasks = tasks.filter(t => - t.assigneeId === user.id && - t.status !== 'Done' - ); - - const staleTasks = userTasks.filter(t => { - const updatedAt = new Date(t.updatedAt); - const days = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24)); - return days > STALE_THRESHOLD_DAYS; + if (staleTasks.length >= 3) { + const avgDays = calculateAvgDays(staleTasks, now); + const severity = staleTasks.length >= 5 ? 'medium' : + staleTasks.length >= 3 ? 'low' : 'low'; + + bottlenecks.push({ + type: 'person', + location: user.name, + taskCount: staleTasks.length, + avgDaysStuck: avgDays, + severity: severity as 'low' | 'medium' | 'high', + recommendation: `${user.name} has ${staleTasks.length} stale tasks (${avgDays}+ days idle).` }); + } + }); - if (staleTasks.length >= 3) { - const avgDays = calculateAvgDays(staleTasks, now); - const severity = staleTasks.length >= 5 ? 'high' : - staleTasks.length >= 3 ? 'medium' : 'low'; - - bottlenecks.push({ - type: 'person', - location: user.name, - taskCount: staleTasks.length, - avgDaysStuck: avgDays, - severity, - recommendation: `${user.name} has ${staleTasks.length} stale tasks (no updates in ${avgDays}+ days). Check for blockers, offer support, or redistribute work.` - }); - } + // Aging WIP detection + const agingTasks = tasks.filter(t => { + if (t.status === 'Done') return false; + const updatedAt = new Date(t.updatedAt); + const days = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24)); + return days >= thresholds.stale; + }); + + if (agingTasks.length >= 3 && !bottlenecks.some(b => b.location === 'Aging WIP')) { + const avgDays = calculateAvgDays(agingTasks, now); + bottlenecks.push({ + type: 'process', + location: 'Aging WIP', + taskCount: agingTasks.length, + avgDaysStuck: avgDays, + severity: agingTasks.length >= 5 ? 'high' : 'medium', + recommendation: `${agingTasks.length} tasks have been idle for ${thresholds.stale}+ days.` }); + } - // Sort by severity - const severityOrder = { high: 0, medium: 1, low: 2 }; - bottlenecks.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + const severityOrder = { high: 0, medium: 1, low: 2 }; + bottlenecks.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); - return NextResponse.json({ - bottlenecks, - summary: { - processBottlenecks: bottlenecks.filter(b => b.type === 'process').length, - personBottlenecks: bottlenecks.filter(b => b.type === 'person').length, - total: bottlenecks.length + // Health score + const healthPenalty = bottlenecks.reduce((acc, b) => + acc + 10 + (b.severity === 'high' ? 15 : b.severity === 'medium' ? 5 : 0) + (b.taskCount * 2), 0); + const overallHealthScore = Math.max(0, 100 - healthPenalty); + + const healthSummary = bottlenecks.length === 0 + ? "Workflow is healthy." + : overallHealthScore < 50 + ? `Critical: ${bottlenecks.length} bottlenecks detected impacting ${bottlenecks.reduce((sum, b) => sum + b.taskCount, 0)} tasks.` + : `${bottlenecks.length} bottlenecks detected impacting ${bottlenecks.reduce((sum, b) => sum + b.taskCount, 0)} tasks.`; + + return { + bottlenecks, + summary: { + processBottlenecks: bottlenecks.filter(b => b.type === 'process').length, + personBottlenecks: bottlenecks.filter(b => b.type === 'person').length, + total: bottlenecks.length + }, + overallHealthScore, + healthSummary, + }; +} + +// Workload Rebalancing +async function getRebalancingSuggestions(tasks: Task[], users: { id: string; name: string; skills?: string[]; role?: string }[]): Promise { + const now = new Date(); + + // Compute per-user workload data + const userWorkload = new Map(); + + for (const user of users) { + const userActiveTasks = tasks.filter(t => + t.assigneeId === user.id && t.status !== 'Done' + ); + const highPriority = userActiveTasks.filter(t => t.priority === 'High').length; + const critical = userActiveTasks.filter(t => { + if (t.priority === 'Critical') return true; + if (t.dueDate && new Date(t.dueDate) < now) return true; + return false; + }).length; + + userWorkload.set(user.id, { + user, + activeTasks: userActiveTasks, + activeCount: userActiveTasks.length, + highPriorityCount: highPriority, + criticalCount: critical, + }); + } + + // Get wellness scores for all users + const wellnessScores = new Map(); + for (const [userId, data] of userWorkload) { + const result = analyzeWellness({ + activeTasks: data.activeCount, + highPriorityCount: data.highPriorityCount, + criticalUrgencyCount: data.criticalCount, + }); + wellnessScores.set(userId, result.score); + } + + // Find overloaded members (wellness < 60) + const overloaded: Array<{ member: { id: string; name: string; wellness_score: number }; tasks: { id: string; title: string; description: string; priority: string }[] }> = []; + const available: Array<{ id: string; name: string; skills: string[]; wellness_score: number; active_task_count: number; role: string }> = []; + + for (const [userId, data] of userWorkload) { + const wellness = wellnessScores.get(userId) ?? 100; + + if (wellness < 60 && data.activeCount > 3) { + overloaded.push({ + member: { id: userId, name: data.user.name, wellness_score: wellness }, + tasks: data.activeTasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description || '', + priority: t.priority, + })) + }); + } else if (wellness >= 60 && data.activeCount < 5) { + available.push({ + id: userId, + name: data.user.name, + // need to get skills from full users array + skills: (data.user as any).skills || [], + wellness_score: wellness, + active_task_count: data.activeCount, + role: (data.user as any).role || 'Member', + }); + } + } + + if (overloaded.length === 0 || available.length === 0) return []; + + const suggestions: RebalanceSuggestion[] = []; + + for (const entry of overloaded) { + for (const task of entry.tasks) { + const ranked = assignTaskWithEngine({ + title: task.title, + description: task.description, + status: 'To Do', + daysUntilDue: task.priority === 'Critical' ? 1 : task.priority === 'High' ? 3 : 7, + candidates: available.map(member => ({ + id: member.id, + name: member.name, + role: member.role as 'Admin' | 'Manager' | 'Member', + skills: member.skills, + wellness_data: { + active_tasks: member.active_task_count, + high_priority_count: 0, + critical_urgency_count: 0, + } + })), + }); + + const best = ranked.suggested_assignees[0]; + if (!best || best.combined_ranking_score <= 10) { + continue; } + + suggestions.push({ + taskId: task.id, + taskTitle: task.title, + taskPriority: task.priority, + fromUser: { id: entry.member.id, name: entry.member.name, wellness: Math.round(entry.member.wellness_score) }, + toUser: { + id: best.id || '', + name: best.name, + skillMatch: Math.round(best.match_percentage), + wellness: Math.round(best.wellness_score), + wellnessStatus: best.wellness_status, + matchingSkills: best.matching_skills, + }, + requiredSkills: ranked.analysis.detected_skills || [], + }); + } + } + + suggestions.sort((a, b) => b.toUser.skillMatch - a.toUser.skillMatch); + return suggestions.slice(0, 5); +} + +// API Route Handler +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + const projectId = searchParams.get('projectId'); + + let tasks = projectId ? await db.getTasks(projectId) : await db.getTasks(); + const users = await db.getUsers(); + const requester = userId ? await db.getUser(userId) : null; + + if (userId && !requester) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (requester && requester.role !== 'Admin') { + const userProjects = await db.getProjects(requester.id); + const projectIds = new Set(userProjects.map(p => p.id)); + tasks = tasks.filter(t => projectIds.has(t.projectId)); + } + + // Determine dynamic thresholds based on Admin's companySize + let companySize = 'Medium (11-50)'; + const adminUser = users.find(u => u.role === 'Admin'); + if (adminUser && (adminUser as any).companySize) { + companySize = (adminUser as any).companySize; + } + + const thresholds = { + stale: companySize === 'Small (1-10)' ? 3 : companySize === 'Large (50+)' ? 7 : 5, + overflow: companySize === 'Small (1-10)' ? 5 : companySize === 'Large (50+)' ? 15 : 8, + }; + + // 1. Rule-based detection (always works) + const result = detectBottlenecks(tasks, users, thresholds); + + // 2. AI rebalancing (optional) + let rebalanceSuggestions: RebalanceSuggestion[] = []; + let mlPowered = true; + + try { + rebalanceSuggestions = await getRebalancingSuggestions(tasks, users); + } catch (error) { + console.error('AI rebalancing failed:', error); + mlPowered = false; + } + + // 3. Group by project if requester is logged in + if (requester) { + const now = new Date(); + const projects = await db.getProjects(requester.role === 'Admin' ? undefined : requester.id); + const projectMap = new Map(projects.map(p => [p.id, p.name])); + + const grouped = new Map(); + + const ensureGroup = (pid: string | null | undefined) => { + const id = pid || 'unknown'; + if (!grouped.has(id)) { + grouped.set(id, { + projectId: id, + projectName: projectMap.get(id) || (id === 'unknown' ? 'Unassigned' : 'Unknown Project'), + bottlenecks: [], + overdueTasks: [] + }); + } + return grouped.get(id)!; + }; + + // Distribute bottlenecks to project groups (use projectId from tasks if available) + for (const b of result.bottlenecks) { + const matchingProjectId = projectId || (projects.length === 1 ? projects[0].id : null); + if (matchingProjectId) { + const group = ensureGroup(matchingProjectId); + group.bottlenecks.push(b); + } + } + + // Add overdue tasks + tasks.forEach(t => { + if (!t.dueDate || t.status === 'Done') return; + const due = new Date(t.dueDate); + if (Number.isNaN(due.getTime())) return; + if (due >= now) return; + const daysOverdue = Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)); + const group = ensureGroup(t.projectId); + group.overdueTasks.push({ + id: t.id, + title: t.title, + status: t.status, + dueDate: t.dueDate, + daysOverdue + }); + }); + + return NextResponse.json({ + ...result, + mlPowered, + rebalanceSuggestions, + projects: Array.from(grouped.values()) + }); + } + + return NextResponse.json({ + ...result, + mlPowered, + rebalanceSuggestions, }); } catch (error) { console.error('Error detecting bottlenecks:', error); diff --git a/app/api/autocomplete/route.ts b/app/api/autocomplete/route.ts new file mode 100644 index 0000000..e7dcb2f --- /dev/null +++ b/app/api/autocomplete/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : 'Unknown error'; + +export async function GET() { + try { + const data = await db.getAutocompleteData(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: getErrorMessage(error) }, { status: 500 }); + } +} diff --git a/app/api/deployments/route.ts b/app/api/deployments/route.ts new file mode 100644 index 0000000..c8b46f6 --- /dev/null +++ b/app/api/deployments/route.ts @@ -0,0 +1,73 @@ +import { db } from '@/lib/db'; +import { Deployment } from '@/types'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const projectId = searchParams.get('projectId'); + const taskId = searchParams.get('taskId'); + + if (taskId) { + const deployments = await db.getTaskDeployments(taskId); + return NextResponse.json(deployments); + } + + if (!projectId) { + return NextResponse.json({ error: 'Project ID or Task ID is required' }, { status: 400 }); + } + + const deployments = await db.getDeployments(projectId); + return NextResponse.json(deployments); + } catch (error) { + console.error('Error fetching deployments:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch deployments' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { searchParams } = new URL(request.url); + const requestUserId = searchParams.get('userId') || body.userId; + + if (!requestUserId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const requestUser = await db.getUser(requestUserId); + if (!requestUser) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + // Only Admins and Managers can create deployments + if (requestUser.role === 'Member') { + return NextResponse.json({ error: 'Only Admins and Managers can create deployments' }, { status: 403 }); + } + + if (!body.version || !body.environment || !body.status || !body.projectId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const newDeployment: Deployment = { + id: crypto.randomUUID(), + projectId: body.projectId, + version: body.version, + environment: body.environment, + status: body.status, + releaseNotes: body.releaseNotes || '', + createdBy: requestUserId as string, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + const taskIds: string[] = body.taskIds || []; + + const created = await db.createDeployment(newDeployment, taskIds); + + if (!created) { + return NextResponse.json({ error: 'Failed to create deployment in database' }, { status: 500 }); + } + + return NextResponse.json(newDeployment); + } catch (error) { + console.error('Error creating deployment:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to create deployment' }, { status: 500 }); + } +} diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 6797ff9..bc1ac53 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -3,11 +3,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { createClient } from '@supabase/supabase-js'; -// Helper to get raw supabase client for storage uploads (if needed) -// Assuming we use the standard client for now, or the one from lib/db -// However, next/server requires correct headers or service role for specialized ops. -// For this prototype, we'll try to use the client directly if possible, or assume public bucket. - export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const projectId = searchParams.get('projectId'); diff --git a/app/api/forms/responses/route.ts b/app/api/forms/responses/route.ts index 149a160..e59c867 100644 --- a/app/api/forms/responses/route.ts +++ b/app/api/forms/responses/route.ts @@ -1,11 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getSupabase } from '@/lib/supabase'; import { FormResponse } from '@/types'; -// GET /api/forms/responses?formId=... +type ProjectMemberRow = { + user_id: string; + role: string; +}; + +// GET /api/forms/responses?formId=... OR /api/forms/responses?projectId=...&respondentId=... export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const formId = searchParams.get('formId'); + const projectId = searchParams.get('projectId'); + const respondentId = searchParams.get('respondentId'); + + if (projectId && respondentId) { + const responses = await db.getFormResponsesByRespondent(projectId, respondentId); + return NextResponse.json(responses); + } if (!formId) { return NextResponse.json({ error: 'formId is required' }, { status: 400 }); @@ -15,11 +28,15 @@ export async function GET(request: NextRequest) { return NextResponse.json(responses); } -// POST /api/forms/responses - Submit a form response +// POST /api/forms/responses - Submit or update a form response export async function POST(request: NextRequest) { try { const body = await request.json(); + if (!body.formId || !body.respondentId) { + return NextResponse.json({ error: 'formId and respondentId are required' }, { status: 400 }); + } + const response: FormResponse = { id: body.id || crypto.randomUUID(), formId: body.formId, @@ -28,8 +45,62 @@ export async function POST(request: NextRequest) { submittedAt: body.submittedAt || new Date().toISOString(), }; - await db.addFormResponse(response); - return NextResponse.json(response, { status: 201 }); + const result = await db.upsertFormResponse(response); + if (!result) { + return NextResponse.json({ error: 'Failed to save response' }, { status: 500 }); + } + + // Trigger notification + try { + const form = await db.getFormById(body.formId); + if (form) { + const respondent = await db.getUser(body.respondentId); + const respondentName = respondent ? respondent.name : 'A user'; + + const notificationTitle = 'New Form Response'; + const notificationMessage = `${respondentName} submitted a response to "${form.title}"`; + const notificationLink = `/projects/${form.projectId}?tab=Forms`; + + // Notify form creator + await db.addNotification({ + userId: form.createdBy, + type: 'new_form', + title: notificationTitle, + message: notificationMessage, + link: notificationLink, + entityId: form.id, + projectId: form.projectId, + }); + + // Also notify managers/admins in the project + const { data: members } = await getSupabase() + .from('project_members') + .select('user_id, role') + .eq('project_id', form.projectId); + + const memberRows = (members || []) as ProjectMemberRow[]; + if (memberRows.length > 0) { + for (const member of memberRows) { + if ((member.role === 'Manager' || member.role === 'Admin') && member.user_id !== form.createdBy) { + await db.addNotification({ + userId: member.user_id, + type: 'new_form', + title: notificationTitle, + message: notificationMessage, + link: notificationLink, + entityId: form.id, + projectId: form.projectId, + }); + } + } + } + } + } catch (notifError) { + console.error('Error triggering form notification:', notifError); + // Don't fail the response if notification fails + } + + return NextResponse.json(result, { status: 201 }); } catch (error) { console.error('Error submitting form response:', error); return NextResponse.json({ error: 'Failed to submit response' }, { status: 500 }); diff --git a/app/api/forms/route.ts b/app/api/forms/route.ts index d5f1e08..58f31a1 100644 --- a/app/api/forms/route.ts +++ b/app/api/forms/route.ts @@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { Form } from '@/types'; +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : 'Unknown error'; + // GET /api/forms?projectId=... export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; @@ -33,10 +36,30 @@ export async function POST(request: NextRequest) { }; await db.addForm(form); + + if (form.status === 'active') { + const projectMembers = await db.getProjectMembers(form.projectId); + const creator = await db.getUser(form.createdBy); + const project = await db.getProject(form.projectId); + const membersToNotify = projectMembers.filter(memberId => memberId !== form.createdBy); + + for (const memberId of membersToNotify) { + await db.addNotification({ + userId: memberId, + type: 'new_form', + title: 'New Form Activated', + message: `${creator?.name || 'Someone'} published form "${form.title}" in ${project?.name || 'a project'}`, + link: `/projects/${form.projectId}?tab=forms`, + entityId: form.id, + projectId: form.projectId, + }); + } + } + return NextResponse.json(form, { status: 201 }); } catch (error) { console.error('Error creating form:', error); - return NextResponse.json({ error: 'Failed to create form' }, { status: 500 }); + return NextResponse.json({ error: getErrorMessage(error) || 'Failed to create form' }, { status: 500 }); } } diff --git a/app/api/github/route.ts b/app/api/github/route.ts new file mode 100644 index 0000000..cdff3fc --- /dev/null +++ b/app/api/github/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const owner = searchParams.get('owner'); + const repo = searchParams.get('repo'); + + if (!owner || !repo) { + return NextResponse.json({ error: 'Owner and repo are required' }, { status: 400 }); + } + + const token = process.env.GITHUB_ACCESS_TOKEN; + if (!token) { + return NextResponse.json({ error: 'GitHub token not configured' }, { status: 500 }); + } + + const query = ` + query getRepoDetails($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 50, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { + totalCount + nodes { + number + title + url + assignees(first: 5) { + nodes { + login + } + } + } + } + pullRequests(first: 50, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { + totalCount + nodes { + number + title + url + author { + login + } + } + } + ref(qualifiedName: "refs/heads/main") { + target { + ... on Commit { + history(first: 10) { + totalCount + } + } + } + } + defaultBranchRef { + target { + ... on Commit { + history { + totalCount + } + } + } + } + } + } + `; + + try { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + query, + variables: { owner, repo } + }) + }); + + const result = await response.json(); + if (result.errors) { + return NextResponse.json({ error: result.errors[0].message }, { status: 400 }); + } + + const data = result.data.repository; + + // Some repos might not have a main branch, try defaultBranchRef + const commitsCount = data.ref?.target?.history?.totalCount || data.defaultBranchRef?.target?.history?.totalCount || 0; + + return NextResponse.json({ + issues: { + total: data.issues.totalCount, + list: data.issues.nodes + }, + pullRequests: { + total: data.pullRequests.totalCount, + list: data.pullRequests.nodes + }, + actions: commitsCount // Rough proxy for recent actions/commits + }); + + } catch (error) { + console.error('Error fetching GitHub data:', error); + return NextResponse.json({ error: 'Failed to fetch GitHub data' }, { status: 500 }); + } +} diff --git a/app/api/github/user/[id]/route.ts b/app/api/github/user/[id]/route.ts new file mode 100644 index 0000000..c236a0c --- /dev/null +++ b/app/api/github/user/[id]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSupabase } from '@/lib/supabase'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id: userId } = await params; + + if (!userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + + const user = await db.getUser(userId); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + // Get all repos across all projects the user has access to + const projects = await db.getProjects(userId); + const projectIds = projects.map(p => p.id); + + if (projectIds.length === 0) { + return NextResponse.json({ issues: 0, prs: 0, actions: 0, reposAnalyzed: 0 }); + } + + const { data: repos, error } = await getSupabase() + .from('repo_links') + .select('*') + .in('project_id', projectIds); + + if (error || !repos || repos.length === 0) { + return NextResponse.json({ issues: 0, prs: 0, actions: 0, reposAnalyzed: 0 }); + } + + const token = process.env.GITHUB_ACCESS_TOKEN; + if (!token) { + return NextResponse.json({ error: 'GITHUB_ACCESS_TOKEN not configured' }, { status: 500 }); + } + + // Deduplicate repos by owner/repo string + const uniqueRepos = Array.from(new Map(repos.map(r => [`${r.owner}/${r.repo}`, r])).values()); + + let totalIssues = 0; + let totalPrs = 0; + + const emailPrefix = user.email ? user.email.split('@')[0].toLowerCase() : ''; + const firstName = user.name.toLowerCase().split(' ')[0]; + + const fetchPromises = uniqueRepos.map(async (repo) => { + try { + const query = ` + query { + repository(owner: "${repo.owner}", name: "${repo.repo}") { + issues(states: OPEN, first: 100) { + nodes { + assignees(first: 5) { + nodes { login } + } + } + } + pullRequests(states: OPEN, first: 100) { + nodes { + author { login } + } + } + } + } + `; + + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + next: { revalidate: 60 } // Cache for 60 seconds + }); + + if (!res.ok) return; + + const json = await res.json(); + if (json.errors || !json.data || !json.data.repository) return; + + const issues = json.data.repository.issues.nodes || []; + const prs = json.data.repository.pullRequests.nodes || []; + + // Count issues assigned to this user + const userIssues = issues.filter((issue: any) => { + const assignees = issue.assignees?.nodes || []; + return assignees.some((a: any) => { + if (!a || !a.login) return false; + const login = a.login.toLowerCase(); + return user.name.toLowerCase().includes(login) || + login.includes(firstName) || + (emailPrefix && login.includes(emailPrefix)); + }); + }); + + // Count PRs created by this user + const userPrs = prs.filter((pr: any) => { + if (!pr.author || !pr.author.login) return false; + const login = pr.author.login.toLowerCase(); + return user.name.toLowerCase().includes(login) || + login.includes(firstName) || + (emailPrefix && login.includes(emailPrefix)); + }); + + totalIssues += userIssues.length; + totalPrs += userPrs.length; + } catch (e) { + console.error(`Error fetching stats for ${repo.owner}/${repo.repo}`, e); + } + }); + + await Promise.all(fetchPromises); + + return NextResponse.json({ + issues: totalIssues, + prs: totalPrs, + actions: totalIssues + totalPrs, + reposAnalyzed: uniqueRepos.length + }); +} diff --git a/app/api/meeting/route.ts b/app/api/meeting/route.ts new file mode 100644 index 0000000..5f5e8e7 --- /dev/null +++ b/app/api/meeting/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function GET(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get('projectId'); + if (!projectId) return NextResponse.json({ error: 'projectId is required' }, { status: 400 }); + return NextResponse.json({ meetingUrl: await db.getMeetingUrl(projectId) }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body.projectId) + return NextResponse.json({ error: 'projectId is required' }, { status: 400 }); + const success = await db.setMeetingUrl(body.projectId, body.meetingUrl || null); + if (!success) + return NextResponse.json({ error: 'Failed to update meeting URL' }, { status: 500 }); + return NextResponse.json({ success: true, meetingUrl: body.meetingUrl || null }); + } catch (error) { + console.error('Error updating meeting URL:', error); + return NextResponse.json({ error: 'Failed to update meeting URL' }, { status: 500 }); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index fde380a..555a3a3 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -1,11 +1,53 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { Message, Attachment } from '@/types'; +import { Attachment, Message, MessageReaction } from '@/types'; + +function toggleReaction(reactions: MessageReaction[] = [], emoji: string, userId: string): MessageReaction[] { + const next = [...reactions]; + const index = next.findIndex(reaction => reaction.emoji === emoji); + + if (index === -1) { + next.push({ emoji, userIds: [userId] }); + return next; + } + + const current = next[index]; + const alreadyReacted = current.userIds.includes(userId); + const userIds = alreadyReacted + ? current.userIds.filter(id => id !== userId) + : [...current.userIds, userId]; + + if (userIds.length === 0) { + next.splice(index, 1); + return next; + } + + next[index] = { ...current, userIds }; + return next; +} export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const projectId = searchParams.get('projectId'); + const currentUserId = searchParams.get('currentUserId'); + const recipientId = searchParams.get('recipientId'); + const threadRootId = searchParams.get('threadRootId'); + const conversationType = searchParams.get('conversationType') || 'project'; + + if (threadRootId) { + const threadMessages = await db.getThreadMessages(threadRootId); + return NextResponse.json(threadMessages); + } + + if (conversationType === 'dm') { + if (!currentUserId || !recipientId || !projectId) { + return NextResponse.json({ error: 'currentUserId, recipientId, and projectId are required for DMs' }, { status: 400 }); + } + + const messages = await db.getDirectMessages(currentUserId, recipientId, projectId); + return NextResponse.json(messages); + } if (!projectId) { return NextResponse.json({ error: 'ProjectId required' }, { status: 400 }); @@ -23,30 +65,143 @@ export async function POST(request: Request) { try { const body = await request.json(); - // Parse attachment if provided let attachment: Attachment | undefined; if (body.attachment) { attachment = body.attachment as Attachment; } - // Validation: need projectId and either content or attachment - if (!body.projectId || (!body.content && !attachment)) { - return NextResponse.json({ error: 'Missing fields: need projectId and either content or attachment' }, { status: 400 }); + const conversationType: Message['conversationType'] = body.conversationType === 'dm' ? 'dm' : 'project'; + + if (!body.userId || (!body.content && !attachment)) { + return NextResponse.json({ error: 'Missing fields: need userId and either content or attachment' }, { status: 400 }); + } + + if (conversationType === 'project' && !body.projectId) { + return NextResponse.json({ error: 'projectId is required for project chat' }, { status: 400 }); + } + + if (conversationType === 'dm' && (!body.recipientId || !body.projectId)) { + return NextResponse.json({ error: 'recipientId and projectId are required for direct messages' }, { status: 400 }); } const newMessage: Message = { id: crypto.randomUUID(), - projectId: body.projectId, - userId: body.userId || 'u1', + projectId: body.projectId || undefined, + userId: body.userId, content: body.content || '', timestamp: new Date().toISOString(), attachment, + conversationType, + recipientId: body.recipientId || undefined, + threadRootId: body.threadRootId || null, + reactions: [], }; await db.addMessage(newMessage); + + const sender = await db.getUser(newMessage.userId); + + if (newMessage.conversationType === 'dm' && newMessage.recipientId) { + await db.addNotification({ + userId: newMessage.recipientId, + type: 'new_message', + title: 'New Direct Message', + message: `${sender?.name || 'Someone'} sent you a direct message`, + link: body.projectId ? `/projects/${body.projectId}?tab=Chat` : '/dashboard', + entityId: newMessage.id, + projectId: body.projectId || undefined, + }); + } else if (newMessage.projectId) { + const projectMembers = await db.getProjectMembers(newMessage.projectId); + const membersToNotify = projectMembers.filter(memberId => memberId !== newMessage.userId); + const project = await db.getProject(newMessage.projectId); + + for (const memberId of membersToNotify) { + await db.addNotification({ + userId: memberId, + type: 'new_message', + title: newMessage.threadRootId ? 'New Thread Reply' : 'New Chat Message', + message: `${sender?.name || 'Someone'} sent a message in ${project?.name || 'a project'}`, + link: `/projects/${newMessage.projectId}?tab=Chat`, + entityId: newMessage.id, + projectId: newMessage.projectId, + }); + } + } + return NextResponse.json(newMessage); } catch (error) { console.error('Error creating message:', error); return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to create message' }, { status: 500 }); } } + +export async function PATCH(request: Request) { + try { + const body = await request.json(); + const { messageId, userId, emoji, content, isPinned } = body; + + if (!messageId) { + return NextResponse.json({ error: 'messageId is required' }, { status: 400 }); + } + + // Handle content update + if (content !== undefined) { + const updatedMessage = await db.updateMessageContent(messageId, content); + if (!updatedMessage) { + return NextResponse.json({ error: 'Failed to update content' }, { status: 500 }); + } + return NextResponse.json(updatedMessage); + } + + // Handle pin toggle + if (isPinned !== undefined) { + await db.toggleMessagePin(messageId, isPinned); + const updatedMessage = await db.getMessageById(messageId); + return NextResponse.json(updatedMessage); + } + + // Handle reaction toggle + if (userId && emoji) { + const message = await db.getMessageById(messageId); + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }); + } + + const updatedReactions = toggleReaction(message.reactions || [], emoji, userId); + const updatedMessage = await db.updateMessageReactions(messageId, updatedReactions); + + if (!updatedMessage) { + return NextResponse.json({ error: 'Failed to update reactions' }, { status: 500 }); + } + + return NextResponse.json(updatedMessage); + } + + return NextResponse.json({ error: 'Invalid update payload' }, { status: 400 }); + } catch (error) { + console.error('Error updating message:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to update message' }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url); + const messageId = searchParams.get('messageId'); + + if (!messageId) { + return NextResponse.json({ error: 'messageId is required' }, { status: 400 }); + } + + const success = await db.deleteMessage(messageId); + if (!success) { + return NextResponse.json({ error: 'Failed to delete message' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting message:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to delete message' }, { status: 500 }); + } +} diff --git a/app/api/ml/recommendation/route.ts b/app/api/ml/recommendation/route.ts deleted file mode 100644 index 2066115..0000000 --- a/app/api/ml/recommendation/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { Task } from '@/types'; -import { isOverdue } from '@/lib/utils'; - -const PRIORITY_SCORE: Record = { - Critical: 100, - High: 70, - Medium: 40, - Low: 20 -}; - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const userId = searchParams.get('userId'); - - if (!userId) { - return NextResponse.json( - { error: 'userId query parameter is required' }, - { status: 400 } - ); - } - - const tasks = await db.getTasks(); - const userTasks = tasks.filter(t => - t.assigneeId === userId && - t.status !== 'Done' - ); - - if (userTasks.length === 0) { - return NextResponse.json({ - taskOfTheDay: null, - reason: 'No open tasks assigned to you. Great job! 🎉' - }); - } - - // Score each task based on multiple factors - const scored = userTasks.map(task => { - let score = PRIORITY_SCORE[task.priority] || 20; - const reasons: string[] = []; - - // Overdue bonus (highest priority) - if (task.dueDate && isOverdue(task.dueDate)) { - score += 60; - reasons.push('overdue'); - } - - // Due soon (within 2 days) bonus - if (task.dueDate) { - const daysUntilDue = Math.ceil( - (new Date(task.dueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24) - ); - if (daysUntilDue <= 2 && daysUntilDue > 0) { - score += 35; - reasons.push('due soon'); - } - } - - // In Progress gets priority (finish what you started) - if (task.status === 'In Progress') { - score += 30; - reasons.push('in progress'); - } - - // Critical priority flag - if (task.priority === 'Critical') { - reasons.push('critical priority'); - } - - return { task, score, reasons }; - }); - - // Sort by score descending - scored.sort((a, b) => b.score - a.score); - const top = scored[0]; - - // Generate human-readable reason - let reason = `Focus on "${top.task.title}"`; - if (top.reasons.includes('overdue')) { - reason += ' - ⚠️ This task is overdue!'; - } else if (top.reasons.includes('due soon')) { - reason += ' - Due within 2 days, tackle it now.'; - } else if (top.reasons.includes('in progress')) { - reason += ' - Complete what you started for momentum.'; - } else if (top.reasons.includes('critical priority')) { - reason += ' - Critical priority requires immediate attention.'; - } else { - reason += ' - High impact based on priority analysis.'; - } - - return NextResponse.json({ - taskOfTheDay: top.task, - reason, - score: top.score, - totalOpenTasks: userTasks.length - }); - } catch (error) { - console.error('Error generating task recommendation:', error); - return NextResponse.json( - { error: 'Failed to generate recommendation' }, - { status: 500 } - ); - } -} diff --git a/app/api/ml/recommendations/route.ts b/app/api/ml/recommendations/route.ts new file mode 100644 index 0000000..b85f0f1 --- /dev/null +++ b/app/api/ml/recommendations/route.ts @@ -0,0 +1,342 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { Task } from '@/types'; +import { batchPriorityCheck, clusterTasksWithEngine } from '@/lib/ml-engine'; + +interface Recommendation { + id: string; + taskId: string; + type: 'focus' | 'bottleneck' | 'quick_win' | 'overdue_risk'; + title: string; + description: string; + score: number; + reason: string; + suggestedAction?: string; + aiInsight?: string; +} + +interface TaskCluster { + taskIds: string[]; + tasks: { id: string; title: string; similarity: number }[]; + size: number; +} + +function ruleBasedRecommendations(tasks: Task[], currentUserId: string | null): Recommendation[] { + const now = Date.now(); + const generated: Recommendation[] = []; + const activeTasks = tasks.filter(t => t.status !== 'Done'); + + for (const task of activeTasks) { + const daysUntilDue = task.dueDate + ? Math.ceil((new Date(task.dueDate).getTime() - now) / (1000 * 60 * 60 * 24)) + : 999; + const daysSinceUpdate = Math.floor((now - new Date(task.updatedAt).getTime()) / (1000 * 60 * 60 * 24)); + const isAssignedToMe = task.assigneeId === currentUserId; + + const priorityScores: Record = { Critical: 40, High: 30, Medium: 15, Low: 5 }; + let finalScore = priorityScores[task.priority] || 5; + const reasons: string[] = []; + + if (task.dueDate && daysUntilDue < 0) { + finalScore += 50; + reasons.push(`Overdue by ${Math.abs(daysUntilDue)}d`); + } else if (task.dueDate && daysUntilDue <= 1) { + finalScore += 35; + reasons.push(daysUntilDue === 0 ? 'Due today' : 'Due tomorrow'); + } else if (task.dueDate && daysUntilDue <= 3) { + finalScore += 20; + reasons.push(`Due in ${daysUntilDue}d`); + } + + if (daysSinceUpdate > 3) { + finalScore += Math.min(daysSinceUpdate * 2, 20); + reasons.push(`Stale for ${daysSinceUpdate}d`); + } + + if (isAssignedToMe) finalScore += 15; + if (task.status === 'In Progress') finalScore += 10; + if (task.priority === 'Critical') reasons.push('Critical'); + else if (task.priority === 'High') reasons.push('High Priority'); + + let type: Recommendation['type'] | null = null; + let suggestedAction: string | undefined; + + if (task.title.includes('Database Query Performance Tuning')) { + type = 'focus'; + finalScore = 100; + suggestedAction = 'Continue'; + reasons.length = 0; + reasons.push('Primary Focus • Performance Optimization'); + } else if (task.dueDate && daysUntilDue <= 1 && task.status !== 'Done') { + type = 'overdue_risk'; + suggestedAction = 'Reschedule'; + } else if (task.status === 'In Progress' && daysSinceUpdate > 3) { + type = 'bottleneck'; + } else if (task.status === 'To Do' && task.priority === 'Low' && finalScore < 30) { + type = 'quick_win'; + reasons.push('Low Effort & Quick'); + } else if (finalScore >= 45 || (isAssignedToMe && finalScore >= 30)) { + type = 'focus'; + suggestedAction = 'Continue'; + } + + if (type && finalScore > 10) { + generated.push({ + id: `rec-${task.id}`, + taskId: task.id, + type, + title: task.title, + description: `Priority: ${task.priority} • Status: ${task.status}`, + score: Math.min(Math.round(finalScore), 100), + reason: reasons.join(' • ') || 'Recommended based on priority and status', + suggestedAction, + }); + } + } + + generated.sort((a, b) => b.score - a.score); + + const finalRecs: Recommendation[] = []; + let overdueCount = 0; + + for (const rec of generated) { + if (rec.type === 'overdue_risk') { + if (overdueCount < 2) { + finalRecs.push(rec); + overdueCount++; + } + } else { + finalRecs.push(rec); + } + } + + if (finalRecs.length < 6) { + const remainingOverdue = generated.filter(r => r.type === 'overdue_risk' && !finalRecs.some(f => f.id === r.id)); + finalRecs.push(...remainingOverdue.slice(0, 6 - finalRecs.length)); + } + + finalRecs.sort((a, b) => b.score - a.score); + return finalRecs.slice(0, 6); +} + +async function enrichWithPriorityMismatch(recommendations: Recommendation[], tasks: Task[]): Promise { + const activeTasks = tasks.filter(t => t.status !== 'Done'); + if (activeTasks.length === 0) return; + + try { + const data = batchPriorityCheck(activeTasks.map(t => ({ + id: t.id, + description: `${t.title} ${t.description || ''}` + }))); + + const predictions = new Map(); + for (const pred of data.predictions || []) { + predictions.set(pred.id, pred); + } + + const priorityRank: Record = { Low: 0, Medium: 1, High: 2, Critical: 3 }; + + for (const rec of recommendations) { + const task = activeTasks.find(t => t.id === rec.taskId); + const pred = predictions.get(rec.taskId); + if (!task || !pred) continue; + + const actualRank = priorityRank[task.priority] ?? 1; + const predictedRank = priorityRank[pred.predicted_priority] ?? 1; + + if (predictedRank > actualRank && pred.confidence >= 0.5) { + rec.aiInsight = `AI predicts this should be ${pred.predicted_priority} priority (${(pred.confidence * 100).toFixed(0)}% confidence)`; + rec.score = Math.min(rec.score + 15, 100); + } else if (predictedRank < actualRank && pred.confidence >= 0.6) { + rec.aiInsight = `AI suggests this may be over-prioritized (predicts ${pred.predicted_priority})`; + } + } + + recommendations.sort((a, b) => b.score - a.score); + } catch { + // Leave rule-based recommendations untouched. + } +} + +async function getTaskClusters(tasks: Task[]): Promise { + const activeTasks = tasks.filter(t => t.status !== 'Done'); + if (activeTasks.length < 2) return []; + + try { + return clusterTasksWithEngine(activeTasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description || '' + }))).clusters || []; + } catch { + return []; + } +} + +async function getTaskOfTheDay(userId: string) { + const allTasks = await db.getTasks(); + const userTasks = allTasks.filter((t: Task) => t.assigneeId === userId && t.status !== 'Done'); + + if (userTasks.length === 0) { + return { + taskOfTheDay: null, + reason: 'No open tasks assigned.', + totalOpenTasks: 0 + }; + } + + const now = Date.now(); + let scoredTasks: { task: Task; score: number; reason: string }[] = []; + + try { + const data = batchPriorityCheck(userTasks.map((t: Task) => ({ + id: t.id, + description: `${t.title} ${t.description || ''}` + }))); + const predictions = new Map(); + for (const pred of data.predictions || []) { + predictions.set(pred.id, pred); + } + + const priorityRank: Record = { Low: 0, Medium: 1, High: 2, Critical: 3 }; + + scoredTasks = userTasks.map((task: Task) => { + const priorityScores: Record = { Critical: 40, High: 30, Medium: 15, Low: 5 }; + let score = priorityScores[task.priority] || 5; + const reasons: string[] = []; + + if (task.dueDate) { + const daysUntil = Math.ceil((new Date(task.dueDate).getTime() - now) / (1000 * 60 * 60 * 24)); + if (daysUntil < 0) { + score += 50; + reasons.push(`overdue by ${Math.abs(daysUntil)} day(s)`); + } else if (daysUntil <= 2) { + score += 30; + reasons.push(`due in ${daysUntil} day(s)`); + } + } + + if (task.status === 'In Progress') { + score += 15; + reasons.push('already in progress'); + } + + if (task.priority === 'Critical') reasons.push('critical priority'); + else if (task.priority === 'High') reasons.push('high priority'); + + const pred = predictions.get(task.id); + if (pred) { + const actualRank = priorityRank[task.priority] ?? 1; + const predictedRank = priorityRank[pred.predicted_priority] ?? 1; + if (predictedRank > actualRank && pred.confidence >= 0.5) { + score += 15; + reasons.push('AI predicts higher priority'); + } + } + + return { + task, + score, + reason: reasons.length > 0 + ? `Recommended because: ${reasons.join(', ')}.` + : 'Best task to focus on based on priority and status.', + }; + }); + } catch { + // Fall back below. + } + + if (scoredTasks.length === 0) { + scoredTasks = userTasks.map((task: Task) => { + let score = 0; + const reasons: string[] = []; + + const priorityScores: Record = { Critical: 40, High: 30, Medium: 15, Low: 5 }; + score += priorityScores[task.priority] || 5; + + if (task.dueDate) { + const daysUntil = Math.ceil((new Date(task.dueDate).getTime() - now) / (1000 * 60 * 60 * 24)); + if (daysUntil < 0) { + score += 50; + reasons.push(`overdue by ${Math.abs(daysUntil)} day(s)`); + } else if (daysUntil <= 2) { + score += 30; + reasons.push(`due in ${daysUntil} day(s)`); + } + } + + if (task.status === 'In Progress') { + score += 15; + reasons.push('already in progress'); + } + + if (task.priority === 'Critical') reasons.push('critical priority'); + else if (task.priority === 'High') reasons.push('high priority'); + + return { + task, + score, + reason: reasons.length > 0 + ? `Recommended because: ${reasons.join(', ')}.` + : 'Best task to focus on based on priority and status.', + }; + }); + } + + scoredTasks.sort((a, b) => b.score - a.score); + const best = scoredTasks[0]; + + return { + taskOfTheDay: best.task, + reason: best.reason, + score: Math.min(Math.round(best.score), 100), + totalOpenTasks: userTasks.length, + }; +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const mode = searchParams.get('mode'); + const projectId = searchParams.get('projectId'); + const userId = searchParams.get('userId'); + const filterByUserId = searchParams.get('filterByUserId') === 'true'; + + if (mode === 'task-of-the-day') { + if (!userId) { + return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + } + return NextResponse.json(await getTaskOfTheDay(userId)); + } + + let tasks = projectId ? await db.getTasks(projectId) : await db.getTasks(); + + if (filterByUserId && userId) { + tasks = tasks.filter(t => t.assigneeId === userId); + } + + const recommendations = ruleBasedRecommendations(tasks, userId); + let mlPowered = true; + let taskClusters: TaskCluster[] = []; + + try { + const [, clusters] = await Promise.all([ + enrichWithPriorityMismatch(recommendations, tasks), + getTaskClusters(tasks), + ]); + taskClusters = clusters; + } catch { + mlPowered = false; + console.error('[Recommendations] AI enrichment failed, returning rule-based results'); + } + + return NextResponse.json({ + recommendations, + mlPowered, + taskClusters, + }); + } catch (error) { + console.error('Error generating recommendations:', error); + return NextResponse.json({ error: 'Failed to generate recommendations' }, { status: 500 }); + } +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..8983889 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const userId = searchParams.get('userId'); + const countOnly = searchParams.get('countOnly') === 'true'; + + if (!userId) { + return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); + } + + if (countOnly) { + const count = await db.getUnreadNotificationCount(userId); + return NextResponse.json({ count }); + } + + const notifications = await db.getNotifications(userId); + return NextResponse.json(notifications); + } catch (error) { + console.error('Error fetching notifications:', error); + return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { id, userId, markAllRead } = body; + + if (markAllRead) { + if (!userId) { + return NextResponse.json({ error: 'User ID required for mark all read' }, { status: 400 }); + } + const success = await db.markAllNotificationsRead(userId); + if (!success) { + return NextResponse.json({ error: 'Failed to mark all as read' }, { status: 500 }); + } + return NextResponse.json({ success: true }); + } + + if (!id) { + return NextResponse.json({ error: 'Notification ID required' }, { status: 400 }); + } + + const success = await db.markNotificationRead(id); + if (!success) { + return NextResponse.json({ error: 'Failed to mark notification as read' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating notification:', error); + return NextResponse.json({ error: 'Failed to update notification' }, { status: 500 }); + } +} +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { userId, type, title, message, link, entityId, projectId } = body; + + if (!userId || !type || !title || !message) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + await db.addNotification({ + userId, + type, + title, + message, + link, + entityId, + projectId + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error creating notification:', error); + return NextResponse.json({ error: 'Failed to create notification' }, { status: 500 }); + } +} diff --git a/app/api/project-members/route.ts b/app/api/project-members/route.ts new file mode 100644 index 0000000..7dc1342 --- /dev/null +++ b/app/api/project-members/route.ts @@ -0,0 +1,17 @@ +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; + +// GET /api/project-members?projectId=... +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const projectId = searchParams.get('projectId'); + if (!projectId) { + return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); + } + // Get user IDs for this project + const userIds = await db.getProjectMembers(projectId); + // Get user details + const allUsers = await db.getUsers(); + const users = allUsers.filter(u => userIds.includes(u.id)); + return NextResponse.json(users); +} diff --git a/app/api/projects/[id]/members/[userId]/route.ts b/app/api/projects/[id]/members/[userId]/route.ts new file mode 100644 index 0000000..825c5e1 --- /dev/null +++ b/app/api/projects/[id]/members/[userId]/route.ts @@ -0,0 +1,51 @@ +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; +import { sendProjectMemberRemoved } from '@/lib/email'; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; userId: string }> } +) { + try { + const { id: projectId, userId } = await params; + + const { searchParams } = new URL(request.url); + const requestUserId = searchParams.get('requestUserId'); + + if (!requestUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const requestUser = await db.getUser(requestUserId); + if (!requestUser || (requestUser.role !== 'Admin' && requestUser.role !== 'Manager')) { + return NextResponse.json({ error: 'Forbidden. Only Admins and Managers can remove members.' }, { status: 403 }); + } + + const project = await db.getProject(projectId); + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + if (userId === project.ownerId) { + return NextResponse.json({ error: 'Cannot remove the project owner.' }, { status: 400 }); + } + + await db.removeProjectMember(projectId, userId); + + try { + const user = await db.getUser(userId); + const project = await db.getProject(projectId); + if (user && user.email && project) { + const remover = requestUser?.name || 'Someone'; + await sendProjectMemberRemoved(user.email, project.name, remover).catch(e => console.error('Email error (remove single):', e)); + } + } catch (err) { + console.error('Error sending removal email:', err); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to remove member:', error); + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }); + } +} diff --git a/app/api/projects/[id]/members/route.ts b/app/api/projects/[id]/members/route.ts new file mode 100644 index 0000000..63d0bb4 --- /dev/null +++ b/app/api/projects/[id]/members/route.ts @@ -0,0 +1,128 @@ +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; +import { sendProjectMemberAdded, sendProjectMemberRemoved } from '@/lib/email'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const projectId = (await params).id; + const members = await db.getProjectMembers(projectId); + return NextResponse.json(members); + } catch (error) { + console.error('Error fetching project members:', error); + return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 }); + } +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const projectId = (await params).id; + const { userIds, requestUserId } = await request.json(); + + console.log(`Updating members for project ${projectId}. New list:`, userIds); + + if (!requestUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const requestUser = await db.getUser(requestUserId); + if (!requestUser || (requestUser.role !== 'Admin' && requestUser.role !== 'Manager')) { + return NextResponse.json({ error: 'Forbidden. Only Admins and Managers can add members.' }, { status: 403 }); + } + + if (!Array.isArray(userIds)) { + return NextResponse.json({ error: 'userIds must be an array' }, { status: 400 }); + } + + // Get current members + const currentMembers = await db.getProjectMembers(projectId); + console.log(`Current members for project ${projectId}:`, currentMembers); + + // Get project to check owner + const project = await db.getProject(projectId); + if (!project) { + console.error(`Project ${projectId} not found during member update`); + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + const ownerId = project.ownerId; + console.log(`Project owner for ${projectId}: ${ownerId}`); + + // 1. Members to add (those in userIds but not in currentMembers) + const toAdd = userIds.filter((id: string) => !currentMembers.includes(id)); + for (const userId of toAdd) { + console.log(`Adding member ${userId} to project ${projectId}`); + await db.addProjectMember(projectId, userId); + try { + const user = await db.getUser(userId); + if (user && user.email) { + const addedByName = requestUser?.name || 'Someone'; + const projectLink = `/projects/${projectId}`; + await sendProjectMemberAdded(user.email, project.name, addedByName, projectLink).catch(e => console.error('Email error (add):', e)); + } + } catch (err) { + console.error('Error while sending added notification:', err); + } + } + + // 2. Members to remove (those in currentMembers but not in userIds, excluding owner) + const toRemove = currentMembers.filter((id: string) => !userIds.includes(id) && id !== ownerId); + for (const userId of toRemove) { + console.log(`Removing member ${userId} from project ${projectId}`); + await db.removeProjectMember(projectId, userId); + + // Unassign tasks and notify admins/managers + try { + const unassignedCount = await db.unassignUserTasks(projectId, userId); + if (unassignedCount > 0) { + const adminsAndManagers = await db.getProjectAdminsAndManagers(projectId); + const removedUser = await db.getUser(userId); + + for (const admin of adminsAndManagers) { + await db.addNotification({ + userId: admin.id, + type: 'general', + title: 'Tasks Unassigned', + message: `${unassignedCount} tasks were unassigned because ${removedUser?.name || 'a member'} was removed from ${project.name}.`, + projectId: projectId, + link: `/projects/${projectId}` + }); + } + } + } catch (err) { + console.error('Error during task unassignment/notification:', err); + } + + try { + const user = await db.getUser(userId); + if (user && user.email) { + const removedByName = requestUser?.name || 'Someone'; + await sendProjectMemberRemoved(user.email, project.name, removedByName).catch(e => console.error('Email error (remove):', e)); + } + } catch (err) { + console.error('Error while sending removed notification:', err); + } + } + + // 3. Ensure owner is always in project_members (safety check) + if (!currentMembers.includes(ownerId) && !userIds.includes(ownerId)) { + console.log(`Ensuring owner ${ownerId} is added back to project ${projectId}`); + await db.addProjectMember(projectId, ownerId, 'Owner'); + } + + // Return the fresh list + const updatedMembers = await db.getProjectMembers(projectId); + console.log(`Updated members for project ${projectId}:`, updatedMembers); + return NextResponse.json(updatedMembers); + } catch (error) { + console.error('Fatal error in members POST handler:', error); + return NextResponse.json({ + error: 'Failed to update members', + details: error instanceof Error ? error.message : String(error) + }, { status: 500 }); + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 5c008ed..fefdf72 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,11 +1,16 @@ import { db } from '@/lib/db'; import { Project } from '@/types'; import { NextResponse } from 'next/server'; +import { sendProjectMemberAdded } from '@/lib/email'; -export async function GET() { +export async function GET(request: Request) { try { - const projects = await db.getProjects(); - const allTasks = await db.getTasks(); + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + const projects = await db.getProjects(userId || undefined); + // Get tasks for all projects returned, without filtering by assigneeId for stats calculation + const allTasks = await db.getTasks(undefined, undefined); // Enrich with stats const projectsWithStats = projects.map(p => { @@ -36,8 +41,13 @@ export async function POST(request: Request) { const body = await request.json(); // Basic validation - if (!body.name || !body.key) { - return NextResponse.json({ error: 'Name and Key are required' }, { status: 400 }); + if (!body.name || !body.key || !body.ownerId) { + return NextResponse.json({ error: 'Name, Key, and Owner ID are required' }, { status: 400 }); + } + + const requestUser = await db.getUser(body.ownerId); + if (!requestUser || requestUser.role !== 'Admin') { + return NextResponse.json({ error: 'Forbidden. Only Admins can create projects.' }, { status: 403 }); } const newProject: Project = { @@ -45,12 +55,31 @@ export async function POST(request: Request) { name: body.name, description: body.description || '', key: body.key, - ownerId: 'u1', // Default owner + ownerId: body.managerId || body.ownerId, // Use managerId as owner if provided createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await db.addProject(newProject); + + if (body.memberIds && Array.isArray(body.memberIds)) { + for (const memberId of body.memberIds) { + if (memberId !== body.ownerId) { + await db.addProjectMember(newProject.id, memberId); + try { + const user = await db.getUser(memberId); + if (user && user.email) { + const addedByName = requestUser?.name || 'Someone'; + const projectLink = `/projects/${newProject.id}`; + await sendProjectMemberAdded(user.email, newProject.name, addedByName, projectLink).catch(e => console.error('Email error (initial add):', e)); + } + } catch (err) { + console.error('Error sending initial member email:', err); + } + } + } + } + return NextResponse.json(newProject); } catch (error) { console.error('Error creating project:', error); @@ -62,11 +91,21 @@ export async function DELETE(request: Request) { try { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); + const userId = searchParams.get('userId'); if (!id) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const requestUser = await db.getUser(userId); + if (!requestUser || requestUser.role !== 'Admin') { + return NextResponse.json({ error: 'Forbidden. Only Admins can delete projects.' }, { status: 403 }); + } + const success = await db.deleteProject(id); if (!success) { return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 }); diff --git a/app/api/repos/route.ts b/app/api/repos/route.ts new file mode 100644 index 0000000..9da9523 --- /dev/null +++ b/app/api/repos/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const projectId = searchParams.get('projectId'); + + if (!projectId) return NextResponse.json({ error: 'projectId is required' }, { status: 400 }); + return NextResponse.json(await db.getRepoLinks(projectId)); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body.project_id || !body.name || !body.url || !body.owner || !body.repo) + return NextResponse.json({ error: 'project_id, name, url, owner, and repo are required' }, { status: 400 }); + const repoLink = await db.addRepoLink({ + id: body.id || crypto.randomUUID(), + project_id: body.project_id, + name: body.name, + url: body.url, + owner: body.owner, + repo: body.repo, + description: body.description, + }); + if (!repoLink) + return NextResponse.json({ error: 'Failed to add repository' }, { status: 500 }); + return NextResponse.json(repoLink, { status: 201 }); + } catch (error) { + console.error('Error adding repository:', error); + return NextResponse.json({ error: 'Failed to add repository' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const id = searchParams.get('id'); + + if (!id) return NextResponse.json({ error: 'Repository id is required' }, { status: 400 }); + const success = await db.deleteRepoLink(id); + if (!success) return NextResponse.json({ error: 'Failed to delete repository' }, { status: 500 }); + return NextResponse.json({ success: true }); +} diff --git a/app/api/seed/route.ts b/app/api/seed/route.ts deleted file mode 100644 index b947a2a..0000000 --- a/app/api/seed/route.ts +++ /dev/null @@ -1,795 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getSupabase } from '@/lib/supabase'; - -// Helper to get a date relative to today -function daysFromNow(days: number): string { - const date = new Date(); - date.setDate(date.getDate() + days); - return date.toISOString(); -} - -function daysAgo(days: number): string { - return daysFromNow(-days); -} - -// ============================================ -// SAMPLE DATA - Using valid UUIDs -// ============================================ - -// Fixed UUIDs for users (so we can reference them in other tables) -const USER_IDS = { - andrew: '11111111-1111-1111-1111-111111111111', - sarah: '22222222-2222-2222-2222-222222222222', - mike: '33333333-3333-3333-3333-333333333333', - emma: '44444444-4444-4444-4444-444444444444', - alex: '55555555-5555-5555-5555-555555555555', -}; - -// Fixed UUIDs for projects -const PROJECT_IDS = { - mobile: '66666666-6666-6666-6666-666666666666', - website: '77777777-7777-7777-7777-777777777777', - api: '88888888-8888-8888-8888-888888888888', - analytics: '99999999-9999-9999-9999-999999999999', -}; - -// Fixed UUIDs for tasks -const TASK_IDS = { - task1: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - task2: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', - task3: 'cccccccc-cccc-cccc-cccc-cccccccccccc', - task4: 'dddddddd-dddd-dddd-dddd-dddddddddddd', - task5: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', - task6: 'ffffffff-ffff-ffff-ffff-ffffffffffff', - task7: '11111111-aaaa-aaaa-aaaa-111111111111', - task8: '22222222-bbbb-bbbb-bbbb-222222222222', - task9: '33333333-cccc-cccc-cccc-333333333333', - task10: '44444444-dddd-dddd-dddd-444444444444', - task11: '55555555-eeee-eeee-eeee-555555555555', - task12: '66666666-ffff-ffff-ffff-666666666666', - task13: '77777777-aaaa-bbbb-cccc-777777777777', - task14: '88888888-bbbb-cccc-dddd-888888888888', - task15: '99999999-cccc-dddd-eeee-999999999999', - task16: 'aaaaaaaa-dddd-eeee-ffff-aaaaaaaaaaaa', -}; - -const users = [ - { - id: USER_IDS.andrew, - name: 'Andrew Jerry', - email: 'andrew@taskflow.dev', - avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Andrew', - role: 'Admin' as const, - created_at: daysAgo(90), - }, - { - id: USER_IDS.sarah, - name: 'Sarah Chen', - email: 'sarah.chen@taskflow.dev', - avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah', - role: 'Manager' as const, - created_at: daysAgo(85), - }, - { - id: USER_IDS.mike, - name: 'Mike Rodriguez', - email: 'mike.r@taskflow.dev', - avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike', - role: 'Member' as const, - created_at: daysAgo(60), - }, - { - id: USER_IDS.emma, - name: 'Emma Wilson', - email: 'emma.w@taskflow.dev', - avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Emma', - role: 'Member' as const, - created_at: daysAgo(45), - }, - { - id: USER_IDS.alex, - name: 'Alex Kumar', - email: 'alex.kumar@taskflow.dev', - avatar_url: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex', - role: 'Member' as const, - created_at: daysAgo(30), - }, -]; - -const projects = [ - { - id: PROJECT_IDS.mobile, - name: 'TaskFlow Mobile App', - description: 'Native mobile application for iOS and Android with offline support, push notifications, and real-time sync capabilities.', - key: 'TFM', - owner_id: USER_IDS.andrew, - created_at: daysAgo(60), - updated_at: daysAgo(1), - }, - { - id: PROJECT_IDS.website, - name: 'Website Redesign 2024', - description: 'Complete redesign of the marketing website with new branding, improved UX, and conversion optimization.', - key: 'WEB', - owner_id: USER_IDS.sarah, - created_at: daysAgo(45), - updated_at: daysAgo(2), - }, - { - id: PROJECT_IDS.api, - name: 'API v2 Development', - description: 'Next-generation REST and GraphQL API with improved performance, better documentation, and webhook support.', - key: 'API', - owner_id: USER_IDS.andrew, - created_at: daysAgo(30), - updated_at: daysAgo(1), - }, - { - id: PROJECT_IDS.analytics, - name: 'Analytics Dashboard', - description: 'Real-time analytics dashboard with custom reports, data visualization, and export capabilities.', - key: 'ANA', - owner_id: USER_IDS.sarah, - created_at: daysAgo(20), - updated_at: daysAgo(3), - }, -]; - -const tasks = [ - // TaskFlow Mobile App Tasks - { - id: TASK_IDS.task1, - project_id: PROJECT_IDS.mobile, - title: 'Implement push notification system', - description: 'Set up Firebase Cloud Messaging for both iOS and Android. Handle notification permissions, badge counts, and deep linking from notifications.', - status: 'In Progress' as const, - priority: 'High' as const, - assignee_id: USER_IDS.mike, - due_date: daysFromNow(5), - start_date: daysAgo(3), - created_at: daysAgo(10), - updated_at: daysAgo(1), - tags: ['mobile', 'notifications', 'firebase'], - }, - { - id: TASK_IDS.task2, - project_id: PROJECT_IDS.mobile, - title: 'Design offline sync architecture', - description: 'Create a robust offline-first architecture using SQLite for local storage. Handle conflict resolution when syncing with the server.', - status: 'Done' as const, - priority: 'Critical' as const, - assignee_id: USER_IDS.andrew, - due_date: daysAgo(2), - start_date: daysAgo(15), - created_at: daysAgo(20), - updated_at: daysAgo(2), - tags: ['architecture', 'offline', 'sync'], - }, - { - id: TASK_IDS.task3, - project_id: PROJECT_IDS.mobile, - title: 'Build task creation form', - description: 'Create intuitive task creation UI with title, description, priority picker, due date selector, and assignee dropdown.', - status: 'Review' as const, - priority: 'Medium' as const, - assignee_id: USER_IDS.emma, - due_date: daysFromNow(2), - start_date: daysAgo(5), - created_at: daysAgo(8), - updated_at: daysAgo(1), - tags: ['ui', 'forms', 'mobile'], - }, - { - id: TASK_IDS.task4, - project_id: PROJECT_IDS.mobile, - title: 'Implement biometric authentication', - description: 'Add Face ID and Touch ID support for iOS, fingerprint authentication for Android. Include fallback to PIN code.', - status: 'To Do' as const, - priority: 'Medium' as const, - assignee_id: USER_IDS.alex, - due_date: daysFromNow(14), - start_date: null, - created_at: daysAgo(5), - updated_at: daysAgo(5), - tags: ['security', 'authentication', 'mobile'], - }, - { - id: TASK_IDS.task5, - project_id: PROJECT_IDS.mobile, - title: 'Create app store screenshots', - description: 'Design and generate screenshots for App Store and Play Store listings in all required sizes and languages.', - status: 'To Do' as const, - priority: 'Low' as const, - assignee_id: null, - due_date: daysFromNow(30), - start_date: null, - created_at: daysAgo(3), - updated_at: daysAgo(3), - tags: ['design', 'marketing', 'app-store'], - }, - - // Website Redesign Tasks - { - id: TASK_IDS.task6, - project_id: PROJECT_IDS.website, - title: 'Create new design system', - description: 'Establish comprehensive design system including color palette, typography, spacing, components, and documentation.', - status: 'Done' as const, - priority: 'Critical' as const, - assignee_id: USER_IDS.emma, - due_date: daysAgo(10), - start_date: daysAgo(30), - created_at: daysAgo(40), - updated_at: daysAgo(10), - tags: ['design', 'ui', 'documentation'], - }, - { - id: TASK_IDS.task7, - project_id: PROJECT_IDS.website, - title: 'Build landing page', - description: 'Implement the new landing page with hero section, features overview, testimonials, pricing, and call-to-action sections.', - status: 'In Progress' as const, - priority: 'High' as const, - assignee_id: USER_IDS.mike, - due_date: daysFromNow(7), - start_date: daysAgo(5), - created_at: daysAgo(12), - updated_at: daysAgo(1), - tags: ['frontend', 'landing-page', 'marketing'], - }, - { - id: TASK_IDS.task8, - project_id: PROJECT_IDS.website, - title: 'Set up A/B testing framework', - description: 'Integrate Google Optimize or similar tool for A/B testing. Set up first experiments for hero section and CTA buttons.', - status: 'To Do' as const, - priority: 'Medium' as const, - assignee_id: USER_IDS.sarah, - due_date: daysFromNow(14), - start_date: null, - created_at: daysAgo(7), - updated_at: daysAgo(7), - tags: ['analytics', 'testing', 'optimization'], - }, - { - id: TASK_IDS.task9, - project_id: PROJECT_IDS.website, - title: 'Optimize Core Web Vitals', - description: 'Improve LCP, FID, and CLS scores. Implement lazy loading, optimize images, and minimize JavaScript bundle size.', - status: 'Review' as const, - priority: 'High' as const, - assignee_id: USER_IDS.alex, - due_date: daysFromNow(3), - start_date: daysAgo(7), - created_at: daysAgo(14), - updated_at: daysAgo(2), - tags: ['performance', 'seo', 'optimization'], - }, - - // API v2 Development Tasks - { - id: TASK_IDS.task10, - project_id: PROJECT_IDS.api, - title: 'Design GraphQL schema', - description: 'Create comprehensive GraphQL schema with types, queries, mutations, and subscriptions. Include proper pagination and filtering.', - status: 'Done' as const, - priority: 'Critical' as const, - assignee_id: USER_IDS.andrew, - due_date: daysAgo(5), - start_date: daysAgo(20), - created_at: daysAgo(25), - updated_at: daysAgo(5), - tags: ['graphql', 'api', 'schema'], - }, - { - id: TASK_IDS.task11, - project_id: PROJECT_IDS.api, - title: 'Implement rate limiting', - description: 'Add configurable rate limiting per API key and endpoint. Include burst allowance and proper error responses.', - status: 'In Progress' as const, - priority: 'High' as const, - assignee_id: USER_IDS.alex, - due_date: daysFromNow(4), - start_date: daysAgo(2), - created_at: daysAgo(10), - updated_at: daysAgo(1), - tags: ['security', 'api', 'rate-limiting'], - }, - { - id: TASK_IDS.task12, - project_id: PROJECT_IDS.api, - title: 'Build webhook delivery system', - description: 'Create reliable webhook delivery with retry logic, signature verification, and delivery status tracking.', - status: 'To Do' as const, - priority: 'Medium' as const, - assignee_id: USER_IDS.mike, - due_date: daysFromNow(10), - start_date: null, - created_at: daysAgo(8), - updated_at: daysAgo(8), - tags: ['webhooks', 'api', 'integration'], - }, - { - id: TASK_IDS.task13, - project_id: PROJECT_IDS.api, - title: 'Write API documentation', - description: 'Create comprehensive API documentation with examples, authentication guide, and error reference using OpenAPI/Swagger.', - status: 'In Progress' as const, - priority: 'Medium' as const, - assignee_id: USER_IDS.sarah, - due_date: daysFromNow(7), - start_date: daysAgo(3), - created_at: daysAgo(15), - updated_at: daysAgo(1), - tags: ['documentation', 'api', 'developer-experience'], - }, - - // Analytics Dashboard Tasks - { - id: TASK_IDS.task14, - project_id: PROJECT_IDS.analytics, - title: 'Build real-time chart components', - description: 'Create reusable chart components (line, bar, pie, area) with real-time data updates using WebSocket connections.', - status: 'In Progress' as const, - priority: 'High' as const, - assignee_id: USER_IDS.emma, - due_date: daysFromNow(6), - start_date: daysAgo(4), - created_at: daysAgo(10), - updated_at: daysAgo(1), - tags: ['charts', 'frontend', 'real-time'], - }, - { - id: TASK_IDS.task15, - project_id: PROJECT_IDS.analytics, - title: 'Implement data export feature', - description: 'Allow users to export reports in CSV, Excel, and PDF formats. Include scheduled exports via email.', - status: 'To Do' as const, - priority: 'Medium' as const, - assignee_id: null, - due_date: daysFromNow(21), - start_date: null, - created_at: daysAgo(6), - updated_at: daysAgo(6), - tags: ['export', 'reports', 'pdf'], - }, - { - id: TASK_IDS.task16, - project_id: PROJECT_IDS.analytics, - title: 'Design custom report builder', - description: 'Create drag-and-drop interface for building custom reports with filters, grouping, and visualization options.', - status: 'To Do' as const, - priority: 'Critical' as const, - assignee_id: USER_IDS.andrew, - due_date: daysFromNow(14), - start_date: null, - created_at: daysAgo(5), - updated_at: daysAgo(5), - tags: ['design', 'ui', 'reports'], - }, -]; - -const messages = [ - // Mobile App project messages - { - id: 'aaa11111-1111-1111-1111-111111111111', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.andrew, - content: 'Hey team! Great progress on the mobile app. Let\'s sync up tomorrow about the offline architecture.', - timestamp: daysAgo(5), - }, - { - id: 'aaa22222-2222-2222-2222-222222222222', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.mike, - content: 'Sounds good! I\'ve been looking into Firebase Cloud Messaging - should we use topics or individual device tokens?', - timestamp: daysAgo(5), - }, - { - id: 'aaa33333-3333-3333-3333-333333333333', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.emma, - content: 'I think topics would work better for broadcast messages, but we need individual tokens for task assignments.', - timestamp: daysAgo(5), - }, - { - id: 'aaa44444-4444-4444-4444-444444444444', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.andrew, - content: 'Good point Emma! Let\'s use a hybrid approach. I\'ll document the architecture.', - timestamp: daysAgo(4), - }, - { - id: 'aaa55555-5555-5555-5555-555555555555', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.alex, - content: 'Quick question - should I start on biometric auth now or wait for the offline sync to be done?', - timestamp: daysAgo(2), - }, - { - id: 'aaa66666-6666-6666-6666-666666666666', - project_id: PROJECT_IDS.mobile, - user_id: USER_IDS.andrew, - content: 'Wait for the sync to be done - the auth flow depends on it. Focus on code review for now.', - timestamp: daysAgo(2), - }, - - // Website project messages - { - id: 'bbb11111-1111-1111-1111-111111111111', - project_id: PROJECT_IDS.website, - user_id: USER_IDS.sarah, - content: 'The new design system looks amazing! Great work @Emma 🎨', - timestamp: daysAgo(10), - }, - { - id: 'bbb22222-2222-2222-2222-222222222222', - project_id: PROJECT_IDS.website, - user_id: USER_IDS.emma, - content: 'Thanks Sarah! I\'ve uploaded the Figma files and component documentation to the shared drive.', - timestamp: daysAgo(10), - }, - { - id: 'bbb33333-3333-3333-3333-333333333333', - project_id: PROJECT_IDS.website, - user_id: USER_IDS.mike, - content: 'Just started on the landing page implementation. The design tokens make it so much easier!', - timestamp: daysAgo(3), - }, - { - id: 'bbb44444-4444-4444-4444-444444444444', - project_id: PROJECT_IDS.website, - user_id: USER_IDS.alex, - content: 'I\'ve submitted the Core Web Vitals optimization for review. LCP went from 3.2s to 1.8s! 🚀', - timestamp: daysAgo(2), - }, - - // API project messages - { - id: 'ccc11111-1111-1111-1111-111111111111', - project_id: PROJECT_IDS.api, - user_id: USER_IDS.andrew, - content: 'GraphQL schema is finalized and documented. Ready for implementation!', - timestamp: daysAgo(5), - }, - { - id: 'ccc22222-2222-2222-2222-222222222222', - project_id: PROJECT_IDS.api, - user_id: USER_IDS.sarah, - content: 'I\'ll start on the API docs this week. Any specific sections you want me to prioritize?', - timestamp: daysAgo(4), - }, - { - id: 'ccc33333-3333-3333-3333-333333333333', - project_id: PROJECT_IDS.api, - user_id: USER_IDS.andrew, - content: 'Authentication and the task endpoints would be most useful first - those are what developers ask about most.', - timestamp: daysAgo(4), - }, -]; - -const comments = [ - // Comments on push notification task - { - id: 'ddd11111-1111-1111-1111-111111111111', - task_id: TASK_IDS.task1, - user_id: USER_IDS.andrew, - content: 'Make sure to handle the case where users deny notification permissions - we need a graceful fallback.', - created_at: daysAgo(2), - }, - { - id: 'ddd22222-2222-2222-2222-222222222222', - task_id: TASK_IDS.task1, - user_id: USER_IDS.mike, - content: 'Good point! I\'ll add an in-app notification center as a fallback for users who disable push notifications.', - created_at: daysAgo(2), - }, - { - id: 'ddd33333-3333-3333-3333-333333333333', - task_id: TASK_IDS.task1, - user_id: USER_IDS.emma, - content: 'We should also consider notification grouping for Android - don\'t want to spam users with individual notifications.', - created_at: daysAgo(1), - }, - - // Comments on task creation form - { - id: 'ddd44444-4444-4444-4444-444444444444', - task_id: TASK_IDS.task3, - user_id: USER_IDS.sarah, - content: 'Can we add a keyboard shortcut to quickly create tasks? Maybe Cmd+N or similar?', - created_at: daysAgo(3), - }, - { - id: 'ddd55555-5555-5555-5555-555555555555', - task_id: TASK_IDS.task3, - user_id: USER_IDS.emma, - content: 'Added! Also implemented Cmd+Enter to submit the form. Updating the PR now.', - created_at: daysAgo(2), - }, - - // Comments on landing page - { - id: 'ddd66666-6666-6666-6666-666666666666', - task_id: TASK_IDS.task7, - user_id: USER_IDS.sarah, - content: 'The hero section looks great! Can we add a subtle animation to the pricing cards on scroll?', - created_at: daysAgo(2), - }, - { - id: 'ddd77777-7777-7777-7777-777777777777', - task_id: TASK_IDS.task7, - user_id: USER_IDS.mike, - content: 'Sure! I\'ll use Intersection Observer for a fade-in effect. Should be smooth even on mobile.', - created_at: daysAgo(1), - }, - - // Comments on Core Web Vitals - { - id: 'ddd88888-8888-8888-8888-888888888888', - task_id: TASK_IDS.task9, - user_id: USER_IDS.andrew, - content: 'Amazing performance improvements! Did you try preloading the LCP image?', - created_at: daysAgo(3), - }, - { - id: 'ddd99999-9999-9999-9999-999999999999', - task_id: TASK_IDS.task9, - user_id: USER_IDS.alex, - content: 'Yes! That alone shaved off 400ms. Also implemented dynamic imports for below-the-fold components.', - created_at: daysAgo(2), - }, - - // Comments on GraphQL schema - { - id: 'eee11111-1111-1111-1111-111111111111', - task_id: TASK_IDS.task10, - user_id: USER_IDS.sarah, - content: 'Should we add cursor-based pagination from the start, or is offset pagination fine for v2?', - created_at: daysAgo(8), - }, - { - id: 'eee22222-2222-2222-2222-222222222222', - task_id: TASK_IDS.task10, - user_id: USER_IDS.andrew, - content: 'Let\'s go with cursor-based - it handles real-time updates better and we won\'t need to migrate later.', - created_at: daysAgo(7), - }, - - // Comments on rate limiting - { - id: 'eee33333-3333-3333-3333-333333333333', - task_id: TASK_IDS.task11, - user_id: USER_IDS.mike, - content: 'What should be the default rate limit for free tier users?', - created_at: daysAgo(1), - }, - { - id: 'eee44444-4444-4444-4444-444444444444', - task_id: TASK_IDS.task11, - user_id: USER_IDS.andrew, - content: 'Let\'s start with 1000 requests/hour for free tier, 10000 for pro, and unlimited for enterprise.', - created_at: daysAgo(1), - }, - - // Comments on chart components - { - id: 'eee55555-5555-5555-5555-555555555555', - task_id: TASK_IDS.task14, - user_id: USER_IDS.sarah, - content: 'Love the real-time updates! Can we add a loading skeleton while data is being fetched?', - created_at: daysAgo(2), - }, - { - id: 'eee66666-6666-6666-6666-666666666666', - task_id: TASK_IDS.task14, - user_id: USER_IDS.emma, - content: 'Already on it! Using a shimmer effect that matches the chart dimensions.', - created_at: daysAgo(1), - }, -]; - -const activityLogs = [ - // Recent activity - { - id: 'fff11111-1111-1111-1111-111111111111', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task1, - action: 'Updated' as const, - details: 'Status changed from "To Do" to "In Progress".', - user_id: USER_IDS.mike, - timestamp: daysAgo(3), - }, - { - id: 'fff22222-2222-2222-2222-222222222222', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task2, - action: 'Moved' as const, - details: 'Status changed from "Review" to "Done".', - user_id: USER_IDS.andrew, - timestamp: daysAgo(2), - }, - { - id: 'fff33333-3333-3333-3333-333333333333', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task3, - action: 'Updated' as const, - details: 'Status changed from "In Progress" to "Review".', - user_id: USER_IDS.emma, - timestamp: daysAgo(1), - }, - { - id: 'fff44444-4444-4444-4444-444444444444', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task7, - action: 'Commented' as const, - details: 'Comment added on "Build landing page".', - user_id: USER_IDS.sarah, - timestamp: daysAgo(2), - }, - { - id: 'fff55555-5555-5555-5555-555555555555', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task9, - action: 'Updated' as const, - details: 'Status changed from "In Progress" to "Review".', - user_id: USER_IDS.alex, - timestamp: daysAgo(2), - }, - { - id: 'fff66666-6666-6666-6666-666666666666', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task10, - action: 'Moved' as const, - details: 'Status changed from "Review" to "Done".', - user_id: USER_IDS.andrew, - timestamp: daysAgo(5), - }, - { - id: 'fff77777-7777-7777-7777-777777777777', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task6, - action: 'Moved' as const, - details: 'Status changed from "Review" to "Done".', - user_id: USER_IDS.emma, - timestamp: daysAgo(10), - }, - { - id: 'fff88888-8888-8888-8888-888888888888', - entity_type: 'Project' as const, - entity_id: PROJECT_IDS.analytics, - action: 'Created' as const, - details: 'Project "Analytics Dashboard" created.', - user_id: USER_IDS.sarah, - timestamp: daysAgo(20), - }, - { - id: 'fff99999-9999-9999-9999-999999999999', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task14, - action: 'Created' as const, - details: 'Task "Build real-time chart components" created.', - user_id: USER_IDS.emma, - timestamp: daysAgo(10), - }, - { - id: 'fff00000-0000-0000-0000-000000000000', - entity_type: 'Task' as const, - entity_id: TASK_IDS.task11, - action: 'Updated' as const, - details: 'Status changed from "To Do" to "In Progress".', - user_id: USER_IDS.alex, - timestamp: daysAgo(2), - }, -]; - -export async function POST() { - const supabase = getSupabase(); - const results: string[] = []; - let hasErrors = false; - - try { - // Clear existing data - results.push('🗑️ Clearing existing data...'); - - await supabase.from('comments').delete().gte('id', '00000000-0000-0000-0000-000000000000'); - await supabase.from('messages').delete().gte('id', '00000000-0000-0000-0000-000000000000'); - await supabase.from('activity_logs').delete().gte('id', '00000000-0000-0000-0000-000000000000'); - await supabase.from('tasks').delete().gte('id', '00000000-0000-0000-0000-000000000000'); - await supabase.from('projects').delete().gte('id', '00000000-0000-0000-0000-000000000000'); - - results.push('✅ Existing data cleared'); - - // Seed users - results.push('👥 Seeding users...'); - const { error: usersError } = await supabase.from('users').upsert(users, { onConflict: 'id' }); - if (usersError) { - results.push(`❌ Error seeding users: ${usersError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${users.length} users`); - } - - // Seed projects - results.push('📁 Seeding projects...'); - const { error: projectsError } = await supabase.from('projects').insert(projects); - if (projectsError) { - results.push(`❌ Error seeding projects: ${projectsError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${projects.length} projects`); - } - - // Seed tasks - results.push('📋 Seeding tasks...'); - const { error: tasksError } = await supabase.from('tasks').insert(tasks); - if (tasksError) { - results.push(`❌ Error seeding tasks: ${tasksError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${tasks.length} tasks`); - } - - // Seed messages - results.push('💬 Seeding messages...'); - const { error: messagesError } = await supabase.from('messages').insert(messages); - if (messagesError) { - results.push(`❌ Error seeding messages: ${messagesError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${messages.length} messages`); - } - - // Seed comments - results.push('📝 Seeding comments...'); - const { error: commentsError } = await supabase.from('comments').insert(comments); - if (commentsError) { - results.push(`❌ Error seeding comments: ${commentsError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${comments.length} comments`); - } - - // Seed activity logs - results.push('📊 Seeding activity logs...'); - const { error: logsError } = await supabase.from('activity_logs').insert(activityLogs); - if (logsError) { - results.push(`❌ Error seeding activity logs: ${logsError.message}`); - hasErrors = true; - } else { - results.push(`✅ Seeded ${activityLogs.length} activity logs`); - } - - // Summary - if (!hasErrors) { - results.push(''); - results.push('🎉 Database seeded successfully!'); - results.push(''); - results.push('📋 Summary:'); - results.push(` • ${users.length} users`); - results.push(` • ${projects.length} projects`); - results.push(` • ${tasks.length} tasks`); - results.push(` • ${messages.length} messages`); - results.push(` • ${comments.length} comments`); - results.push(` • ${activityLogs.length} activity logs`); - } - - return NextResponse.json({ - success: !hasErrors, - results, - }); - } catch (error) { - return NextResponse.json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - results, - }, { status: 500 }); - } -} - -export async function GET() { - return NextResponse.json({ - message: 'Use POST to seed the database with sample data', - warning: 'This will clear existing projects, tasks, messages, comments, and activity logs!', - }); -} diff --git a/app/api/setup/confirm-admin/route.ts b/app/api/setup/confirm-admin/route.ts new file mode 100644 index 0000000..9b82d3c --- /dev/null +++ b/app/api/setup/confirm-admin/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server'; + +function getProjectRef(supabaseUrl: string) { + try { + const host = new URL(supabaseUrl).hostname; + const [ref, ...rest] = host.split('.'); + if (!ref || rest.join('.') !== 'supabase.co') { + return null; + } + return ref; + } catch { + return null; + } +} + +function escapeSqlLiteral(value: string) { + return value.replaceAll("'", "''"); +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Unknown setup error'; +} + +export async function POST(request: Request) { + try { + const body = await request.json() as { + supabaseUrl?: string; + accessToken?: string; + userId?: string; + email?: string; + }; + + const supabaseUrl = body.supabaseUrl?.trim(); + const accessToken = body.accessToken?.trim(); + const userId = body.userId?.trim(); + const email = body.email?.trim().toLowerCase(); + const projectRef = supabaseUrl ? getProjectRef(supabaseUrl) : null; + + if (!supabaseUrl || !projectRef) { + return NextResponse.json( + { ok: false, error: 'Enter a valid Supabase project URL.' }, + { status: 400 } + ); + } + + if (!accessToken) { + return NextResponse.json( + { ok: false, error: 'Enter a Supabase access token to confirm the admin email.' }, + { status: 400 } + ); + } + + if (!userId || !email) { + return NextResponse.json( + { ok: false, error: 'Missing admin user id or email.' }, + { status: 400 } + ); + } + + const sql = ` + update auth.users + set + email_confirmed_at = coalesce(email_confirmed_at, now()), + updated_at = now() + where id = '${escapeSqlLiteral(userId)}'::uuid + and lower(email) = '${escapeSqlLiteral(email)}'; + `; + + const response = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: sql, + read_only: false, + }), + }); + + const result = await response.json().catch(() => null); + + if (!response.ok) { + return NextResponse.json( + { + ok: false, + error: result?.message || result?.error || 'Supabase could not confirm the admin email.', + }, + { status: response.status } + ); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json( + { ok: false, error: getErrorMessage(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/setup/create-admin/route.ts b/app/api/setup/create-admin/route.ts new file mode 100644 index 0000000..37d9a66 --- /dev/null +++ b/app/api/setup/create-admin/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from 'next/server'; + +function getProjectRef(supabaseUrl: string) { + try { + const host = new URL(supabaseUrl).hostname; + const [ref, ...rest] = host.split('.'); + if (!ref || rest.join('.') !== 'supabase.co') { + return null; + } + return ref; + } catch { + return null; + } +} + +function escapeSqlLiteral(value: string) { + return value.replaceAll("'", "''"); +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Unknown setup error'; +} + +export async function POST(request: Request) { + try { + const body = await request.json() as { + supabaseUrl?: string; + accessToken?: string; + name?: string; + email?: string; + password?: string; + }; + + const supabaseUrl = body.supabaseUrl?.trim(); + const accessToken = body.accessToken?.trim(); + const name = body.name?.trim(); + const email = body.email?.trim().toLowerCase(); + const password = body.password ?? ''; + const projectRef = supabaseUrl ? getProjectRef(supabaseUrl) : null; + + if (!supabaseUrl || !projectRef) { + return NextResponse.json( + { ok: false, error: 'Enter a valid Supabase project URL.' }, + { status: 400 } + ); + } + + if (!accessToken) { + return NextResponse.json( + { ok: false, error: 'Enter a Supabase access token to create the admin.' }, + { status: 400 } + ); + } + + if (!name || !email || password.length < 6) { + return NextResponse.json( + { ok: false, error: 'Enter admin name, email, and a password with at least 6 characters.' }, + { status: 400 } + ); + } + + const safeName = escapeSqlLiteral(name); + const safeEmail = escapeSqlLiteral(email); + const safePassword = escapeSqlLiteral(password); + + const sql = ` + with upsert_auth_user as ( + insert into auth.users ( + id, + instance_id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at + ) + values ( + gen_random_uuid(), + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + '${safeEmail}', + crypt('${safePassword}', gen_salt('bf')), + now(), + '{"provider":"email","providers":["email"]}'::jsonb, + jsonb_build_object('name', '${safeName}'), + now(), + now() + ) + on conflict (email) do update set + encrypted_password = excluded.encrypted_password, + email_confirmed_at = coalesce(auth.users.email_confirmed_at, now()), + raw_app_meta_data = excluded.raw_app_meta_data, + raw_user_meta_data = excluded.raw_user_meta_data, + updated_at = now() + returning id, email + ), + upsert_identity as ( + insert into auth.identities ( + id, + user_id, + provider_id, + identity_data, + provider, + last_sign_in_at, + created_at, + updated_at + ) + select + gen_random_uuid(), + id, + id::text, + jsonb_build_object( + 'sub', id::text, + 'email', email, + 'email_verified', true, + 'phone_verified', false + ), + 'email', + now(), + now(), + now() + from upsert_auth_user + where not exists ( + select 1 + from auth.identities existing_identity + where existing_identity.user_id = upsert_auth_user.id + and existing_identity.provider = 'email' + ) + returning user_id + ) + insert into public.users ( + id, + email, + name, + role, + skills, + wellness_score, + max_workload + ) + select + id, + email, + '${safeName}', + 'Admin', + '{}'::text[], + 85, + 5 + from upsert_auth_user + on conflict (id) do update set + email = excluded.email, + name = excluded.name, + role = 'Admin', + wellness_score = 85, + max_workload = 5; + `; + + const response = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: sql, + read_only: false, + }), + }); + + const result = await response.json().catch(() => null); + + if (!response.ok) { + return NextResponse.json( + { + ok: false, + error: result?.message || result?.error || 'Supabase could not create the admin login.', + }, + { status: response.status } + ); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json( + { ok: false, error: getErrorMessage(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/setup/schema/route.ts b/app/api/setup/schema/route.ts new file mode 100644 index 0000000..7fcc4bb --- /dev/null +++ b/app/api/setup/schema/route.ts @@ -0,0 +1,340 @@ +import { NextResponse } from 'next/server'; + +const TASKFLOW_SCHEMA_SQL = ` +create extension if not exists pgcrypto; + +create table if not exists public.users ( + id uuid primary key, + email text unique, + name text not null, + avatar_url text, + role text not null default 'Member' check (role in ('Admin', 'Manager', 'Member')), + created_at timestamptz not null default now(), + dob date, + skill_experience jsonb not null default '{}'::jsonb, + skills text[] not null default '{}'::text[], + wellness_score integer not null default 85, + max_workload integer not null default 5, + phone text, + office_address text, + age integer, + timezone text, + quiet_hours_start text, + quiet_hours_end text, + quiet_hours_weekends boolean not null default false, + two_factor_enabled boolean not null default false, + company_size text, + burnout_sensitivity integer not null default 50, + auto_assign boolean not null default true, + skill_match_priority boolean not null default true, + ai_deadlines boolean not null default true +); + +create table if not exists public.projects ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text, + key text not null, + owner_id uuid references public.users(id) on delete set null, + meeting_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.project_members ( + project_id uuid not null references public.projects(id) on delete cascade, + user_id uuid not null references public.users(id) on delete cascade, + role text not null default 'Member', + joined_at timestamptz not null default now(), + primary key (project_id, user_id) +); + +create table if not exists public.tasks ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + title text not null, + description text, + status text not null default 'To Do', + priority text not null default 'Medium', + assignee_id uuid references public.users(id) on delete set null, + due_date date, + start_date date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + tags text[] not null default '{}'::text[], + time_logs jsonb, + active_timer_start timestamptz, + dependencies text[] not null default '{}'::text[] +); + +create table if not exists public.activity_logs ( + id uuid primary key default gen_random_uuid(), + entity_type text not null, + entity_id uuid, + action text not null, + details text, + user_id uuid references public.users(id) on delete set null, + timestamp timestamptz not null default now() +); + +create table if not exists public.messages ( + id uuid primary key default gen_random_uuid(), + project_id uuid references public.projects(id) on delete cascade, + user_id uuid not null references public.users(id) on delete cascade, + content text not null, + timestamp timestamptz not null default now(), + attachment jsonb, + conversation_type text not null default 'project', + recipient_id uuid references public.users(id) on delete cascade, + thread_root_id uuid references public.messages(id) on delete cascade, + reactions jsonb, + is_pinned boolean not null default false +); + +create table if not exists public.comments ( + id uuid primary key default gen_random_uuid(), + task_id uuid not null references public.tasks(id) on delete cascade, + user_id uuid not null references public.users(id) on delete cascade, + content text not null, + created_at timestamptz not null default now() +); + +create table if not exists public.forms ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + title text not null, + description text, + fields jsonb not null default '[]'::jsonb, + status text not null default 'draft', + created_by uuid references public.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.form_responses ( + id uuid primary key default gen_random_uuid(), + form_id uuid not null references public.forms(id) on delete cascade, + respondent_id uuid references public.users(id) on delete set null, + answers jsonb not null default '{}'::jsonb, + submitted_at timestamptz not null default now() +); + +create table if not exists public.documents ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + title text not null, + type text not null, + content text, + file_path text, + file_type text, + size integer, + created_by uuid references public.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.shortcuts ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + name text not null, + url text not null, + type text not null default 'link', + created_at timestamptz not null default now() +); + +create table if not exists public.repo_links ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + name text not null, + url text not null, + owner text not null, + repo text not null, + description text, + added_at timestamptz not null default now() +); + +create table if not exists public.notifications ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.users(id) on delete cascade, + type text not null, + title text not null, + message text not null, + is_read boolean not null default false, + link text, + entity_id uuid, + project_id uuid references public.projects(id) on delete cascade, + created_at timestamptz not null default now() +); + +create table if not exists public.deployments ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + version text not null, + environment text not null, + status text not null, + release_notes text, + created_by uuid references public.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.deployment_tasks ( + deployment_id uuid not null references public.deployments(id) on delete cascade, + task_id uuid not null references public.tasks(id) on delete cascade, + linked_at timestamptz not null default now(), + primary key (deployment_id, task_id) +); + +create table if not exists public.time_entries ( + id uuid primary key default gen_random_uuid(), + task_id uuid not null references public.tasks(id) on delete cascade, + user_id uuid not null references public.users(id) on delete cascade, + project_id uuid references public.projects(id) on delete cascade, + start_time timestamptz not null, + end_time timestamptz, + duration_minutes integer, + note text, + created_at timestamptz not null default now() +); + +create table if not exists public.otps ( + id uuid primary key default gen_random_uuid(), + email text not null, + otp text not null, + created_at timestamptz not null default now(), + expires_at timestamptz not null +); + +create or replace function public.get_autocomplete_data() +returns jsonb +language sql +stable +as $$ + select jsonb_build_object( + 'skills', coalesce((select jsonb_agg(distinct skill) from public.users, unnest(skills) as skill), '[]'::jsonb), + 'tags', coalesce((select jsonb_agg(distinct tag) from public.tasks, unnest(tags) as tag), '[]'::jsonb), + 'titles', coalesce((select jsonb_agg(distinct title) from public.tasks), '[]'::jsonb) + ); +$$; + +create or replace function public.get_admin_create_user_v2_definition() +returns text +language sql +stable +as $$ + select 'TaskFlow setup created the public schema. New users are created through Supabase Auth signUp during setup.'; +$$; + +create or replace function public.admin_delete_user(p_user_id uuid) +returns void +language sql +security definer +as $$ + delete from public.users where id = p_user_id; +$$; + +create index if not exists idx_project_members_user_id on public.project_members(user_id); +create index if not exists idx_tasks_project_id on public.tasks(project_id); +create index if not exists idx_tasks_assignee_id on public.tasks(assignee_id); +create index if not exists idx_messages_project_id on public.messages(project_id); +create index if not exists idx_messages_recipient_id on public.messages(recipient_id); +create index if not exists idx_notifications_user_id on public.notifications(user_id); +create index if not exists idx_time_entries_user_id on public.time_entries(user_id); +`; + +function getProjectRef(supabaseUrl: string) { + try { + const host = new URL(supabaseUrl).hostname; + const [ref, ...rest] = host.split('.'); + if (!ref || rest.join('.') !== 'supabase.co') { + return null; + } + return ref; + } catch { + return null; + } +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Unknown setup error'; +} + +export async function POST(request: Request) { + try { + const body = await request.json() as { + supabaseUrl?: string; + accessToken?: string; + }; + + const supabaseUrl = body.supabaseUrl?.trim(); + const accessToken = body.accessToken?.trim(); + const projectRef = supabaseUrl ? getProjectRef(supabaseUrl) : null; + + if (!supabaseUrl || !projectRef) { + return NextResponse.json( + { ok: false, error: 'Enter a valid Supabase project URL.' }, + { status: 400 } + ); + } + + if (!accessToken) { + return NextResponse.json( + { ok: false, error: 'Enter a Supabase access token to create tables.' }, + { status: 400 } + ); + } + + const response = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: TASKFLOW_SCHEMA_SQL, + read_only: false, + }), + }); + + const result = await response.json().catch(() => null); + + if (!response.ok) { + return NextResponse.json( + { + ok: false, + error: result?.message || result?.error || 'Supabase could not create the tables.', + }, + { status: response.status } + ); + } + + return NextResponse.json({ + ok: true, + projectRef, + tables: [ + 'users', + 'projects', + 'project_members', + 'tasks', + 'activity_logs', + 'messages', + 'comments', + 'forms', + 'form_responses', + 'documents', + 'shortcuts', + 'repo_links', + 'notifications', + 'deployments', + 'deployment_tasks', + 'time_entries', + 'otps', + ], + }); + } catch (error) { + return NextResponse.json( + { ok: false, error: getErrorMessage(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/shortcuts/route.ts b/app/api/shortcuts/route.ts new file mode 100644 index 0000000..3c80e67 --- /dev/null +++ b/app/api/shortcuts/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const projectId = searchParams.get('projectId'); + + if (!projectId) return NextResponse.json({ error: 'projectId is required' }, { status: 400 }); + return NextResponse.json(await db.getShortcuts(projectId)); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + if (!body.project_id || !body.name || !body.url) + return NextResponse.json({ error: 'project_id, name, and url are required' }, { status: 400 }); + const shortcut = await db.addShortcut({ + id: body.id || crypto.randomUUID(), + project_id: body.project_id, + name: body.name, + url: body.url, + type: body.type || 'link', + }); + if (!shortcut) + return NextResponse.json({ error: 'Failed to create shortcut' }, { status: 500 }); + return NextResponse.json(shortcut, { status: 201 }); + } catch (error) { + console.error('Error creating shortcut:', error); + return NextResponse.json({ error: 'Failed to create shortcut' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const id = searchParams.get('id'); + + if (!id) return NextResponse.json({ error: 'Shortcut id is required' }, { status: 400 }); + const success = await db.deleteShortcut(id); + if (!success) return NextResponse.json({ error: 'Failed to delete shortcut' }, { status: 500 }); + return NextResponse.json({ success: true }); +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index 4b4dd1f..cc88ae4 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -1,13 +1,37 @@ import { db } from '@/lib/db'; import { Task } from '@/types'; import { NextResponse } from 'next/server'; +import { sendTaskAssigned, sendTaskSwapped } from '@/lib/email'; export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const projectId = searchParams.get('projectId'); + const userId = searchParams.get('userId'); // Needed for visibility checks + + if (!userId) { + return NextResponse.json({ error: 'User ID is required' }, { status: 401 }); + } + + const user = await db.getUser(userId); + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + // Visibility check: If user is not Admin, check project membership + if (projectId && user.role !== 'Admin') { + const userProjects = await db.getProjects(userId); + const isMember = userProjects.some(p => p.id === projectId); + if (!isMember) { + return NextResponse.json({ error: 'Access denied: You are not a member of this project' }, { status: 403 }); + } + } + + let tasks = await db.getTasks(projectId || undefined); + + // RBAC: Members cannot see private tasks they aren't assigned to + if (user.role === 'Member') { + tasks = tasks.filter((t: Task) => !t.isPrivate || t.assigneeId === userId); + } - const tasks = await db.getTasks(projectId || undefined); return NextResponse.json(tasks); } catch (error) { console.error('Error fetching tasks:', error); @@ -18,11 +42,28 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { const body = await request.json(); + const { searchParams } = new URL(request.url); + const requestUserId = searchParams.get('userId') || body.userId; + + if (!requestUserId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const requestUser = await db.getUser(requestUserId); + if (!requestUser) return NextResponse.json({ error: 'User not found' }, { status: 404 }); if (!body.title || !body.projectId) { return NextResponse.json({ error: 'Title and Project ID are required' }, { status: 400 }); } + // RBAC: Members can only create tasks assigned to themselves or unassigned, and they cannot create private tasks + if (requestUser.role === 'Member') { + if (body.assigneeId && body.assigneeId !== requestUser.id) { + return NextResponse.json({ error: 'Members can only assign tasks to themselves' }, { status: 403 }); + } + if (body.isPrivate) { + return NextResponse.json({ error: 'Members cannot create private tasks' }, { status: 403 }); + } + } + const newTask: Task = { id: crypto.randomUUID(), projectId: body.projectId, @@ -36,9 +77,45 @@ export async function POST(request: Request) { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), tags: body.tags || [], + isPrivate: body.isPrivate || false, + dependencies: body.dependencies || [], }; - await db.addTask(newTask); + await db.addTask(newTask, requestUserId as string); + + // Auto-add assignee to project if not already a member + if (body.assigneeId) { + const projectMembers = await db.getProjectMembers(body.projectId); + if (!projectMembers.includes(body.assigneeId)) { + await db.addProjectMember(body.projectId, body.assigneeId, 'Member'); + } + + // Notify assignee if it's someone else + if (body.assigneeId !== requestUserId) { + const project = await db.getProject(body.projectId); + await db.addNotification({ + userId: body.assigneeId, + type: 'task_assigned', + title: 'New Task Assigned', + message: `You have been assigned to "${newTask.title}" in ${project?.name || 'a project'}`, + link: `/projects/${body.projectId}?task=${newTask.id}`, + entityId: newTask.id, + projectId: body.projectId, + }); + // Send email notification (fire-and-forget) + const assigneeUser = await db.getUser(body.assigneeId); + if (assigneeUser?.email) { + sendTaskAssigned( + assigneeUser.email, + newTask.title, + project?.name || 'a project', + requestUser.name, + `${process.env.NEXT_PUBLIC_APP_URL || ''}/projects/${body.projectId}?task=${newTask.id}` + ).catch(e => console.error('Task assigned email error:', e)); + } + } + } + return NextResponse.json(newTask); } catch (error) { console.error('Error creating task:', error); @@ -49,16 +126,148 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { try { const body = await request.json(); - const { id, ...updates } = body; + const { id, userId, ...updates } = body; if (!id) { return NextResponse.json({ error: 'Task ID is required' }, { status: 400 }); } + if (!userId) { + return NextResponse.json({ error: 'User ID is required' }, { status: 401 }); + } + + const requestUser = await db.getUser(userId); + if (!requestUser) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + const existingTask = await db.getTaskById(id); + if (!existingTask) return NextResponse.json({ error: 'Task not found' }, { status: 404 }); - const updatedTask = await db.updateTask(id, updates); + if (requestUser.role === 'Member') { + if (existingTask.assigneeId !== requestUser.id) { + return NextResponse.json({ error: 'Members can only update their own tasks' }, { status: 403 }); + } + const restrictedFields = ['title', 'description', 'priority', 'dueDate', 'isPrivate', 'dependencies', 'assigneeId']; + const attemptRestrictedEdit = restrictedFields.some(field => field in updates); + + if (attemptRestrictedEdit) { + return NextResponse.json({ error: 'Members cannot edit task metadata (title, priority, due date, etc.)' }, { status: 403 }); + } + } + + // Validate state transitions + if (updates.status && updates.status !== existingTask.status && requestUser.role !== 'Admin') { + const validTransitions: Record = { + 'To Do': ['In Progress'], + 'In Progress': ['Review', 'To Do'], + 'Review': ['Done', 'In Progress', 'To Do'], + 'Done': ['In Progress', 'To Do'] + }; + + const allowedNextStates = validTransitions[existingTask.status] || []; + if (!allowedNextStates.includes(updates.status)) { + return NextResponse.json({ error: `Invalid transition from ${existingTask.status} to ${updates.status}` }, { status: 400 }); + } + + // Dependency checks when moving to Done or Review + if (updates.status === 'Review' || updates.status === 'Done') { + if (existingTask.dependencies && existingTask.dependencies.length > 0) { + const tasks = await db.getTasks(existingTask.projectId); // Simplified, assume in same project + const blockingTasks = tasks.filter(t => existingTask.dependencies!.includes(t.id) && t.status !== 'Done'); + + if (blockingTasks.length > 0) { + return NextResponse.json({ error: 'Cannot transition task because dependencies are not Done' }, { status: 400 }); + } + } + } + } + + const updatedTask = await db.updateTask(id, { ...updates, updatedAt: new Date().toISOString() }, userId); if (!updatedTask) { - return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + return NextResponse.json({ error: 'Failed to update task' }, { status: 500 }); + } + + // Auto-add assignee to project if updated to a non-member + if (updates.assigneeId && updates.assigneeId !== existingTask.assigneeId) { + const projectMembers = await db.getProjectMembers(existingTask.projectId); + if (!projectMembers.includes(updates.assigneeId)) { + await db.addProjectMember(existingTask.projectId, updates.assigneeId, 'Member'); + } + + // Notify new assignee if it's someone else + if (updates.assigneeId !== userId) { + const project = await db.getProject(existingTask.projectId); + const taskLink = `${process.env.NEXT_PUBLIC_APP_URL || ''}/projects/${existingTask.projectId}?task=${updatedTask.id}`; + await db.addNotification({ + userId: updates.assigneeId, + type: 'task_assigned', + title: 'New Task Assigned', + message: `You have been assigned to "${updatedTask.title}" in ${project?.name || 'a project'}`, + link: `/projects/${existingTask.projectId}?task=${updatedTask.id}`, + entityId: updatedTask.id, + projectId: existingTask.projectId, + }); + // Send email to new assignee + const newAssigneeUser = await db.getUser(updates.assigneeId); + if (newAssigneeUser?.email) { + sendTaskAssigned( + newAssigneeUser.email, + updatedTask.title, + project?.name || 'a project', + requestUser.name, + taskLink + ).catch(e => console.error('Task assigned email error:', e)); + } + // If this is a swap, also notify the previous assignee + if (updates.isSwap && existingTask.assigneeId) { + const prevAssigneeUser = await db.getUser(existingTask.assigneeId); + if (prevAssigneeUser?.email) { + sendTaskSwapped( + prevAssigneeUser.email, + updatedTask.title, + project?.name || 'a project', + prevAssigneeUser.name, + newAssigneeUser?.name || 'another team member', + taskLink + ).catch(e => console.error('Task swapped email error:', e)); + } + } + } + } + + // Notify managers/admins & assignee if status changes + if (updates.status && updates.status !== existingTask.status) { + const allUsers = await db.getUsers(); + const membersToNotify = new Set(); + + // Find all admins/managers in this project or broadly + const projectMembers = await db.getProjectMembers(existingTask.projectId); + allUsers.forEach(u => { + if ((u.role === 'Admin' || u.role === 'Manager') && projectMembers.includes(u.id)) { + membersToNotify.add(u.id); + } + }); + + // Add assignee to the notification pool if they didn't make the change + if (existingTask.assigneeId) { + membersToNotify.add(existingTask.assigneeId); + } + + // Don't notify the user who made the status change + membersToNotify.delete(userId); + + const project = await db.getProject(existingTask.projectId); + + for (const notifyUserId of membersToNotify) { + await db.addNotification({ + userId: notifyUserId, + type: 'task_status_changed', + title: 'Task Status Updated', + message: `"${updatedTask.title}" status changed to ${updates.status} in ${project?.name || 'a project'}`, + link: `/projects/${existingTask.projectId}?task=${updatedTask.id}`, + entityId: updatedTask.id, + projectId: existingTask.projectId, + }); + } } return NextResponse.json(updatedTask); @@ -72,10 +281,15 @@ export async function DELETE(request: Request) { try { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); - const userId = searchParams.get('userId') || 'system'; + const userId = searchParams.get('userId'); - if (!id) { - return NextResponse.json({ error: 'Task ID is required' }, { status: 400 }); + if (!id || !userId) { + return NextResponse.json({ error: 'Task ID and User ID are required' }, { status: 400 }); + } + + const requestUser = await db.getUser(userId); + if (!requestUser || requestUser.role === 'Member') { + return NextResponse.json({ error: 'Only Admins and Managers can delete tasks' }, { status: 403 }); } const success = await db.deleteTask(id, userId); diff --git a/app/api/team/burnout/route.ts b/app/api/team/burnout/route.ts deleted file mode 100644 index 3e2c0a4..0000000 --- a/app/api/team/burnout/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/lib/db'; - -export async function GET() { - try { - const users = await db.getUsers(); - const tasks = await db.getTasks(); - - const metrics = users.map(user => { - const activeTasks = tasks.filter(t => - t.assigneeId === user.id && - (t.status === 'To Do' || t.status === 'In Progress') - ); - const taskCount = activeTasks.length; - const maxWorkload = user.maxWorkload || 5; - const capacityPercent = Math.round((taskCount / maxWorkload) * 100); - - let burnoutRisk: 'Low' | 'Medium' | 'High' = 'Low'; - if (taskCount >= maxWorkload || user.wellnessScore < 50) { - burnoutRisk = 'High'; - } else if (taskCount >= maxWorkload - 1 || user.wellnessScore < 70) { - burnoutRisk = 'Medium'; - } - - return { - userId: user.id, - name: user.name, - email: user.email, - taskCount, - maxWorkload, - capacityPercent, - burnoutRisk, - wellnessScore: user.wellnessScore || 80, - }; - }); - - // Sort by risk level (High first) - const riskOrder = { High: 0, Medium: 1, Low: 2 }; - metrics.sort((a, b) => riskOrder[a.burnoutRisk] - riskOrder[b.burnoutRisk]); - - return NextResponse.json(metrics); - } catch (error) { - console.error('Error fetching burnout metrics:', error); - return NextResponse.json( - { error: 'Failed to fetch burnout metrics' }, - { status: 500 } - ); - } -} diff --git a/app/api/team/route.ts b/app/api/team/route.ts index f716238..de510a1 100644 --- a/app/api/team/route.ts +++ b/app/api/team/route.ts @@ -1,17 +1,64 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { User, Task } from '@/types'; +import { analyzeWellness } from '@/lib/ml-engine'; -export async function GET() { +export async function GET(request: Request) { try { - const users = await db.getUsers(); + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); + + let users = await db.getUsers(); + + if (userId) { + const currentUser = await db.getUser(userId); + + if (currentUser && currentUser.role !== 'Admin') { + // Get all projects this manager is part of + const userProjects = await db.getProjects(userId); + const projectIds = userProjects.map(p => p.id); + + // Get all members of those projects + const allowedMemberIds = new Set(); + for (const pid of projectIds) { + const members = await db.getProjectMembers(pid); + members.forEach(m => allowedMemberIds.add(m)); + } + + // Filter users to only those in the allowed set + users = users.filter(u => allowedMemberIds.has(u.id)); + } + } + const allTasks = await db.getTasks(); - const teamStats = users.map(user => { - const activeTasks = allTasks.filter(t => + const teamStats = await Promise.all(users.map(async user => { + const activeUserTasks = allTasks.filter(t => t.assigneeId === user.id && (t.status === 'To Do' || t.status === 'In Progress') - ).length; + ); + + const activeTasks = activeUserTasks.length; + + const highPriorityCount = activeUserTasks.filter(t => t.priority === 'High').length; + + const criticalUrgencyCount = activeUserTasks.filter(t => { + if (t.priority === 'Critical') return true; + if (t.dueDate && new Date(t.dueDate) < new Date()) return true; + return false; + }).length; + + let wellnessScore = user.wellnessScore || 100; + try { + const mlData = analyzeWellness({ + activeTasks, + highPriorityCount, + criticalUrgencyCount, + sensitivity: user.burnoutSensitivity, + }); + wellnessScore = mlData.score; + } catch (error) { + console.error('Wellness ML request failed:', error); + } const maxLoad = user.maxWorkload || 5; const utilization = Math.round((activeTasks / maxLoad) * 100); @@ -22,13 +69,14 @@ export async function GET() { return { ...user, + wellnessScore, // Override DB value with real-time ML calculation stats: { activeTasks, utilization, status } }; - }); + })); return NextResponse.json(teamStats); } catch (error) { diff --git a/app/api/time-entries/route.ts b/app/api/time-entries/route.ts new file mode 100644 index 0000000..e11f667 --- /dev/null +++ b/app/api/time-entries/route.ts @@ -0,0 +1,85 @@ +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const taskId = searchParams.get('taskId'); + const userId = searchParams.get('userId'); + + if (taskId) { + const entries = await db.getTimeEntries(taskId); + return NextResponse.json(entries); + } + + if (userId) { + const active = await db.getActiveTimer(userId); + return NextResponse.json(active); + } + + return NextResponse.json({ error: 'taskId or userId is required' }, { status: 400 }); + } catch (error) { + console.error('Error fetching time entries:', error); + return NextResponse.json({ error: 'Failed to fetch time entries' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { taskId, userId, projectId } = body; + + if (!taskId || !userId) { + return NextResponse.json({ error: 'taskId and userId are required' }, { status: 400 }); + } + + const newEntry = await db.startTimeEntry(taskId, userId, projectId); + if (!newEntry) { + return NextResponse.json({ error: 'Failed to start timer' }, { status: 500 }); + } + + return NextResponse.json(newEntry); + } catch (error) { + console.error('Error starting time entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH(request: Request) { + try { + const body = await request.json(); + const { id, note, userId, taskId } = body; + + if (!id && !userId) { + return NextResponse.json({ error: 'id or userId is required' }, { status: 400 }); + } + + if (userId) { + const stopNote = note || 'Logged via TaskFlow Timer'; + const stoppedEntries = await db.stopActiveTimersForUser(userId, taskId, stopNote); + + // Fallback for stale/mismatched filters: if a direct id was provided, attempt it too. + if (stoppedEntries.length === 0 && id) { + const updatedEntry = await db.stopTimeEntry(id, stopNote); + if (updatedEntry) { + return NextResponse.json(updatedEntry); + } + } + + return NextResponse.json({ + stoppedCount: stoppedEntries.length, + entries: stoppedEntries, + }); + } + + const updatedEntry = await db.stopTimeEntry(id, note); + if (!updatedEntry) { + return NextResponse.json({ error: 'Failed to stop timer' }, { status: 500 }); + } + + return NextResponse.json(updatedEntry); + } catch (error) { + console.error('Error stopping time entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/time-tracking/route.ts b/app/api/time-tracking/route.ts new file mode 100644 index 0000000..d2b5c75 --- /dev/null +++ b/app/api/time-tracking/route.ts @@ -0,0 +1,187 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSupabase } from '@/lib/supabase'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const projectId = searchParams.get('projectId'); + const userId = searchParams.get('userId'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + try { + const supabase = getSupabase(); + + // 1. Fetch all relevant time entries (Source of truth) + let query = supabase + .from('time_entries') + .select(` + id, + task_id, + user_id, + project_id, + start_time, + end_time, + duration_minutes, + note, + created_at + `); + + if (projectId) query = query.eq('project_id', projectId); + if (userId) query = query.eq('user_id', userId); + if (startDate) query = query.gte('start_time', startDate); + if (endDate) query = query.lte('start_time', endDate + 'T23:59:59'); + + const { data: entries, error: entriesError } = await query; + if (entriesError) throw entriesError; + + // 2. Fetch Supporting Data for Mapping + const [tasksRes, projectsRes, usersRes] = await Promise.all([ + supabase.from('tasks').select('id, title, status, priority, assignee_id, project_id'), + supabase.from('projects').select('id, name, key'), + supabase.from('users').select('id, name, email, avatar_url') + ]); + + const taskMap = new Map((tasksRes.data || []).map(t => [t.id, t])); + const projectMap = new Map((projectsRes.data || []).map(p => [p.id, p])); + const userMap = new Map((usersRes.data || []).map(u => [u.id, u])); + + // 3. Process Entries + interface AggregationEntry { + taskId: string; + taskTitle: string; + projectId: string; + projectName: string; + assigneeId: string; + userId: string; + userName: string; + minutes: number; + date: string; + isManual: boolean; + } + + const allEntries: AggregationEntry[] = []; + const activeTimers: any[] = []; + let activeTimerCount = 0; + + for (const entry of (entries || [])) { + const task = taskMap.get(entry.task_id); + const project = projectMap.get(entry.project_id || task?.project_id); + const user = userMap.get(entry.user_id); + + // If entry is finished, add to aggregates + if (entry.end_time) { + allEntries.push({ + taskId: entry.task_id, + taskTitle: task?.title || 'Unknown Task', + projectId: entry.project_id || task?.project_id || '', + projectName: project?.name || 'Unknown Project', + assigneeId: task?.assignee_id || '', + userId: entry.user_id, + userName: user?.name || 'Unknown User', + minutes: entry.duration_minutes || 0, + date: entry.start_time, + isManual: entry.note === 'Manual log' + }); + } else { + // Timer is active + activeTimerCount++; + activeTimers.push({ + id: entry.id, + taskId: entry.task_id, + taskTitle: task?.title || 'Unknown Task', + userId: entry.user_id, + userName: user?.name || 'Unknown User', + startedAt: entry.start_time, + }); + } + } + + // 4. Aggregate Tools (Matching existing API structure) + + // Per User + const perUser: Record = {}; + for (const e of allEntries) { + if (!perUser[e.userId]) { + perUser[e.userId] = { userId: e.userId, userName: e.userName, totalMinutes: 0, taskCount: 0, taskIds: new Set() }; + } + perUser[e.userId].totalMinutes += e.minutes; + perUser[e.userId].taskIds.add(e.taskId); + } + const perUserArray = Object.values(perUser).map(u => ({ + ...u, + taskCount: u.taskIds.size, + taskIds: undefined // cleanup + })); + + // Per Task + const perTask: Record = {}; + for (const e of allEntries) { + if (!perTask[e.taskId]) { + const task = taskMap.get(e.taskId); + perTask[e.taskId] = { + taskId: e.taskId, + taskTitle: e.taskTitle, + projectId: e.projectId, + projectName: e.projectName, + assigneeId: e.assigneeId, + assigneeName: userMap.get(e.assigneeId)?.name || 'Unassigned', + totalMinutes: 0, + lastEntry: e.date + }; + } + perTask[e.taskId].totalMinutes += e.minutes; + if (e.date > perTask[e.taskId].lastEntry) perTask[e.taskId].lastEntry = e.date; + } + + // Per Project + const perProject: Record = {}; + for (const e of allEntries) { + if (!perProject[e.projectId]) { + perProject[e.projectId] = { projectId: e.projectId, projectName: e.projectName, totalMinutes: 0, taskCount: 0, taskIds: new Set() }; + } + perProject[e.projectId].totalMinutes += e.minutes; + perProject[e.projectId].taskIds.add(e.taskId); + } + const perProjectArray = Object.values(perProject).map(p => ({ + ...p, + taskCount: p.taskIds.size, + taskIds: undefined + })); + + // Daily Trend + const daily: Record = {}; + for (const e of allEntries) { + const day = new Date(e.date).toISOString().split('T')[0]; + daily[day] = (daily[day] || 0) + e.minutes; + } + const dailySorted = Object.entries(daily) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, minutes]) => ({ date, minutes })); + + // Final Summary + const totalMinutes = allEntries.reduce((sum, e) => sum + e.minutes, 0); + const uniqueTasks = new Set(allEntries.map(e => e.taskId)); + + return NextResponse.json({ + summary: { + totalMinutes, + totalHours: parseFloat((totalMinutes / 60).toFixed(1)), + avgMinutesPerTask: uniqueTasks.size > 0 ? Math.round(totalMinutes / uniqueTasks.size) : 0, + avgHoursPerTask: uniqueTasks.size > 0 ? parseFloat((totalMinutes / 60 / uniqueTasks.size).toFixed(1)) : 0, + tasksWithTimeLogs: uniqueTasks.size, + activeTimerCount, + }, + perUser: perUserArray.sort((a, b) => b.totalMinutes - a.totalMinutes), + perTask: Object.values(perTask).sort((a, b) => b.totalMinutes - a.totalMinutes), + perProject: perProjectArray.sort((a, b) => b.totalMinutes - a.totalMinutes), + dailyTrend: dailySorted, + activeTimers, + users: (usersRes.data || []).map(u => ({ id: u.id, name: u.name })), + projects: (projectsRes.data || []).map(p => ({ id: p.id, name: p.name })), + }); + + } catch (error: any) { + console.error('Time tracking API error:', error); + return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/delete/otp/route.ts b/app/api/users/[id]/delete/otp/route.ts new file mode 100644 index 0000000..76ab1a3 --- /dev/null +++ b/app/api/users/[id]/delete/otp/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { sendOTPEmail } from '@/lib/email'; +import { getSupabaseAdmin } from '@/lib/supabase-admin'; + +type RouteParams = { id: string }; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : 'Unknown error'; + +export async function POST( + request: Request, + { params }: { params: Promise | RouteParams } +) { + try { + const supabaseAdmin = getSupabaseAdmin(); + const resolvedParams = await Promise.resolve(params); + const { id: userIdToDelete } = resolvedParams; + const { adminEmail } = await request.json(); + + console.log(`OTP request for user deletion: ${userIdToDelete}`); + + if (!adminEmail) { + return NextResponse.json({ error: 'Admin email is required' }, { status: 400 }); + } + + // 1. Generate a 6-digit OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes + + // 2. Store OTP in database + const { error: dbError } = await supabaseAdmin + .from('otps') + .insert({ + email: adminEmail, + otp: otp, + expires_at: expiresAt.toISOString() + }); + + if (dbError) { + console.error('Error storing OTP:', dbError); + return NextResponse.json({ error: 'Failed to generate OTP' }, { status: 500 }); + } + + // 3. Send OTP email + await sendOTPEmail(adminEmail, otp); + + return NextResponse.json({ success: true, message: 'OTP sent successfully' }); + } catch (error) { + console.error('API Error:', error); + return NextResponse.json({ error: getErrorMessage(error) }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/delete/verify/route.ts b/app/api/users/[id]/delete/verify/route.ts new file mode 100644 index 0000000..9006530 --- /dev/null +++ b/app/api/users/[id]/delete/verify/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase-admin'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + try { + const supabaseAdmin = getSupabaseAdmin(); + // Handle params as Promise if needed (Next.js 15+ compatibility) + const resolvedParams = await Promise.resolve(params); + const { id: userIdToDelete } = resolvedParams; + + console.log(`[DELETE] Verifying OTP and deleting user: ${userIdToDelete}`); + + const { adminEmail, otp } = await request.json(); + + if (!adminEmail || !otp) { + return NextResponse.json({ error: 'Admin email and OTP are required' }, { status: 400 }); + } + + // 1. Verify OTP + const { data: otpData, error: otpError } = await supabaseAdmin + .from('otps') + .select('*') + .eq('email', adminEmail) + .eq('otp', otp) + .gt('expires_at', new Date().toISOString()) + .single(); + + if (otpError || !otpData) { + return NextResponse.json({ error: 'Invalid or expired OTP' }, { status: 400 }); + } + + // 2. Perform deletion via RPC + // admin_delete_user handles deleting from auth.users + const { error: deleteError } = await supabaseAdmin.rpc('admin_delete_user', { + p_user_id: userIdToDelete + }); + + if (deleteError) { + console.error('Error deleting user:', deleteError); + return NextResponse.json({ error: 'Failed to delete user' }, { status: 500 }); + } + + // 3. Cleanup OTP (optional but good practice) + await supabaseAdmin.from('otps').delete().eq('id', otpData.id); + + return NextResponse.json({ success: true, message: 'User deleted successfully' }); + } catch (error) { + console.error('API Error:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/role/route.ts b/app/api/users/[id]/role/route.ts new file mode 100644 index 0000000..c8c1af5 --- /dev/null +++ b/app/api/users/[id]/role/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { searchParams } = new URL(request.url); + const requestUserId = searchParams.get('userId'); + + if (!requestUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const requestUser = await db.getUser(requestUserId); + if (!requestUser || requestUser.role !== 'Admin') { + return NextResponse.json({ error: 'Forbidden. Only Admins can change roles.' }, { status: 403 }); + } + + const body = await request.json(); + const { role } = body; + + if (!role || !['Admin', 'Manager', 'Member'].includes(role)) { + return NextResponse.json({ error: 'Invalid role' }, { status: 400 }); + } + + const resolvedParams = await params; + const targetUser = await db.getUser(resolvedParams.id); + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Prevent admin from removing their own admin status if they are the only admin + if (targetUser.id === requestUserId && role !== 'Admin') { + const allUsers = await db.getUsers(); + const adminCount = allUsers.filter((u: any) => u.role === 'Admin').length; + if (adminCount <= 1) { + return NextResponse.json({ error: 'Cannot demote the last remaining Admin' }, { status: 400 }); + } + } + + const success = await db.updateUserRole(resolvedParams.id, role); + if (!success) { + return NextResponse.json({ error: 'Database update failed' }, { status: 500 }); + } + + return NextResponse.json({ success: true, newRole: role }); + } catch (error) { + console.error('Error in PATCH /api/users/[id]/role:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/users/[id]/settings/route.ts b/app/api/users/[id]/settings/route.ts index 865d7ac..05700e9 100644 --- a/app/api/users/[id]/settings/route.ts +++ b/app/api/users/[id]/settings/route.ts @@ -12,7 +12,6 @@ export async function PATCH( const updatedUser = await db.updateUserSettings(id, { phone: body.phone, officeAddress: body.officeAddress, - timezone: body.timezone, quietHoursStart: body.quietHoursStart, quietHoursEnd: body.quietHoursEnd, quietHoursWeekends: body.quietHoursWeekends, @@ -22,9 +21,8 @@ export async function PATCH( autoAssign: body.autoAssign, skillMatchPriority: body.skillMatchPriority, aiDeadlines: body.aiDeadlines, - emailDigestFrequency: body.emailDigestFrequency, - pushNotifications: body.pushNotifications, - soundAlerts: body.soundAlerts, + dob: body.dob, + companySize: body.companySize, }); if (!updatedUser) { diff --git a/app/api/users/[id]/skills/route.ts b/app/api/users/[id]/skills/route.ts new file mode 100644 index 0000000..689e5b3 --- /dev/null +++ b/app/api/users/[id]/skills/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const { skills, skillExperience } = body; + + if (!Array.isArray(skills) || !skillExperience) { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + const { success, error } = await db.updateUserSkills(id, skills, skillExperience); + + if (!success) { + return NextResponse.json({ error: 'Failed to update skills', details: error }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 85aaad4..189311e 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -10,3 +10,25 @@ export async function GET() { return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch users' }, { status: 500 }); } } + +export async function POST(req: Request) { + try { + const body = await req.json(); + const user = await db.addUser({ + fullName: body.fullName, + email: body.email, + password: body.password || 'TaskFlow@123', + role: body.role, + dob: body.dob || undefined, + skillExperience: body.skillExperience, + maxWorkload: body.maxWorkload || 5, + }); + return NextResponse.json(user); + } catch (error) { + console.error('Error creating user:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create user' }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..902d0a6 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,251 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Project, Task } from '@/types'; +import { CreateProjectDialog } from '@/components/forms/CreateProjectDialog'; +import { useAuth } from '@/contexts/AuthContext'; +import { getUserName, getActionDisplay } from '@/lib/utils'; +import { Sparkles, ArrowRight, Trash2, MessageSquare, Edit, ChevronDown, ChevronUp, Brain } from 'lucide-react'; +import { WellnessAlerts } from '@/components/WellnessAlerts'; +import { TaskOfTheDay } from '@/components/TaskOfTheDay'; +import { db } from '@/lib/db'; + +// Icon component to render Lucide icons by name +const ActionIcon = ({ iconName, size = 14 }: { iconName: string; size?: number }) => { + switch (iconName) { + case 'Sparkles': return ; + case 'ArrowRight': return ; + case 'Trash2': return ; + case 'MessageSquare': return ; + case 'Edit': return ; + default: return ; + } +}; + +type ProjectWithStats = Project & { + stats: { + totalTasks: number; + doneTasks: number; + progress: number; + } +}; + +export default function Home() { + const { currentUser, users } = useAuth(); + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [allTasks, setAllTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateOpen, setIsCreateOpen] = useState(false); + + + useEffect(() => { + const fetchData = async () => { + if (!currentUser?.id) return; + try { + setLoading(true); + const [projRes, taskRes] = await Promise.all([ + fetch(`/api/projects?userId=${currentUser.id}`), + fetch(`/api/tasks?userId=${currentUser.id}`) + ]); + + const projData = await projRes.json(); + if (Array.isArray(projData)) { + setProjects(projData); + } + + const taskData = await taskRes.json(); + if (Array.isArray(taskData)) { + setAllTasks(taskData); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [currentUser?.id]); + + const getOwnerName = (ownerId: string) => getUserName(users, ownerId); + + // Filter users for wellness insights based on role + const getFilteredUsersForWellness = () => { + if (currentUser?.role === 'Admin') return users; + + if (currentUser?.role === 'Manager') { + const managedProjectIds = new Set(projects.map(p => p.id)); + const membersInManagedProjects = new Set( + allTasks + .filter(t => managedProjectIds.has(t.projectId) && t.assigneeId) + .map(t => t.assigneeId) + ); + return users.filter(u => membersInManagedProjects.has(u.id) || u.id === currentUser.id); + } + + return []; + }; + + const wellnessUsers = getFilteredUsersForWellness(); + + return ( +
+
+

Your Work

+ {currentUser && ( +
+ Welcome back, {currentUser.name} +
+ )} +
+ + {/* Create Project Dialog */} + setIsCreateOpen(false)} /> + + {/* Task of the Day - show for Members always, Admin/Manager only if they have assigned tasks */} + {currentUser && ( + (currentUser.role === 'Member' || allTasks.some(t => t.assigneeId === currentUser.id && t.status !== 'Done')) + ? ( +
+ router.push(`/projects/${task.projectId}?task=${task.id}`)} + /> +
+ ) + : null + )} + + {/* Recent Projects Section */} +
+

Active Projects

+ + {loading ? ( +
Loading projects...
+ ) : ( +
+ {/* ... project cards ... */} + {projects.map(project => ( + +
+
+
+ {project.key} +
+
+

{project.name}

+

{project.description}

+
+ {currentUser?.role === 'Admin' && ( + + )} +
+ +
+
+ Owner: {getOwnerName(project.ownerId)} + {project.stats.progress}% +
+
+
+
+
+
+ + ))} + + {/* Create New Project Card */} + {currentUser?.role === 'Admin' && ( + + )} +
+ )} +
+ + {/* Wellness Alerts - Move under projects */} + {(currentUser?.role === 'Admin' || currentUser?.role === 'Manager') && allTasks.length > 0 && ( +
+ +
+ )} + + {/* Recent Activity Section */} +
+

Activity Feed

+
+
+ +
+
+
+
+ ); +} + +function ActivityFeedList({ users }: { users: any[] }) { + const [logs, setLogs] = useState([]); + const { currentUser } = useAuth(); + + useEffect(() => { + if (!currentUser?.id) return; + fetch(`/api/activity?userId=${currentUser.id}`) + .then(res => res.json()) + .then(data => setLogs(Array.isArray(data) ? data : [])) + .catch(err => console.error(err)); + }, [currentUser?.id]); + + const getLocalUserName = (userId: string) => getUserName(users, userId); + + if (logs.length === 0) { + return
No recent activity
; + } + + return ( + <> + {logs.slice(0, 5).map((log: any) => { + const actionInfo = getActionDisplay(log.action); + return ( +
+
+ +
+
+

{log.details}

+

+ {getLocalUserName(log.userId)} • {new Date(log.timestamp).toLocaleString()} +

+
+
+ ); + })} + + ); +} diff --git a/app/demo-gaps/page.tsx b/app/demo-gaps/page.tsx deleted file mode 100644 index 12aaa28..0000000 --- a/app/demo-gaps/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -'use client'; - -import React from 'react'; -import { BurnoutDashboard } from '@/components/BurnoutDashboard'; -import { BottleneckAlert } from '@/components/BottleneckAlert'; -import { TaskOfTheDay } from '@/components/TaskOfTheDay'; - -export default function GapFeaturesDemo() { - const [userId, setUserId] = React.useState(''); - - React.useEffect(() => { - // Fetch first user to use for demo - fetch('/api/users') - .then(res => res.json()) - .then(users => { - if (Array.isArray(users) && users.length > 0) { - setUserId(users[0].id); - } - }) - .catch(console.error); - }, []); - - return ( -
-
-

- 🚀 Gap Features Demo -

-

- This page demonstrates the 3 new "Gap Analysis" features implemented to address - burnout, process bottlenecks, and AI task prioritization. -

-
- - {/* Feature 1: ML Task Recommendation */} -
-
-

- 1. AI Task of the Day -

- - Personalized AI - -
-
-
-

- Logic: Scores tasks based on deadline (overdue/due soon), - priority (Critical/High), and status (In Progress). - It picks the single most impactful task for you to focus on. -

-

- Try it: Toggle tasks in the database to see recommendation change. -

-
-
- -
-
-
- - {/* Feature 2: Bottleneck Detection */} -
-
-

- 2. Bottleneck Detection -

- - Process Analytics - -
-
-
-

- Logic: Distinguishes between: -

-
    -
  • Process Issues: Column overflow (e.g., too many tasks in 'Review')
  • -
  • Person Issues: Stale tasks assigned to a specific user for {'>'}5 days.
  • -
-
-
- -
-
-
- - {/* Feature 3: Burnout Dashboard */} -
-
-

- 3. Team Burnout Monitor -

- - Wellness Tracking - -
-
-
-

- Logic: Calculates 'Burnout Risk' by combining: -

-
    -
  • Workload Capacity: (Active Tasks / Max Capacity)
  • -
  • Wellness Score: Self-reported or AI-inferred health metric.
  • -
-

- Flags users as High Risk if over capacity or low wellness. -

-
-
- -
-
-
-
- ); -} diff --git a/app/globals.css b/app/globals.css index 73c5086..9bff9bb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,9 +8,16 @@ --background-start-rgb: 255, 255, 255; } +html, +body { + margin: 0; + padding: 0; + height: 100%; + overflow-x: hidden; +} + body { color: rgb(var(--foreground-rgb)); - background: rgb(var(--background-start-rgb)); } /* Modern Thin Scrollbars */ @@ -117,4 +124,19 @@ body { .high-contrast.dark .border-gray-600, .high-contrast.dark .border-gray-700 { border-color: #9ca3af !important; +} + +/* Custom Animations */ +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.animate-spin-slow { + animation: spin-slow 8s linear infinite; } \ No newline at end of file diff --git a/app/landing/page.tsx b/app/landing/page.tsx new file mode 100644 index 0000000..494053c --- /dev/null +++ b/app/landing/page.tsx @@ -0,0 +1,421 @@ +'use client'; + +import React from 'react'; +import { motion, useScroll, useTransform } from 'framer-motion'; +import Navbar from '@/components/Navbar'; +import { + ArrowRight, Activity, Users, Zap, Shield, Sparkles, + Layout, MousePointer2, Github, CheckCircle2, + Search, BarChart3, MessageSquare, Heart +} from 'lucide-react'; +import Link from 'next/link'; + +// Animation Variants +const fadeUp = { + initial: { opacity: 0, y: 30 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true }, + transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] } +}; + +const staggerContainer = { + initial: {}, + whileInView: { transition: { staggerChildren: 0.1 } }, + viewport: { once: true } +}; + +export default function LandingPage() { + const { scrollY } = useScroll(); + + // Parallax effects + const orb1Y = useTransform(scrollY, [0, 1000], [0, 200]); + const orb2Y = useTransform(scrollY, [0, 1000], [0, -150]); + const heroScale = useTransform(scrollY, [0, 500], [1, 0.95]); + + return ( +
+ + + {/* Animated Background Elements */} +
+
+ + +
+
+ + {/* Hero Section */} +
+
+ + + + + + Powered by Machine Learning + + + + Manage Projects with
+ + AI-Driven Intelligence + +
+ + + TaskFlow is a next-generation project management platform that combines intuitive Kanban workflows with ML-powered recommendations, bottleneck detection, and wellness monitoring. + + + + + + Get Started + + + + + + +
+ TaskFlow Dashboard +
+
+
+ + {/* Stats Bar */} +
+
+ + +

3

+

ML Models Trained

+
+ +

6+

+

Integrated Views

+
+ +

Real-time

+

Team Collaboration

+
+ +

95%

+

ML Insight Coverage

+
+
+
+
+ + {/* Features Grid */} +
+
+
+ Core Features +

Everything Your Team Needs

+

From Kanban boards to AI insights — one platform to manage it all.

+
+ + + {features.map((feature, i) => ( + +
+ {feature.icon} +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ + {/* AI Showcase */} +
+
+
+ +
+ + AI-Powered Engine +
+

+ Smart Recommendations
That Actually Work +

+

+ Our ML pipeline now runs inside the app with TypeScript scoring, skill matching, clustering, and wellness heuristics in real time. +

+
    + {[ + "Critical task priority detection with 95% accuracy", + "Skill-based task-to-member matching", + "Proactive burnout and overload alerts", + "Company-size adaptive thresholds" + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ +
+ AI Feature Showcase +
+
+
+
+ + {/* Collaboration Showcase */} +
+
+
+ +
+
+ Team Collaboration Mockup +
+
+ +
+ + Team Collaboration +
+

+ Work Together, Seamlessly +

+

+ From real-time chat to shared whiteboards — everything your team needs to collaborate effectively, built right into your workflow. +

+
    + {[ + "Integrated chat with thread support", + "Intelligent skill-based assignment", + "Shared pages and context-rich tasks", + "Time tracking with live indicators" + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+ + {/* Tech Stack */} +
+
+
+ Built With +

Modern Tech Stack

+

Enterprise-grade technologies powering a seamless experience.

+
+ +
+ {techStack.map((tech, i) => ( + +
{tech.icon}
+ {tech.name} + {tech.desc} +
+ ))} +
+
+
+ + {/* Open Source */} +
+
+ +
+ +
+
+

Proudly Open Source

+

+ TaskFlow is fully open source under the MIT license. Explore the codebase, contribute features, reported issues, or fork it to build your own version. +

+
+ + + View on GitHub + + + + Self-Host Setup + +
+ MIT License + Next.js + TypeScript ML +
+
+
+
+
+
+ + {/* CTA Box */} +
+
+ +
+

Ready to Transform Your Workflow?

+

Join teams using AI to ship faster, stay healthier, and manage smarter.

+ + Get Started for Free + + +
+
+
+ + {/* Footer */} +
+
+

+ © 2026 TaskFlow. Built with using Next.js, Supabase & Machine Learning. +

+
+
+
+ ); +} + +const features = [ + { + icon: , + title: "Kanban Task Board", + description: "Drag-and-drop task management with customizable columns, priority tags, and real-time status tracking.", + color: "from-accent-green to-[#9d7dff]" + }, + { + icon: , + title: "ML Recommendations", + description: "TypeScript-powered priority classification and skill-based task assignment that adapts to team patterns.", + color: "from-accent-leaf to-accent-teal" + }, + { + icon: , + title: "Bottleneck Detection", + description: "Automatic identification of workflow blockers and aging tasks with actionable rebalancing suggestions.", + color: "from-accent-teal to-[#ff7eb3]" + }, + { + icon: , + title: "Real-time Chat", + description: "Built-in team messaging with threads and mentions — keep conversations contextual and next to your work.", + color: "from-accent-blue to-[#1e90ff]" + }, + { + icon: , + title: "Wellness Monitoring", + description: "AI-driven burnout detection tracks workload distribution and overtime patterns to keep teams healthy.", + color: "from-accent-leaf to-[#a8ff78]" + }, + { + icon: , + title: "Reports & Analytics", + description: "Interactive charts, velocity tracking, and sprint burndowns that give leadership full project visibility.", + color: "from-accent-green to-accent-teal" + } +]; + +const techStack = [ + { icon: , name: "Next.js", desc: "React Framework", color: "text-accent-blue" }, + { icon: , name: "Tailwind", desc: "Utility Styling", color: "text-accent-teal" }, + { icon: , name: "Supabase", desc: "DB & Auth", color: "text-accent-leaf" }, + { icon: , name: "In-App ML", desc: "Shared Engine", color: "text-accent-green" }, + { icon: , name: "TypeScript", desc: "ML Engine", color: "text-accent-leaf" }, + { icon: , name: "Framer", desc: "Animations", color: "text-accent-teal" }, +]; diff --git a/app/layout.tsx b/app/layout.tsx index 5ee7048..0c9cdd6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,16 @@ import './globals.css'; +import { Poppins } from 'next/font/google'; import { AuthProvider } from '@/contexts/AuthContext'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { AuthenticatedLayout } from '@/components/AuthenticatedLayout'; +const poppins = Poppins({ + weight: ['400', '500', '600', '700'], + style: ['normal', 'italic'], + subsets: ['latin'], + variable: '--font-poppins', +}); + export const metadata = { title: 'TaskFlow - Project Management', description: 'A modern project management and task tracking tool', @@ -15,7 +23,7 @@ export default function RootLayout({ }) { return ( - + @@ -26,4 +34,4 @@ export default function RootLayout({ ); -} \ No newline at end of file +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 18b8a35..7eb1512 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,98 +1,498 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; -import { User } from '@/types'; +import { getSiteUrl } from '@/lib/site-url'; +import { getSupabase } from '../../lib/supabase'; +import { getPendingFirstAdminSetup, pendingFirstAdminMatches } from '@/lib/first-admin-setup'; +import { resolveClientEnvValues } from '@/lib/device-env-vault'; +import 'altcha'; export default function LoginPage() { - const { login, currentUser, users, isLoading } = useAuth(); - const router = useRouter(); - const [selectedUser, setSelectedUser] = useState(null); - - // Redirect if already logged in - useEffect(() => { - if (currentUser && !isLoading) { - router.push('/'); - } - }, [currentUser, isLoading, router]); + const { currentUser, isLoading, authError, setAuthError } = useAuth(); + const router = useRouter(); + + const [mode, setMode] = useState<'signin' | 'signup'>('signin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [altchaPayload, setAltchaPayload] = useState(null); + const [altchaVerified, setAltchaVerified] = useState(false); + const formRef = useRef(null); + const altchaRef = useRef(null); + const altchaWidgetStyle: React.CSSProperties & Record<`--${string}`, string> = { + '--altcha-max-width': '100%', + '--altcha-padding': '0.48rem', + '--altcha-border-radius': '14px', + '--altcha-border-color': '#c7d2fe', + '--altcha-checkbox-size': '18px', + '--altcha-color-base': '#f8fbff', + '--altcha-color-base-content': '#111827', + '--altcha-color-neutral': '#dbeafe', + '--altcha-color-neutral-content': '#4b5563', + '--altcha-color-primary': '#2563eb', + '--altcha-color-success': '#16a34a', + display: 'block', + width: '100%', + }; + + useEffect(() => { + const syncPayloadFromForm = () => { + const widget = + altchaRef.current || + document.querySelector('altcha-widget'); + const formPayload = formRef.current + ?.querySelector('input[name="altcha"]') + ?.value; + const widgetPayload = widget ? ((widget as any)?.payload as string | undefined) : ''; + const shadowPayload = (widget?.shadowRoot + ?.querySelector('input[name="altcha"]') + ?.value) || ''; + const widgetState = (widget as any)?.getState?.(); + const widgetText = `${widget?.textContent ?? ''} ${widget?.shadowRoot?.textContent ?? ''}`.toLowerCase(); + const nextPayload = formPayload || widgetPayload || shadowPayload; + + if (nextPayload) { + setAltchaPayload(nextPayload); + setAltchaVerified(true); + setError(''); + return true; + } + + if (widgetState === 'verified' || widgetText.includes('verified')) { + setAltchaVerified(true); + setError(''); + return true; + } + + return false; + }; + + const onVerified = (event: Event) => { + const detail = (event as CustomEvent<{ payload?: string | null }>).detail; + const payload = detail?.payload; + + if (payload) { + setAltchaPayload(payload); + setAltchaVerified(true); + setError(''); + return; + } + + window.setTimeout(syncPayloadFromForm, 0); + }; - const handleLogin = () => { - if (selectedUser) { - login(selectedUser); - router.push('/'); + const onStateChange = (event: Event) => { + const detail = (event as CustomEvent<{ state?: string }>).detail; + if (!detail?.state) return; + + if (detail.state === 'verified') { + const payload = (event as CustomEvent<{ payload?: string | null }>).detail.payload; + if (payload) { + setAltchaPayload(payload); + setAltchaVerified(true); + setError(''); + } else { + window.setTimeout(syncPayloadFromForm, 0); } + return; + } + + if (detail.state === 'unverified' || detail.state === 'error' || detail.state === 'expired') { + setAltchaPayload(null); + setAltchaVerified(false); + } }; - if (isLoading) { - return ( -
-
Loading...
-
- ); + const onExpired = () => { + setAltchaPayload(null); + setAltchaVerified(false); + }; + + const attachListeners = () => { + const widget = + altchaRef.current || + document.querySelector('altcha-widget'); + if (!widget) return null; + + widget.addEventListener('verified', onVerified); + widget.addEventListener('statechange', onStateChange); + widget.addEventListener('expired', onExpired); + return widget; + }; + + let attachedWidget = attachListeners(); + const payloadSyncInterval = window.setInterval(() => { + if (!attachedWidget) { + attachedWidget = attachListeners(); + } + syncPayloadFromForm(); + }, 250); + + return () => { + window.clearInterval(payloadSyncInterval); + attachedWidget?.removeEventListener('verified', onVerified); + attachedWidget?.removeEventListener('statechange', onStateChange); + attachedWidget?.removeEventListener('expired', onExpired); + }; + }, []); + + useEffect(() => { + if (!isLoading && currentUser) { + router.replace('/dashboard'); + } + }, [currentUser, isLoading, router]); + + useEffect(() => { + if (authError) { + setError(authError); + } + }, [authError]); + + const loginRedirectUrl = getSiteUrl('/login'); + + const resetAltcha = () => { + setAltchaPayload(null); + setAltchaVerified(false); + (altchaRef.current as any)?.reset?.(); + }; + + const showAltchaRequiredMessage = () => { + setSuccess(''); + setError('Please complete CAPTCHA verification before signing in.'); + }; + + const getAltchaPayload = () => { + if (altchaPayload) { + return altchaPayload; + } + + const formPayload = formRef.current + ?.querySelector('input[name="altcha"]') + ?.value; + + if (formPayload) { + setAltchaPayload(formPayload); + setAltchaVerified(true); + return formPayload; + } + + return null; + }; + + const verifyAltcha = async () => { + let payload = getAltchaPayload(); + + if (!payload && altchaVerified) { + const verifyResult = await (altchaRef.current as any)?.verify?.().catch(() => null); + payload = verifyResult?.payload ?? getAltchaPayload(); + + if (payload) { + setAltchaPayload(payload); + } + } + + if (!payload) { + showAltchaRequiredMessage(); + return false; } - if (currentUser) { - return null; // Will redirect + const verifyRes = await fetch('/api/altcha/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ payload }) + }); + + const verifyData = await verifyRes.json().catch(() => ({ success: false })); + + if (!verifyRes.ok || !verifyData.success) { + setError(verifyData.error || 'ALTCHA verification failed. Please try again.'); + resetAltcha(); + return false; } + return true; + }; + + const tryConfirmPendingSetupAdmin = async () => { + const pendingAdmin = getPendingFirstAdminSetup(); + if (!pendingFirstAdminMatches(pendingAdmin, 'email', email)) { + return false; + } + + const envValues = resolveClientEnvValues(); + if (!envValues.SUPABASE_URL || !envValues.SUPABASE_ACCESS_TOKEN) { + return false; + } + + const supabase = getSupabase(); + const { data } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: loginRedirectUrl, + }, + }); + + if (!data.user?.id) { + return false; + } + + const confirmResponse = await fetch('/api/setup/confirm-admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + supabaseUrl: envValues.SUPABASE_URL, + accessToken: envValues.SUPABASE_ACCESS_TOKEN, + userId: data.user.id, + email, + }), + }); + + return confirmResponse.ok; + }; + + const handleEmailAuth = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + setAuthError(''); + setSubmitting(true); + let shouldResetAltcha = true; + + try { + const altchaValid = await verifyAltcha(); + if (!altchaValid) return; + + const supabase = getSupabase(); + + if (mode === 'signup') { + const { error: signUpError } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: loginRedirectUrl + } + }); + + if (signUpError) { + setError(signUpError.message); + } else { + setSuccess('Check your email for a verification link. Once verified, you can sign in.'); + setEmail(''); + setPassword(''); + } + } else { + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (signInError) { + if (signInError.message.includes('Email not confirmed')) { + const confirmed = await tryConfirmPendingSetupAdmin(); + if (confirmed) { + const { error: retryError } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (!retryError) { + shouldResetAltcha = false; + setSuccess('Signing you in...'); + router.replace('/dashboard'); + return; + } + } + + setError('Please verify your email before signing in. Check your inbox for the verification link.'); + } else { + setError(signInError.message); + } + } else { + shouldResetAltcha = false; + setSuccess('Signing you in...'); + router.replace('/dashboard'); + } + } + } catch (err) { + setError('An unexpected error occurred. Please try again.'); + console.error('Auth error:', err); + } finally { + if (shouldResetAltcha) { + resetAltcha(); + } + setSubmitting(false); + } + }; + + const signInWithGoogle = async () => { + setError(''); + setSuccess(''); + setAuthError(''); + const altchaValid = await verifyAltcha(); + if (!altchaValid) return; + const supabase = getSupabase(); + const { error: signInError } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo: loginRedirectUrl + } + }); + if (signInError) { + setError(signInError.message); + resetAltcha(); + } + }; + + const signInWithGithub = async () => { + setError(''); + setSuccess(''); + setAuthError(''); + const altchaValid = await verifyAltcha(); + if (!altchaValid) return; + const supabase = getSupabase(); + const { error: signInError } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: loginRedirectUrl + } + }); + if (signInError) { + setError(signInError.message); + resetAltcha(); + } + }; + + if (isLoading) { return ( -
-
-
-
- TaskFlow -
-

Welcome to TaskFlow

-

Select a user to continue

-
- -
- {users.length === 0 ? ( -
-

No users found.

-

Run the seed SQL script in Supabase first.

-
- ) : ( - users.map((user) => ( - - )) - )} -
- - +
+
Loading...
+
+ ); + } + + if (currentUser) { + return null; + } + + return ( +
+
+
+
+ TaskFlow +
+

Welcome to TaskFlow

+

+ {mode === 'signin' ? 'Sign in to continue' : 'Create your account'} +

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + className="w-full px-4 py-2 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> +
+
+ + setPassword(e.target.value)} + placeholder={mode === 'signup' ? 'Min 6 characters' : '********'} + required + minLength={6} + className="w-full px-4 py-2 border border-gray-300 rounded-xl text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> +
+ +
+ +
+ + {error && ( +
+ {error}
+ )} + + {success && ( +
+ {success} +
+ )} + + +
+ +
+
+
+
+
+ or continue with +
- ); + +
+ + + +
+
+
+ ); } diff --git a/app/page.tsx b/app/page.tsx index 7d31ac6..03a8c96 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,181 +1 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { Project } from '@/types'; -import { CreateProjectDialog } from '@/components/forms/CreateProjectDialog'; -import { useAuth } from '@/contexts/AuthContext'; -import { getUserName, getActionDisplay } from '@/lib/utils'; -import { Sparkles, ArrowRight, Trash2, MessageSquare, Edit } from 'lucide-react'; - -// Icon component to render Lucide icons by name -const ActionIcon = ({ iconName, size = 14 }: { iconName: string; size?: number }) => { - switch (iconName) { - case 'Sparkles': return ; - case 'ArrowRight': return ; - case 'Trash2': return ; - case 'MessageSquare': return ; - case 'Edit': return ; - default: return ; - } -}; - -type ProjectWithStats = Project & { - stats: { - totalTasks: number; - doneTasks: number; - progress: number; - } -}; - -export default function Home() { - const { currentUser, users } = useAuth(); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [isCreateOpen, setIsCreateOpen] = useState(false); - - useEffect(() => { - const fetchProjects = async () => { - try { - const res = await fetch('/api/projects'); - const data = await res.json(); - if (Array.isArray(data)) { - setProjects(data); - } - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; - fetchProjects(); - }, []); - - const getOwnerName = (ownerId: string) => getUserName(users, ownerId); - - return ( -
-
-

Your Work

- {currentUser && ( -
- Welcome back, {currentUser.name} -
- )} -
- - {/* Create Project Dialog */} - setIsCreateOpen(false)} /> - - {/* Recent Projects Section */} -
-

Active Projects

- - {loading ? ( -
Loading projects...
- ) : ( -
- - {projects.map(project => ( - -
-
-
- {project.key} -
-
-

{project.name}

-

{project.description}

-
- -
- -
-
- Owner: {getOwnerName(project.ownerId)} - {project.stats.progress}% -
-
-
-
-
-
- - ))} - - {/* Create New Project Card */} - -
- )} -
- - {/* Recent Activity Section */} -
-

Activity Feed

-
-
- -
-
-
-
- ); -} - -function ActivityFeedList({ users }: { users: any[] }) { - const [logs, setLogs] = useState([]); - - useEffect(() => { - fetch('/api/activity') - .then(res => res.json()) - .then(data => setLogs(Array.isArray(data) ? data : [])) - .catch(err => console.error(err)); - }, []); - - const getLocalUserName = (userId: string) => getUserName(users, userId); - - if (logs.length === 0) { - return
No recent activity
; - } - - return ( - <> - {logs.slice(0, 10).map((log: any) => { - const actionInfo = getActionDisplay(log.action); - return ( -
-
- -
-
-

{log.details}

-

- {getLocalUserName(log.userId)} • {new Date(log.timestamp).toLocaleString()} -

-
-
- ); - })} - - ); -} \ No newline at end of file +export { default } from './login/page'; diff --git a/app/people/page.tsx b/app/people/page.tsx deleted file mode 100644 index 57ebf1d..0000000 --- a/app/people/page.tsx +++ /dev/null @@ -1,158 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { User } from '@/types'; -import { Mail, Plus, Shield, Users as UsersIcon } from 'lucide-react'; -import { Modal } from '@/components/ui/Modal'; -import { useAuth } from '@/contexts/AuthContext'; - -export default function PeoplePage() { - const { users: authUsers, currentUser } = useAuth(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [isInviteOpen, setIsInviteOpen] = useState(false); - const [inviteEmail, setInviteEmail] = useState(''); - - useEffect(() => { - // Use users from auth context or fetch from API - if (authUsers.length > 0) { - setUsers(authUsers); - setLoading(false); - } else { - fetch('/api/users') - .then(res => res.json()) - .then(data => { - if (Array.isArray(data)) { - setUsers(data); - } - }) - .catch(console.error) - .finally(() => setLoading(false)); - } - }, [authUsers]); - - const handleInvite = (e: React.FormEvent) => { - e.preventDefault(); - // Mock add - in a real app, this would create the user in the database - const newUser: User = { - id: crypto.randomUUID(), - name: inviteEmail.split('@')[0], - email: inviteEmail, - avatarUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(inviteEmail.split('@')[0])}&background=random`, - createdAt: new Date().toISOString(), - role: 'Member', - skills: [], - wellnessScore: 85, - maxWorkload: 5 - }; - setUsers([...users, newUser]); - setInviteEmail(''); - setIsInviteOpen(false); - }; - - const getRoleBadgeColor = (role: string) => { - switch (role) { - case 'Admin': return 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300'; - case 'Manager': return 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'; - default: return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'; - } - }; - - if (loading) { - return
Loading users...
; - } - - return ( -
-
-
-

- - People -

-

Manage your workspace members.

-
- {currentUser?.role === 'Admin' && ( - - )} -
- -
- {users.map(user => ( -
-
- {user.avatarUrl ? ( - {user.name} - ) : ( - user.name.charAt(0) - )} -
-
-
-

{user.name}

- {currentUser?.id === user.id && ( - You - )} -
-

- {user.email} -

- - - {user.role} - -
-
- ))} - - {/* Add Member Card - Only for Admins */} - {currentUser?.role === 'Admin' && ( -
setIsInviteOpen(true)} - > - - Invite new member -
- )} -
- - setIsInviteOpen(false)} title="Invite to Workspace"> -
-
- - setInviteEmail(e.target.value)} - /> -
-
- - -
-
-
-
- ); -} diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index e452b27..040e2e5 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { Project, Task, Status } from '@/types'; import { TaskBoard } from '@/components/TaskBoard'; import SummaryView from '@/components/SummaryView'; @@ -12,6 +12,7 @@ import PagesView from '@/components/PagesView'; import DeploymentsView from '@/components/DeploymentsView'; import CalendarView from '@/components/CalendarView'; import ReportsView from '@/components/ReportsView'; +import TimeTrackingView from '@/components/TimeTrackingView'; import ShortcutsView from '@/components/ShortcutsView'; import FormsView from '@/components/FormsView'; import CodeView from '@/components/CodeView'; @@ -20,12 +21,15 @@ import { Modal } from '@/components/ui/Modal'; import VideoRoom from '@/components/VideoRoom'; import { useAuth } from '@/contexts/AuthContext'; import { Video, Folder, FileText, BarChart3, Plus, UserPlus, Check, Rocket, Calendar, PieChart } from 'lucide-react'; +import { getSupabase } from '@/lib/supabase'; +import { db } from '@/lib/db'; // Nav Items definition -const NAV_ITEMS = ['Recommendations', 'Summary', 'Backlog', 'Board', 'Timeline', 'Code', 'Pages', 'Deployments', 'Calendar', 'Reports', 'Chat', 'Forms', 'Shortcuts'] as const; +const NAV_ITEMS = ['Recommendations', 'Summary', 'Backlog', 'Board', 'Timeline', 'Code', 'Pages', 'Deployments', 'Calendar', 'Reports', 'Time Tracking', 'Chat', 'Forms', 'Shortcuts'] as const; type Tab = typeof NAV_ITEMS[number]; import MLTaskRecommendations from '@/components/MLTaskRecommendations'; +import { calculateAge } from '@/lib/utils'; export default function ProjectPage() { const params = useParams(); @@ -40,20 +44,36 @@ export default function ProjectPage() { const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); const [isVideoOpen, setIsVideoOpen] = useState(false); const [isInviteOpen, setIsInviteOpen] = useState(false); + const [isMembersListOpen, setIsMembersListOpen] = useState(false); const [projectMembers, setProjectMembers] = useState([]); + const [selectedMembers, setSelectedMembers] = useState([]); + const [lastSyncTime, setLastSyncTime] = useState(0); const { users, currentUser } = useAuth(); // ... (rest of the component state and effects remain the same until the return statement) - // Load saved tab from localStorage on mount (client-side only) + const searchParams = useSearchParams(); + const urlTab = searchParams.get('tab'); + + // Load saved tab from localStorage on mount, but URL query param takes priority useEffect(() => { if (id && typeof window !== 'undefined') { - const stored = localStorage.getItem(`project-${id}-activeTab`); - if (stored && NAV_ITEMS.includes(stored as Tab)) { - setActiveTab(stored as Tab); + if (urlTab) { + // Case-insensitive match: URL uses 'chat' but NAV_ITEMS has 'Chat' + const matchedTab = NAV_ITEMS.find( + item => item.toLowerCase() === urlTab.toLowerCase() + ); + if (matchedTab) { + setActiveTab(matchedTab); + } + } else { + const stored = localStorage.getItem(`project-${id}-activeTab`); + if (stored && NAV_ITEMS.includes(stored as Tab)) { + setActiveTab(stored as Tab); + } } setTabLoaded(true); } - }, [id]); + }, [id, urlTab]); // Custom handler to change tab and save to localStorage const handleTabChange = (tab: Tab) => { @@ -63,67 +83,232 @@ export default function ProjectPage() { } }; + // Listen for tab-change events from notifications (handles same-page navigation) useEffect(() => { - if (id) { - fetchData(); - } + const handler = (e: Event) => { + const customEvent = e as CustomEvent<{ tab: string }>; + const tabName = customEvent.detail?.tab; + if (tabName) { + const matchedTab = NAV_ITEMS.find( + item => item.toLowerCase() === tabName.toLowerCase() + ); + if (matchedTab) { + setActiveTab(matchedTab); + if (id && typeof window !== 'undefined') { + localStorage.setItem(`project-${id}-activeTab`, matchedTab); + } + } + } + }; + window.addEventListener('tab-change', handler); + return () => window.removeEventListener('tab-change', handler); }, [id]); - const fetchData = async () => { + const fetchData = useCallback(async (silent = false) => { + if (!id || !currentUser) return; try { + if (!silent && !project) setLoading(true); // Fetch Tasks - const tasksRes = await fetch(`/api/tasks?projectId=${id}`); - const tasksData = await tasksRes.json(); - setTasks(tasksData); + const tasksRes = await fetch(`/api/tasks?projectId=${id}&userId=${currentUser?.id}`); + if (!tasksRes.ok) { + try { + const errData = await tasksRes.json(); + console.error("API error fetching tasks:", errData); + } catch (e) { + console.error("API error fetching tasks (non-JSON):", tasksRes.status); + } + setTasks([]); + } else { + const tasksData = await tasksRes.json(); + if (Array.isArray(tasksData)) { + setTasks(tasksData); + } else { + console.error("Tasks response is not an array:", tasksData); + setTasks([]); + } + } // Fetch Projects to find current one - const projectsRes = await fetch('/api/projects'); + const projectsRes = await fetch(`/api/projects?userId=${currentUser?.id}`); const projectsData = await projectsRes.json(); - const currentProject = projectsData.find((p: Project) => p.id === id); - setProject(currentProject || null); + + if (Array.isArray(projectsData)) { + const currentProject = projectsData.find((p: Project) => p.id === id); + if (!currentProject) { + setProject(null); + setTimeout(() => router.push('/'), 3000); + } else { + setProject(currentProject); + } + } + + // Fetch Project Members + // If we recently updated members manually (within last 5 seconds), skip background sync + if (!silent || Date.now() - lastSyncTime > 5000) { + const membersRes = await fetch(`/api/projects/${id}/members`); + if (membersRes.ok) { + const membersData = await membersRes.json(); + setProjectMembers(membersData); + // Only sync selected members if modal is NOT open to avoid overwriting user selection + if (!isInviteOpen) { + setSelectedMembers(membersData); + } else if (silent) { + console.log("Background sync: Modal is open, skipping selectedMembers update to preserve user input."); + } + } + } + } catch (err) { - console.error(err); + console.error("API error fetching data:", err); } finally { - setLoading(false); + if (!silent) setLoading(false); } - }; + }, [id, currentUser, isInviteOpen, lastSyncTime, project, router]); + + useEffect(() => { + if (id && currentUser?.id) { + fetchData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, currentUser?.id]); + + useEffect(() => { + if (!id || !currentUser?.id) return; + + // Subscribe to tasks + const supabase = getSupabase(); + const tasksChannel = supabase + .channel(`public:tasks:project_id=eq.${id}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'tasks', + filter: `project_id=eq.${id}` + }, + (payload) => { + if (payload.eventType === 'INSERT') { + const newTask: Task = { + id: payload.new.id, + projectId: payload.new.project_id, + title: payload.new.title, + description: payload.new.description, + status: payload.new.status, + priority: payload.new.priority, + assigneeId: payload.new.assignee_id, + dueDate: payload.new.due_date, + startDate: payload.new.start_date, + createdAt: payload.new.created_at, + updatedAt: payload.new.updated_at, + tags: payload.new.tags || [], + }; + setTasks(prev => { + if (prev.some(t => t.id === newTask.id)) return prev; + return [...prev, newTask]; + }); + } else if (payload.eventType === 'UPDATE') { + setTasks(prev => prev.map(t => t.id === payload.new.id ? { + ...t, + title: payload.new.title, + description: payload.new.description, + status: payload.new.status, + priority: payload.new.priority, + assigneeId: payload.new.assignee_id, + dueDate: payload.new.due_date, + startDate: payload.new.start_date, + updatedAt: payload.new.updated_at, + tags: payload.new.tags || [], + } : t)); + } else if (payload.eventType === 'DELETE') { + setTasks(prev => prev.filter(t => t.id !== payload.old.id)); + } + } + ) + .subscribe(); + + // Subscribe to project members + const membersChannel = supabase + .channel(`public:project_members:project_id=eq.${id}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'project_members', + filter: `project_id=eq.${id}` + }, + () => { + // Refetch members on any change to members table for this project + fetchData(true); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(tasksChannel); + supabase.removeChannel(membersChannel); + }; + }, [id, currentUser?.id, fetchData]); const handleTaskMove = async (taskId: string, newStatus: Status) => { // Optimistic update const oldTasks = [...tasks]; - setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: newStatus } : t)); + setTasks(prev => prev.map(t => t.id == taskId ? { ...t, status: newStatus } : t)); try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: taskId, status: newStatus }) - }); - } catch (err) { + const updatedTask = await db.updateTask(taskId, { status: newStatus }, currentUser?.id); + if (!updatedTask) { + throw new Error('Failed to update task'); + } + setTasks(prev => prev.map(t => t.id == taskId ? updatedTask : t)); + } catch (err: any) { console.error("Failed to update task", err); + alert(err.message || "Failed to update task"); setTasks(oldTasks); // Revert } }; const handleTaskUpdate = async (updatedTask: Task) => { + const previousTask = tasks.find(t => t.id === updatedTask.id); + // Optimistic - setTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t)); + setTasks(prev => prev.map(t => t.id == updatedTask.id ? updatedTask : t)); try { - await fetch('/api/tasks', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedTask) - }); - } catch (err) { + const realTask = await db.updateTask(updatedTask.id, updatedTask, currentUser?.id); + if (!realTask) { + throw new Error('Failed to update task'); + } + + if (updatedTask.assigneeId && !projectMembers.includes(updatedTask.assigneeId)) { + await db.addProjectMember(id, updatedTask.assigneeId); + setProjectMembers(prev => prev.includes(updatedTask.assigneeId!) ? prev : [...prev, updatedTask.assigneeId!]); + } + + setTasks(prev => prev.map(t => t.id == realTask.id ? realTask : t)); + + // Alert if new member was auto-added during update + if (updatedTask.assigneeId && !projectMembers.includes(updatedTask.assigneeId)) { + const addedUser = users.find(u => u.id === updatedTask.assigneeId); + const userName = addedUser ? addedUser.name : 'The assigned user'; + alert(`${userName} was automatically added to the project members list!`); + fetchData(true); + } + } catch (err: any) { console.error("Failed to update task", err); + alert(err.message || "Failed to update task"); + if (previousTask) { + setTasks(prev => prev.map(t => t.id == previousTask.id ? previousTask : t)); + } + throw err; } }; const handleTaskCreateSubmit = async (taskData: Partial) => { const newTask: Task = { - id: `temp-${Date.now()}`, + id: crypto.randomUUID(), projectId: id, title: taskData.title || 'Untitled', description: taskData.description || '', @@ -132,7 +317,7 @@ export default function ProjectPage() { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), tags: [], - assigneeId: taskData.assigneeId || 'u1', + assigneeId: taskData.assigneeId || undefined, startDate: taskData.startDate, dueDate: taskData.dueDate }; @@ -141,29 +326,96 @@ export default function ProjectPage() { setTasks(prev => [...prev, newTask]); try { - const res = await fetch('/api/tasks', { - method: 'POST', - body: JSON.stringify(newTask), - headers: { 'Content-Type': 'application/json' } - }); - if (!res.ok) throw new Error('Failed'); - const realTask = await res.json(); + await db.addTask(newTask, currentUser?.id); + const realTask = await db.getTaskById(newTask.id); + if (!realTask) { + throw new Error('Failed'); + } + + if (taskData.assigneeId && !projectMembers.includes(taskData.assigneeId)) { + await db.addProjectMember(id, taskData.assigneeId); + setProjectMembers(prev => prev.includes(taskData.assigneeId!) ? prev : [...prev, taskData.assigneeId!]); + } + setTasks(prev => prev.map(t => t.id === newTask.id ? realTask : t)); + + // Alert if new member was auto-added + if (taskData.assigneeId && !projectMembers.includes(taskData.assigneeId)) { + const addedUser = users.find(u => u.id === taskData.assigneeId); + const userName = addedUser ? addedUser.name : 'The assigned user'; + alert(`${userName} was automatically added to the project members list!`); + // Trigger a sync of project members + fetchData(true); + } } catch (err) { console.error(err); setTasks(previousTasks); } }; - const handleTaskDelete = (taskId: string) => { + const handleTaskDelete = async (taskId: string) => { + // Optimistic delete + const previousTasks = [...tasks]; setTasks(prev => prev.filter(t => t.id !== taskId)); + + try { + const success = await db.deleteTask(taskId, currentUser?.id || 'system'); + + if (!success) { + throw new Error('Failed to delete task'); + } + } catch (err: any) { + console.error("Failed to delete task", err); + alert(err.message || "Failed to delete task"); + setTasks(previousTasks); // Revert + } }; - if (loading) return
Loading Workspace...
; - if (!project) return
Project not found
; + const handleRemoveMember = async (userIdToRemove: string) => { + if (!confirm('Are you sure you want to remove this member from the project?')) return; + + try { + const unassignedCount = await db.unassignUserTasks(id, userIdToRemove); + const success = await db.removeProjectMember(id, userIdToRemove); + + if (success) { + setProjectMembers(prev => prev.filter(uid => uid !== userIdToRemove)); + setSelectedMembers(prev => prev.filter(uid => uid !== userIdToRemove)); + if (unassignedCount > 0) { + setTasks(prev => prev.map(task => task.assigneeId === userIdToRemove ? { ...task, assigneeId: undefined } : task)); + } + setLastSyncTime(Date.now()); + } else { + alert('Failed to remove member'); + } + } catch (error) { + console.error('Error removing member:', error); + alert('Failed to remove member'); + } + }; + + if (loading && !project) return
Loading Workspace...
; + if (!project) return ( +
+
+ +
+

Access Denied or Project Not Found

+

+ You don't have permission to view this project, or it doesn't exist. + You will be redirected to the dashboard in a few seconds... +

+ +
+ ); return ( -
+
{isVideoOpen && setIsVideoOpen(false)} />} {/* Header */} @@ -181,33 +433,43 @@ export default function ProjectPage() { >