From d35c806d5b6fc196efe3face4145ea9b43da557a Mon Sep 17 00:00:00 2001 From: Ace Date: Mon, 13 Jul 2026 13:05:30 +0200 Subject: [PATCH] feat: Enhance provider components with detailed documentation and comments - Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability. - Improved type definitions in index.ts for better code understanding and usage. - Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service. - Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend. - Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker. - Included a .dockerignore file to exclude unnecessary files from Docker builds. --- .dockerignore | 12 + Dockerfile | 23 + Dockerfile.signaling | 7 + Web_Calendar/types.ts | 9 + ai-server/Dockerfile | 12 + ai-server/index.mjs | 9 + browser-use-service/Dockerfile | 15 + docker-compose.yml | 111 +++++ eslint.config.mjs | 5 + next.config.ts | 8 + postcss.config.mjs | 4 + scripts/backup.ps1 | 37 +- scripts/code-repair-agent.mjs | 18 +- scripts/ensure-ollama.mjs | 4 +- scripts/lib/module-generator.ts | 29 +- scripts/lib/ts-error-parser.ts | 7 +- scripts/open-browser.mjs | 3 + scripts/precheck.mjs | 2 + scripts/run-migrations.mjs | 19 +- scripts/run-python.mjs | 5 +- scripts/setup.mjs | 25 +- signaling-server.mjs | 63 +++ src/app/(dashboard)/ai-assistant/page.tsx | 34 ++ src/app/(dashboard)/calendar/page.tsx | 56 ++- src/app/(dashboard)/chats/page.tsx | 75 ++- src/app/(dashboard)/dashboard/page.tsx | 31 ++ src/app/(dashboard)/emails/page.tsx | 25 + src/app/(dashboard)/leads/[id]/page.tsx | 36 ++ src/app/(dashboard)/leads/new/page.tsx | 30 ++ src/app/(dashboard)/leads/page.tsx | 23 + src/app/(dashboard)/profile/page.tsx | 28 ++ src/app/(dashboard)/settings/page.tsx | 40 +- src/app/(dashboard)/users/page.tsx | 29 ++ src/app/api/ai/chat/route.ts | 10 + src/app/api/ai/giphy/route.ts | 14 + src/app/api/ai/jobs/route.ts | 8 + src/app/api/auth/login/route.ts | 17 + src/app/api/auth/logout/route.ts | 8 + src/app/api/auth/me/route.ts | 8 + src/app/api/auth/recover/route.ts | 15 + src/app/api/bug-reports/[id]/route.ts | 10 + src/app/api/bug-reports/route.ts | 12 + .../[id]/messages/[messageId]/route.ts | 9 + .../api/conversations/[id]/messages/route.ts | 31 +- src/app/api/conversations/[id]/read/route.ts | 11 + src/app/api/conversations/route.ts | 27 +- src/app/api/dashboard/route.ts | 36 +- src/app/api/emails/route.ts | 8 + src/app/api/event-users/route.ts | 9 + src/app/api/events/[id]/ics/route.ts | 10 + src/app/api/events/[id]/route.ts | 19 +- src/app/api/events/route.ts | 18 +- src/app/api/gifs/route.ts | 15 + src/app/api/invite/generate/route.ts | 11 + src/app/api/leads/[id]/notes/route.ts | 17 + src/app/api/leads/[id]/route.ts | 30 +- src/app/api/leads/route.ts | 37 +- src/app/api/notifications/[id]/route.ts | 10 + .../api/notifications/preferences/route.ts | 13 + src/app/api/notifications/route.ts | 16 + src/app/api/settings/company/route.ts | 12 + .../settings/facebook/accounts/[id]/route.ts | 11 + .../api/settings/facebook/accounts/route.ts | 12 + src/app/api/settings/facebook/logs/route.ts | 8 + src/app/api/settings/preferences/route.ts | 12 + src/app/api/settings/website-theme/route.ts | 15 + src/app/api/system/monitor/route.ts | 12 +- src/app/api/upload/voice/route.ts | 11 + src/app/api/users/[id]/route.ts | 9 + src/app/api/users/avatar/route.ts | 14 +- src/app/api/users/lookup/route.ts | 8 + src/app/api/users/route.ts | 15 + src/app/api/users/search/route.ts | 9 + src/app/call/[roomId]/page.tsx | 41 ++ src/app/join/[token]/page.tsx | 41 ++ src/app/login/page.tsx | 69 +++ src/app/page.tsx | 20 + src/components/ai/ai-chat.tsx | 41 ++ src/components/ai/gif-picker.tsx | 23 + src/components/ai/job-selector.tsx | 18 + src/components/chats/media-picker.tsx | 13 + src/components/chats/voice-call-modal.tsx | 10 + .../dashboard/crossed-lightsabers.tsx | 7 + .../dashboard/lead-status-chart.tsx | 11 + .../dashboard/leads-per-month-chart.tsx | 11 + .../dashboard/recent-leads-table.tsx | 9 + src/components/dashboard/star-field.tsx | 7 + .../dashboard/stat-card-skeleton.tsx | 8 + src/components/dashboard/stat-card.tsx | 12 + src/components/layout/app-shell.tsx | 15 +- src/components/layout/crm-icon.tsx | 7 + src/components/layout/sidebar-name-logo.tsx | 7 + src/components/layout/sidebar.tsx | 9 + src/components/layout/system-monitor.tsx | 11 + src/components/layout/topbar.tsx | 14 +- src/components/leads/delete-lead-dialog.tsx | 7 + .../leads/lead-actions-dropdown.tsx | 8 + src/components/leads/lead-details-card.tsx | 8 + src/components/leads/lead-form-dialog.tsx | 10 + src/components/leads/lead-status-badge.tsx | 7 + src/components/leads/leads-table-toolbar.tsx | 7 + src/components/leads/leads-table.tsx | 8 + src/components/notes/note-form.tsx | 8 + src/components/notes/note-timeline.tsx | 9 + .../settings/company-settings-form.tsx | 8 + .../settings/facebook-accounts-dialog.tsx | 9 + .../settings/notification-settings.tsx | 8 + src/components/settings/theme-settings.tsx | 12 + .../settings/user-preferences-form.tsx | 8 + src/components/shared/bug-report-modal.tsx | 9 + .../shared/captain-america-shield.tsx | 7 + .../shared/data-table-column-header.tsx | 7 + .../shared/data-table-pagination.tsx | 8 + src/components/shared/data-table.tsx | 10 + src/components/shared/empty-state.tsx | 7 + src/components/shared/error-boundary.tsx | 7 + src/components/shared/page-header.tsx | 7 + src/components/ui/alert-dialog.tsx | 3 + src/components/ui/avatar.tsx | 3 + src/components/ui/badge.tsx | 3 + src/components/ui/button.tsx | 4 + src/components/ui/card.tsx | 3 + src/components/ui/checkbox.tsx | 6 + src/components/ui/dialog.tsx | 6 + src/components/ui/dropdown-menu.tsx | 3 + src/components/ui/form.tsx | 14 + src/components/ui/input.tsx | 7 + src/components/ui/label.tsx | 6 + src/components/ui/radio-group.tsx | 9 + src/components/ui/scroll-area.tsx | 9 + src/components/ui/select.tsx | 11 + src/components/ui/separator.tsx | 6 + src/components/ui/sheet.tsx | 8 + src/components/ui/skeleton.tsx | 6 + src/components/ui/sonner.tsx | 7 + src/components/ui/switch.tsx | 6 + src/components/ui/table.tsx | 27 ++ src/components/ui/tabs.tsx | 13 + src/components/ui/textarea.tsx | 6 + src/components/ui/tooltip.tsx | 10 + src/components/users/user-form-dialog.tsx | 19 + src/components/users/user-status-badge.tsx | 6 + src/components/users/users-table.tsx | 9 + src/hooks/useWebRTCCall.ts | 54 +++ src/lib/ai.ts | 48 ++ src/lib/auth.ts | 164 +++++++ src/lib/avatar.ts | 52 ++ src/lib/blocked-extensions.ts | 103 ++++ src/lib/constants.ts | 25 +- src/lib/date-utils.ts | 59 ++- src/lib/db.ts | 53 ++- src/lib/email.ts | 68 ++- src/lib/ics.ts | 47 +- src/lib/jwt.ts | 39 ++ src/lib/sanitize.ts | 19 + src/lib/supabase.ts | 23 +- src/lib/utils.ts | 19 + src/middleware.ts | 34 ++ src/providers/notification-provider.tsx | 15 + src/providers/theme-provider.tsx | 8 + src/providers/user-provider.tsx | 13 + src/providers/website-theme-provider.tsx | 20 + src/styles/ai-assistant.css | 5 + src/types/index.ts | 35 ++ startup.txt | 444 ++++++++++++++++++ tailwind.config.ts | 17 + vitest.config.ts | 8 +- 167 files changed, 3474 insertions(+), 102 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.signaling create mode 100644 ai-server/Dockerfile create mode 100644 browser-use-service/Dockerfile create mode 100644 docker-compose.yml create mode 100644 startup.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e87839f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +*.md +.vscode +__pycache__ +*.pyc +.DS_Store +.env.local +.env +.gitignore +.prettierrc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f26cdf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-slim AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:20-slim AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ARG NEXT_PUBLIC_SCRAPER_URL=http://localhost:3008 +ENV NEXT_PUBLIC_SCRAPER_URL=$NEXT_PUBLIC_SCRAPER_URL +RUN npm run build + +FROM node:20-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.ts ./next.config.ts +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules +EXPOSE 3006 +CMD ["./node_modules/.bin/next", "start", "-p", "3006"] diff --git a/Dockerfile.signaling b/Dockerfile.signaling new file mode 100644 index 0000000..c58b644 --- /dev/null +++ b/Dockerfile.signaling @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY signaling-server.mjs ./ +EXPOSE 3007 +CMD ["node", "signaling-server.mjs"] diff --git a/Web_Calendar/types.ts b/Web_Calendar/types.ts index 85e62c7..e59c508 100644 --- a/Web_Calendar/types.ts +++ b/Web_Calendar/types.ts @@ -1,6 +1,14 @@ +// ── Web Calendar Types ────────────────────────────────────────────── +// Core calendar event types used across the CRM system, including +// event classification, status tracking, and participant metadata. + +/** Possible types of calendar events — triggered by different CRM workflows. */ export type EventType = "call" | "follow_up" | "website_creation" + +/** Lifecycle state of a calendar event. */ export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled" +/** A single calendar entry linking a user, participant, developer, and optional lead/conversation. */ export interface CalendarEvent { id: string userId: string @@ -16,6 +24,7 @@ export interface CalendarEvent { endTime: string | null durationMinutes: number | null status: EventStatus + // Denormalized user references for display (avoid extra joins in list views) creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null diff --git a/ai-server/Dockerfile b/ai-server/Dockerfile new file mode 100644 index 0000000..1cb5452 --- /dev/null +++ b/ai-server/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-slim +RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +COPY ai-server/ ./ai-server/ +COPY splash.html ./ +COPY data/ ./data/ +COPY scripts/run-migrations.mjs ./scripts/run-migrations.mjs +COPY database/ ./database/ +EXPOSE 3001 +CMD ["node", "ai-server/index.mjs"] diff --git a/ai-server/index.mjs b/ai-server/index.mjs index f5c81df..03a4be4 100644 --- a/ai-server/index.mjs +++ b/ai-server/index.mjs @@ -60,6 +60,7 @@ let pullProgress = { status: "idle", progress: 0, message: "" } // ── Job loading ───────────────────────────────────────────────── // Loads job categories from a JSONL file (one JSON object per line). // Used as context for the AI sales coach chat responses. +/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */ function loadJobs() { try { const content = fs.readFileSync(JOBS_PATH, "utf-8") @@ -86,6 +87,7 @@ function loadJobs() { // ── ai.md management ──────────────────────────────────────────── // ai.md is a Markdown file containing system instructions for the AI. // It can be read, written, or appended to via the API. +/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */ function readInstructions() { try { return fs.readFileSync(AI_MD_PATH, "utf-8") @@ -94,11 +96,13 @@ function readInstructions() { } } +/** Overwrite ai.md with new content. Returns the content that was written. */ function writeInstructions(content) { fs.writeFileSync(AI_MD_PATH, content, "utf-8") return content } +/** Append a timestamped entry to the ## Improvement Log section of ai.md. Creates the section if it doesn't exist. */ function appendToImprovementLog(entry) { // Adds a timestamped entry to the ## Improvement Log section of ai.md const current = readInstructions() @@ -153,6 +157,7 @@ async function scrapeFacebook() { } } +/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */ function formatLeads(leads) { if (!leads || leads.length === 0) return "No leads found from the latest scrape." let output = `**${leads.length} leads found:**\n\n` @@ -239,6 +244,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego // ── PG pool (lazy init) ──────────────────────────────────────── // PostgreSQL connection pool for storing conversation history. // Lazy-initialized so the server starts even without a DB. +/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */ let pgPool = null async function initPg() { if (!DATABASE_URL) return @@ -255,11 +261,13 @@ async function initPg() { // ── Request router ───────────────────────────────────────────── const loadedJobs = loadJobs() +/** Write a JSON response with the given HTTP status code. */ function sendJSON(res, status, data) { res.writeHead(status, { "Content-Type": "application/json" }) res.end(JSON.stringify(data)) } +/** Parse the JSON body of an incoming HTTP request. */ function parseBody(req) { return new Promise((resolve, reject) => { let body = "" @@ -275,6 +283,7 @@ function parseBody(req) { }) } +/** Parse a request URL into its pathname and search params. Falls back to localhost if no Host header is present. */ function parseURL(req) { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`) return { pathname: url.pathname, searchParams: url.searchParams } diff --git a/browser-use-service/Dockerfile b/browser-use-service/Dockerfile new file mode 100644 index 0000000..44c682b --- /dev/null +++ b/browser-use-service/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ + libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \ + libatspi2.0-0 libwayland-client0 libxshmfence1 && \ + rm -rf /var/lib/apt/lists/* +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +RUN playwright install --with-deps firefox chromium 2>&1 | tail -5 +COPY main.py ./ +ENV PYTHONUNBUFFERED=1 +EXPOSE 3008 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3008"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4851cb2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: crm + POSTGRES_USER: crm + POSTGRES_PASSWORD: ${DB_PASSWORD:-crm} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U crm"] + interval: 5s + timeout: 5s + retries: 5 + + ollama: + image: ollama/ollama + restart: unless-stopped + volumes: + - ollama-models:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 30s + timeout: 10s + retries: 3 + + migrate: + build: + context: . + dockerfile: ai-server/Dockerfile + command: ["node", "scripts/run-migrations.mjs"] + environment: + DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm + depends_on: + db: + condition: service_healthy + restart: "no" + + ai: + build: + context: . + dockerfile: ai-server/Dockerfile + restart: unless-stopped + ports: + - "3001:3001" + environment: + AI_PORT: 3001 + DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm + OLLAMA_BASE_URL: http://ollama:11434 + SCRAPER_URL: http://scraper:3008 + FRONTEND_URL: http://next:3006 + AI_MODEL: ${AI_MODEL:-dolphin-llama3:8b} + JWT_SECRET: ${JWT_SECRET} + SELECTED_BROWSER: ${SELECTED_BROWSER:-firefox} + depends_on: + db: + condition: service_healthy + ollama: + condition: service_started + + scraper: + build: + context: ./browser-use-service + restart: unless-stopped + ports: + - "3008:3008" + environment: + PORT: 3008 + OLLAMA_URL: http://ollama:11434 + CLASSIFY_MODEL: ${CLASSIFY_MODEL:-dolphin-llama3:8b} + CORS_ORIGINS: http://localhost:3006,http://next:3006 + depends_on: + - ollama + + next: + build: + context: . + args: + NEXT_PUBLIC_SCRAPER_URL: ${NEXT_PUBLIC_SCRAPER_URL:-http://localhost:3008} + restart: unless-stopped + ports: + - "3006:3006" + environment: + DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm + AI_SERVICE_URL: http://ai:3001 + JWT_SECRET: ${JWT_SECRET} + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + + signaling: + build: + context: . + dockerfile: Dockerfile.signaling + restart: unless-stopped + ports: + - "3007:3007" + environment: + SIGNALING_PORT: 3007 + DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm + JWT_SECRET: ${JWT_SECRET} + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: + ollama-models: diff --git a/eslint.config.mjs b/eslint.config.mjs index a8a2835..962d7ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,13 @@ +// ── ESLint Config ────────────────────────────────────────────────── +// Extends Next.js core-web-vitals + TypeScript recommended rules. +// Ignores build output directories and auto-generated type stubs. + import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals.js"; import nextTs from "eslint-config-next/typescript.js"; const eslintConfig = defineConfig([ + // Next.js core Web Vitals rules + TypeScript strict checks ...nextVitals, ...nextTs, // Override default ignores of eslint-config-next. diff --git a/next.config.ts b/next.config.ts index b7ce0bb..2de7765 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,15 @@ +// ── Next.js Config ────────────────────────────────────────────────── +// Security headers, image remote patterns, and build-time ESLint checks. +// Applied globally to all routes via the headers() async function. + import type { NextConfig } from "next" const nextConfig: NextConfig = { + // Fail the build on ESLint errors — no silent ignores eslint: { ignoreDuringBuilds: false, }, + // Allow external avatar images from ui-avatars.com images: { remotePatterns: [ { @@ -12,6 +18,8 @@ const nextConfig: NextConfig = { }, ], }, + // ── Security Headers ────────────────────────────────────────────── + // Applied to all routes. HSTS is production-only to avoid localhost issues. async headers() { return [ { diff --git a/postcss.config.mjs b/postcss.config.mjs index 393a10f..14d8387 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,3 +1,7 @@ +// ── PostCSS Config ───────────────────────────────────────────────── +// Tailwind CSS v4 + Autoprefixer for cross-browser vendor prefixes. +// Minimal — both plugins run with defaults. + const config = { plugins: { tailwindcss: {}, diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 index 06b7ced..6bff474 100644 --- a/scripts/backup.ps1 +++ b/scripts/backup.ps1 @@ -1,17 +1,30 @@ +<# +.SYNOPSIS + PostgreSQL database backup with retention management and logging. +.DESCRIPTION + Uses pg_dump (custom format) to back up the CRM database. Tracks the + backup in the backup_logs table and auto-prunes files older than + $RetentionDays. Reads DATABASE_URL from the environment or .env.local. +.PARAMETER BackupDir + Directory where backup files are stored (default: ../backups). +.PARAMETER RetentionDays + Number of days to retain backups (default: 30). +#> param( [string]$BackupDir = "$PSScriptRoot\..\backups", [int]$RetentionDays = 30 ) +# Fail fast on any error $ErrorActionPreference = "Stop" $startTime = Get-Date -# Ensure backup directory exists +# ── Ensure backup directory exists ──────────────────────────────── if (-not (Test-Path $BackupDir)) { New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null } -# Determine database URL from env or .env.local +# ── Determine database URL from env or .env.local ───────────────── $dbUrl = $env:DATABASE_URL if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) { $envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw @@ -25,7 +38,7 @@ if (-not $dbUrl) { exit 1 } -# Parse the database URL +# ── Parse the database URL into connection components ───────────── $uri = [System.Uri]$dbUrl $pgUser = $uri.UserInfo.Split(':')[0] $pgPass = $uri.UserInfo.Split(':')[1] @@ -33,7 +46,7 @@ $pgHost = $uri.Host $pgPort = $uri.Port $pgDb = $uri.AbsolutePath.TrimStart('/') -# Set PGPASSWORD for pg_dump +# pg_dump reads password from this env var (avoids interactive prompt) $env:PGPASSWORD = $pgPass $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" @@ -44,13 +57,15 @@ Write-Output "Starting backup of database '$pgDb' to $filepath" Write-Output "Database: $pgHost:$pgPort/$pgDb" try { - # Run pg_dump + # ── Run pg_dump (custom format) ────────────────────────────── + # Check PATH first, then fall back to the bundled psql location $pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) { "pg_dump" } else { "$env:TEMP\pg\pgsql\bin\pg_dump.exe" } + # Custom format (-Fc) for compressed, restorable output $output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1 $exitCode = $LASTEXITCODE @@ -58,11 +73,13 @@ try { throw "pg_dump failed with exit code $exitCode" } + # Move to final backup location (dg_dump writes to CWD first) Move-Item -Path $filename -Destination $filepath -Force $fileInfo = Get-Item $filepath $fileSize = $fileInfo.Length - # Verify backup by checking if file is valid custom format + # ── Verify backup integrity ────────────────────────────────── + # Run a schema-only dump against the same file to check it's valid custom format $verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1 $verifyExitCode = $LASTEXITCODE if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force } @@ -73,7 +90,8 @@ try { $endTime = Get-Date $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) - # Log the backup + # ── Log backup to database ─────────────────────────────────── + # Records metadata in backup_logs table for audit trail $logQuery = @" INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days) VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays); @@ -93,7 +111,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt Write-Output " Duration: ${durationSeconds}s" Write-Output " Status: $verificationStatus" - # Cleanup old backups (beyond retention period) + # ── Cleanup old backups beyond retention period ────────────── $cutoff = (Get-Date).AddDays(-$RetentionDays) $oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object { $_.CreationTime -lt $cutoff @@ -106,7 +124,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt } catch { Write-Error "Backup failed: $_" - # Log failure + # Log failure to database for monitoring $endTime = Get-Date $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) $errorMsg = $_.ToString().Replace("'", "''") @@ -121,5 +139,6 @@ VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($sta exit 1 } finally { + # Clean up the password environment variable for security Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue } diff --git a/scripts/code-repair-agent.mjs b/scripts/code-repair-agent.mjs index c9534b5..546666c 100644 --- a/scripts/code-repair-agent.mjs +++ b/scripts/code-repair-agent.mjs @@ -28,19 +28,23 @@ import { createRequire } from "node:module" const SELF = dirname(fileURLToPath(import.meta.url)) const _require = createRequire(import.meta.url) const ROOT = resolve(SELF, "..") +// Ollama endpoint and model can be overridden via environment variables const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434" const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base" +// Safety limits: max repair iterations, minimum AI confidence to auto-apply, escalation timeout const MAX_ITERATIONS = 5 const CONFIDENCE_THRESHOLD = 0.7 const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min // ── Helpers ──────────────────────────────────────────────────────── +/** Log a message with a level-based prefix for visual scanning of output. */ function log(msg, level = "info") { const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓" console.log(` ${prefix} [repair] ${msg}`) } +/** Run `npx tsc --noEmit` and return the output (success or failure). */ function runTsc() { try { const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }) @@ -50,6 +54,7 @@ function runTsc() { } } +/** Parse TypeScript compiler output into structured error objects with file, line, column, and message. */ function parseErrors(output) { const errors = [] // Match: src/file.ts(line,col): error TSxxxx: message @@ -66,6 +71,7 @@ function parseErrors(output) { return errors } +/** Read source file lines surrounding the error, returning a snippet with context line offset. */ function readContext(filePath, errorLine, contextLines = 20) { try { const lines = readFileSync(filePath, "utf-8").split("\n") @@ -83,6 +89,7 @@ function readContext(filePath, errorLine, contextLines = 20) { // ── Ollama Integration ───────────────────────────────────────────── +/** Send a repair prompt to the Ollama model and return the raw response text. */ async function queryOllama(prompt) { const response = await fetch(`${OLLAMA_HOST}/api/generate`, { method: "POST", @@ -99,6 +106,7 @@ async function queryOllama(prompt) { return data.response || "" } +/** Build a structured prompt for the AI model, including file path, error message, and surrounding code context. */ function buildRepairPrompt(filePath, errorMessage, codeContext) { return `You are a TypeScript repair agent. Fix the following error. @@ -122,6 +130,7 @@ Rules: - If you cannot determine a fix, set confidence to 0` } +/** Parse the AI's JSON response (handles optional markdown fences around the JSON block). */ function parseFixResponse(text) { try { // Extract JSON from response (it might have markdown fences) @@ -133,6 +142,7 @@ function parseFixResponse(text) { } } +/** Apply a fix suggestion to disk, returning success/failure and a backup of the original content. */ function applyFix(filePath, fixSuggestion) { if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" } @@ -147,8 +157,8 @@ function applyFix(filePath, fixSuggestion) { // ── Breaking Change Escalation ───────────────────────────────────── +/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */ function isBreakingChange(errors, fixes) { - // Heuristic: if fix touches import structure or exports, it's breaking for (const fix of fixes) { if (!fix || !fix.fix) continue if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) { @@ -158,6 +168,7 @@ function isBreakingChange(errors, fixes) { return false } +/** Log the breaking change, wait for the escalation timeout, then auto-apply. */ async function escalateBreakingChange(errors, fixes) { const logFile = resolve(ROOT, "repair-escalation.log") const timestamp = new Date().toISOString() @@ -181,6 +192,7 @@ async function escalateBreakingChange(errors, fixes) { // ── Git Auto-Commit ──────────────────────────────────────────────── +/** Git commit all changes with a [bot]-prefixed message. Returns false on failure (non-blocking). */ function gitCommit(message) { try { execSync("git add -A", { stdio: "pipe", cwd: ROOT }) @@ -195,6 +207,7 @@ function gitCommit(message) { // ── Main Repair Loop ─────────────────────────────────────────────── +/** Single pass: run tsc, parse errors, ask AI for fixes, apply them, and verify. Returns result summary. */ async function repairOnce(doCommit = false) { log("Running build check...") const build = runTsc() @@ -276,6 +289,7 @@ async function repairOnce(doCommit = false) { return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes } } +/** Run up to maxIterations repair passes, stopping once all errors are resolved or no progress is made. */ async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) { for (let i = 0; i < maxIterations; i++) { log(`=== Repair iteration ${i + 1}/${maxIterations} ===`) @@ -293,8 +307,10 @@ async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) { // ── Watch Mode ───────────────────────────────────────────────────── +/** Watch mode: monitor src/ for file changes and auto-repair errors. Falls back to polling if chokidar is unavailable. */ function watchMode() { log("Watch mode active — monitoring src/ for changes...", "info") + // Try loading chokidar; if not installed, fall back to simple interval polling const chokidar = (() => { try { return _require("chokidar") diff --git a/scripts/ensure-ollama.mjs b/scripts/ensure-ollama.mjs index 9a299be..a2db83e 100644 --- a/scripts/ensure-ollama.mjs +++ b/scripts/ensure-ollama.mjs @@ -7,6 +7,7 @@ import { execSync, spawn } from "node:child_process" import { platform } from "node:os" +/** Check if Ollama is already running by querying the OS process list. */ function isRunning() { try { if (platform() === "win32") { @@ -20,6 +21,7 @@ function isRunning() { } } +/** Locate the Ollama binary via PATH, then fall back to common install directories. */ function findOllama() { if (platform() === "win32") { // Try PATH first, then common install locations @@ -49,7 +51,7 @@ if (!isRunning()) { } else { spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref() } - // Give it a moment to start listening + // Give it a moment to start listening before we return control execSync("sleep 3", { stdio: "ignore", timeout: 5000 }) } else { console.log("Ollama already running") diff --git a/scripts/lib/module-generator.ts b/scripts/lib/module-generator.ts index 7170e05..7f87de3 100644 --- a/scripts/lib/module-generator.ts +++ b/scripts/lib/module-generator.ts @@ -1,6 +1,8 @@ // ── Module Generator ───────────────────────────────────────────────── // Generates missing internal modules from templates. // Supports built-in templates and custom .setup-templates/ directory. +// Part of the self-healing setup pipeline — invoked when tsc reports +// TS2307 errors for @/ imports that don't exist yet. import fs from "node:fs"; import path from "node:path"; @@ -12,6 +14,7 @@ const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates"); const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json"); const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates"); +/** Maps internal import paths (e.g. "data/stickers") to their template file and optional params. */ export interface TemplateRegistry { [importPath: string]: { template: string; @@ -20,6 +23,7 @@ export interface TemplateRegistry { }; } +/** Result of a single module generation attempt. */ export interface GenerationResult { success: boolean; filePath: string; @@ -28,18 +32,21 @@ export interface GenerationResult { error?: string; } +/** Maps glob patterns (e.g. "@/hooks/*") to fallback template file names. */ export interface FallbackRegistry { [pattern: string]: string; } -/** Load the template registry from .setup-templates/registry.json */ +/** Load the template registry from .setup-templates/registry.json, merging with built-in templates. */ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } { + // Built-in mappings for the most commonly imported modules const builtInTemplates: TemplateRegistry = { "data/stickers": { template: "stickers.ts" }, "data/constants": { template: "constants.ts" }, "lib/utils": { template: "utils.ts" }, "types/index": { template: "types-index.ts", params: { inferFromUsage: true } }, }; + // Directory-level catch-all fallbacks for unknown imports const builtInFallbacks: FallbackRegistry = { "@/data/*": "data-generic.ts", "@/lib/*": "lib-generic.ts", @@ -49,6 +56,7 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba "@/utils/*": "utils-generic.ts", }; + // Merge in any custom templates from the .setup-templates directory try { if (fs.existsSync(REGISTRY_PATH)) { const content = fs.readFileSync(REGISTRY_PATH, "utf-8"); @@ -64,10 +72,10 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba return { templates: builtInTemplates, fallbacks: builtInFallbacks }; } -/** Match an internal path against fallback patterns */ +/** Match an internal path (e.g. "data/stickers") against glob fallback patterns. Converts glob to regex internally. */ export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined { for (const [pattern, template] of Object.entries(fallbacks)) { - // Convert glob pattern to regex + // Convert glob pattern (e.g. "@/hooks/*") to a regular expression const regexStr = "^" + pattern .replace(/\//g, "\\/") .replace(/\./g, "\\.") @@ -82,7 +90,7 @@ export function matchFallback(internalPath: string, fallbacks: FallbackRegistry) return undefined; } -/** Render a template with parameters */ +/** Render a template string by replacing {{ paramName }} placeholders with actual values. */ function renderTemplate(templateContent: string, params: Record = {}): string { let result = templateContent; for (const [key, value] of Object.entries(params)) { @@ -92,7 +100,7 @@ function renderTemplate(templateContent: string, params: Record return result; } -/** Get template content - first from custom templates dir, then built-in */ +/** Get template content — first tries the custom .setup-templates/templates dir, then falls back to built-in embedded templates. */ function getTemplateContent(templateName: string): string { // Check custom templates first const customPath = path.join(TEMPLATES_DIR, templateName); @@ -100,7 +108,7 @@ function getTemplateContent(templateName: string): string { return fs.readFileSync(customPath, "utf-8"); } - // Built-in templates (embedded) + // Built-in templates (embedded in the script — no external file needed) const builtInTemplates: Record = { "stickers.ts": `// ── Sticker Packs ───────────────────────────────────────────────── // Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize. @@ -312,16 +320,16 @@ export interface DashboardStats { return builtInTemplates[templateName] || ""; } -/** Generate a single missing module */ +/** Generate a single missing module: resolve template, render parameters, write to src/. */ export function generateModule( internalPath: string, templates: TemplateRegistry, fallbacks: FallbackRegistry ): GenerationResult { + // Look up exact template first, then fall back to directory-level glob patterns let entry = templates[internalPath]; let templateFile = entry?.template; - // Try fallback patterns if (!entry) { const fallbackTemplate = matchFallback(internalPath, fallbacks); if (fallbackTemplate) { @@ -349,10 +357,11 @@ export function generateModule( }; } + // Replace template parameter placeholders with actual values const rendered = renderTemplate(templateContent, entry.params || {}); const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts")); - // Ensure directory exists + // Ensure directory exists before writing const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -377,7 +386,7 @@ export function generateModule( } } -/** Generate all missing modules for a list of internal paths */ +/** Generate all missing modules for a list of internal paths, logging each result. */ export function generateAll(internalPaths: string[]): GenerationResult[] { const { templates, fallbacks } = loadRegistry(); const results: GenerationResult[] = []; diff --git a/scripts/lib/ts-error-parser.ts b/scripts/lib/ts-error-parser.ts index 2261930..507df1b 100644 --- a/scripts/lib/ts-error-parser.ts +++ b/scripts/lib/ts-error-parser.ts @@ -2,6 +2,7 @@ // Parses `tsc --noEmit` or `npm run build` output to extract // "Module not found: Can't resolve '@/path/to/module'" errors. // Returns actionable missing module info for the generator. +// Used by both setup.mjs (self-heal) and code-repair-agent.mjs. export interface MissingModule { /** The import path that failed, e.g. "@/data/stickers" */ @@ -20,7 +21,7 @@ export interface MissingModule { internalPath?: string; } -/** Parse TypeScript compiler output for missing module errors */ +/** Parse TypeScript compiler output for missing module errors (TS2307 + webpack-style "Can't resolve"). */ export function parseMissingModules(tscOutput: string): MissingModule[] { const results: MissingModule[] = []; @@ -74,12 +75,12 @@ export function parseMissingModules(tscOutput: string): MissingModule[] { }); } -/** Filter to only internal (@/...) modules we can auto-generate */ +/** Filter to only internal (@/... or ~/...) modules we can auto-generate from templates. */ export function filterGeneratable(modules: MissingModule[]): MissingModule[] { return modules.filter((m) => m.isInternal && m.internalPath); } -/** Group missing modules by internal path */ +/** Group missing modules by their normalized internal path (deduplicates same module imported from multiple files). */ export function groupByInternalPath(modules: MissingModule[]): Map { const groups = new Map(); for (const m of modules) { diff --git a/scripts/open-browser.mjs b/scripts/open-browser.mjs index ae1c04d..0554ba5 100644 --- a/scripts/open-browser.mjs +++ b/scripts/open-browser.mjs @@ -9,11 +9,14 @@ import { platform } from "node:os" const url = process.argv[2] || "http://localhost:3001/splash" +/** Promise-based sleep helper for the startup delay. */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) async function main() { + // Wait for the AI server, scraper, and frontend to finish booting await sleep(8000) try { + // Platform-specific command to open the default browser if (platform() === "win32") { execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 }) } else if (platform() === "darwin") { diff --git a/scripts/precheck.mjs b/scripts/precheck.mjs index e564f58..f3005fa 100644 --- a/scripts/precheck.mjs +++ b/scripts/precheck.mjs @@ -12,6 +12,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, "..") try { + // Read package.json and check each dependency's package.json in node_modules const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) const allDeps = { ...pkg.dependencies, ...pkg.devDependencies } @@ -45,6 +46,7 @@ if (platform() === "win32") { const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 }) const lines = out.trim().split("\n").filter(Boolean) for (const line of lines) { + // Parse the last column of netstat output — the PID const parts = line.trim().split(/\s+/) const pid = parts[parts.length - 1] if (pid) { diff --git a/scripts/run-migrations.mjs b/scripts/run-migrations.mjs index c26b85c..033e2b6 100644 --- a/scripts/run-migrations.mjs +++ b/scripts/run-migrations.mjs @@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(__dirname, "..") // ── Load .env.local into process.env ───────────────────────────────── +// Simple key=value parser (no dotenv dependency needed). +// Handles quoted values and skips comments / blank lines. const envPath = resolve(ROOT, ".env.local") if (existsSync(envPath)) { @@ -24,9 +26,11 @@ if (existsSync(envPath)) { if (eq === -1) continue const key = trimmed.slice(0, eq).trim() let value = trimmed.slice(eq + 1).trim() + // Strip matching surrounding quotes (single or double) if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } + // Don't override already-set env vars if (!process.env[key]) process.env[key] = value } } @@ -38,6 +42,7 @@ if (!DATABASE_URL) { } // ── Find psql ──────────────────────────────────────────────────────── +// Check PATH first, then probe common PostgreSQL v14–17 install paths. function findPsql() { try { @@ -84,8 +89,9 @@ try { // ── Migration logic ────────────────────────────────────────────────── +/** Run all unapplied database migrations in the order defined by run_all.sql. */ async function runMigrations() { - // 1. Create tracking table if not exists + // 1. Ensure the _migrations tracking table exists (idempotent) await pool.query(` CREATE TABLE IF NOT EXISTS _migrations ( id SERIAL PRIMARY KEY, @@ -94,11 +100,12 @@ async function runMigrations() { ) `) - // 2. Determine file order from run_all.sql (authoritative) + // 2. Determine file order from run_all.sql (authoritative source of truth) const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql") const runAllContent = readFileSync(runAllPath, "utf-8") const fileOrder = [] for (const line of runAllContent.split("\n")) { + // `\i filename.sql` is the psql include directive const match = line.match(/\\i\s+(\S+\.sql)/) if (match) fileOrder.push(match[1]) } @@ -117,7 +124,7 @@ async function runMigrations() { appliedSet = new Set() } - // 4. Build psql command base + // 4. Build psql command base with ON_ERROR_STOP=1 so psql aborts on first error const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1` // 5. Run unapplied files in order. @@ -133,6 +140,7 @@ async function runMigrations() { let count = 0 for (const file of fileOrder) { + // Skip files already recorded in the _migrations table if (appliedSet.has(file)) continue const filePath = resolve(ROOT, "database", "migrations", file) @@ -149,12 +157,14 @@ async function runMigrations() { maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }) - // psql outputs notices/warnings to stderr even on success + // psql outputs notices/warnings to stderr even on success — capture for diagnostics stderr = result.stderr || "" } catch (err) { + // Distinguish between "already applied" (harmless) and genuine failures const msg = err.stderr || err.stdout || err.message const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p)) if (isAlreadyApplied) { + // File was applied before tracking existed — record it and continue await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file]) console.log(` ~ Already applied: ${file}`) count++ @@ -165,6 +175,7 @@ async function runMigrations() { process.exit(1) } + // Record successful migration await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file]) console.log(` ✓ Applied: ${file}`) count++ diff --git a/scripts/run-python.mjs b/scripts/run-python.mjs index fc0a2db..f8a351f 100644 --- a/scripts/run-python.mjs +++ b/scripts/run-python.mjs @@ -8,7 +8,9 @@ import { execSync, spawn } from "node:child_process" import { platform } from "node:os" import { statSync } from "node:fs" +/** Locate the Python 3 executable. Checks common install paths first, then the system PATH. */ function detectPython() { + // Pre-check common Windows install directories to avoid PATH lookup const commonPaths = [ `${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`, `${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`, @@ -21,6 +23,7 @@ function detectPython() { return p } catch {} } + // Fall back to PATH resolution (prefer python3 on Unix) const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"] for (const cmd of candidates) { try { @@ -42,6 +45,6 @@ if (!script) { process.exit(1) } -// Spawn Python with inherited stdio so the script's output is visible +// Spawn Python with inherited stdio so the script's output is visible in real-time const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" }) proc.on("exit", (code) => process.exit(code ?? 1)) diff --git a/scripts/setup.mjs b/scripts/setup.mjs index aa40c7e..a942d67 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -15,12 +15,14 @@ import { platform } from "node:os" import { resolve, dirname } from "node:path" import { fileURLToPath } from "node:url" +// Shell command separator differs per platform (Windows uses &, POSIX uses ;) const SEP = platform() === "win32" ? "&" : ";" const SELF = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SELF, "..") // ── Helpers ──────────────────────────────────────────────────────── +/** Try known Python command names until one succeeds, then exit if none found. */ function detectPython() { const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"] for (const cmd of candidates) { @@ -33,6 +35,7 @@ function detectPython() { process.exit(1) } +/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */ function detectPip(python) { const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"] for (const cmd of candidates) { @@ -44,6 +47,7 @@ function detectPip(python) { return `${python} -m pip` } +/** Run a shell command with label output. Exits on failure unless opts.optional is set. */ function run(cmd, label, opts = {}) { console.log(`\n── ${label} ──`) try { @@ -60,11 +64,15 @@ function run(cmd, label, opts = {}) { } // ── Self-Healing Module Registry ─────────────────────────────────── +// Maps internal import paths (like @/data/stickers) to template files. +// Supports custom templates in .setup-templates/ that override built-ins. +// Fallback patterns catch whole directories (e.g. @/hooks/* → hook-generic.ts). function loadRegistry() { const registryPath = resolve(ROOT, ".setup-templates", "registry.json") const templatesDir = resolve(ROOT, ".setup-templates", "templates") + // Hard-coded templates for commonly imported modules const builtInTemplates = { "data/stickers": { template: "stickers.ts" }, "data/constants": { template: "constants.ts" }, @@ -75,6 +83,7 @@ function loadRegistry() { "hooks/use-local-storage": { template: "hooks/use-local-storage.ts" }, } + // Catch-all fallbacks for entire directory prefixes const builtInFallbacks = { "@/data/*": "data-generic.ts", "@/lib/*": "lib-generic.ts", @@ -87,6 +96,7 @@ function loadRegistry() { let templates = { ...builtInTemplates } let fallbacks = { ...builtInFallbacks } + // Merge custom templates from .setup-templates/registry.json (if it exists) try { if (existsSync(registryPath)) { const custom = JSON.parse(readFileSync(registryPath, "utf-8")) @@ -97,6 +107,7 @@ function loadRegistry() { const templateCache = {} + /** Read a template file, checking the custom templates dir first, then hooks subdirectory. */ function getTemplate(templateName) { if (templateCache[templateName]) return templateCache[templateName] const candidatePaths = [ @@ -113,6 +124,7 @@ function loadRegistry() { return "" } + /** Convert a glob pattern (e.g. @/hooks/*) to a regex and test against the internal path. */ function matchFallback(internalPath) { for (const [pattern, templateFile] of Object.entries(fallbacks)) { const regexStr = "^" + pattern @@ -125,6 +137,7 @@ function loadRegistry() { return null } + /** Generate (or regenerate) a single module file from its template. */ function generateModule(internalPath) { let templateFile = templates[internalPath]?.template if (!templateFile) templateFile = matchFallback(internalPath) @@ -143,6 +156,7 @@ function loadRegistry() { return { generateModule } } +/** Parse `tsc --noEmit` or `npm run build` output for missing-module errors, returning unique import paths. */ function parseMissingModules(buildOutput) { const results = [] const seen = new Set() @@ -177,21 +191,22 @@ function parseMissingModules(buildOutput) { // ── Main ─────────────────────────────────────────────────────────── +// Detect Python and pip executables before running any steps const PY = detectPython() const PIP = detectPip(PY) console.log("=== CoastIT CRM Setup ===\n") -// 1. Node dependencies +// Step 1 — Install all Node.js dependencies (includes next, react, etc.) run("npm install", "Installing Node.js dependencies") -// 2. Python dependencies +// Step 2 — Install Python deps for the browser-use service (Playwright-based scraper) run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies") -// 3. Playwright browsers +// Step 3 — Download Playwright browser binaries for scraper automation run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers") -// 4. .env file +// Step 4 — Bootstrap .env.local from the example template (won't overwrite existing) if (!existsSync(resolve(ROOT, ".env.local"))) { console.log("\n── Creating .env.local ──") copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local")) @@ -200,7 +215,7 @@ if (!existsSync(resolve(ROOT, ".env.local"))) { console.log("\n── .env.local already exists, skipping ──") } -// 5. Final summary +// Step 5 — Print post-setup instructions console.log("\n── Final Steps ──") console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b") diff --git a/signaling-server.mjs b/signaling-server.mjs index a20d73e..84dfc68 100644 --- a/signaling-server.mjs +++ b/signaling-server.mjs @@ -1,8 +1,20 @@ +// ── WebRTC Signaling Server ───────────────────────────────────────── +// Provides real-time signaling for the CRM chat system using Socket.IO: +// - JWT-based authentication middleware +// - Online user presence tracking (online/offline) +// - Peer-to-peer call signaling (offer/answer/ICE candidates) +// - Multi-participant room-based WebRTC (join/leave/relay) +// - Message deletion broadcast to conversation participants +// +// Clients authenticate via socket handshake auth token; unauthenticated +// users can still join rooms as anonymous participants. + import { createServer } from "http" import { Server } from "socket.io" import { SignJWT, jwtVerify } from "jose" import pg from "pg" +// ── Configuration ───────────────────────────────────────────────── const PORT = process.env.SIGNALING_PORT || 3007 const rawSecret = process.env.JWT_SECRET if (!rawSecret) throw new Error("JWT_SECRET environment variable is required") @@ -10,14 +22,23 @@ const JWT_SECRET = new TextEncoder().encode(rawSecret) const DATABASE_URL = process.env.DATABASE_URL if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required") +// ── Database pool ───────────────────────────────────────────────── const pool = new pg.Pool({ connectionString: DATABASE_URL }) +// ── HTTP + Socket.IO server ─────────────────────────────────────── const httpServer = createServer() const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } }) +// ── In-memory state ─────────────────────────────────────────────── +// onlineUsers maps userId -> socketId for presence tracking. +// rooms maps roomId -> array of { socketId, id, label } participants. const onlineUsers = new Map() const rooms = new Map() +// ── Authentication middleware ───────────────────────────────────── +// Verifies the JWT token from handshake auth. If valid, attaches +// userId and role to the socket's data bag. Unauthenticated sockets +// proceed with null user info. io.use((socket, next) => { const token = socket.handshake.auth?.token if (token) { @@ -39,15 +60,25 @@ io.use((socket, next) => { } }) +// ── Connection handler ──────────────────────────────────────────── +// Registers authenticated users as online, then wires up all +// per-socket event listeners for signaling and presence. io.on("connection", (socket) => { const userId = socket.data.userId + // Mark the user online and broadcast to other clients if (userId) { onlineUsers.set(userId, socket.id) socket.broadcast.emit("user:online", { userId }) } + // ── Authenticated event handlers ────────────────────────────── + // Only users with a valid JWT token may use these features. if (userId) { + + // user:lookup — looks up a user by phone number in the database. + // Fires when the client searches for a contact to start a call. + // Returns { found, user } or { found: false }. socket.on("user:lookup", async ({ phone }, callback) => { try { const result = await pool.query( @@ -79,6 +110,9 @@ io.on("connection", (socket) => { } }) + // call:offer — relays an SDP offer to the target user. + // Fires when the caller initiates a WebRTC call. + // callerInfo is fetched from the DB if not supplied. socket.on("call:offer", async ({ to, sdp, callerInfo }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -112,6 +146,8 @@ io.on("connection", (socket) => { } }) + // call:answer — relays an SDP answer back to the caller. + // Fires when the callee accepts the incoming call. socket.on("call:answer", ({ to, sdp }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -119,6 +155,8 @@ io.on("connection", (socket) => { } }) + // call:ice-candidate — relays ICE candidates between peers. + // Fires during connection negotiation for NAT traversal. socket.on("call:ice-candidate", ({ to, candidate }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -126,6 +164,7 @@ io.on("connection", (socket) => { } }) + // call:end — notifies the remote peer that the call ended. socket.on("call:end", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -133,6 +172,7 @@ io.on("connection", (socket) => { } }) + // call:reject — tells the caller the callee rejected the call. socket.on("call:reject", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -140,6 +180,7 @@ io.on("connection", (socket) => { } }) + // call:busy — tells the caller the callee is on another call. socket.on("call:busy", ({ to }) => { const targetSocketId = onlineUsers.get(to) if (targetSocketId) { @@ -147,6 +188,9 @@ io.on("connection", (socket) => { } }) + // message:deleted — broadcasts a deletion event to all other + // participants in the conversation. Looks up participant IDs + // from the database so only relevant clients are notified. socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => { try { const result = await pool.query( @@ -167,6 +211,15 @@ io.on("connection", (socket) => { }) } + // ── Room-based signaling (authenticated + anonymous) ────────── + // These events support multi-participant rooms used for features + // like screen sharing or group calls. Anonymous users are assigned + // an "anon-" prefix ID. + + // room:join — adds the socket to a named room. When the first + // participant joins they receive "room:waiting". When the second + // joins the first is told to initiate an offer. Additional + // participants are notified normally. socket.on("room:join", ({ roomId, displayName }) => { const participantId = userId || `anon-${socket.id}` const participantLabel = displayName || participantId @@ -191,18 +244,24 @@ io.on("connection", (socket) => { } }) + // room:offer — relays an SDP offer to all other room participants. socket.on("room:offer", ({ roomId, sdp }) => { socket.to(roomId).emit("room:offer", { sdp }) }) + // room:answer — relays an SDP answer to all other room participants. socket.on("room:answer", ({ roomId, sdp }) => { socket.to(roomId).emit("room:answer", { sdp }) }) + // room:ice-candidate — relays ICE candidates to all other room participants. socket.on("room:ice-candidate", ({ roomId, candidate }) => { socket.to(roomId).emit("room:ice-candidate", { candidate }) }) + // room:leave — removes the socket from a room. Cleans up the + // in-memory room map and notifies remaining participants. Rooms + // are fully deleted when empty. socket.on("room:leave", ({ roomId }) => { socket.leave(roomId) const participants = rooms.get(roomId) @@ -218,6 +277,9 @@ io.on("connection", (socket) => { } }) + // disconnect — fires when the socket loses connection. Cleans up + // the user from all rooms they were in, removes them from the + // online users map, and broadcasts the offline event. socket.on("disconnect", () => { for (const [roomId, participants] of rooms) { const idx = participants.findIndex(p => p.socketId === socket.id) @@ -238,6 +300,7 @@ io.on("connection", (socket) => { }) }) +// ── Start ───────────────────────────────────────────────────────── httpServer.listen(PORT, () => { console.log(`[signaling] server running on port ${PORT}`) }) diff --git a/src/app/(dashboard)/ai-assistant/page.tsx b/src/app/(dashboard)/ai-assistant/page.tsx index dddb24c..68967a1 100644 --- a/src/app/(dashboard)/ai-assistant/page.tsx +++ b/src/app/(dashboard)/ai-assistant/page.tsx @@ -1,3 +1,16 @@ +// ─────────────────────────────────────────────── +// AI Assistant Page (/(dashboard)/ai-assistant) +// ─────────────────────────────────────────────── +// Route: /ai-assistant +// Purpose: AI-powered sales assistant with +// conversational chat and Facebook lead +// scraping by job title. Users select a target +// job, trigger a Facebook scrape, and review +// results in the chat panel. +// Layout: Two-column — main chat area + right +// sidebar with job selector / details. +// ─────────────────────────────────────────────── + "use client" import { useState, useCallback, useRef } from "react" @@ -5,6 +18,16 @@ import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat" import { JobSelector } from "@/components/ai/job-selector" import { Bot, ChevronRight } from "lucide-react" +/** + * AIAssistantPage + * ─────────────── + * Manages the AI chat + job search workflow. + * Selected job is shown in the sidebar; search + * triggers a Facebook scrape via an external + * scraper service with a 6-minute timeout. + * + * @returns AI assistant layout. + */ export default function AIAssistantPage() { const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [recentPrompts, setRecentPrompts] = useState([]) @@ -15,11 +38,13 @@ export default function AIAssistantPage() { setSelectedJob(job) }, []) + // ── Facebook lead scraper handler ── const handleSearch = useCallback(async (job: NonNullable) => { setSearching(true) const keyword = job.keywords?.[0] || job.job_title aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`) + // 6-minute hard abort, with a "still searching" status message at 45 s const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 360000) const statusId = setTimeout(() => { @@ -33,7 +58,9 @@ export default function AIAssistantPage() { clearTimeout(timeoutId) clearTimeout(statusId) const data = await res.json() + if (data.success && data.leads?.length > 0) { + // Format scraped leads as a markdown list for the chat const leadLines = data.leads .filter(Boolean) .map((lead: Record, i: number) => @@ -61,6 +88,7 @@ export default function AIAssistantPage() { aiChatRef.current?.fillInput(prompt) }, []) + // Keep track of last 3 sent prompts for quick reuse const handleMessageSent = useCallback((msg: string) => { setRecentPrompts((prev) => { const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3) @@ -70,7 +98,9 @@ export default function AIAssistantPage() { return (
+ {/* ── Main chat panel ── */}
+ {/* Header */}
@@ -82,6 +112,7 @@ export default function AIAssistantPage() {

Powered by local AI

+ {/* Status indicator */}
@@ -96,6 +127,8 @@ export default function AIAssistantPage() {
+ + {/* ── Right sidebar: Job selector ── */}
@@ -103,6 +136,7 @@ export default function AIAssistantPage() { Target Job
+ {/* Selected job details card */} {selectedJob && (

{selectedJob.job_title}

diff --git a/src/app/(dashboard)/calendar/page.tsx b/src/app/(dashboard)/calendar/page.tsx index 164f88d..683be3c 100644 --- a/src/app/(dashboard)/calendar/page.tsx +++ b/src/app/(dashboard)/calendar/page.tsx @@ -1,3 +1,16 @@ +// ─────────────────────────────────────────────── +// Calendar Page (/(dashboard)/calendar) +// ─────────────────────────────────────────────── +// Route: /calendar +// Purpose: Full calendar view with month grid, +// upcoming events sidebar, and CRUD dialogs +// for creating/editing events (call, follow-up, +// website creation). Supports participant and +// developer assignment, client details, notes. +// Layout: Header (nav + "Today" + "New Event") + +// body row: left = month grid, right = upcoming. +// ─────────────────────────────────────────────── + "use client" import { useState, useEffect, useCallback, Component, type ReactNode } from "react" @@ -25,16 +38,19 @@ import { ArrowUpRight, Zap, StickyNote, Globe, } from "lucide-react" +// ── Event type icon mapping ── const EVENT_ICONS: Record = { call: Phone, follow_up: Clock, website_creation: Globe, } +/** CSS variable name for an event type (e.g. `--event-call`). */ function eventVars(type: EventType) { return `--event-${type}` } +/** Background + text + border style derived from the event type's CSS variable. */ function eventBgStyle(type: EventType): React.CSSProperties { const v = eventVars(type) return { @@ -44,14 +60,17 @@ function eventBgStyle(type: EventType): React.CSSProperties { } } +/** A small colored dot using the event type's CSS variable. */ function eventDotStyle(type: EventType): React.CSSProperties { return { backgroundColor: `hsl(var(--event-${type}))` } } +/** Get the number of days in a given month (0-indexed). */ function getDaysInMonth(year: number, month: number) { return new Date(year, month + 1, 0).getDate() } +/** Get the day-of-week (0=Sun) of the first day of a month. */ function getFirstDayOfMonth(year: number, month: number) { return new Date(year, month, 1).getDay() } @@ -67,6 +86,17 @@ function formatDate(iso: string) { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) } +/** + * CalendarPage + * ──────────── + * Displays a monthly calendar grid with events + * fetched from the API. Supports creating, + * editing, deleting, and status updates for + * events. Includes an upcoming events sidebar + * (90-day lookahead) and auto-polls every 15 s. + * + * @returns Full calendar layout. + */ export default function CalendarPage() { const router = useRouter() const { user } = useUser() @@ -286,6 +316,7 @@ export default function CalendarPage() { } } + // ── Update event status (complete, cancel, reopen) ── const updateEventStatus = async (event: CalendarEvent, newStatus: string) => { try { const res = await fetch(`/api/events/${event.id}`, { @@ -295,6 +326,7 @@ export default function CalendarPage() { }) if (!res.ok) throw new Error("Failed to update") const data = await res.json() + // Merge updated event while preserving loaded relations setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e)) toast.success(`Event ${newStatus}`) setDetailEvent(null) @@ -303,6 +335,7 @@ export default function CalendarPage() { } } + // ── Save participant notes for an event ── const saveNotes = async (event: CalendarEvent) => { if (notesText === (event.participantNotes || "")) { setEditNotes(false); return } setNotesSaving(true) @@ -315,6 +348,7 @@ export default function CalendarPage() { if (!res.ok) throw new Error("Failed to save") const data = await res.json() setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e)) + // Also update the open detail dialog state if (detailEvent && detailEvent.id === event.id) { setDetailEvent({ ...detailEvent, participantNotes: notesText || null }) } @@ -327,6 +361,7 @@ export default function CalendarPage() { } } + // ── Delete an event ── const deleteEvent = async (event: CalendarEvent) => { try { const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" }) @@ -340,6 +375,7 @@ export default function CalendarPage() { } } + // ── Build array of day cells (null = empty leading days) ── const calendarDays: (number | null)[] = [] for (let i = 0; i < firstDay; i++) calendarDays.push(null) for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d) @@ -348,7 +384,7 @@ export default function CalendarPage() { return (
- {/* Header */} + {/* ── HEADER: month nav + today + new event ── */}
@@ -377,7 +413,7 @@ export default function CalendarPage() {
- {/* Body - flex row with independent scroll */} + {/* ── BODY: month grid (left) + upcoming sidebar (right) ── */}
{loading ? (
@@ -391,9 +427,9 @@ export default function CalendarPage() {
) : ( <> - {/* Left: Calendar Grid */} + {/* ── CALENDAR GRID (left column) ── */}
- {/* Stats bar */} + {/* Stats bar: scheduled / completed / total counts */}
@@ -412,10 +448,10 @@ export default function CalendarPage() {
- {/* Calendar grid wrapper - scrollable */} + {/* Scrollable calendar grid wrapper */}
- {/* Day headers */} + {/* Day-of-week column headers (Sun–Sat) */}
{DAYS.map((d, i) => (
- {/* Day cells */} + {/* Calendar day cells */}
{calendarDays.map((day, i) => { if (day === null) return
@@ -502,7 +538,7 @@ export default function CalendarPage() {
- {/* Right: Upcoming sidebar */} + {/* ── UPCOMING EVENTS SIDEBAR (right column) ── */}

@@ -612,7 +648,7 @@ export default function CalendarPage() { )}

- {/* Create/Edit Dialog */} + {/* ── CREATE / EDIT EVENT DIALOG ── */} @@ -768,7 +804,7 @@ export default function CalendarPage() { - {/* Event Detail Dialog */} + {/* ── EVENT DETAIL DIALOG ── */} { if (!open) setDetailEvent(null) }}> {detailEvent && ( diff --git a/src/app/(dashboard)/chats/page.tsx b/src/app/(dashboard)/chats/page.tsx index 7d5d58e..8065d82 100644 --- a/src/app/(dashboard)/chats/page.tsx +++ b/src/app/(dashboard)/chats/page.tsx @@ -1,3 +1,17 @@ +// ─────────────────────────────────────────────── +// Chats Page (/(dashboard)/chats) +// ─────────────────────────────────────────────── +// Route: /chats +// Purpose: Full-featured real-time messaging with +// voice notes, file attachments, GIFs, stickers, +// message editing/deletion/forwarding, read +// receipts, emoji picker, inline image/avatar +// previews, voice calls, and event scheduling. +// Layout: Resizable two-panel layout — left: +// conversation list; right: chat area (header, +// messages, input bar). +// ─────────────────────────────────────────────── + "use client" import { useState, useRef, useCallback, useEffect, useMemo } from "react" @@ -37,10 +51,24 @@ import { io, Socket } from "socket.io-client" import VoiceCallModal from "@/components/chats/voice-call-modal" import MediaPicker from "@/components/chats/media-picker" +/** Module-level refs to prevent multiple audio instances from playing simultaneously. */ const activeAudioRef: { current: HTMLAudioElement | null } = { current: null } const previewAudioRef: { current: HTMLAudioElement | null } = { current: null } const previewAnimRef: { current: number } = { current: 0 } +// ────────────────────────────────────────────── +// VoiceMessagePlayer +// ────────────────────────────────────────────── +/** + * Renders a voice note message with: + * - Play/pause toggle + * - Animated waveform bars + * - Seekable progress slider + * - Elapsed / total duration + * - Replay button + * + * Only one audio plays globally via activeAudioRef. + */ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) { const [playing, setPlaying] = useState(false) const [current, setCurrent] = useState(0) @@ -49,6 +77,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init const animRef = useRef(0) const progRef = useRef(null) + // Load actual duration from metadata (fallback to initialDuration) useEffect(() => { const audio = audioRef.current if (!audio) return @@ -59,6 +88,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) } }, [src]) + // Animation frame loop — updates current time while playing useEffect(() => { if (!playing) { cancelAnimationFrame(animRef.current); return } const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) } @@ -70,6 +100,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init if (!audioRef.current) return if (playing) { audioRef.current.pause(); setPlaying(false) } else { + // Pause any other playing voice note if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() } activeAudioRef.current = audioRef.current audioRef.current.play(); setPlaying(true) @@ -91,6 +122,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0 const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` } + // Generate 28 pseudo-random waveform bar heights using sine combinations const barCount = 28 const waveform = Array.from({ length: barCount }, (_, i) => { const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3 @@ -122,9 +154,24 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init ) } +/** Max conversations to keep in client-side cache (LRU). */ const MAX_CACHED_CONVERSATIONS = 5 const otherParticipant = (conv: any) => conv.otherUser +// ────────────────────────────────────────────── +// ChatsPage — Main Chat Application Component +// ────────────────────────────────────────────── +/** + * Full-featured chat interface. Manages: + * - Conversation list with search / create + * - Real-time message polling (4 s) + Socket.io + * - Voice recording, file attachments, GIF/stickers + * - Message edit, delete, forward, reply + * - Resizable panel, read receipts, emoji picker + * - Voice call modal + event scheduling + * + * @returns Chat layout or null if no user. + */ export default function ChatsPage() { const { theme } = useTheme() const { user } = useUser() @@ -187,8 +234,10 @@ export default function ChatsPage() { const socketRef = useRef(null) const isDeletingRef = useRef(false) + /** Check if a string consists only of emoji characters. */ const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim()) +/** Parse message content for preview display in conversation list. */ const formatPreviewContent = (content: string) => { if (!content?.startsWith("{")) return content try { @@ -200,6 +249,7 @@ const formatPreviewContent = (content: string) => { return content } + /** Auto-resize the textarea based on its scroll height. */ const autoResizeTextarea = useCallback(() => { const ta = textareaRef.current if (!ta) return @@ -209,6 +259,7 @@ const formatPreviewContent = (content: string) => { if (!user) return null + // ── Memoized derived data ── const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat]) const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat]) const filteredConversations = useMemo(() => { @@ -794,11 +845,12 @@ const formatPreviewContent = (content: string) => { return (
- {/* Conversations list - left panel */} + {/* ── CONVERSATIONS LIST (left panel) ── */}
+ {/* Search header */}

Chats

@@ -806,6 +858,7 @@ const formatPreviewContent = (content: string) => { setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
+ {/* Scrollable conversation list */} {filteredConversations.map((conv) => { const person = otherParticipant(conv) @@ -897,7 +950,7 @@ const formatPreviewContent = (content: string) => {
- {/* Resize handle */} + {/* ── RESIZE HANDLE ── */}
{
- {/* Chat area - right panel */} + {/* ── CHAT AREA (right panel) ── */} {conversation ? (
- {/* Chat header */} + {/* Chat header: avatar, name, actions */}
setPreviewAvatarUrl(otherParticipant(conversation).avatar)}> @@ -951,7 +1004,7 @@ const formatPreviewContent = (content: string) => {
- {/* Messages */} + {/* ── MESSAGES ── */}
{messages.map((msg) => { @@ -1105,7 +1158,7 @@ const formatPreviewContent = (content: string) => {
- {/* Input */} + {/* ── INPUT BAR ── */}
{attachments.length > 0 && (
@@ -1234,9 +1287,11 @@ const formatPreviewContent = (content: string) => {
)} - {/* Resize overlay */} + {/* Resize drag overlay (captures mouse events during resize) */} {isResizing &&
} + {/* ── DIALOGS ── */} + {/* Report dialog */} @@ -1255,7 +1310,7 @@ const formatPreviewContent = (content: string) => { - {/* Image preview dialog */} + {/* Image preview dialog (full-size) */} { if (!o) setPreviewImageUrl(null) }}> Image preview @@ -1269,7 +1324,7 @@ const formatPreviewContent = (content: string) => { - {/* Avatar preview dialog */} + {/* ── Avatar preview dialog ── */} { if (!o) setPreviewAvatarUrl(null) }}> {previewAvatarUrl && ( @@ -1282,7 +1337,7 @@ const formatPreviewContent = (content: string) => { - {/* Forward dialog */} + {/* ── Forward message dialog ── */} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index cb288a3..f07841a 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,3 +1,15 @@ +// ─────────────────────────────────────────────── +// Dashboard Page (/(dashboard)/dashboard) +// ─────────────────────────────────────────────── +// Route: /dashboard +// Purpose: Main overview page showing pipeline +// stats, trend sparklines, status/leads-per-month +// charts, and a recent-leads table. Polls the +// API every 30 seconds for live updates. +// Layout: Uses PageHeader + period selector, +// 6 stat cards, 2 charts, and a table. +// ─────────────────────────────────────────────── + "use client" import { useState, useEffect, useRef } from "react" @@ -26,12 +38,23 @@ import { import { DashboardStats } from "@/types" import { useWebsiteTheme } from "@/providers/website-theme-provider" +/** + * DashboardPage + * ───────────── + * Fetches dashboard stats for a selected period + * and renders stat cards, charts, and a recent- + * leads table. Includes auto-polling every 30 s. + * + * @returns Full dashboard layout with loading + * skeleton states. + */ export default function DashboardPage() { const { websiteTheme } = useWebsiteTheme() const [period, setPeriod] = useState("6months") const [stats, setStats] = useState(null) const pollingRef = useRef(null) + // ── Fetch dashboard stats from API ── async function fetchStats(p: string) { try { const res = await fetch(`/api/dashboard?period=${p}`) @@ -44,6 +67,7 @@ export default function DashboardPage() { } } + // ── Initial fetch + 30-second polling ── useEffect(() => { fetchStats(period) pollingRef.current = setInterval(() => fetchStats(period), 30000) @@ -52,6 +76,7 @@ export default function DashboardPage() { } }, [period]) + // ── Build stat-card configs from API response ── const statCards = stats ? [ { title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const }, @@ -66,6 +91,7 @@ export default function DashboardPage() { return (
+ {/* ── Header with period filter ── */}
Dashboard} @@ -86,6 +112,7 @@ export default function DashboardPage() {
+ {/* ── Pipeline stat cards (or skeletons while loading) ── */}

Pipeline Overview

@@ -97,6 +124,7 @@ export default function DashboardPage() { }
+ {/* ── Analytics charts ── */}

Analytics

@@ -104,8 +132,11 @@ export default function DashboardPage() {
+ {/* ── Recent leads table ── */}
+ + {/* ── Themed decorative overlay (Spidey theme) ── */} {websiteTheme === "spidey" && (
diff --git a/src/app/(dashboard)/emails/page.tsx b/src/app/(dashboard)/emails/page.tsx index 8a6b905..bd54a39 100644 --- a/src/app/(dashboard)/emails/page.tsx +++ b/src/app/(dashboard)/emails/page.tsx @@ -1,3 +1,15 @@ +// ─────────────────────────────────────────────── +// Emails Page (/(dashboard)/emails) +// ─────────────────────────────────────────────── +// Route: /emails +// Purpose: Admin-only email log viewer. Lists +// all sent emails with subject, recipient, +// and timestamp. Extracts "With:" metadata +// from the email body text. +// Layout: Simple list with header + description. +// Access restricted to admin / super_admin. +// ─────────────────────────────────────────────── + "use client" import { useState, useEffect } from "react" @@ -5,11 +17,21 @@ import { useRouter } from "next/navigation" import { useUser } from "@/providers/user-provider" import { Mail, Clock } from "lucide-react" +/** + * EmailsPage + * ────────── + * Dispatched-email log. Only available to admin + * and super_admin roles. Redirects unauthorized + * users back to /dashboard or /login. + * + * @returns Email list UI or null if unauthorized. + */ export default function EmailsPage() { const router = useRouter() const { user } = useUser() const [emails, setEmails] = useState([]) + // ── Auth guard + fetch emails ── useEffect(() => { if (!user) { router.push("/login"); return } if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return } @@ -24,11 +46,14 @@ export default function EmailsPage() {

Emails are stored locally. To actually deliver them, add EMAIL_API_KEY to your .env.

+ + {/* ── Email list or empty state ── */} {emails.length === 0 ? (

No emails sent yet.

) : (
{emails.map((e: any) => { + // Extract "With:" metadata from email body for display const withMatch = e.bodyText?.match(/With:\s*(.+)/) const withName = withMatch ? withMatch[1].trim() : null return ( diff --git a/src/app/(dashboard)/leads/[id]/page.tsx b/src/app/(dashboard)/leads/[id]/page.tsx index babf5da..86de8ac 100644 --- a/src/app/(dashboard)/leads/[id]/page.tsx +++ b/src/app/(dashboard)/leads/[id]/page.tsx @@ -1,3 +1,17 @@ +// ─────────────────────────────────────────────── +// Lead Details Page (/(dashboard)/leads/[id]) +// ─────────────────────────────────────────────── +// Route: /leads/:id +// Purpose: Full detail view for a single lead. +// Displays company/contact info, notes timeline, +// activity log, and quick actions. Supports +// inline status changes with optimistic UI. +// Layout: 3-column grid — 2-col main area +// (details card + notes) + 1-col sidebar +// (activity + quick actions). Uses framer-motion +// for staggered entrance animations. +// ─────────────────────────────────────────────── + "use client" import { useState, useEffect, useCallback } from "react" @@ -20,17 +34,31 @@ import { import { ArrowLeft, Edit, ExternalLink } from "lucide-react" import { Lead, Note } from "@/types" +/** + * LeadDetailsPage + * ─────────────── + * Fetches lead + notes in parallel on mount. + * Handles loading, not-found, and error states. + * Status select uses optimistic updates with + * rollback on failure. + * + * @param params - Next.js route params (Promise + * unwrapped via React.use()). + * @returns Lead detail layout. + */ export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const [lead, setLead] = useState(null) const [leadNotes, setLeadNotes] = useState([]) const [loading, setLoading] = useState(true) + // ── Fetch notes for this lead ── const fetchNotes = useCallback(async () => { const notesRes = await fetch(`/api/leads/${id}/notes`) if (notesRes.ok) setLeadNotes(await notesRes.json()) }, [id]) + // ── Initial data load ── useEffect(() => { async function fetchData() { try { @@ -47,6 +75,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri fetchData() }, [id, fetchNotes]) + // ── Loading state ── if (loading) { return (
@@ -55,6 +84,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri ) } + // ── Not-found state ── if (!lead) { return (
@@ -74,6 +104,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri return (
+ {/* ── Back link ── */} + {/* ── Header: company name, status badge, status select, edit button ── */} @@ -94,6 +126,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
+ {/* ── Users data table ── */}
+ {/* ── Create-user dialog ── */} ({ id: gif.id, title: gif.title, diff --git a/src/app/api/ai/jobs/route.ts b/src/app/api/ai/jobs/route.ts index ca8e726..a8f979c 100644 --- a/src/app/api/ai/jobs/route.ts +++ b/src/app/api/ai/jobs/route.ts @@ -1,7 +1,15 @@ +// ── AI: Job Listing ────────────────────────────────────────────────────────── +// GET /api/ai/jobs — Fetch AI-related job postings +// +// Auth: authenticated; role must be sales, admin, or super_admin +// Returns an empty array on failure (graceful degradation). + import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { fetchJobs } from "@/lib/ai" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3a2e0d8..32a1f57 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,3 +1,9 @@ +// ── Auth: Login ────────────────────────────────────────────────────────────── +// POST /api/auth/login +// Authenticates a user with email/username + password and returns a JWT session +// cookie. Supports account lockout, credential validation, and login audit +// logging. Returns user data on success, error details on failure. + import { NextRequest } from "next/server" import { comparePassword, @@ -13,6 +19,7 @@ import { SESSION_COOKIE, } from "@/lib/auth" +// ── Helpers ────────────────────────────────────────────────────────────────── function jsonResponse(data: unknown, status: number) { return new Response(JSON.stringify(data), { @@ -21,12 +28,15 @@ function jsonResponse(data: unknown, status: number) { }) } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const { email, username, password } = await request.json() const credential = email || username + // Validate that both credential and password are present if (!credential || !password) { return jsonResponse( { error: "Email/Username and password are required." }, @@ -34,6 +44,7 @@ export async function POST(request: NextRequest) { ) } + // Reject empty strings (whitespace-only credentials) if (credential.trim().length === 0 || password.trim().length === 0) { return jsonResponse( { error: "Credentials cannot be empty." }, @@ -41,6 +52,7 @@ export async function POST(request: NextRequest) { ) } + // Extract client IP from proxy headers for audit logging const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || @@ -58,6 +70,7 @@ export async function POST(request: NextRequest) { dbUser = await getUserByUsername(credential) } + // If user does not exist, log attempt and return generic error if (!dbUser) { await recordLoginAttempt( null, @@ -73,6 +86,7 @@ export async function POST(request: NextRequest) { ) } + // Check if account is temporarily locked due to too many failed attempts const lockStatus = await isAccountLocked(dbUser) if (lockStatus.locked) { await recordLoginAttempt( @@ -89,6 +103,7 @@ export async function POST(request: NextRequest) { ) } + // Verify password hash against stored hash const valid = await comparePassword(password, dbUser.password_hash) if (!valid) { await incrementFailedAttempts(dbUser.id) @@ -106,6 +121,7 @@ export async function POST(request: NextRequest) { ) } + // Successful login: reset failure counter, log success, create session await resetFailedAttempts(dbUser.id) await recordLoginAttempt( dbUser.id, @@ -120,6 +136,7 @@ export async function POST(request: NextRequest) { const user = mapDbUserToSessionUser(dbUser) + // Set HttpOnly session cookie with configurable Secure flag in production const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}` return new Response(JSON.stringify({ user }), { diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 32b8f21..81d6d0b 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,15 @@ +// ── Auth: Logout ───────────────────────────────────────────────────────────── +// POST /api/auth/logout +// Clears the session cookie by setting Max-Age=0, effectively logging the +// user out. Stateless — no server-side session invalidation needed. + import { SESSION_COOKIE } from "@/lib/auth" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST() { try { + // Overwrite cookie with an immediate expiry (Max-Age=0) return new Response(JSON.stringify({ success: true }), { status: 200, headers: { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index a832e3c..caedc3b 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,6 +1,14 @@ +// ── Auth: Current User ────────────────────────────────────────────────────── +// GET /api/auth/me +// Returns the authenticated user's profile from the current session. Used by +// the front-end to validate tokens and hydrate user context on load. +// Returns 401 if no valid session exists. + import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() diff --git a/src/app/api/auth/recover/route.ts b/src/app/api/auth/recover/route.ts index b96711f..7b3b4db 100644 --- a/src/app/api/auth/recover/route.ts +++ b/src/app/api/auth/recover/route.ts @@ -1,3 +1,12 @@ +// ── Auth: Password Recovery ────────────────────────────────────────────────── +// POST /api/auth/recover +// Super-admin only. Decrypts and returns the plaintext password for a given +// user. Used as an admin recovery tool, not a self-service reset flow. +// +// Auth: super_admin only +// Body: { userId: string } +// Response: { user: ..., password: plaintext } + import { NextRequest, NextResponse } from "next/server" import { getSessionUser, @@ -6,12 +15,15 @@ import { } from "@/lib/auth" import { query } from "@/lib/db" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const sessionUser = await getSessionUser() if (!sessionUser) { return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) } + // Only super_admin is allowed to recover other users' passwords if (sessionUser.role !== "super_admin") { return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 }) } @@ -28,6 +40,7 @@ export async function POST(request: NextRequest) { await setSessionContext(sessionUser.id, ipAddress) + // Fetch the target user (excluding soft-deleted records) const result = await query( `SELECT id, username, email, first_name, last_name, password_encrypted FROM users WHERE id = $1 AND deleted_at IS NULL`, @@ -39,10 +52,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "User not found." }, { status: 404 }) } + // Guard against missing encrypted password column if (!user.password_encrypted) { return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 }) } + // Decrypt using the master key; failure suggests key rotation or corruption const plaintextPassword = await decryptPassword(user.password_encrypted) if (!plaintextPassword) { return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 }) diff --git a/src/app/api/bug-reports/[id]/route.ts b/src/app/api/bug-reports/[id]/route.ts index b0e652f..d8f3ed0 100644 --- a/src/app/api/bug-reports/[id]/route.ts +++ b/src/app/api/bug-reports/[id]/route.ts @@ -1,7 +1,15 @@ +// ── Bug Reports: Single Report ─────────────────────────────────────────────── +// PATCH /api/bug-reports/[id] — Update bug report status, assignment, or notes +// +// Auth: admin/super_admin only +// Supports partial updates for: status, assigned_to, resolution_notes + import { NextRequest, NextResponse } from "next/server" import { query } from "@/lib/db" import { getSessionUser, setSessionContext } from "@/lib/auth" +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) { try { const sessionUser = await getSessionUser() @@ -27,11 +35,13 @@ export async function PATCH(request: NextRequest, { params: routeParams }: { par return NextResponse.json({ error: "Invalid status." }, { status: 400 }) } + // Verify the bug report exists before updating const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id]) if (existing.rows.length === 0) { return NextResponse.json({ error: "Bug report not found." }, { status: 404 }) } + // Build dynamic SET clause for partial updates const updates: string[] = [] const values: unknown[] = [] let paramIndex = 1 diff --git a/src/app/api/bug-reports/route.ts b/src/app/api/bug-reports/route.ts index b83315c..5994462 100644 --- a/src/app/api/bug-reports/route.ts +++ b/src/app/api/bug-reports/route.ts @@ -1,7 +1,16 @@ +// ── Bug Reports: Collection ────────────────────────────────────────────────── +// POST /api/bug-reports — Submit a new bug report (any authenticated user) +// GET /api/bug-reports — List bug reports with filters (admin/super_admin only) +// +// Auth: POST = authenticated; GET = admin/super_admin +// GET supports: status, severity, limit, offset + import { NextRequest, NextResponse } from "next/server" import { query } from "@/lib/db" import { getSessionUser } from "@/lib/auth" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const sessionUser = await getSessionUser() @@ -38,6 +47,8 @@ export async function POST(request: NextRequest) { } } +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const sessionUser = await getSessionUser() @@ -54,6 +65,7 @@ export async function GET(request: NextRequest) { const limit = parseInt(searchParams.get("limit") || "50", 10) const offset = parseInt(searchParams.get("offset") || "0", 10) + // Build dynamic SQL with optional status/severity filters let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url, br.screenshot_url, br.status, br.resolution_notes, br.created_at, br.updated_at, diff --git a/src/app/api/conversations/[id]/messages/[messageId]/route.ts b/src/app/api/conversations/[id]/messages/[messageId]/route.ts index 12b2f83..5e91fb6 100644 --- a/src/app/api/conversations/[id]/messages/[messageId]/route.ts +++ b/src/app/api/conversations/[id]/messages/[messageId]/route.ts @@ -1,3 +1,10 @@ +// ── Messages: Single Message ───────────────────────────────────────────────── +// DELETE /api/conversations/[id]/messages/[messageId] +// — Delete a specific message (soft-delete) owned by the current user. +// — Handles cleanup of associated voice note audio files. +// +// Auth: authenticated; user must be a participant and the message sender + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" @@ -5,6 +12,8 @@ import { unlink } from "node:fs/promises" import { join } from "node:path" import { existsSync } from "node:fs" +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ id: string; messageId: string }> }, diff --git a/src/app/api/conversations/[id]/messages/route.ts b/src/app/api/conversations/[id]/messages/route.ts index 4dcb550..43192be 100644 --- a/src/app/api/conversations/[id]/messages/route.ts +++ b/src/app/api/conversations/[id]/messages/route.ts @@ -1,9 +1,19 @@ +// ── Messages: Collection ───────────────────────────────────────────────────── +// GET /api/conversations/[id]/messages — List messages in a conversation +// POST /api/conversations/[id]/messages — Send a new message +// DELETE /api/conversations/[id]/messages — Delete own message (by messageId in body) +// PATCH /api/conversations/[id]/messages — Edit own message content +// +// Auth: authenticated; user must be a participant in the conversation + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" import { hasBlockedCodeExtension } from "@/lib/blocked-extensions" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -37,6 +47,7 @@ export async function GET( WHERE m.conversation_id = $1 AND m.deleted_at IS NULL` const msgParams: any[] = [id] + // Optional cursor-based pagination: fetch messages before a given timestamp if (before) { msgSql += ` AND m.created_at < $2` msgParams.push(before) @@ -46,6 +57,7 @@ export async function GET( msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}` msgParams.push(limit, offset) + // Fetch messages and other participant's last_read_at in parallel const [msgResult, otherReadResult] = await Promise.all([ query(msgSql, msgParams), query( @@ -59,6 +71,7 @@ export async function GET( ? new Date(otherReadResult.rows[0].last_read_at).getTime() : 0 + // Mark outgoing messages as "read" if the other participant has seen them const messages = msgResult.rows.map((row: any) => ({ id: row.id, conversationId: id, @@ -80,6 +93,8 @@ export async function GET( } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -104,7 +119,7 @@ export async function POST( return NextResponse.json({ error: "Message content is required" }, { status: 400 }) } - // Server-side blocked code extension check + // Server-side check: reject messages with blocked code file extensions try { const parsed = JSON.parse(content) if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) { @@ -123,6 +138,7 @@ export async function POST( [id, user.id, content.trim()], ) + // Bump the conversation's updated_at timestamp await query( `UPDATE conversations SET updated_at = NOW() WHERE id = $1`, [id], @@ -131,6 +147,7 @@ export async function POST( const msg = result.rows[0] const senderName = `${user.firstName} ${user.lastName}` + // Notify the other participant about the new message const otherResult = await query( `SELECT user_id FROM conversation_participants WHERE conversation_id = $1 AND user_id != $2`, @@ -164,6 +181,12 @@ export async function POST( } } +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Formats a Date to a short time string. + * Shows time only for today, date + time for older messages. + */ function formatTime(date: Date): string { const now = new Date() const isToday = date.toDateString() === now.toDateString() @@ -174,6 +197,8 @@ function formatTime(date: Date): string { date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) } +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -187,6 +212,7 @@ export async function DELETE( const messageId = body.messageId if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 }) + // Only the sender can soft-delete their own message const result = await query( `UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`, [messageId, user.id, id], @@ -202,6 +228,8 @@ export async function DELETE( } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -218,6 +246,7 @@ export async function PATCH( return NextResponse.json({ error: "messageId and content required" }, { status: 400 }) } + // Only the sender can edit their own message; also check it's not deleted const result = await query( `UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`, [newContent.trim(), messageId, user.id, id], diff --git a/src/app/api/conversations/[id]/read/route.ts b/src/app/api/conversations/[id]/read/route.ts index e5357e8..edf7f02 100644 --- a/src/app/api/conversations/[id]/read/route.ts +++ b/src/app/api/conversations/[id]/read/route.ts @@ -1,7 +1,16 @@ +// ── Conversations: Mark as Read ───────────────────────────────────────────── +// POST /api/conversations/[id]/read +// — Updates the current user's last_read_at timestamp for the conversation. +// — Also marks all related (unread) notifications for this conversation as read. +// +// Auth: authenticated; user must be a participant + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST( _request: NextRequest, { params }: { params: Promise<{ id: string }> }, @@ -12,6 +21,7 @@ export async function POST( const { id } = await params + // Update the participant's last_read_at to now await query( `UPDATE conversation_participants SET last_read_at = NOW() @@ -19,6 +29,7 @@ export async function POST( [id, user.id], ) + // Clear any existing notifications for this conversation await query( `UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`, diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index f9a97ef..d509258 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,13 +1,26 @@ +// ── Conversations: Collection ──────────────────────────────────────────────── +// GET /api/conversations — List the current user's conversations +// POST /api/conversations — Start a new conversation with another user +// +// Auth: authenticated +// GET returns up to 50 conversations with last message preview and unread count. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query, transaction } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Fetch all conversations the user participates in, with: + // - other participant's info + // - last message content/time via LATERAL join + // - unread count (messages after last_read_at, excluding own messages) const result = await query( `SELECT c.id, @@ -33,8 +46,8 @@ export async function GET() { WHERE c.id IN ( SELECT conversation_id FROM conversation_participants WHERE user_id = $1 ) - ORDER BY c.updated_at DESC - LIMIT 50`, + ORDER BY c.updated_at DESC + LIMIT 50`, [user.id], ) @@ -59,6 +72,8 @@ export async function GET() { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -73,6 +88,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 }) } + // Check if a conversation between these two users already exists const existing = await query( `SELECT c.id FROM conversations c JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1 @@ -85,12 +101,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ conversationId: existing.rows[0].id }) } + // Create new conversation + participants in a single transaction const result = await transaction(async (client) => { const convResult = await client.query( `INSERT INTO conversations DEFAULT VALUES RETURNING id`, ) const conversationId = convResult.rows[0].id + // Insert both participants with immediate last_read_at (auto-read) await client.query( `INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`, [conversationId, user.id, userId], @@ -128,6 +146,11 @@ export async function POST(request: NextRequest) { } } +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Converts a Date to a relative time string (e.g., "5m ago", "2h ago"). + */ function timeAgo(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000) if (seconds < 60) return "now" diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts index c372015..c914333 100644 --- a/src/app/api/dashboard/route.ts +++ b/src/app/api/dashboard/route.ts @@ -1,8 +1,21 @@ +// ── Dashboard ──────────────────────────────────────────────────────────────── +// GET /api/dashboard?period=6months&year=2025 +// — Returns aggregated stats, trends, monthly breakdown, and recent leads. +// +// Auth: authenticated; non-admin users scoped to their own leads +// +// Periods: 7days, 30days, 6months (default), 12months, or a specific year + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Returns the { start, end } Date range for a named period. + */ function getPeriodDateRange(period: string): { start: Date; end: Date } { const end = new Date() let start: Date @@ -20,6 +33,9 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } { return { start, end } } +/** + * Returns the range for the *previous* period of the same length. + */ function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } { const end = new Date(currentStart) const diff = end.getTime() - currentStart.getTime() @@ -34,6 +50,9 @@ const periodLabels: Record = { "12months": "Last 12 months", } +/** + * Maps DB stage name to client-facing status string. + */ function stageToStatus(name: string): string { switch (name) { case "New": return "open" @@ -48,12 +67,16 @@ function stageToStatus(name: string): string { } } +/** + * Computes a trend object from current vs previous count. + */ function computeTrend(current: number, previous: number): { pct: number; up: boolean } { if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 } const pct = Math.round(((current - previous) / previous) * 100) return { pct: Math.abs(pct), up: pct >= 0 } } +// Reusable SQL snippet that maps stage names to status strings const stageStatusSql = ` CASE WHEN ls.name = 'New' THEN 'open' @@ -64,6 +87,8 @@ const stageStatusSql = ` ELSE 'open' END` +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const user = await getSessionUser() @@ -76,6 +101,7 @@ export async function GET(request: NextRequest) { const yearParam = searchParams.get("year") let start: Date, end: Date, prevRange: { start: Date; end: Date } if (yearParam) { + // Year mode: compare full year to previous full year const y = parseInt(yearParam) start = new Date(y, 0, 1) end = new Date(y, 11, 31, 23, 59, 59) @@ -86,9 +112,10 @@ export async function GET(request: NextRequest) { prevRange = getPreviousPeriodRange(period, start) } + // Non-admin users only see their own leads const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3" - // Status counts for current period (SQL aggregation) + // ── Status counts for current period (SQL aggregation) ────────── const countSql = ` SELECT ${stageStatusSql} AS status, COUNT(*) AS count FROM leads l @@ -129,7 +156,7 @@ export async function GET(request: NextRequest) { const closedLeads = currentCounts.closed const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0 - // Monthly/weekly breakdown via date_trunc + // ── Monthly/weekly breakdown via date_trunc ───────────────────── const isMonthly = period === "6months" || period === "12months" const truncUnit = isMonthly ? "month" : "day" const breakdownSql = ` @@ -148,7 +175,7 @@ export async function GET(request: NextRequest) { : [truncUnit, start.toISOString(), end.toISOString(), user.id] const breakdownResult = await query(breakdownSql, breakdownParams) - // Build monthly breakdown from aggregated data + // Aggregate the grouped rows into a label-keyed map const breakdownMap: Record = {} for (const r of breakdownResult.rows) { const d = new Date(r.period_start) @@ -165,7 +192,7 @@ export async function GET(request: NextRequest) { } const monthlyBreakdown = Object.values(breakdownMap) - // Recent 10 leads + // ── Recent 10 leads ───────────────────────────────────────────── const recentSql = ` SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone, l.notes, l.assigned_to, l.score, @@ -206,6 +233,7 @@ export async function GET(request: NextRequest) { updatedAt: r.updated_at, })) + // ── Trends (current vs previous period) ───────────────────────── const trends = { totalLeads: computeTrend(totalLeads, prevTotal), openLeads: computeTrend(currentCounts.open, prevCounts.open), diff --git a/src/app/api/emails/route.ts b/src/app/api/emails/route.ts index e31ed44..01d0923 100644 --- a/src/app/api/emails/route.ts +++ b/src/app/api/emails/route.ts @@ -1,7 +1,15 @@ +// ── Emails: Sent Log ───────────────────────────────────────────────────────── +// GET /api/emails — List the 50 most recently sent emails +// +// Auth: admin/super_admin only +// Used for auditing outbound email communications from the system. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() diff --git a/src/app/api/event-users/route.ts b/src/app/api/event-users/route.ts index 7c0a315..103046f 100644 --- a/src/app/api/event-users/route.ts +++ b/src/app/api/event-users/route.ts @@ -1,7 +1,16 @@ +// ── Event Users ───────────────────────────────────────────────────────────── +// GET /api/event-users — List all active users available for event assignment +// +// Auth: authenticated +// Returns users with their roles, excluding the current user (cannot assign +// events to yourself as a participant). Used by the calendar UI for dropdowns. + import { NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() diff --git a/src/app/api/events/[id]/ics/route.ts b/src/app/api/events/[id]/ics/route.ts index 3b99bbe..56615f1 100644 --- a/src/app/api/events/[id]/ics/route.ts +++ b/src/app/api/events/[id]/ics/route.ts @@ -1,8 +1,16 @@ +// ── Events: ICS Export ─────────────────────────────────────────────────────── +// GET /api/events/[id]/ics — Export a calendar event as an .ics file +// +// Auth: authenticated; only the event creator can download the ICS +// Returns a text/calendar file for import into Outlook, Google Calendar, etc. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { buildIcs } from "@/lib/ics" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -10,6 +18,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const { id } = await params + // Fetch event details with creator and participant info; enforce ownership const result = await query( `SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type, e.start_time, e.end_time, e.duration_minutes, e.status, @@ -28,6 +37,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const r = result.rows[0] + // Build the ICS string via the shared utility const icsContent = buildIcs({ uid: r.id, title: r.title, diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts index 6199c7e..e32ca3b 100644 --- a/src/app/api/events/[id]/route.ts +++ b/src/app/api/events/[id]/route.ts @@ -1,8 +1,17 @@ +// ── Events: Single Event ───────────────────────────────────────────────────── +// PATCH /api/events/[id] — Update an event (with permission checks & lead auto-close) +// DELETE /api/events/[id] — Delete an event (hard delete, creator only) +// +// Auth: authenticated; participants/developers can update limited fields; +// only the creator can delete or edit core event details. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { sendEventRescheduled } from "@/lib/email" +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -11,6 +20,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { id } = await params const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json() + // Fetch current event with creator + participant details const existing = await query( `SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type, e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone, @@ -37,6 +47,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } + // Non-creators cannot change core event details (title, type, time, participants) if (!isCreator && ( (title !== undefined && title !== old.title) || (eventType !== undefined && eventType !== old.event_type) || @@ -49,6 +60,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 }) } + // COALESCE-based partial update: only overwrite provided fields const result = await query( `UPDATE scheduled_events SET title = COALESCE($1, title), @@ -88,7 +100,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const r = result.rows[0] - // Auto-close lead when developer marks event as completed + // Auto-close the associated lead when a developer marks the event as completed if (status === "completed" && r.lead_id) { const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'") if (stageResult.rows.length > 0) { @@ -99,6 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } + // Send reschedule email if core fields changed const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time || r.title !== old.title || r.event_type !== old.event_type || r.status !== old.status @@ -118,6 +131,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< }).catch((err) => console.error("Reschedule email error:", err)) } + // Fetch creator info to return in the response const creatorResult = await query( `SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url, ur.role_id, r.name AS role_name, r.display_name AS role_display @@ -163,6 +177,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -180,6 +196,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise return NextResponse.json({ error: "Event not found" }, { status: 404 }) } + // Only the event creator can delete if (existing.rows[0].user_id !== user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 0e08b36..1bbf7a3 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,8 +1,17 @@ +// ── Events: Collection ────────────────────────────────────────────────────── +// GET /api/events — List scheduled events (scoped to user's involvement) +// POST /api/events — Create a new event (with notifications & lead updates) +// +// Auth: authenticated +// GET supports filters: start, end (date range), status + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query, transaction } from "@/lib/db" import { sendEventConfirmation } from "@/lib/email" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const user = await getSessionUser() @@ -13,6 +22,7 @@ export async function GET(request: NextRequest) { const end = searchParams.get("end") const status = searchParams.get("status") + // Build a large JOIN query that includes all associated user + lead info let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id, e.title, e.description, e.participant_notes, e.event_type, e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at, @@ -38,6 +48,7 @@ export async function GET(request: NextRequest) { const params: unknown[] = [] let idx = 1 + // Non-super_admins only see events where they are creator, participant, or developer if (user.role !== "super_admin") { sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}` params.push(user.id) @@ -68,6 +79,7 @@ export async function GET(request: NextRequest) { const result = await query(sql, params, user.id) + // Map rows to camelCase API shape with denormalized user/lead objects const events = result.rows.map((r: any) => ({ id: r.id, userId: r.user_id, @@ -100,6 +112,8 @@ export async function GET(request: NextRequest) { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -115,6 +129,7 @@ export async function POST(request: NextRequest) { if (!title) { return NextResponse.json({ error: "Title is required" }, { status: 400 }) } + // website_creation events don't require start time; all others do if (eventType !== "website_creation" && !startTime) { return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 }) } @@ -129,6 +144,7 @@ export async function POST(request: NextRequest) { [ user.id, participantId || null, developerId || null, leadId || null, conversationId || null, title, description || null, eventType || "website_creation", startTime || null, + // For website_creation, endTime and durationMinutes are irrelevant eventType === "website_creation" ? null : (endTime || null), eventType === "website_creation" ? null : (durationMinutes || null), clientName || null, clientEmail || null, clientPhone || null, @@ -136,7 +152,7 @@ export async function POST(request: NextRequest) { ) const r = ins.rows[0] - // 2. Auto-update lead stage if website creation event + // 2. Auto-update lead stage to "Qualified" when a website creation event is scheduled if (r.event_type === "website_creation" && leadId) { const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'") if (stageResult.rows.length > 0) { diff --git a/src/app/api/gifs/route.ts b/src/app/api/gifs/route.ts index 1b7c5b6..5820742 100644 --- a/src/app/api/gifs/route.ts +++ b/src/app/api/gifs/route.ts @@ -1,9 +1,20 @@ +// ── GIFs: Search/Trending ──────────────────────────────────────────────────── +// GET /api/gifs?q=&limit=&pos= — Search or get trending GIFs via GIPHY proxy +// +// Auth: authenticated +// Supports search queries and trending fallback with pagination. +// Gracefully degrades: returns empty results array on error or missing API key. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" +// ── Constants ──────────────────────────────────────────────────────────────── + const GIPHY_API_KEY = process.env.GIPHY_API_KEY const GIPHY_BASE = "https://api.giphy.com/v1/gifs" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const user = await getSessionUser() @@ -22,6 +33,7 @@ export async function GET(request: NextRequest) { let data: any if (q) { + // Search mode const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g` const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) if (!res.ok) { @@ -31,6 +43,7 @@ export async function GET(request: NextRequest) { } data = await res.json() } else { + // Trending mode with fallback to "funny" search if trending fails or is empty const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g` const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) }) @@ -61,6 +74,8 @@ export async function GET(request: NextRequest) { data = await fallbackRes.json() } } + + // Normalize response to a minimal set of fields const results = (data.data || []).map((item: any) => { const dims = item.images?.original return { diff --git a/src/app/api/invite/generate/route.ts b/src/app/api/invite/generate/route.ts index 5de99d7..a7cb16e 100644 --- a/src/app/api/invite/generate/route.ts +++ b/src/app/api/invite/generate/route.ts @@ -1,8 +1,17 @@ +// ── Invite: Generate ───────────────────────────────────────────────────────── +// POST /api/invite/generate — Generate an invite link for a phone number +// +// Auth: super_admin only +// Rate-limited to 10 invites per hour. Creates a cryptographically random +// token and returns a full URL for sharing. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser, setSessionContext } from "@/lib/auth" import { query } from "@/lib/db" import crypto from "crypto" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const sessionUser = await getSessionUser() @@ -25,6 +34,7 @@ export async function POST(request: NextRequest) { await setSessionContext(sessionUser.id, ipAddress) + // Rate limit: max 10 invites per rolling hour const recentCount = await query( `SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`, ) @@ -32,6 +42,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 }) } + // Generate a 48-character hex token using cryptographically secure random bytes const token = crypto.randomBytes(24).toString("hex") await query( `INSERT INTO invites (token, phone) VALUES ($1, $2)`, diff --git a/src/app/api/leads/[id]/notes/route.ts b/src/app/api/leads/[id]/notes/route.ts index 06b7b88..0a368c1 100644 --- a/src/app/api/leads/[id]/notes/route.ts +++ b/src/app/api/leads/[id]/notes/route.ts @@ -1,8 +1,20 @@ +// ── Leads: Notes ───────────────────────────────────────────────────────────── +// POST /api/leads/[id]/notes — Add a note to a lead +// GET /api/leads/[id]/notes — List notes for a lead (paginated) +// +// Auth: authenticated users with access to the lead (owner or admin) + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Checks whether the requesting user has access to the given lead. + * Access is granted if the user is the assigned owner or has an admin role. + */ async function checkLeadAccess(leadId: string, userId: string): Promise { const result = await query( `SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL @@ -15,6 +27,8 @@ async function checkLeadAccess(leadId: string, userId: string): Promise return result.rows.length > 0 } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -43,6 +57,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -56,6 +72,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const limit = parseInt(searchParams.get("limit") || "50", 10) const offset = parseInt(searchParams.get("offset") || "0", 10) + // Fetch notes with author info, ordered newest-first const result = await query( `SELECT cn.id, cn.created_at, cn.updated_at, cn.content, u.id AS user_id, u.first_name, u.last_name, u.avatar_url diff --git a/src/app/api/leads/[id]/route.ts b/src/app/api/leads/[id]/route.ts index c3c1a5f..2a8fb54 100644 --- a/src/app/api/leads/[id]/route.ts +++ b/src/app/api/leads/[id]/route.ts @@ -1,8 +1,19 @@ +// ── Leads: Single Lead ────────────────────────────────────────────────────── +// GET /api/leads/[id] — Get a single lead by ID +// PATCH /api/leads/[id] — Update lead fields (dynamic partial update) +// +// Auth: all authenticated users; scoped to own assignments or admin + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Maps the DB stage name to the client-facing status string. + */ function stageToStatus(name: string): string { switch (name) { case "New": return "open" @@ -17,6 +28,8 @@ function stageToStatus(name: string): string { } } +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -25,6 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params const isAdmin = user.role === "admin" || user.role === "super_admin" + // Fetch lead with stage + user JOINs; enforce ownership scoping const result = await query( `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id, @@ -70,6 +84,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Maps client-facing status back to the default DB stage name. + * Used when creating or updating leads via PATCH. + */ function statusToStageName(status: string): string { switch (status) { case "open": return "New" @@ -81,6 +101,8 @@ function statusToStageName(status: string): string { } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -89,7 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { id } = await params const isAdmin = user.role === "admin" || user.role === "super_admin" - // Verify access + // Verify the user has access to this lead before applying updates const accessCheck = await query( `SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3)`, @@ -104,6 +126,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const values: any[] = [] let idx = 1 + // Build dynamic SET clause — only include provided fields if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) } if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) } if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) } @@ -111,15 +134,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) } if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) } if (body.assignedUserId !== undefined) { - const isAdmin = user.role === "admin" || user.role === "super_admin" + // Only admins can reassign leads if (!isAdmin) { - // non-admin cannot reassign return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 }) } fields.push(`assigned_to = $${idx++}`) values.push(body.assignedUserId === "none" ? null : body.assignedUserId) } if (body.status !== undefined) { + // Resolve the client status → stage ID via the lookup table const stageName = statusToStageName(body.status) const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName]) if (stageResult.rows.length > 0) { @@ -140,6 +163,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: "No fields to update" }, { status: 400 }) } + // Always bump the updated_at timestamp fields.push(`updated_at = NOW()`) values.push(id) diff --git a/src/app/api/leads/route.ts b/src/app/api/leads/route.ts index 9e2943e..f5343d9 100644 --- a/src/app/api/leads/route.ts +++ b/src/app/api/leads/route.ts @@ -1,8 +1,21 @@ +// ── Leads: Collection ──────────────────────────────────────────────────────── +// GET /api/leads — List leads with search, filter, pagination +// POST /api/leads — Create a new lead +// DELETE /api/leads?id= — Soft-delete a lead +// +// Auth: all authenticated users; non-admins scoped to own assignments + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Maps the DB stage name to the client-facing status string. + * Used for consistent status labels across the app. + */ function stageToStatus(name: string): string { switch (name) { case "New": return "open" @@ -17,6 +30,10 @@ function stageToStatus(name: string): string { } } +/** + * Returns { start, end } Date range for a given period key. + * Returns null for "all" (no date filtering). + */ function getPeriodDateRange(period: string): { start: Date; end: Date } | null { if (period === "all") return null const end = new Date() @@ -30,6 +47,8 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } | null { return { start, end } } +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const user = await getSessionUser() @@ -46,8 +65,10 @@ export async function GET(request: NextRequest) { const params: any[] = [] let paramIdx = 1 + // Base condition: exclude soft-deleted leads const conditions: string[] = ["l.deleted_at IS NULL"] + // Apply date range filter if period is specified if (period !== "all") { const range = getPeriodDateRange(period) if (range) { @@ -57,19 +78,22 @@ export async function GET(request: NextRequest) { } } + // Apply free-text search across name, company, email, phone (case-insensitive) if (search) { conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`) params.push(`%${search}%`) paramIdx++ } + // Non-admin users can only see their own assigned leads if (!isAdmin) { conditions.push(`l.assigned_to = $${paramIdx}`) params.push(user.id) paramIdx++ } - // Push status filter into SQL (avoid client-side filtering after pagination) + // Map client-facing status → DB stage names for server-side filtering + // This avoids pulling all rows and filtering client-side after pagination const statusStageMap: Record = { open: ["New"], contacted: ["Contacted"], @@ -89,14 +113,14 @@ export async function GET(request: NextRequest) { const whereClause = conditions.join(" AND ") - // Total count (same filters, no pagination) + // Total count query (same filters, no pagination) const countResult = await query( `SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`, params.slice(0, paramIdx - 1), ) const total = parseInt(countResult.rows[0]?.total || "0", 10) - // Data query with pagination + // Paginated data query with JOINs for stage name and assigned user const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id, ls.name AS stage_name, @@ -110,6 +134,7 @@ export async function GET(request: NextRequest) { const dataParams = [...params, limit, offset] const result = await query(dataSql, dataParams) + // Shape rows into camelCase API response with denormalized assigned user const leads = result.rows.map((r: any) => { const s = stageToStatus(r.stage_name) return { @@ -140,6 +165,8 @@ export async function GET(request: NextRequest) { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -156,6 +183,7 @@ export async function POST(request: NextRequest) { assignedUserId = null } + // Look up the stage ID from the status name (default to "New" for open) const stageResult = await query( "SELECT id FROM lead_stages WHERE name = $1", [body.status === "open" ? "New" : "Contacted"] @@ -185,6 +213,8 @@ export async function POST(request: NextRequest) { } } +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE(request: NextRequest) { try { const user = await getSessionUser() @@ -196,6 +226,7 @@ export async function DELETE(request: NextRequest) { const id = searchParams.get("id") if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 }) + // Soft-delete by setting deleted_at; admins can delete any lead, others only their own const result = await query( "UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id", [id, isAdmin, user.id] diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts index f6404f8..f939b8d 100644 --- a/src/app/api/notifications/[id]/route.ts +++ b/src/app/api/notifications/[id]/route.ts @@ -1,7 +1,15 @@ +// ── Notifications: Single Notification ────────────────────────────────────── +// PATCH /api/notifications/[id] — Mark a single notification as read +// DELETE /api/notifications/[id] — Dismiss/delete a single notification +// +// Auth: authenticated; only the owning user can modify their notifications + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -25,6 +33,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index dd1682a..d4689ba 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -1,7 +1,16 @@ +// ── Notification Preferences ───────────────────────────────────────────────── +// GET /api/notifications/preferences — Get current user's notification prefs +// PATCH /api/notifications/preferences — Upsert notification preferences +// +// Auth: authenticated +// Uses INSERT ... ON CONFLICT (user_id) DO UPDATE for idempotent upsert. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() @@ -14,6 +23,7 @@ export async function GET() { [user.id], ) + // Return defaults if no row exists yet if (result.rowCount === 0) { return NextResponse.json({ leadAssigned: true, @@ -38,6 +48,8 @@ export async function GET() { } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest) { try { const user = await getSessionUser() @@ -45,6 +57,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json() + // Upsert: create row if not exists, otherwise update await query( `INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index c4ddd5c..ac3f63e 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,7 +1,16 @@ +// ── Notifications: Collection ──────────────────────────────────────────────── +// GET /api/notifications — List the current user's recent notifications +// POST /api/notifications — Create a new notification (self or target user) +// PATCH /api/notifications — Mark all unread notifications as read +// +// Auth: authenticated + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() @@ -28,6 +37,7 @@ export async function GET() { contextType: r.context_type, })) + // Also return total unread count for badge display const unreadResult = await query( `SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`, [user.id], @@ -43,6 +53,8 @@ export async function GET() { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -50,6 +62,7 @@ export async function POST(request: NextRequest) { const { type, title, description, link, userId } = await request.json() const isAdmin = user.role === "admin" || user.role === "super_admin" + // Only admins can create notifications for other users; otherwise self-only const targetUserId = (userId && isAdmin) ? userId : user.id const result = await query( @@ -75,11 +88,14 @@ export async function POST(request: NextRequest) { } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH() { try { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Mark all unread notifications as read for the current user await query( `UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`, [user.id], diff --git a/src/app/api/settings/company/route.ts b/src/app/api/settings/company/route.ts index 87aa37c..db853b8 100644 --- a/src/app/api/settings/company/route.ts +++ b/src/app/api/settings/company/route.ts @@ -1,7 +1,16 @@ +// ── Settings: Company ─────────────────────────────────────────────────────── +// GET /api/settings/company — Get company-wide settings +// PATCH /api/settings/company — Update company settings +// +// Auth: admin/super_admin only +// Falls back to sensible defaults if no row exists in company_settings. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() @@ -36,6 +45,8 @@ export async function GET() { } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest) { try { const user = await getSessionUser() @@ -46,6 +57,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json() + // Attempt update on existing row; if no row exists, insert one const result = await query( `UPDATE company_settings SET company_name = $1, company_email = $2, company_phone = $3, diff --git a/src/app/api/settings/facebook/accounts/[id]/route.ts b/src/app/api/settings/facebook/accounts/[id]/route.ts index a4954ce..4b3c905 100644 --- a/src/app/api/settings/facebook/accounts/[id]/route.ts +++ b/src/app/api/settings/facebook/accounts/[id]/route.ts @@ -1,7 +1,15 @@ +// ── Settings: Single Facebook Account ─────────────────────────────────────── +// PATCH /api/settings/facebook/accounts/[id] +// — Update a Facebook scraping account (label, profile path, active, unflag) +// +// Auth: admin/super_admin only + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const user = await getSessionUser() @@ -16,6 +24,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const values: any[] = [] let idx = 1 + // Build dynamic SET clause for partial updates if (body.isActive !== undefined) { fields.push(`is_active = $${idx++}`) values.push(body.isActive) @@ -25,12 +34,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< values.push(body.label.trim()) } if (body.profilePath !== undefined) { + // Update both profile_path and the derived cookie_file path fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`) values.push(body.profilePath.trim()) values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`) idx++ } if (body.unflag === true) { + // Clear flag status and reset failure counter fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`) } diff --git a/src/app/api/settings/facebook/accounts/route.ts b/src/app/api/settings/facebook/accounts/route.ts index e5f0005..e3d89bb 100644 --- a/src/app/api/settings/facebook/accounts/route.ts +++ b/src/app/api/settings/facebook/accounts/route.ts @@ -1,7 +1,15 @@ +// ── Settings: Facebook Accounts ───────────────────────────────────────────── +// GET /api/settings/facebook/accounts — List all Facebook scraping accounts +// POST /api/settings/facebook/accounts — Add a new Facebook account +// +// Auth: admin/super_admin only + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() @@ -10,6 +18,7 @@ export async function GET() { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } + // Fetch accounts with the latest scrape log result via LATERAL join const accounts = await query( `SELECT fa.id, fa.label, fa.profile_path, fa.is_active, fa.last_scrape_at, fa.last_success_at, fa.last_error_at, @@ -37,6 +46,8 @@ export async function GET() { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -50,6 +61,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 }) } + // Derive the cookies file path from the profile path const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite` const result = await query( `INSERT INTO facebook_accounts (label, profile_path, cookie_file) diff --git a/src/app/api/settings/facebook/logs/route.ts b/src/app/api/settings/facebook/logs/route.ts index 7f10470..a9dbd46 100644 --- a/src/app/api/settings/facebook/logs/route.ts +++ b/src/app/api/settings/facebook/logs/route.ts @@ -1,7 +1,15 @@ +// ── Settings: Facebook Scrape Logs ────────────────────────────────────────── +// GET /api/settings/facebook/logs?accountId=&limit=&offset= +// — List Facebook scraping session logs, optionally filtered by account +// +// Auth: admin/super_admin only + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const user = await getSessionUser() diff --git a/src/app/api/settings/preferences/route.ts b/src/app/api/settings/preferences/route.ts index 6a7da54..dd154c4 100644 --- a/src/app/api/settings/preferences/route.ts +++ b/src/app/api/settings/preferences/route.ts @@ -1,7 +1,16 @@ +// ── Settings: User Preferences ─────────────────────────────────────────────── +// GET /api/settings/preferences — Get the current user's preferences +// PATCH /api/settings/preferences — Upsert user preferences +// +// Auth: authenticated +// Preferences: timezone, dateFormat, itemsPerPage + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() @@ -32,6 +41,8 @@ export async function GET() { } } +// ── PATCH ──────────────────────────────────────────────────────────────────── + export async function PATCH(request: NextRequest) { try { const user = await getSessionUser() @@ -39,6 +50,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json() + // Upsert: create if not exists, update if it does await query( `INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at) VALUES ($1, $2, $3, $4, NOW()) diff --git a/src/app/api/settings/website-theme/route.ts b/src/app/api/settings/website-theme/route.ts index a89c2c1..84c3488 100644 --- a/src/app/api/settings/website-theme/route.ts +++ b/src/app/api/settings/website-theme/route.ts @@ -1,12 +1,23 @@ +// ── Settings: Website Theme ────────────────────────────────────────────────── +// GET /api/settings/website-theme — Get the current user's theme preference +// PUT /api/settings/website-theme — Update theme preference (stored in JSONB) +// +// Auth: authenticated +// Reads and writes the website_theme and color_theme keys inside the user's +// preferences JSONB column, preserving other preference data. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { try { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + // Extract website_theme and color_theme from the JSONB preferences column const result = await query( `SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`, [user.id], @@ -21,6 +32,8 @@ export async function GET() { } } +// ── PUT ────────────────────────────────────────────────────────────────────── + export async function PUT(request: NextRequest) { try { const user = await getSessionUser() @@ -35,6 +48,7 @@ export async function PUT(request: NextRequest) { update.color_theme = body.colorTheme || "default" } + // Merge into the existing JSONB preferences using || operator if (Object.keys(update).length > 0) { await query( `UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`, @@ -42,6 +56,7 @@ export async function PUT(request: NextRequest) { ) } + // Return the updated values const result = await query( `SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`, [user.id], diff --git a/src/app/api/system/monitor/route.ts b/src/app/api/system/monitor/route.ts index baecb2e..1d54c5e 100644 --- a/src/app/api/system/monitor/route.ts +++ b/src/app/api/system/monitor/route.ts @@ -1,10 +1,20 @@ +// ── System: Monitor ────────────────────────────────────────────────────────── +// GET /api/system/monitor — Return basic system health metrics +// +// Auth: authenticated +// Returns: RSS memory (MB), CPU usage (% of one core), CPU core count +// CPU is calculated as process CPU time delta over wall clock time. + import { NextResponse } from "next/server" import os from "os" import { getSessionUser } from "@/lib/auth" +// Module-level state for CPU delta calculation let prevCpu = process.cpuUsage() let prevTime = Date.now() +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET() { const sessionUser = await getSessionUser() if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) @@ -17,7 +27,7 @@ export async function GET() { const sys = currentCpu.system - prevCpu.system const totalUs = user + sys - // CPU time (ms) / wall time (ms) * 100 = % of one core + // CPU time (microseconds) / wall time (milliseconds * 1000) * 100 = % of one core const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0 prevCpu = currentCpu diff --git a/src/app/api/upload/voice/route.ts b/src/app/api/upload/voice/route.ts index d379c9e..b09b90c 100644 --- a/src/app/api/upload/voice/route.ts +++ b/src/app/api/upload/voice/route.ts @@ -1,9 +1,19 @@ +// ── Upload: Voice Message ──────────────────────────────────────────────────── +// POST /api/upload/voice — Upload a voice note audio file +// +// Auth: authenticated +// Accepts multipart/form-data with an "audio" file field and "duration" field. +// Saves the file to public/uploads/voice/ with a UUID filename. +// Returns the public URL path and duration for use in chat messages. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { writeFile } from "node:fs/promises" import { join } from "node:path" import crypto from "node:crypto" +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -15,6 +25,7 @@ export async function POST(request: NextRequest) { const duration = parseFloat(formData.get("duration")?.toString() || "0") + // Always use .webm extension (normalize from whatever the client sends) const ext = file.name.endsWith(".webm") ? "webm" : "webm" const filename = `${crypto.randomUUID()}.${ext}` const buffer = Buffer.from(await file.arrayBuffer()) diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index b816bc5..bfebbe4 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,7 +1,14 @@ +// ── Users: Single User ─────────────────────────────────────────────────────── +// DELETE /api/users/[id] — Soft-delete a user account +// +// Auth: super_admin only; cannot delete yourself + import { NextRequest, NextResponse } from "next/server" import { query } from "@/lib/db" import { getSessionUser } from "@/lib/auth" +// ── DELETE ─────────────────────────────────────────────────────────────────── + export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const sessionUser = await getSessionUser() @@ -14,10 +21,12 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const { id } = await params + // Prevent self-deletion to avoid admin lockout if (id === sessionUser.id) { return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 }) } + // Soft-delete: set deleted_at rather than removing the row await query( `UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, [id] diff --git a/src/app/api/users/avatar/route.ts b/src/app/api/users/avatar/route.ts index f05f18b..c5a4b22 100644 --- a/src/app/api/users/avatar/route.ts +++ b/src/app/api/users/avatar/route.ts @@ -1,10 +1,20 @@ +// ── Users: Avatar Upload ──────────────────────────────────────────────────── +// POST /api/users/avatar — Update the current user's avatar (base64 data URL) +// +// Auth: authenticated +// Accepts PNG, JPEG, GIF data URIs up to 2MB decoded size. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── Constants ──────────────────────────────────────────────────────────────── + const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"] const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const user = await getSessionUser() @@ -15,18 +25,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 }) } + // Validate image format by checking the data URL prefix const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p)) if (!allowed) { return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 }) } - // Approximate decoded size: base64 is ~4/3 of original + // Approximate decoded size: base64 encodes ~4 bytes as 3 bytes of data const base64Data = avatar.split(",")[1] || "" const estimatedBytes = Math.round(base64Data.length * 0.75) if (estimatedBytes > MAX_AVATAR_BYTES) { return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 }) } + // Store the full data URL in the avatar_url column await query( `UPDATE users SET avatar_url = $1 WHERE id = $2`, [avatar, user.id], diff --git a/src/app/api/users/lookup/route.ts b/src/app/api/users/lookup/route.ts index 2d8f3df..8de7443 100644 --- a/src/app/api/users/lookup/route.ts +++ b/src/app/api/users/lookup/route.ts @@ -1,7 +1,15 @@ +// ── Users: Phone Lookup ────────────────────────────────────────────────────── +// GET /api/users/lookup?phone= — Look up a user by phone number +// +// Auth: admin/super_admin only +// Returns found: true + user data or found: false if no match. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { const sessionUser = await getSessionUser() if (!sessionUser) { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index fd4b2ca..1bb5b95 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -1,8 +1,16 @@ +// ── Users: Collection ──────────────────────────────────────────────────────── +// GET /api/users — List users (paginated); admin/super_admin only +// POST /api/users — Create a new user; super_admin only +// +// Auth: GET requires admin/super_admin; POST requires super_admin + import { NextRequest, NextResponse } from "next/server" import { query } from "@/lib/db" import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth" import { avatarSvgUrl } from "@/lib/avatar" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const sessionUser = await getSessionUser() @@ -15,6 +23,7 @@ export async function GET(request: NextRequest) { const limit = parseInt(searchParams.get("limit") || "50", 10) const offset = parseInt(searchParams.get("offset") || "0", 10) + // Fetch users with their role name, excluding soft-deleted records const result = await query( `SELECT u.id, u.username, u.email, u.first_name, u.last_name, u.is_active AS active, u.created_at, u.avatar_url, @@ -43,6 +52,8 @@ export async function GET(request: NextRequest) { } } +// ── POST ───────────────────────────────────────────────────────────────────── + export async function POST(request: NextRequest) { try { const sessionUser = await getSessionUser() @@ -63,6 +74,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid role" }, { status: 400 }) } + // Derive first/last name from full name string const nameParts = name.trim().split(/\s+/) const firstName = nameParts[0] const lastName = nameParts.slice(1).join(" ") || firstName @@ -72,6 +84,7 @@ export async function POST(request: NextRequest) { await setSessionContext(sessionUser.id) + // Insert user record with both hash (for login) and encrypted (for recovery) const result = await query( `INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -79,6 +92,7 @@ export async function POST(request: NextRequest) { [username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id] ) + // Assign the requested role via the user_roles junction table const roleId = ( await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role]) ).rows[0]?.id @@ -94,6 +108,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 }) } catch (error: any) { console.error("Error creating user:", error) + // Handle unique constraint violations for username or email if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") { return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 }) } diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts index 77fcb87..5bce77d 100644 --- a/src/app/api/users/search/route.ts +++ b/src/app/api/users/search/route.ts @@ -1,8 +1,16 @@ +// ── Users: Search ──────────────────────────────────────────────────────────── +// GET /api/users/search?q= — Typeahead search users by name or email +// +// Auth: admin/super_admin only +// Returns up to 10 matching users, excluding the requester themselves. + import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" +// ── GET ────────────────────────────────────────────────────────────────────── + export async function GET(request: NextRequest) { try { const currentUser = await getSessionUser() @@ -17,6 +25,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ users: [] }) } + // Case-insensitive search on full name and email; exclude current user const result = await query( `SELECT id, first_name || ' ' || last_name AS name, email, avatar_url FROM users diff --git a/src/app/call/[roomId]/page.tsx b/src/app/call/[roomId]/page.tsx index f438469..61d3832 100644 --- a/src/app/call/[roomId]/page.tsx +++ b/src/app/call/[roomId]/page.tsx @@ -1,9 +1,33 @@ +// ─────────────────────────────────────────────── +// Call Room Page (/call/[roomId]) +// ─────────────────────────────────────────────── +// Route: /call/:roomId +// Purpose: Anonymous WebRTC voice-call room. +// Handles pre-join (name entry, mic permission), +// connection states (calling, waiting, connecting, +// connected), and post-call (ended) states. +// Layout: Centered card with state-dependent UI +// — join form, call controls, or end screen. +// Uses: useWebRTCCall hook with anonymous mode. +// ─────────────────────────────────────────────── + "use client" import { useState, useEffect, use, useCallback } from "react" import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react" import { useWebRTCCall } from "@/hooks/useWebRTCCall" +/** + * CallRoomPage + * ──────────── + * Manages the full lifecycle of an anonymous voice + * call in a room. Pre-join: requests mic, shows + * name input. Joined: renders call state UI. + * Auto-detects connection timeouts (10 s). + * + * @param params - Route params containing roomId. + * @returns Call room UI for each state. + */ export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) { const { roomId } = use(params) @@ -27,6 +51,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str const [micError, setMicError] = useState(null) const [connectionTimeout, setConnectionTimeout] = useState(false) + // ── 10-second connection timeout detection ── useEffect(() => { if (isReady) return const timer = setTimeout(() => { @@ -35,6 +60,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str return () => clearTimeout(timer) }, [isReady]) + // ── Join handler: requests mic, then joins room ── const handleJoin = useCallback(async () => { if (!displayName.trim()) return setMicError(null) @@ -51,15 +77,18 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str endCall() }, [endCall]) + // ── Pre-join UI (not yet in the room) ── if (!joined) { return (
{connectionTimeout ? ( + // Socket connection failed after 10 s <>

This call is no longer available.

) : !isReady ? ( + // Still connecting to signaling server <>
@@ -67,6 +96,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
) : ( + // Name entry form <>

Join Call

{micError && ( @@ -95,9 +125,11 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str ) } + // ── In-call UI (joined) ── return (
+ {/* ── Calling state ── */} {callState === "calling" && (

Calling

@@ -110,6 +142,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)} + {/* ── Waiting for other participant ── */} {callState === "waiting" && (

Waiting for participant

@@ -133,6 +166,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)} + {/* ── Participant joined — transitioning to connect ── */} {callState === "participant_joined" && (

Participant joined

@@ -156,6 +190,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)} + {/* ── Establishing WebRTC connection ── */} {callState === "connecting" && (

Connecting

@@ -168,6 +203,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)} + {/* ── Active call with controls ── */} {callState === "connected" && (

Connected

@@ -186,18 +222,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str {formatDuration(callDuration)}
+ {/* Speaker toggle */} + {/* Mute toggle */} + {/* End call */} + {/* Legal footer links */}

By signing in, you agree to our{" "}

+ {/* ── Input area (sticky bottom) ── */}
@@ -307,13 +342,16 @@ export const AIChat = forwardRef(({ onMessageSent }, ) } + // ── Chat view (messages present) ── return (
+ {/* ── Message list ── */}
{messages.map((msg, i) => (
{msg.role === "assistant" ? ( + /* ── AI assistant bubble (left-aligned) ── */
@@ -326,6 +364,7 @@ export const AIChat = forwardRef(({ onMessageSent },
) : ( + /* ── User bubble (right-aligned) ── */
@@ -345,6 +384,7 @@ export const AIChat = forwardRef(({ onMessageSent }, )}
))} + {/* ── Typing indicator (bouncing dots) ── */} {loading && (
@@ -360,6 +400,7 @@ export const AIChat = forwardRef(({ onMessageSent },
+ {/* ── Input area (sticky bottom) ── */}
{error && ( diff --git a/src/components/ai/gif-picker.tsx b/src/components/ai/gif-picker.tsx index 022c6ae..bc0af72 100644 --- a/src/components/ai/gif-picker.tsx +++ b/src/components/ai/gif-picker.tsx @@ -1,8 +1,13 @@ +// ── AI Assistant: GIF Picker Component ── +// Inline GIF search and selection UI backed by GIPHY. +// Supports trending, search with debounce, infinite scroll, and client-side caching. + "use client" import { useState, useRef, useEffect, useCallback } from "react" import { Search, Loader2, ImageIcon } from "lucide-react" +/** A single GIF result from the GIPHY API */ interface GifResult { id: string title: string @@ -14,12 +19,19 @@ interface GifResult { } interface GifPickerProps { + /** Called when a GIF is selected */ onSelect: (gif: { url: string; previewUrl: string; title: string }) => void + /** Called to close the picker */ onClose: () => void } +/** In-memory cache keyed by search query or "__trending__" */ const SEARCH_CACHE = new Map() +/** + * GifPicker — inline GIF search/selection popover. + * Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver. + */ export function GifPicker({ onSelect, onClose }: GifPickerProps) { const [gifs, setGifs] = useState([]) const [loading, setLoading] = useState(true) @@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { const inputRef = useRef(null) const searchTimeoutRef = useRef>() + // ── Fetch GIFs from the API, with optional cache hit ── const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => { if (off === 0) { const cacheKey = q || "__trending__" @@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { } } + // ── Perform the API request ── try { if (!append) setLoading(true) else setLoadingMore(true) @@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { const data = await res.json() const newGifs: GifResult[] = data.gifs || [] + // Append or replace results, caching on first page if (append) { setGifs((prev) => [...prev, ...newGifs]) } else { @@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { } }, []) + // ── Load trending GIFs on mount ── useEffect(() => { fetchGifs("", 0, false) }, [fetchGifs]) + // ── Auto-focus the search input ── useEffect(() => { inputRef.current?.focus() }, []) + // ── Debounced search: 400ms after the user stops typing ── useEffect(() => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) if (!search.trim()) { @@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { } }, [search, fetchGifs]) + // ── Infinite scroll via IntersectionObserver on the sentinel ── useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) { }, [hasMore, loadingMore, loading, offset, search, fetchGifs]) return ( + // ── Popover container ──
+ {/* ── Search bar ── */}
@@ -144,6 +165,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
+ {/* ── GIF grid with loading / error / empty states ── */}
{loading ? (
@@ -184,6 +206,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
)} + {/* Sentinel element for infinite scroll detection */} {hasMore &&
} )} diff --git a/src/components/ai/job-selector.tsx b/src/components/ai/job-selector.tsx index 0b771a3..16fad38 100644 --- a/src/components/ai/job-selector.tsx +++ b/src/components/ai/job-selector.tsx @@ -1,8 +1,13 @@ +// ── AI Assistant: Job Selector Component ── +// Dropdown that fetches and displays job categories from the API. +// Allows the user to pick a job, then triggers a Facebook search. + "use client" import { useState, useEffect } from "react" import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react" +/** A job category returned from the API */ interface Job { job_title: string keywords: string[] @@ -11,17 +16,25 @@ interface Job { } interface JobSelectorProps { + /** Called when a job is selected or cleared (null) */ onSelect: (job: Job | null) => void + /** Called when the user clicks the "Search Facebook" button */ onSearch?: (job: Job) => void + /** Whether a search is currently in progress */ searching?: boolean } +/** + * JobSelector — dropdown to pick a job category and initiate a Facebook search. + * Fetches available jobs on mount and provides a "Search Facebook" CTA once a job is selected. + */ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) { const [jobs, setJobs] = useState([]) const [loading, setLoading] = useState(true) const [open, setOpen] = useState(false) const [selected, setSelected] = useState(null) + // ── Fetch available job categories on mount ── useEffect(() => { fetch("/api/ai/jobs") .then((r) => r.json()) @@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) .finally(() => setLoading(false)) }, []) + // ── Select a job, close dropdown, notify parent ── const handleSelect = (job: Job) => { setSelected(job) setOpen(false) @@ -38,6 +52,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) return (
+ {/* ── Dropdown trigger button ── */} + {/* ── Dropdown menu — visible when open ── */} {open && ( <> + {/* Backdrop to close on click outside */}
setOpen(false)} />
{jobs.map((job, i) => ( @@ -71,6 +88,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
)} + {/* ── "Search Facebook" CTA — shown only when a job is selected ── */} {selected && onSearch && (