Phase 1: Forward Assist initial build
Multi-tenant AI help desk SaaS for the firearms industry. Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind). PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation. Dark Armory theme with tactical branding throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://forward_assist:fa_dev_password_2024@localhost:5432/forward_assist
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars
|
||||||
|
JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars
|
||||||
|
JWT_ACCESS_EXPIRY=15m
|
||||||
|
JWT_REFRESH_EXPIRY=7d
|
||||||
|
|
||||||
|
# Server
|
||||||
|
API_PORT=3001
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Email (defaults for dev)
|
||||||
|
DEFAULT_IMAP_POLL_INTERVAL=60
|
||||||
|
|
||||||
|
# AI (Phase 2+)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=gpt-4o
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.pm2/
|
||||||
|
packages/shared/dist/
|
||||||
|
packages/api/node_modules/
|
||||||
|
packages/worker/node_modules/
|
||||||
|
packages/frontend/node_modules/
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: fa-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: forward_assist
|
||||||
|
POSTGRES_PASSWORD: fa_dev_password_2024
|
||||||
|
POSTGRES_DB: forward_assist
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./packages/api/prisma/rls-setup.sql:/docker-entrypoint-initdb.d/99-rls-setup.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U forward_assist']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: fa-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'fa-api',
|
||||||
|
script: 'npx',
|
||||||
|
args: 'tsx packages/api/src/index.ts',
|
||||||
|
cwd: '/opt/forward-assist',
|
||||||
|
env: { NODE_ENV: 'development' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fa-worker',
|
||||||
|
script: 'npx',
|
||||||
|
args: 'tsx packages/worker/src/index.ts',
|
||||||
|
cwd: '/opt/forward-assist',
|
||||||
|
env: { NODE_ENV: 'development' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fa-frontend',
|
||||||
|
script: 'npx',
|
||||||
|
args: 'vite --host 0.0.0.0',
|
||||||
|
cwd: '/opt/forward-assist/packages/frontend',
|
||||||
|
env: { NODE_ENV: 'development' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Generated
+5907
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "forward-assist",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "AI-powered help desk for the firearms industry",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/shared",
|
||||||
|
"packages/api",
|
||||||
|
"packages/worker",
|
||||||
|
"packages/frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:api": "npm run dev -w packages/api",
|
||||||
|
"dev:worker": "npm run dev -w packages/worker",
|
||||||
|
"dev:frontend": "npm run dev -w packages/frontend",
|
||||||
|
"build": "npm run build -ws",
|
||||||
|
"db:generate": "npm run prisma:generate -w packages/api",
|
||||||
|
"db:migrate": "npm run prisma:migrate -w packages/api",
|
||||||
|
"db:seed": "npm run prisma:seed -w packages/api",
|
||||||
|
"db:studio": "npx prisma studio --schema packages/api/prisma/schema.prisma",
|
||||||
|
"docker:up": "docker compose up -d",
|
||||||
|
"docker:down": "docker compose down"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@forward-assist/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@forward-assist/shared": "*",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"uuid": "^10.0.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "plans" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"maxUsers" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"maxEmailAccounts" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"maxTicketsPerMonth" INTEGER NOT NULL DEFAULT 500,
|
||||||
|
"aiDraftsEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"knowledgeBaseEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"priceMonthly" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
"priceYearly" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "plans_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tenants" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"plan_id" TEXT NOT NULL,
|
||||||
|
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'agent',
|
||||||
|
"avatar_url" TEXT,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"last_login_at" TIMESTAMP(3),
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "email_accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email_address" TEXT NOT NULL,
|
||||||
|
"imap_host" TEXT NOT NULL,
|
||||||
|
"imap_port" INTEGER NOT NULL DEFAULT 993,
|
||||||
|
"imap_user" TEXT NOT NULL,
|
||||||
|
"imap_password" TEXT NOT NULL,
|
||||||
|
"imap_tls" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"smtp_host" TEXT NOT NULL,
|
||||||
|
"smtp_port" INTEGER NOT NULL DEFAULT 587,
|
||||||
|
"smtp_user" TEXT NOT NULL,
|
||||||
|
"smtp_password" TEXT NOT NULL,
|
||||||
|
"smtp_tls" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"last_poll_at" TIMESTAMP(3),
|
||||||
|
"last_error" TEXT,
|
||||||
|
"poll_interval_seconds" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "email_accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tickets" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"ticket_number" TEXT NOT NULL,
|
||||||
|
"email_account_id" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'incoming',
|
||||||
|
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
"assignee_id" TEXT,
|
||||||
|
"customer_email" TEXT NOT NULL,
|
||||||
|
"customer_name" TEXT,
|
||||||
|
"customer_profile_id" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"message_count" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"resolved_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "messages" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ticket_id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"direction" TEXT NOT NULL,
|
||||||
|
"from_email" TEXT NOT NULL,
|
||||||
|
"from_name" TEXT,
|
||||||
|
"to_email" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"body_text" TEXT NOT NULL,
|
||||||
|
"body_html" TEXT,
|
||||||
|
"message_id" TEXT,
|
||||||
|
"in_reply_to" TEXT,
|
||||||
|
"references" TEXT,
|
||||||
|
"headers" JSONB,
|
||||||
|
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "attachments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"message_id" TEXT NOT NULL,
|
||||||
|
"filename" TEXT NOT NULL,
|
||||||
|
"content_type" TEXT NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"storage_key" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ai_drafts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ticket_id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"message_id" TEXT NOT NULL,
|
||||||
|
"draft_body" TEXT NOT NULL,
|
||||||
|
"confidence" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"model" TEXT NOT NULL DEFAULT 'gpt-4o',
|
||||||
|
"tokens_used" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"edited_body" TEXT,
|
||||||
|
"reviewed_by" TEXT,
|
||||||
|
"reviewed_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ai_drafts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "knowledge_base" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "knowledge_base_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_log" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"entity" TEXT NOT NULL,
|
||||||
|
"entity_id" TEXT,
|
||||||
|
"details" JSONB,
|
||||||
|
"ip_address" TEXT,
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "customer_profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"company" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"ticket_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"first_contact_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_contact_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "customer_profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "canned_responses" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"shortcut" TEXT,
|
||||||
|
"created_by" TEXT NOT NULL,
|
||||||
|
"is_shared" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "canned_responses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "notification_preferences" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"new_ticket" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ticket_assigned" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ticket_reply" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ai_draft_ready" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"daily_digest" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"email_notifications" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "plans_slug_key" ON "plans"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "users_tenant_id_idx" ON "users"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_tenant_id_email_key" ON "users"("tenant_id", "email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "email_accounts_tenant_id_idx" ON "email_accounts"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "email_accounts_tenant_id_email_address_key" ON "email_accounts"("tenant_id", "email_address");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tickets_tenant_id_status_idx" ON "tickets"("tenant_id", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tickets_tenant_id_assignee_id_idx" ON "tickets"("tenant_id", "assignee_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tickets_tenant_id_customer_email_idx" ON "tickets"("tenant_id", "customer_email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tickets_tenant_id_created_at_idx" ON "tickets"("tenant_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tickets_tenant_id_ticket_number_key" ON "tickets"("tenant_id", "ticket_number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "messages_ticket_id_idx" ON "messages"("ticket_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "messages_tenant_id_idx" ON "messages"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "messages_message_id_idx" ON "messages"("message_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ai_drafts_ticket_id_idx" ON "ai_drafts"("ticket_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ai_drafts_tenant_id_idx" ON "ai_drafts"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "knowledge_base_tenant_id_idx" ON "knowledge_base"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_log_tenant_id_created_at_idx" ON "audit_log"("tenant_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_log_tenant_id_entity_idx" ON "audit_log"("tenant_id", "entity");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "customer_profiles_tenant_id_idx" ON "customer_profiles"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "customer_profiles_tenant_id_email_key" ON "customer_profiles"("tenant_id", "email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "canned_responses_tenant_id_idx" ON "canned_responses"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "notification_preferences_user_id_key" ON "notification_preferences"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "notification_preferences_tenant_id_idx" ON "notification_preferences"("tenant_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_email_account_id_fkey" FOREIGN KEY ("email_account_id") REFERENCES "email_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_customer_profile_id_fkey" FOREIGN KEY ("customer_profile_id") REFERENCES "customer_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "knowledge_base" ADD CONSTRAINT "knowledge_base_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "customer_profiles" ADD CONSTRAINT "customer_profiles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "canned_responses" ADD CONSTRAINT "canned_responses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "canned_responses" ADD CONSTRAINT "canned_responses_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Forward Assist — Row Level Security Setup
|
||||||
|
-- Run after Prisma migrations to enable tenant isolation
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Enable RLS on tenant-scoped tables
|
||||||
|
-- Note: Prisma handles the actual filtering via middleware,
|
||||||
|
-- but these policies provide defense-in-depth at the DB level.
|
||||||
|
|
||||||
|
-- We use a session variable app.current_tenant_id set per connection.
|
||||||
|
|
||||||
|
-- Function to get current tenant
|
||||||
|
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
|
||||||
|
SELECT current_setting('app.current_tenant_id', true);
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- Enable RLS on all tenant-scoped tables
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
t TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN
|
||||||
|
SELECT unnest(ARRAY[
|
||||||
|
'users', 'email_accounts', 'tickets', 'messages',
|
||||||
|
'ai_drafts', 'knowledge_base', 'audit_log',
|
||||||
|
'customer_profiles', 'canned_responses', 'notification_preferences'
|
||||||
|
])
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||||
|
|
||||||
|
-- Policy: tenant isolation
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY IF NOT EXISTS tenant_isolation ON %I
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_tenant_id())
|
||||||
|
WITH CHECK (tenant_id = current_tenant_id())',
|
||||||
|
t
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Allow the app user to bypass RLS (Prisma uses this role)
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I FORCE ROW LEVEL SECURITY', t
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Grant the application user the ability to set tenant context
|
||||||
|
-- The Prisma middleware will SET app.current_tenant_id before each query
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Plans
|
||||||
|
// ============================================================
|
||||||
|
model Plan {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
maxUsers Int @default(3)
|
||||||
|
maxEmailAccounts Int @default(1)
|
||||||
|
maxTicketsPerMonth Int @default(500)
|
||||||
|
aiDraftsEnabled Boolean @default(false)
|
||||||
|
knowledgeBaseEnabled Boolean @default(false)
|
||||||
|
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
priceYearly Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenants Tenant[]
|
||||||
|
|
||||||
|
@@map("plans")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tenants (Multi-tenant root)
|
||||||
|
// ============================================================
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
planId String @map("plan_id")
|
||||||
|
settings Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
plan Plan @relation(fields: [planId], references: [id])
|
||||||
|
users User[]
|
||||||
|
emailAccounts EmailAccount[]
|
||||||
|
tickets Ticket[]
|
||||||
|
messages Message[]
|
||||||
|
aiDrafts AiDraft[]
|
||||||
|
knowledgeBase KnowledgeBaseEntry[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
customerProfiles CustomerProfile[]
|
||||||
|
cannedResponses CannedResponse[]
|
||||||
|
notificationPreferences NotificationPreference[]
|
||||||
|
|
||||||
|
@@map("tenants")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Users
|
||||||
|
// ============================================================
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
email String
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
name String
|
||||||
|
role String @default("agent")
|
||||||
|
avatarUrl String? @map("avatar_url")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
refreshToken String? @map("refresh_token")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
notificationPreference NotificationPreference?
|
||||||
|
createdCannedResponses CannedResponse[]
|
||||||
|
|
||||||
|
@@unique([tenantId, email])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Email Accounts
|
||||||
|
// ============================================================
|
||||||
|
model EmailAccount {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
name String
|
||||||
|
emailAddress String @map("email_address")
|
||||||
|
imapHost String @map("imap_host")
|
||||||
|
imapPort Int @default(993) @map("imap_port")
|
||||||
|
imapUser String @map("imap_user")
|
||||||
|
imapPassword String @map("imap_password")
|
||||||
|
imapTls Boolean @default(true) @map("imap_tls")
|
||||||
|
smtpHost String @map("smtp_host")
|
||||||
|
smtpPort Int @default(587) @map("smtp_port")
|
||||||
|
smtpUser String @map("smtp_user")
|
||||||
|
smtpPassword String @map("smtp_password")
|
||||||
|
smtpTls Boolean @default(true) @map("smtp_tls")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
lastPollAt DateTime? @map("last_poll_at")
|
||||||
|
lastError String? @map("last_error")
|
||||||
|
pollIntervalSeconds Int @default(60) @map("poll_interval_seconds")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([tenantId, emailAddress])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("email_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tickets
|
||||||
|
// ============================================================
|
||||||
|
model Ticket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
ticketNumber String @map("ticket_number")
|
||||||
|
emailAccountId String @map("email_account_id")
|
||||||
|
subject String
|
||||||
|
status String @default("incoming")
|
||||||
|
priority String @default("medium")
|
||||||
|
assigneeId String? @map("assignee_id")
|
||||||
|
customerEmail String @map("customer_email")
|
||||||
|
customerName String? @map("customer_name")
|
||||||
|
customerProfileId String? @map("customer_profile_id")
|
||||||
|
tags String[] @default([])
|
||||||
|
messageCount Int @default(1) @map("message_count")
|
||||||
|
lastMessageAt DateTime @default(now()) @map("last_message_at")
|
||||||
|
resolvedAt DateTime? @map("resolved_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id])
|
||||||
|
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||||
|
customerProfile CustomerProfile? @relation(fields: [customerProfileId], references: [id])
|
||||||
|
messages Message[]
|
||||||
|
aiDrafts AiDraft[]
|
||||||
|
|
||||||
|
@@unique([tenantId, ticketNumber])
|
||||||
|
@@index([tenantId, status])
|
||||||
|
@@index([tenantId, assigneeId])
|
||||||
|
@@index([tenantId, customerEmail])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@map("tickets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Messages
|
||||||
|
// ============================================================
|
||||||
|
model Message {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ticketId String @map("ticket_id")
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
direction String // 'inbound' | 'outbound'
|
||||||
|
fromEmail String @map("from_email")
|
||||||
|
fromName String? @map("from_name")
|
||||||
|
toEmail String @map("to_email")
|
||||||
|
subject String
|
||||||
|
bodyText String @map("body_text")
|
||||||
|
bodyHtml String? @map("body_html")
|
||||||
|
messageId String? @map("message_id")
|
||||||
|
inReplyTo String? @map("in_reply_to")
|
||||||
|
references String?
|
||||||
|
headers Json?
|
||||||
|
sentAt DateTime @default(now()) @map("sent_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
attachments Attachment[]
|
||||||
|
aiDrafts AiDraft[]
|
||||||
|
|
||||||
|
@@index([ticketId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([messageId])
|
||||||
|
@@map("messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Attachments
|
||||||
|
// ============================================================
|
||||||
|
model Attachment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
messageId String @map("message_id")
|
||||||
|
filename String
|
||||||
|
contentType String @map("content_type")
|
||||||
|
size Int
|
||||||
|
storageKey String @map("storage_key")
|
||||||
|
|
||||||
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI Drafts
|
||||||
|
// ============================================================
|
||||||
|
model AiDraft {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ticketId String @map("ticket_id")
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
messageId String @map("message_id")
|
||||||
|
draftBody String @map("draft_body")
|
||||||
|
confidence Float @default(0)
|
||||||
|
model String @default("gpt-4o")
|
||||||
|
tokensUsed Int @default(0) @map("tokens_used")
|
||||||
|
status String @default("pending")
|
||||||
|
editedBody String? @map("edited_body")
|
||||||
|
reviewedBy String? @map("reviewed_by")
|
||||||
|
reviewedAt DateTime? @map("reviewed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([ticketId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("ai_drafts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Knowledge Base
|
||||||
|
// ============================================================
|
||||||
|
model KnowledgeBaseEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
category String @default("general")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("knowledge_base")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Audit Log
|
||||||
|
// ============================================================
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
userId String? @map("user_id")
|
||||||
|
action String
|
||||||
|
entity String
|
||||||
|
entityId String? @map("entity_id")
|
||||||
|
details Json?
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
userAgent String? @map("user_agent")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([tenantId, entity])
|
||||||
|
@@map("audit_log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Customer Profiles
|
||||||
|
// ============================================================
|
||||||
|
model CustomerProfile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
email String
|
||||||
|
name String?
|
||||||
|
phone String?
|
||||||
|
company String?
|
||||||
|
notes String?
|
||||||
|
tags String[] @default([])
|
||||||
|
ticketCount Int @default(0) @map("ticket_count")
|
||||||
|
firstContactAt DateTime @default(now()) @map("first_contact_at")
|
||||||
|
lastContactAt DateTime @default(now()) @map("last_contact_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([tenantId, email])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("customer_profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Canned Responses
|
||||||
|
// ============================================================
|
||||||
|
model CannedResponse {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
category String @default("general")
|
||||||
|
shortcut String?
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
isShared Boolean @default(true) @map("is_shared")
|
||||||
|
usageCount Int @default(0) @map("usage_count")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("canned_responses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Notification Preferences
|
||||||
|
// ============================================================
|
||||||
|
model NotificationPreference {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique @map("user_id")
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
newTicket Boolean @default(true) @map("new_ticket")
|
||||||
|
ticketAssigned Boolean @default(true) @map("ticket_assigned")
|
||||||
|
ticketReply Boolean @default(true) @map("ticket_reply")
|
||||||
|
aiDraftReady Boolean @default(true) @map("ai_draft_ready")
|
||||||
|
dailyDigest Boolean @default(false) @map("daily_digest")
|
||||||
|
emailNotifications Boolean @default(true) @map("email_notifications")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("notification_preferences")
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding Forward Assist database...');
|
||||||
|
|
||||||
|
// Create plans
|
||||||
|
const trialPlan = await prisma.plan.upsert({
|
||||||
|
where: { slug: 'trial' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Trial',
|
||||||
|
slug: 'trial',
|
||||||
|
maxUsers: 2,
|
||||||
|
maxEmailAccounts: 1,
|
||||||
|
maxTicketsPerMonth: 100,
|
||||||
|
aiDraftsEnabled: true,
|
||||||
|
knowledgeBaseEnabled: false,
|
||||||
|
priceMonthly: 0,
|
||||||
|
priceYearly: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { slug: 'starter' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Starter',
|
||||||
|
slug: 'starter',
|
||||||
|
maxUsers: 5,
|
||||||
|
maxEmailAccounts: 2,
|
||||||
|
maxTicketsPerMonth: 1000,
|
||||||
|
aiDraftsEnabled: true,
|
||||||
|
knowledgeBaseEnabled: false,
|
||||||
|
priceMonthly: 49,
|
||||||
|
priceYearly: 470,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { slug: 'pro' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Pro',
|
||||||
|
slug: 'pro',
|
||||||
|
maxUsers: 20,
|
||||||
|
maxEmailAccounts: 10,
|
||||||
|
maxTicketsPerMonth: 10000,
|
||||||
|
aiDraftsEnabled: true,
|
||||||
|
knowledgeBaseEnabled: true,
|
||||||
|
priceMonthly: 149,
|
||||||
|
priceYearly: 1430,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { slug: 'enterprise' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Enterprise',
|
||||||
|
slug: 'enterprise',
|
||||||
|
maxUsers: 999,
|
||||||
|
maxEmailAccounts: 50,
|
||||||
|
maxTicketsPerMonth: 999999,
|
||||||
|
aiDraftsEnabled: true,
|
||||||
|
knowledgeBaseEnabled: true,
|
||||||
|
priceMonthly: 499,
|
||||||
|
priceYearly: 4790,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test tenant
|
||||||
|
const tenant = await prisma.tenant.upsert({
|
||||||
|
where: { slug: 'demo-armory' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Demo Armory',
|
||||||
|
slug: 'demo-armory',
|
||||||
|
planId: trialPlan.id,
|
||||||
|
settings: {
|
||||||
|
timezone: 'America/Chicago',
|
||||||
|
businessHours: {
|
||||||
|
monday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
tuesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
wednesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
thursday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
friday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
saturday: { enabled: false, start: '10:00', end: '14:00' },
|
||||||
|
sunday: { enabled: false, start: '10:00', end: '14:00' },
|
||||||
|
},
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
autoReplyMessage: 'Thank you for contacting us. We will respond within 24 hours.',
|
||||||
|
aiDraftEnabled: false,
|
||||||
|
aiTone: 'professional',
|
||||||
|
ticketPrefix: 'FA',
|
||||||
|
maxTicketsPerDay: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create admin user (password: "admin123")
|
||||||
|
const passwordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: { tenantId_email: { tenantId: tenant.id, email: 'admin@demo-armory.com' } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
email: 'admin@demo-armory.com',
|
||||||
|
passwordHash,
|
||||||
|
name: 'Range Master',
|
||||||
|
role: 'owner',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create notification preferences for admin
|
||||||
|
await prisma.notificationPreference.upsert({
|
||||||
|
where: { userId: adminUser.id },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
userId: adminUser.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
newTicket: true,
|
||||||
|
ticketAssigned: true,
|
||||||
|
ticketReply: true,
|
||||||
|
aiDraftReady: true,
|
||||||
|
dailyDigest: false,
|
||||||
|
emailNotifications: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seed complete!');
|
||||||
|
console.log(` Plans: trial, starter, pro, enterprise`);
|
||||||
|
console.log(` Tenant: ${tenant.name} (${tenant.slug})`);
|
||||||
|
console.log(` Admin: ${adminUser.email} / admin123`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config({ path: '../../.env' });
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import compression from 'compression';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
|
||||||
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
import { rateLimiter } from './middleware/rateLimiter';
|
||||||
|
import { authRouter } from './routes/auth';
|
||||||
|
import { ticketRouter } from './routes/tickets';
|
||||||
|
import { emailAccountRouter } from './routes/emailAccounts';
|
||||||
|
import { settingsRouter } from './routes/settings';
|
||||||
|
import { userRouter } from './routes/users';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.API_PORT || 3001;
|
||||||
|
|
||||||
|
// Global middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(compression());
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(morgan('short'));
|
||||||
|
app.use(rateLimiter);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({ status: 'operational', service: 'forward-assist-api', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/v1/auth', authRouter);
|
||||||
|
app.use('/api/v1/tickets', ticketRouter);
|
||||||
|
app.use('/api/v1/email-accounts', emailAccountRouter);
|
||||||
|
app.use('/api/v1/settings', settingsRouter);
|
||||||
|
app.use('/api/v1/users', userRouter);
|
||||||
|
|
||||||
|
// Error handler (must be last)
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[Forward Assist API] Server running on port ${PORT}`);
|
||||||
|
console.log(`[Forward Assist API] Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { verifyAccessToken, TokenPayload } from '../services/jwt';
|
||||||
|
|
||||||
|
// Extend Express Request
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: TokenPayload;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized', message: 'Missing or invalid authorization header', statusCode: 401 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const payload = verifyAccessToken(token);
|
||||||
|
req.user = payload;
|
||||||
|
req.tenantId = payload.tenantId;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token', statusCode: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(...roles: string[]) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized', message: 'Not authenticated', statusCode: 401 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'Insufficient permissions', statusCode: 403 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number = 500, details?: Record<string, unknown>) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||||
|
console.error('[Error]', err.message);
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
res.status(err.statusCode).json({
|
||||||
|
error: err.name,
|
||||||
|
message: err.message,
|
||||||
|
statusCode: err.statusCode,
|
||||||
|
details: err.details,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma errors
|
||||||
|
if (err.name === 'PrismaClientKnownRequestError') {
|
||||||
|
const prismaErr = err as any;
|
||||||
|
if (prismaErr.code === 'P2002') {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'Conflict',
|
||||||
|
message: 'A record with that value already exists',
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prismaErr.code === 'P2025') {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'NotFound',
|
||||||
|
message: 'Record not found',
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT errors
|
||||||
|
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||||
|
res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Invalid or expired token',
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'InternalServerError',
|
||||||
|
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
export const rateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200, // limit each IP to 200 requests per windowMs
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
error: 'TooManyRequests',
|
||||||
|
message: 'Too many requests, please try again later',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authRateLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20, // stricter for auth endpoints
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
error: 'TooManyRequests',
|
||||||
|
message: 'Too many authentication attempts, please try again later',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../services/prisma';
|
||||||
|
import { generateTokenPair, verifyRefreshToken, TokenPayload } from '../services/jwt';
|
||||||
|
import { logAudit } from '../services/audit';
|
||||||
|
import { authenticate, requireRole } from '../middleware/auth';
|
||||||
|
import { authRateLimiter } from '../middleware/rateLimiter';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const registerSchema = z.object({
|
||||||
|
tenantName: z.string().min(2).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
role: z.enum(['admin', 'agent', 'viewer']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slugify helper
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/register — Create tenant + owner account
|
||||||
|
authRouter.post('/register', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = registerSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Get trial plan
|
||||||
|
const trialPlan = await prisma.plan.findUnique({ where: { slug: 'trial' } });
|
||||||
|
if (!trialPlan) throw new AppError('Trial plan not configured', 500);
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await prisma.user.findFirst({ where: { email: body.email } });
|
||||||
|
if (existingUser) throw new AppError('Email already registered', 409);
|
||||||
|
|
||||||
|
// Create tenant + user in transaction
|
||||||
|
const slug = slugify(body.tenantName);
|
||||||
|
const passwordHash = await bcrypt.hash(body.password, 12);
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const tenant = await tx.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: body.tenantName,
|
||||||
|
slug: slug + '-' + uuidv4().substring(0, 6),
|
||||||
|
planId: trialPlan.id,
|
||||||
|
settings: {
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
businessHours: {
|
||||||
|
monday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
tuesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
wednesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
thursday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
friday: { enabled: true, start: '09:00', end: '17:00' },
|
||||||
|
saturday: { enabled: false, start: '10:00', end: '14:00' },
|
||||||
|
sunday: { enabled: false, start: '10:00', end: '14:00' },
|
||||||
|
},
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
autoReplyMessage: 'Thank you for contacting us. We will respond within 24 hours.',
|
||||||
|
aiDraftEnabled: false,
|
||||||
|
aiTone: 'professional',
|
||||||
|
ticketPrefix: 'FA',
|
||||||
|
maxTicketsPerDay: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
email: body.email,
|
||||||
|
passwordHash,
|
||||||
|
name: body.name,
|
||||||
|
role: 'owner',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default notification preferences
|
||||||
|
await tx.notificationPreference.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tenant, user };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenPayload: TokenPayload = {
|
||||||
|
userId: result.user.id,
|
||||||
|
tenantId: result.tenant.id,
|
||||||
|
email: result.user.email,
|
||||||
|
role: result.user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = generateTokenPair(tokenPayload);
|
||||||
|
|
||||||
|
// Store refresh token
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: result.user.id },
|
||||||
|
data: { refreshToken: tokens.refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: result.tenant.id,
|
||||||
|
userId: result.user.id,
|
||||||
|
action: 'register',
|
||||||
|
entity: 'tenant',
|
||||||
|
entityId: result.tenant.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: {
|
||||||
|
id: result.user.id,
|
||||||
|
email: result.user.email,
|
||||||
|
name: result.user.name,
|
||||||
|
role: result.user.role,
|
||||||
|
isActive: result.user.isActive,
|
||||||
|
createdAt: result.user.createdAt,
|
||||||
|
updatedAt: result.user.updatedAt,
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
id: result.tenant.id,
|
||||||
|
name: result.tenant.name,
|
||||||
|
slug: result.tenant.slug,
|
||||||
|
},
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { email: body.email, isActive: true },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new AppError('Invalid email or password', 401);
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(body.password, user.passwordHash);
|
||||||
|
if (!validPassword) throw new AppError('Invalid email or password', 401);
|
||||||
|
|
||||||
|
const tokenPayload: TokenPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = generateTokenPair(tokenPayload);
|
||||||
|
|
||||||
|
// Update refresh token and last login
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
userId: user.id,
|
||||||
|
action: 'login',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: user.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
id: user.tenant.id,
|
||||||
|
name: user.tenant.name,
|
||||||
|
slug: user.tenant.slug,
|
||||||
|
},
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/refresh
|
||||||
|
authRouter.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = refreshSchema.parse(req.body);
|
||||||
|
|
||||||
|
let payload: TokenPayload;
|
||||||
|
try {
|
||||||
|
payload = verifyRefreshToken(body.refreshToken);
|
||||||
|
} catch {
|
||||||
|
throw new AppError('Invalid refresh token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token matches stored token (rotation)
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.userId },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive || user.refreshToken !== body.refreshToken) {
|
||||||
|
throw new AppError('Invalid refresh token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPayload: TokenPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = generateTokenPair(newPayload);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { refreshToken: tokens.refreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/invite — Invite a user (owner/admin only)
|
||||||
|
authRouter.post('/invite', authenticate, requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = inviteSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
// Check if user already exists in this tenant
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { tenantId_email: { tenantId, email: body.email } },
|
||||||
|
});
|
||||||
|
if (existing) throw new AppError('User already exists in this tenant', 409);
|
||||||
|
|
||||||
|
// Check plan limits
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
include: { plan: true, users: { where: { isActive: true } } },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||||
|
if (tenant.users.length >= tenant.plan.maxUsers) {
|
||||||
|
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxUsers} users on ${tenant.plan.name} plan.`, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user with temporary password
|
||||||
|
const tempPassword = uuidv4().substring(0, 12);
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
email: body.email,
|
||||||
|
passwordHash,
|
||||||
|
name: body.name,
|
||||||
|
role: body.role,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.notificationPreference.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'invite_user',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: user.id,
|
||||||
|
details: { email: body.email, role: body.role },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
temporaryPassword: tempPassword, // In production, this would be emailed
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me — Get current user
|
||||||
|
authRouter.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
include: { tenant: { include: { plan: true } } },
|
||||||
|
});
|
||||||
|
if (!user) throw new AppError('User not found', 404);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
id: user.tenant.id,
|
||||||
|
name: user.tenant.name,
|
||||||
|
slug: user.tenant.slug,
|
||||||
|
plan: {
|
||||||
|
name: user.tenant.plan.name,
|
||||||
|
slug: user.tenant.plan.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../services/prisma';
|
||||||
|
import { logAudit } from '../services/audit';
|
||||||
|
import { authenticate, requireRole } from '../middleware/auth';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export const emailAccountRouter = Router();
|
||||||
|
emailAccountRouter.use(authenticate);
|
||||||
|
|
||||||
|
const emailAccountSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
emailAddress: z.string().email(),
|
||||||
|
imapHost: z.string().min(1),
|
||||||
|
imapPort: z.number().int().min(1).max(65535),
|
||||||
|
imapUser: z.string().min(1),
|
||||||
|
imapPassword: z.string().min(1),
|
||||||
|
imapTls: z.boolean().default(true),
|
||||||
|
smtpHost: z.string().min(1),
|
||||||
|
smtpPort: z.number().int().min(1).max(65535),
|
||||||
|
smtpUser: z.string().min(1),
|
||||||
|
smtpPassword: z.string().min(1),
|
||||||
|
smtpTls: z.boolean().default(true),
|
||||||
|
pollIntervalSeconds: z.number().int().min(30).max(3600).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEmailAccountSchema = emailAccountSchema.partial().extend({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/email-accounts — List all email accounts for tenant
|
||||||
|
emailAccountRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const accounts = await prisma.emailAccount.findMany({
|
||||||
|
where: { tenantId: req.tenantId! },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
emailAddress: true,
|
||||||
|
imapHost: true,
|
||||||
|
imapPort: true,
|
||||||
|
imapUser: true,
|
||||||
|
imapTls: true,
|
||||||
|
smtpHost: true,
|
||||||
|
smtpPort: true,
|
||||||
|
smtpUser: true,
|
||||||
|
smtpTls: true,
|
||||||
|
isActive: true,
|
||||||
|
lastPollAt: true,
|
||||||
|
lastError: true,
|
||||||
|
pollIntervalSeconds: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
// Deliberately omit passwords
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(accounts);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/email-accounts/:id
|
||||||
|
emailAccountRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const account = await prisma.emailAccount.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
emailAddress: true,
|
||||||
|
imapHost: true,
|
||||||
|
imapPort: true,
|
||||||
|
imapUser: true,
|
||||||
|
imapTls: true,
|
||||||
|
smtpHost: true,
|
||||||
|
smtpPort: true,
|
||||||
|
smtpUser: true,
|
||||||
|
smtpTls: true,
|
||||||
|
isActive: true,
|
||||||
|
lastPollAt: true,
|
||||||
|
lastError: true,
|
||||||
|
pollIntervalSeconds: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) throw new AppError('Email account not found', 404);
|
||||||
|
res.json(account);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/email-accounts — Create email account
|
||||||
|
emailAccountRouter.post('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = emailAccountSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
// Check plan limits
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
include: { plan: true, emailAccounts: { where: { isActive: true } } },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||||
|
if (tenant.emailAccounts.length >= tenant.plan.maxEmailAccounts) {
|
||||||
|
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxEmailAccounts} email accounts on ${tenant.plan.name} plan.`, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await prisma.emailAccount.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: body.name,
|
||||||
|
emailAddress: body.emailAddress,
|
||||||
|
imapHost: body.imapHost,
|
||||||
|
imapPort: body.imapPort,
|
||||||
|
imapUser: body.imapUser,
|
||||||
|
imapPassword: body.imapPassword,
|
||||||
|
imapTls: body.imapTls,
|
||||||
|
smtpHost: body.smtpHost,
|
||||||
|
smtpPort: body.smtpPort,
|
||||||
|
smtpUser: body.smtpUser,
|
||||||
|
smtpPassword: body.smtpPassword,
|
||||||
|
smtpTls: body.smtpTls,
|
||||||
|
pollIntervalSeconds: body.pollIntervalSeconds || 60,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'create_email_account',
|
||||||
|
entity: 'email_account',
|
||||||
|
entityId: account.id,
|
||||||
|
details: { emailAddress: body.emailAddress },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
emailAddress: account.emailAddress,
|
||||||
|
imapHost: account.imapHost,
|
||||||
|
imapPort: account.imapPort,
|
||||||
|
imapUser: account.imapUser,
|
||||||
|
imapTls: account.imapTls,
|
||||||
|
smtpHost: account.smtpHost,
|
||||||
|
smtpPort: account.smtpPort,
|
||||||
|
smtpUser: account.smtpUser,
|
||||||
|
smtpTls: account.smtpTls,
|
||||||
|
isActive: account.isActive,
|
||||||
|
pollIntervalSeconds: account.pollIntervalSeconds,
|
||||||
|
createdAt: account.createdAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/email-accounts/:id — Update email account
|
||||||
|
emailAccountRouter.patch('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = updateEmailAccountSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const existing = await prisma.emailAccount.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError('Email account not found', 404);
|
||||||
|
|
||||||
|
const account = await prisma.emailAccount.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: body,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
emailAddress: true,
|
||||||
|
imapHost: true,
|
||||||
|
imapPort: true,
|
||||||
|
imapUser: true,
|
||||||
|
imapTls: true,
|
||||||
|
smtpHost: true,
|
||||||
|
smtpPort: true,
|
||||||
|
smtpUser: true,
|
||||||
|
smtpTls: true,
|
||||||
|
isActive: true,
|
||||||
|
lastPollAt: true,
|
||||||
|
lastError: true,
|
||||||
|
pollIntervalSeconds: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'update_email_account',
|
||||||
|
entity: 'email_account',
|
||||||
|
entityId: account.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(account);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/email-accounts/:id
|
||||||
|
emailAccountRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const existing = await prisma.emailAccount.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError('Email account not found', 404);
|
||||||
|
|
||||||
|
// Soft delete — just deactivate
|
||||||
|
await prisma.emailAccount.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'delete_email_account',
|
||||||
|
entity: 'email_account',
|
||||||
|
entityId: req.params.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Email account deactivated' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/email-accounts/:id/test — Test connection
|
||||||
|
emailAccountRouter.post('/:id/test', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const account = await prisma.emailAccount.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||||
|
});
|
||||||
|
if (!account) throw new AppError('Email account not found', 404);
|
||||||
|
|
||||||
|
// In Phase 1, we just return a success stub.
|
||||||
|
// Phase 2 will actually test IMAP/SMTP connections.
|
||||||
|
res.json({
|
||||||
|
imap: { success: true, message: 'IMAP connection test not yet implemented (Phase 2)' },
|
||||||
|
smtp: { success: true, message: 'SMTP connection test not yet implemented (Phase 2)' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../services/prisma';
|
||||||
|
import { logAudit } from '../services/audit';
|
||||||
|
import { authenticate, requireRole } from '../middleware/auth';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export const settingsRouter = Router();
|
||||||
|
settingsRouter.use(authenticate);
|
||||||
|
|
||||||
|
const dayHoursSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
start: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
end: z.string().regex(/^\d{2}:\d{2}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
timezone: z.string().optional(),
|
||||||
|
businessHours: z.object({
|
||||||
|
monday: dayHoursSchema,
|
||||||
|
tuesday: dayHoursSchema,
|
||||||
|
wednesday: dayHoursSchema,
|
||||||
|
thursday: dayHoursSchema,
|
||||||
|
friday: dayHoursSchema,
|
||||||
|
saturday: dayHoursSchema,
|
||||||
|
sunday: dayHoursSchema,
|
||||||
|
}).optional(),
|
||||||
|
autoReplyEnabled: z.boolean().optional(),
|
||||||
|
autoReplyMessage: z.string().max(1000).optional(),
|
||||||
|
aiDraftEnabled: z.boolean().optional(),
|
||||||
|
aiTone: z.enum(['professional', 'friendly', 'technical']).optional(),
|
||||||
|
ticketPrefix: z.string().min(1).max(5).optional(),
|
||||||
|
maxTicketsPerDay: z.number().int().min(1).max(10000).optional(),
|
||||||
|
}).partial();
|
||||||
|
|
||||||
|
// GET /api/settings — Get tenant settings
|
||||||
|
settingsRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: req.tenantId! },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tenant: {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
createdAt: tenant.createdAt,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
name: tenant.plan.name,
|
||||||
|
slug: tenant.plan.slug,
|
||||||
|
maxUsers: tenant.plan.maxUsers,
|
||||||
|
maxEmailAccounts: tenant.plan.maxEmailAccounts,
|
||||||
|
maxTicketsPerMonth: tenant.plan.maxTicketsPerMonth,
|
||||||
|
aiDraftsEnabled: tenant.plan.aiDraftsEnabled,
|
||||||
|
knowledgeBaseEnabled: tenant.plan.knowledgeBaseEnabled,
|
||||||
|
},
|
||||||
|
settings: tenant.settings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/settings — Update tenant settings
|
||||||
|
settingsRouter.patch('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = settingsSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||||
|
|
||||||
|
// Merge with existing settings
|
||||||
|
const currentSettings = (tenant.settings as Record<string, unknown>) || {};
|
||||||
|
const newSettings = { ...currentSettings, ...body };
|
||||||
|
|
||||||
|
const updated = await prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: { settings: newSettings },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'update_settings',
|
||||||
|
entity: 'tenant',
|
||||||
|
entityId: tenantId,
|
||||||
|
details: body,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ settings: updated.settings });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../services/prisma';
|
||||||
|
import { logAudit } from '../services/audit';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export const ticketRouter = Router();
|
||||||
|
ticketRouter.use(authenticate);
|
||||||
|
|
||||||
|
const createTicketSchema = z.object({
|
||||||
|
emailAccountId: z.string().uuid(),
|
||||||
|
subject: z.string().min(1).max(500),
|
||||||
|
customerEmail: z.string().email(),
|
||||||
|
customerName: z.string().optional(),
|
||||||
|
body: z.string().min(1),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTicketSchema = z.object({
|
||||||
|
status: z.enum(['incoming', 'in_the_chamber', 'cleared_hot', 'misfire', 'standby', 'resolved', 'archived']).optional(),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||||
|
assigneeId: z.string().uuid().nullable().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const replySchema = z.object({
|
||||||
|
body: z.string().min(1),
|
||||||
|
bodyHtml: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to generate next ticket number
|
||||||
|
async function nextTicketNumber(tenantId: string): Promise<string> {
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
const settings = (tenant?.settings as any) || {};
|
||||||
|
const prefix = settings.ticketPrefix || 'FA';
|
||||||
|
|
||||||
|
const lastTicket = await prisma.ticket.findFirst({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { ticketNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextNum = 1;
|
||||||
|
if (lastTicket) {
|
||||||
|
const match = lastTicket.ticketNumber.match(/(\d+)$/);
|
||||||
|
if (match) nextNum = parseInt(match[1], 10) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tickets — List tickets with filters
|
||||||
|
ticketRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 25));
|
||||||
|
const sortBy = (req.query.sortBy as string) || 'lastMessageAt';
|
||||||
|
const sortOrder = (req.query.sortOrder as string) === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
const where: any = { tenantId };
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (req.query.status) {
|
||||||
|
const statuses = (req.query.status as string).split(',');
|
||||||
|
where.status = { in: statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority filter
|
||||||
|
if (req.query.priority) {
|
||||||
|
const priorities = (req.query.priority as string).split(',');
|
||||||
|
where.priority = { in: priorities };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignee filter
|
||||||
|
if (req.query.assigneeId) {
|
||||||
|
where.assigneeId = req.query.assigneeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email account filter
|
||||||
|
if (req.query.emailAccountId) {
|
||||||
|
where.emailAccountId = req.query.emailAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (req.query.search) {
|
||||||
|
const search = req.query.search as string;
|
||||||
|
where.OR = [
|
||||||
|
{ subject: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ customerEmail: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ customerName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ ticketNumber: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.ticket.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||||
|
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||||
|
},
|
||||||
|
orderBy: { [sortBy]: sortOrder },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.ticket.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tickets/:id — Get single ticket with messages
|
||||||
|
ticketRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const ticket = await prisma.ticket.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: { attachments: true },
|
||||||
|
},
|
||||||
|
assignee: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||||
|
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||||
|
customerProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||||
|
|
||||||
|
res.json(ticket);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tickets — Create a manual ticket
|
||||||
|
ticketRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = createTicketSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
// Verify email account belongs to tenant
|
||||||
|
const emailAccount = await prisma.emailAccount.findFirst({
|
||||||
|
where: { id: body.emailAccountId, tenantId },
|
||||||
|
});
|
||||||
|
if (!emailAccount) throw new AppError('Email account not found', 404);
|
||||||
|
|
||||||
|
const ticketNumber = await nextTicketNumber(tenantId);
|
||||||
|
|
||||||
|
// Find or create customer profile
|
||||||
|
let customerProfile = await prisma.customerProfile.findUnique({
|
||||||
|
where: { tenantId_email: { tenantId, email: body.customerEmail } },
|
||||||
|
});
|
||||||
|
if (!customerProfile) {
|
||||||
|
customerProfile = await prisma.customerProfile.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
email: body.customerEmail,
|
||||||
|
name: body.customerName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketNumber,
|
||||||
|
emailAccountId: body.emailAccountId,
|
||||||
|
subject: body.subject,
|
||||||
|
status: 'incoming',
|
||||||
|
priority: body.priority || 'medium',
|
||||||
|
customerEmail: body.customerEmail,
|
||||||
|
customerName: body.customerName,
|
||||||
|
customerProfileId: customerProfile.id,
|
||||||
|
messageCount: 1,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
direction: 'inbound',
|
||||||
|
fromEmail: body.customerEmail,
|
||||||
|
fromName: body.customerName,
|
||||||
|
toEmail: emailAccount.emailAddress,
|
||||||
|
subject: body.subject,
|
||||||
|
bodyText: body.body,
|
||||||
|
sentAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
messages: true,
|
||||||
|
assignee: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update customer profile ticket count
|
||||||
|
await prisma.customerProfile.update({
|
||||||
|
where: { id: customerProfile.id },
|
||||||
|
data: {
|
||||||
|
ticketCount: { increment: 1 },
|
||||||
|
lastContactAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'create_ticket',
|
||||||
|
entity: 'ticket',
|
||||||
|
entityId: ticket.id,
|
||||||
|
details: { ticketNumber, subject: body.subject },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(ticket);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/tickets/:id — Update ticket
|
||||||
|
ticketRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = updateTicketSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const existing = await prisma.ticket.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new AppError('Ticket not found', 404);
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
updateData.status = body.status;
|
||||||
|
if (body.status === 'resolved') updateData.resolvedAt = new Date();
|
||||||
|
}
|
||||||
|
if (body.priority !== undefined) updateData.priority = body.priority;
|
||||||
|
if (body.assigneeId !== undefined) updateData.assigneeId = body.assigneeId;
|
||||||
|
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
assignee: { select: { id: true, name: true, email: true } },
|
||||||
|
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'update_ticket',
|
||||||
|
entity: 'ticket',
|
||||||
|
entityId: ticket.id,
|
||||||
|
details: body,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(ticket);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tickets/:id/reply — Send a reply
|
||||||
|
ticketRouter.post('/:id/reply', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = replySchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
include: { emailAccount: true },
|
||||||
|
});
|
||||||
|
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
||||||
|
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
direction: 'outbound',
|
||||||
|
fromEmail: ticket.emailAccount.emailAddress,
|
||||||
|
fromName: user?.name,
|
||||||
|
toEmail: ticket.customerEmail,
|
||||||
|
subject: `Re: ${ticket.subject}`,
|
||||||
|
bodyText: body.body,
|
||||||
|
bodyHtml: body.bodyHtml,
|
||||||
|
sentAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update ticket
|
||||||
|
await prisma.ticket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: {
|
||||||
|
status: 'cleared_hot',
|
||||||
|
messageCount: { increment: 1 },
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'reply_ticket',
|
||||||
|
entity: 'message',
|
||||||
|
entityId: message.id,
|
||||||
|
details: { ticketId: ticket.id, ticketNumber: ticket.ticketNumber },
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(message);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tickets/:id/messages — Get messages for a ticket
|
||||||
|
ticketRouter.get('/:id/messages', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const ticket = await prisma.ticket.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||||
|
});
|
||||||
|
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: { ticketId: ticket.id },
|
||||||
|
include: { attachments: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from '../services/prisma';
|
||||||
|
import { logAudit } from '../services/audit';
|
||||||
|
import { authenticate, requireRole } from '../middleware/auth';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
export const userRouter = Router();
|
||||||
|
userRouter.use(authenticate);
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
role: z.enum(['admin', 'agent', 'viewer']).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/users — List users in tenant
|
||||||
|
userRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { tenantId: req.tenantId! },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/users/:id
|
||||||
|
userRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new AppError('User not found', 404);
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/users/:id — Update user (owner/admin only, or self for name)
|
||||||
|
userRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = updateUserSchema.parse(req.body);
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
const requestingUser = req.user!;
|
||||||
|
|
||||||
|
const target = await prisma.user.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
});
|
||||||
|
if (!target) throw new AppError('User not found', 404);
|
||||||
|
|
||||||
|
// Permission check
|
||||||
|
const isSelf = requestingUser.userId === target.id;
|
||||||
|
const isAdmin = ['owner', 'admin'].includes(requestingUser.role);
|
||||||
|
|
||||||
|
if (!isSelf && !isAdmin) {
|
||||||
|
throw new AppError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admins can only update their own name
|
||||||
|
if (!isAdmin && (body.role || body.isActive !== undefined)) {
|
||||||
|
throw new AppError('Only admins can change roles or status', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot change owner role
|
||||||
|
if (target.role === 'owner' && body.role && body.role !== 'owner') {
|
||||||
|
throw new AppError('Cannot change owner role', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: body,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: requestingUser.userId,
|
||||||
|
action: 'update_user',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: target.id,
|
||||||
|
details: body,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users/:id/change-password
|
||||||
|
userRouter.post('/:id/change-password', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const body = changePasswordSchema.parse(req.body);
|
||||||
|
const requestingUser = req.user!;
|
||||||
|
|
||||||
|
// Can only change own password
|
||||||
|
if (requestingUser.userId !== req.params.id) {
|
||||||
|
throw new AppError('Can only change your own password', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!user) throw new AppError('User not found', 404);
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(body.currentPassword, user.passwordHash);
|
||||||
|
if (!valid) throw new AppError('Current password is incorrect', 400);
|
||||||
|
|
||||||
|
const newHash = await bcrypt.hash(body.newPassword, 12);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { passwordHash: newHash, refreshToken: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId: req.tenantId!,
|
||||||
|
userId: requestingUser.userId,
|
||||||
|
action: 'change_password',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: req.params.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Password changed successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id — Deactivate user
|
||||||
|
userRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.tenantId!;
|
||||||
|
|
||||||
|
const target = await prisma.user.findFirst({
|
||||||
|
where: { id: req.params.id, tenantId },
|
||||||
|
});
|
||||||
|
if (!target) throw new AppError('User not found', 404);
|
||||||
|
if (target.role === 'owner') throw new AppError('Cannot delete the owner account', 403);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { isActive: false, refreshToken: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
tenantId,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
action: 'delete_user',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: req.params.id,
|
||||||
|
req,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'User deactivated' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from './prisma';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
tenantId: string;
|
||||||
|
userId?: string;
|
||||||
|
action: string;
|
||||||
|
entity: string;
|
||||||
|
entityId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
req?: Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
tenantId: entry.tenantId,
|
||||||
|
userId: entry.userId,
|
||||||
|
action: entry.action,
|
||||||
|
entity: entry.entity,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
details: entry.details || {},
|
||||||
|
ipAddress: entry.req?.ip || entry.req?.socket.remoteAddress,
|
||||||
|
userAgent: entry.req?.headers['user-agent'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Audit logging should never crash the app
|
||||||
|
console.error('[Audit] Failed to log:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'dev-access-secret-minimum-32-characters';
|
||||||
|
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-minimum-32-characters';
|
||||||
|
const ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m';
|
||||||
|
const REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d';
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signAccessToken(payload: TokenPayload): string {
|
||||||
|
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signRefreshToken(payload: TokenPayload): string {
|
||||||
|
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAccessToken(token: string): TokenPayload {
|
||||||
|
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRefreshToken(token: string): TokenPayload {
|
||||||
|
return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTokenPair(payload: TokenPayload) {
|
||||||
|
return {
|
||||||
|
accessToken: signAccessToken(payload),
|
||||||
|
refreshToken: signRefreshToken(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"paths": {
|
||||||
|
"@forward-assist/shared": ["../shared/src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1A1D21" />
|
||||||
|
<title>Forward Assist — AI-Powered Help Desk</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#1A1D21] text-gray-200 antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@forward-assist/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@forward-assist/shared": "*",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" rx="12" fill="#4A5D23"/>
|
||||||
|
<text x="50" y="68" font-family="Arial Black, sans-serif" font-size="50" font-weight="900" text-anchor="middle" fill="#C3A76B">FA</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,58 @@
|
|||||||
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "./contexts/AuthContext";
|
||||||
|
import { AppLayout } from "./components/layout/AppLayout";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { RegisterPage } from "./pages/RegisterPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { TicketDetailPage } from "./pages/TicketDetailPage";
|
||||||
|
import { EmailAccountsPage } from "./pages/EmailAccountsPage";
|
||||||
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
import { UsersPage } from "./pages/UsersPage";
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-dark-bg">
|
||||||
|
<div className="text-fde text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
if (isLoading) return null;
|
||||||
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||||
|
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/tickets/:id" element={<TicketDetailPage />} />
|
||||||
|
<Route path="/email-accounts" element={<EmailAccountsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Ticket,
|
||||||
|
Mail,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", icon: LayoutDashboard, label: "The Armory", exact: true },
|
||||||
|
{ to: "/email-accounts", icon: Mail, label: "Field Strip" },
|
||||||
|
{ to: "/users", icon: Users, label: "Personnel" },
|
||||||
|
{ to: "/settings", icon: Settings, label: "Ops Config" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, tenant, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-dark-bg">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 z-40 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 w-64 bg-dark-surface border-r border-dark-border flex flex-col transition-transform duration-200 lg:relative lg:translate-x-0 ${
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-5 border-b border-dark-border">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-od-green flex items-center justify-center">
|
||||||
|
<Shield className="w-5 h-5 text-fde" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-fde font-bold text-base leading-tight">Forward Assist</h1>
|
||||||
|
<p className="text-[11px] text-gunmetal-light">Mission Ready Support</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ml-auto lg:hidden text-gray-400 hover:text-white"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenant name */}
|
||||||
|
<div className="px-5 py-3 border-b border-dark-border">
|
||||||
|
<p className="text-xs text-gunmetal-light uppercase tracking-wider">Tenant</p>
|
||||||
|
<p className="text-sm text-gray-200 font-medium truncate">{tenant?.name || "—"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.exact}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-od-green/20 text-fde"
|
||||||
|
: "text-gray-400 hover:bg-dark-hover hover:text-gray-200"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-[18px] h-[18px]" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="px-4 py-4 border-t border-dark-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gunmetal flex items-center justify-center text-fde text-xs font-bold">
|
||||||
|
{user?.name?.charAt(0).toUpperCase() || "?"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-200 font-medium truncate">{user?.name}</p>
|
||||||
|
<p className="text-xs text-gunmetal-light truncate">{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
title="Log out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Mobile header */}
|
||||||
|
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-dark-surface border-b border-dark-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-fde font-bold">Forward Assist</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
isActive: boolean;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
plan?: { name: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
tenant: TenantInfo | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType extends AuthState {
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (tenantName: string, email: string, password: string, name: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<AuthState>({
|
||||||
|
user: null,
|
||||||
|
tenant: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadUser = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
if (!token) {
|
||||||
|
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getMe();
|
||||||
|
setState({
|
||||||
|
user: data.user,
|
||||||
|
tenant: data.tenant,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser();
|
||||||
|
}, [loadUser]);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const data = await api.login(email, password);
|
||||||
|
localStorage.setItem("accessToken", data.accessToken);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
setState({
|
||||||
|
user: data.user,
|
||||||
|
tenant: data.tenant,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (tenantName: string, email: string, password: string, name: string) => {
|
||||||
|
const data = await api.register(tenantName, email, password, name);
|
||||||
|
localStorage.setItem("accessToken", data.accessToken);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
setState({
|
||||||
|
user: data.user,
|
||||||
|
tenant: data.tenant,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
setState({ user: null, tenant: null, isLoading: false, isAuthenticated: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, login, register, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextType {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Archive,
|
||||||
|
Filter,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Crosshair,
|
||||||
|
XCircle,
|
||||||
|
Target,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
|
||||||
|
incoming: { label: "Incoming!", color: "text-red-400", bg: "bg-red-500/15", icon: Inbox },
|
||||||
|
in_the_chamber: { label: "In The Chamber", color: "text-amber-400", bg: "bg-amber-500/15", icon: Target },
|
||||||
|
cleared_hot: { label: "Cleared Hot", color: "text-green-400", bg: "bg-green-500/15", icon: CheckCircle },
|
||||||
|
misfire: { label: "Misfire", color: "text-red-500", bg: "bg-red-600/15", icon: XCircle },
|
||||||
|
standby: { label: "Standby", color: "text-gray-400", bg: "bg-gray-500/15", icon: Clock },
|
||||||
|
resolved: { label: "Resolved", color: "text-emerald-400", bg: "bg-emerald-500/15", icon: CheckCircle },
|
||||||
|
archived: { label: "Archived", color: "text-gray-500", bg: "bg-gray-600/15", icon: Archive },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
low: { label: "Low", color: "text-gray-400" },
|
||||||
|
medium: { label: "Med", color: "text-blue-400" },
|
||||||
|
high: { label: "High", color: "text-amber-400" },
|
||||||
|
critical: { label: "CRIT", color: "text-red-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get("status") || "");
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get("priority") || "");
|
||||||
|
const [search, setSearch] = useState(searchParams.get("search") || "");
|
||||||
|
const [page, setPage] = useState(parseInt(searchParams.get("page") || "1"));
|
||||||
|
|
||||||
|
const fetchTickets = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { page: String(page), pageSize: "25" };
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (priorityFilter) params.priority = priorityFilter;
|
||||||
|
if (search) params.search = search;
|
||||||
|
|
||||||
|
const data = await api.getTickets(params);
|
||||||
|
setTickets(data.data);
|
||||||
|
setTotal(data.total);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch tickets:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, statusFilter, priorityFilter, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTickets();
|
||||||
|
}, [fetchTickets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (priorityFilter) params.priority = priorityFilter;
|
||||||
|
if (search) params.search = search;
|
||||||
|
if (page > 1) params.page = String(page);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}, [statusFilter, priorityFilter, search, page, setSearchParams]);
|
||||||
|
|
||||||
|
const timeAgo = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||||
|
if (diff < 60) return "just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">The Armory</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-0.5">
|
||||||
|
{total} ticket{total !== 1 ? "s" : ""} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchTickets}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 hover:bg-dark-hover transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
Filters:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
|
||||||
|
<option key={key} value={key}>{cfg.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={priorityFilter}
|
||||||
|
onChange={(e) => { setPriorityFilter(e.target.value); setPage(1); }}
|
||||||
|
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search tickets..."
|
||||||
|
className="px-3 py-1.5 bg-dark-surface border border-dark-border rounded-lg text-sm text-gray-300 focus:outline-none focus:border-od-green w-48"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(statusFilter || priorityFilter || search) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setStatusFilter(""); setPriorityFilter(""); setSearch(""); setPage(1); }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket list */}
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl overflow-hidden">
|
||||||
|
{loading && tickets.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading tickets...
|
||||||
|
</div>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-500">
|
||||||
|
<Crosshair className="w-10 h-10 mb-3 text-gunmetal" />
|
||||||
|
<p className="text-lg font-medium">No tickets found</p>
|
||||||
|
<p className="text-sm mt-1">Adjust your filters or wait for incoming rounds</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-dark-border text-xs text-gunmetal-light uppercase tracking-wider">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Ticket</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Subject</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Priority</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Customer</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Assignee</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tickets.map((ticket) => {
|
||||||
|
const statusCfg = STATUS_CONFIG[ticket.status] || STATUS_CONFIG.incoming;
|
||||||
|
const priorityCfg = PRIORITY_CONFIG[ticket.priority] || PRIORITY_CONFIG.medium;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={ticket.id}
|
||||||
|
onClick={() => navigate(`/tickets/${ticket.id}`)}
|
||||||
|
className="border-b border-dark-border/50 hover:bg-dark-hover cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-fde font-mono text-sm font-medium">{ticket.ticketNumber}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-gray-200 truncate max-w-xs">{ticket.subject}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusCfg.bg} ${statusCfg.color}`}>
|
||||||
|
<statusCfg.icon className="w-3 h-3" />
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs font-medium ${priorityCfg.color}`}>{priorityCfg.label}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-gray-300 truncate max-w-[150px]">
|
||||||
|
{ticket.customerName || ticket.customerEmail}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-gray-400">{ticket.assignee?.name || "Unassigned"}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className="text-xs text-gunmetal-light">{timeAgo(ticket.lastMessageAt)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-border">
|
||||||
|
<p className="text-sm text-gunmetal-light">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(1, page - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-1.5 rounded border border-dark-border text-gray-400 hover:bg-dark-hover disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="p-1.5 rounded border border-dark-border text-gray-400 hover:bg-dark-hover disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
TestTube2,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export function EmailAccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
emailAddress: "",
|
||||||
|
imapHost: "",
|
||||||
|
imapPort: 993,
|
||||||
|
imapUser: "",
|
||||||
|
imapPassword: "",
|
||||||
|
imapTls: true,
|
||||||
|
smtpHost: "",
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUser: "",
|
||||||
|
smtpPassword: "",
|
||||||
|
smtpTls: true,
|
||||||
|
pollIntervalSeconds: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getEmailAccounts();
|
||||||
|
setAccounts(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch email accounts:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = (field: string, value: any) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.createEmailAccount(form);
|
||||||
|
setShowForm(false);
|
||||||
|
setForm({
|
||||||
|
name: "", emailAddress: "",
|
||||||
|
imapHost: "", imapPort: 993, imapUser: "", imapPassword: "", imapTls: true,
|
||||||
|
smtpHost: "", smtpPort: 587, smtpUser: "", smtpPassword: "", smtpTls: true,
|
||||||
|
pollIntervalSeconds: 60,
|
||||||
|
});
|
||||||
|
await fetchAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create email account:", err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Deactivate this email account?")) return;
|
||||||
|
try {
|
||||||
|
await api.deleteEmailAccount(id);
|
||||||
|
await fetchAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete email account:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await api.testEmailAccount(id);
|
||||||
|
alert(`IMAP: ${result.imap.message}\nSMTP: ${result.smtp.message}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Connection test failed:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">Field Strip</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-0.5">Email account configuration</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Email Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New account form modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-border">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">New Email Account</h2>
|
||||||
|
<button onClick={() => setShowForm(false)} className="text-gray-500 hover:text-white">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Display Name</label>
|
||||||
|
<input type="text" value={form.name} onChange={(e) => update("name", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="Support Inbox" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Email Address</label>
|
||||||
|
<input type="email" value={form.emailAddress} onChange={(e) => update("emailAddress", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="support@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dark-border" />
|
||||||
|
<h3 className="text-sm font-medium text-fde">IMAP Settings (Incoming)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">IMAP Host</label>
|
||||||
|
<input type="text" value={form.imapHost} onChange={(e) => update("imapHost", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="imap.gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">IMAP Port</label>
|
||||||
|
<input type="number" value={form.imapPort} onChange={(e) => update("imapPort", parseInt(e.target.value))} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">IMAP Username</label>
|
||||||
|
<input type="text" value={form.imapUser} onChange={(e) => update("imapUser", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">IMAP Password</label>
|
||||||
|
<input type="password" value={form.imapPassword} onChange={(e) => update("imapPassword", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" checked={form.imapTls} onChange={(e) => update("imapTls", e.target.checked)} className="rounded border-dark-border" />
|
||||||
|
Use TLS
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<hr className="border-dark-border" />
|
||||||
|
<h3 className="text-sm font-medium text-fde">SMTP Settings (Outgoing)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">SMTP Host</label>
|
||||||
|
<input type="text" value={form.smtpHost} onChange={(e) => update("smtpHost", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" placeholder="smtp.gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">SMTP Port</label>
|
||||||
|
<input type="number" value={form.smtpPort} onChange={(e) => update("smtpPort", parseInt(e.target.value))} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">SMTP Username</label>
|
||||||
|
<input type="text" value={form.smtpUser} onChange={(e) => update("smtpUser", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">SMTP Password</label>
|
||||||
|
<input type="password" value={form.smtpPassword} onChange={(e) => update("smtpPassword", e.target.value)} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input type="checkbox" checked={form.smtpTls} onChange={(e) => update("smtpTls", e.target.checked)} className="rounded border-dark-border" />
|
||||||
|
Use TLS
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<hr className="border-dark-border" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Poll Interval (seconds)</label>
|
||||||
|
<input type="number" value={form.pollIntervalSeconds} onChange={(e) => update("pollIntervalSeconds", parseInt(e.target.value))} min={30} max={3600}
|
||||||
|
className="w-32 px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={() => setShowForm(false)}
|
||||||
|
className="px-4 py-2 border border-dark-border text-gray-400 rounded-lg text-sm hover:bg-dark-hover">Cancel</button>
|
||||||
|
<button type="submit" disabled={saving}
|
||||||
|
className="px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg disabled:opacity-50">
|
||||||
|
{saving ? "Saving..." : "Deploy Email Account"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accounts list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading...
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl p-12 text-center">
|
||||||
|
<Mail className="w-12 h-12 text-gunmetal mx-auto mb-3" />
|
||||||
|
<p className="text-lg font-medium text-gray-300">No email accounts configured</p>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-1">Add an email account to start receiving tickets</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{accounts.map((acct) => (
|
||||||
|
<div key={acct.id} className="bg-dark-surface border border-dark-border rounded-xl p-5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${acct.isActive ? "bg-od-green/20" : "bg-gray-600/20"}`}>
|
||||||
|
<Mail className={`w-5 h-5 ${acct.isActive ? "text-od-green-light" : "text-gray-500"}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-200">{acct.name}</h3>
|
||||||
|
<p className="text-xs text-gunmetal-light">{acct.emailAddress}</p>
|
||||||
|
{acct.lastError && (
|
||||||
|
<p className="text-xs text-red-400 mt-0.5 flex items-center gap-1">
|
||||||
|
<XCircle className="w-3 h-3" /> {acct.lastError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${acct.isActive ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{acct.isActive ? <CheckCircle className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||||
|
{acct.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => handleTest(acct.id)}
|
||||||
|
className="p-2 text-gray-500 hover:text-fde rounded-lg hover:bg-dark-hover" title="Test Connection">
|
||||||
|
<TestTube2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(acct.id)}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-400 rounded-lg hover:bg-dark-hover" title="Deactivate">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { Shield, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || "Login failed. Check your credentials.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-dark-bg px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-od-green flex items-center justify-center mb-4">
|
||||||
|
<Shield className="w-9 h-9 text-fde" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">Forward Assist</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-1">AI-Powered Support, Mission Ready</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-dark-surface border border-dark-border rounded-xl p-8 space-y-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Sign In</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green transition-colors"
|
||||||
|
placeholder="operator@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green transition-colors"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-od-green hover:bg-od-green-light text-white font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
{loading ? "Authenticating..." : "Sign In"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
New unit?{" "}
|
||||||
|
<Link to="/register" className="text-fde hover:text-fde-light transition-colors">
|
||||||
|
Register here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { Shield, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
tenantName: "",
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const update = (field: string, value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register(form.tenantName, form.email, form.password, form.name);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || "Registration failed.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-dark-bg px-4 py-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-od-green flex items-center justify-center mb-4">
|
||||||
|
<Shield className="w-9 h-9 text-fde" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">Forward Assist</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-1">Set up your command post</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-dark-surface border border-dark-border rounded-xl p-8 space-y-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Create Account</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Company / Organization</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tenantName}
|
||||||
|
onChange={(e) => update("tenantName", e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
placeholder="Acme Firearms LLC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Your Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => update("name", e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => update("email", e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => update("password", e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={(e) => update("confirmPassword", e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3.5 py-2.5 bg-dark-bg border border-dark-border rounded-lg text-gray-200 text-sm focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-od-green hover:bg-od-green-light text-white font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
{loading ? "Deploying..." : "Establish Comms"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link to="/login" className="text-fde hover:text-fde-light transition-colors">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { Settings, Save, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<any>(null);
|
||||||
|
const [tenantInfo, setTenantInfo] = useState<any>(null);
|
||||||
|
const [planInfo, setPlanInfo] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
setSettings(data.settings);
|
||||||
|
setTenantInfo(data.tenant);
|
||||||
|
setPlanInfo(data.plan);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch settings:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const result = await api.updateSettings(settings);
|
||||||
|
setSettings(result.settings);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save settings:", err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (field: string, value: any) => {
|
||||||
|
setSettings((prev: any) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading settings...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">Ops Config</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-0.5">Tenant settings and configuration</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{saving ? "Saving..." : saved ? "Saved!" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan info */}
|
||||||
|
{planInfo && (
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl p-5 mb-5">
|
||||||
|
<h2 className="text-sm font-medium text-fde mb-3">Plan Details</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gunmetal-light">Plan</p>
|
||||||
|
<p className="text-gray-200 font-medium">{planInfo.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gunmetal-light">Max Users</p>
|
||||||
|
<p className="text-gray-200">{planInfo.maxUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gunmetal-light">Max Email Accounts</p>
|
||||||
|
<p className="text-gray-200">{planInfo.maxEmailAccounts}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gunmetal-light">Monthly Tickets</p>
|
||||||
|
<p className="text-gray-200">{planInfo.maxTicketsPerMonth.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General settings */}
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl p-5 space-y-5">
|
||||||
|
<h2 className="text-sm font-medium text-fde">General Settings</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Timezone</label>
|
||||||
|
<select
|
||||||
|
value={settings?.timezone || "America/New_York"}
|
||||||
|
onChange={(e) => update("timezone", e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
<option value="America/New_York">Eastern Time</option>
|
||||||
|
<option value="America/Chicago">Central Time</option>
|
||||||
|
<option value="America/Denver">Mountain Time</option>
|
||||||
|
<option value="America/Los_Angeles">Pacific Time</option>
|
||||||
|
<option value="America/Phoenix">Arizona</option>
|
||||||
|
<option value="Pacific/Honolulu">Hawaii</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Ticket Prefix</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings?.ticketPrefix || "FA"}
|
||||||
|
onChange={(e) => update("ticketPrefix", e.target.value.toUpperCase())}
|
||||||
|
maxLength={5}
|
||||||
|
className="w-32 px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gunmetal-light mt-1">Prefix for ticket numbers (e.g., FA-0001)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">AI Tone</label>
|
||||||
|
<select
|
||||||
|
value={settings?.aiTone || "professional"}
|
||||||
|
onChange={(e) => update("aiTone", e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
<option value="professional">Professional</option>
|
||||||
|
<option value="friendly">Friendly</option>
|
||||||
|
<option value="technical">Technical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dark-border" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.autoReplyEnabled || false}
|
||||||
|
onChange={(e) => update("autoReplyEnabled", e.target.checked)}
|
||||||
|
className="rounded border-dark-border"
|
||||||
|
/>
|
||||||
|
Enable auto-reply for new tickets
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings?.autoReplyEnabled && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Auto-Reply Message</label>
|
||||||
|
<textarea
|
||||||
|
value={settings?.autoReplyMessage || ""}
|
||||||
|
onChange={(e) => update("autoReplyMessage", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 resize-none focus:outline-none focus:border-od-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Send,
|
||||||
|
User as UserIcon,
|
||||||
|
Mail,
|
||||||
|
Clock,
|
||||||
|
Tag,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "incoming", label: "Incoming!" },
|
||||||
|
{ value: "in_the_chamber", label: "In The Chamber" },
|
||||||
|
{ value: "cleared_hot", label: "Cleared Hot" },
|
||||||
|
{ value: "misfire", label: "Misfire" },
|
||||||
|
{ value: "standby", label: "Standby" },
|
||||||
|
{ value: "resolved", label: "Resolved" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS = [
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
{ value: "critical", label: "Critical" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TicketDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [ticket, setTicket] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
const fetchTicket = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getTicket(id);
|
||||||
|
setTicket(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch ticket:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTicket();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleReply = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyText.trim() || !id) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.replyToTicket(id, replyText.trim());
|
||||||
|
setReplyText("");
|
||||||
|
await fetchTicket(); // Refresh to show new message
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send reply:", err);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (status: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
await api.updateTicket(id, { status });
|
||||||
|
setTicket((prev: any) => ({ ...prev, status }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update status:", err);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = async (priority: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
await api.updateTicket(id, { priority });
|
||||||
|
setTicket((prev: any) => ({ ...prev, priority }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update priority:", err);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading ticket...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20 text-gray-500">
|
||||||
|
<p>Ticket not found</p>
|
||||||
|
<button onClick={() => navigate("/")} className="text-fde mt-2 text-sm hover:underline">
|
||||||
|
Back to The Armory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="p-2 rounded-lg border border-dark-border text-gray-400 hover:bg-dark-hover hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-fde font-mono font-bold text-lg">{ticket.ticketNumber}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-200 mt-0.5">{ticket.subject}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Messages thread */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{/* Message list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ticket.messages?.map((msg: any) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`bg-dark-surface border rounded-xl p-4 ${
|
||||||
|
msg.direction === "outbound"
|
||||||
|
? "border-od-green/30 ml-8"
|
||||||
|
: "border-dark-border mr-8"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
|
msg.direction === "outbound"
|
||||||
|
? "bg-od-green/20 text-od-green-light"
|
||||||
|
: "bg-fde/20 text-fde"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(msg.fromName || msg.fromEmail).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-200">
|
||||||
|
{msg.fromName || msg.fromEmail}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gunmetal-light ml-2">
|
||||||
|
{msg.direction === "outbound" ? "Agent Reply" : "Customer"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gunmetal-light">{formatDate(msg.sentAt || msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{msg.bodyText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply form */}
|
||||||
|
<form onSubmit={handleReply} className="bg-dark-surface border border-dark-border rounded-xl p-4">
|
||||||
|
<p className="text-sm font-medium text-gray-300 mb-2">Send Reply</p>
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Type your response..."
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 resize-none focus:outline-none focus:border-od-green focus:ring-1 focus:ring-od-green"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!replyText.trim() || sending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
{sending ? "Sending..." : "Send It"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status & Priority */}
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gunmetal-light uppercase tracking-wider mb-1.5">Status</label>
|
||||||
|
<select
|
||||||
|
value={ticket.status}
|
||||||
|
onChange={(e) => handleStatusChange(e.target.value)}
|
||||||
|
disabled={updating}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gunmetal-light uppercase tracking-wider mb-1.5">Priority</label>
|
||||||
|
<select
|
||||||
|
value={ticket.priority}
|
||||||
|
onChange={(e) => handlePriorityChange(e.target.value)}
|
||||||
|
disabled={updating}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl p-4 space-y-3">
|
||||||
|
<h3 className="text-xs text-gunmetal-light uppercase tracking-wider font-medium">Details</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Customer:</span>
|
||||||
|
<span className="text-gray-200 truncate">{ticket.customerEmail}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.customerName && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<UserIcon className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Name:</span>
|
||||||
|
<span className="text-gray-200">{ticket.customerName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<UserIcon className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Assignee:</span>
|
||||||
|
<span className="text-gray-200">{ticket.assignee?.name || "Unassigned"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Via:</span>
|
||||||
|
<span className="text-gray-200 truncate">{ticket.emailAccount?.emailAddress || "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Created:</span>
|
||||||
|
<span className="text-gray-200">{formatDate(ticket.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Tag className="w-4 h-4 text-gunmetal-light" />
|
||||||
|
<span className="text-gray-400">Messages:</span>
|
||||||
|
<span className="text-gray-200">{ticket.messageCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Shield,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
owner: "text-fde bg-fde/15",
|
||||||
|
admin: "text-amber-400 bg-amber-500/15",
|
||||||
|
agent: "text-blue-400 bg-blue-500/15",
|
||||||
|
viewer: "text-gray-400 bg-gray-500/15",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
const [inviteForm, setInviteForm] = useState({ email: "", name: "", role: "agent" });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getUsers();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch users:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setInviting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.invite(inviteForm.email, inviteForm.name, inviteForm.role);
|
||||||
|
setShowInvite(false);
|
||||||
|
setInviteForm({ email: "", name: "", role: "agent" });
|
||||||
|
alert(`User invited! Temporary password: ${result.temporaryPassword}`);
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || "Failed to invite user");
|
||||||
|
} finally {
|
||||||
|
setInviting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (userId: string, currentlyActive: boolean) => {
|
||||||
|
try {
|
||||||
|
if (currentlyActive) {
|
||||||
|
await api.deleteUser(userId);
|
||||||
|
} else {
|
||||||
|
await api.updateUser(userId, { isActive: true });
|
||||||
|
}
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.message || "Failed to update user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = async (userId: string, role: string) => {
|
||||||
|
try {
|
||||||
|
await api.updateUser(userId, { role });
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.message || "Failed to update role");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return "Never";
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-fde">Personnel</h1>
|
||||||
|
<p className="text-sm text-gunmetal-light mt-0.5">Manage your team members</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInvite(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Invite Operator
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite modal */}
|
||||||
|
{showInvite && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-border">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-200">Invite Operator</h2>
|
||||||
|
<button onClick={() => setShowInvite(false)} className="text-gray-500 hover:text-white">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleInvite} className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-red-400 text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4" /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Name</label>
|
||||||
|
<input type="text" value={inviteForm.name}
|
||||||
|
onChange={(e) => setInviteForm((p) => ({ ...p, name: e.target.value }))} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
placeholder="John Doe" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Email</label>
|
||||||
|
<input type="email" value={inviteForm.email}
|
||||||
|
onChange={(e) => setInviteForm((p) => ({ ...p, email: e.target.value }))} required
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
placeholder="user@company.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Role</label>
|
||||||
|
<select value={inviteForm.role}
|
||||||
|
onChange={(e) => setInviteForm((p) => ({ ...p, role: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm text-gray-200 focus:outline-none focus:border-od-green">
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="agent">Agent</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={() => setShowInvite(false)}
|
||||||
|
className="px-4 py-2 border border-dark-border text-gray-400 rounded-lg text-sm hover:bg-dark-hover">Cancel</button>
|
||||||
|
<button type="submit" disabled={inviting}
|
||||||
|
className="px-4 py-2 bg-od-green hover:bg-od-green-light text-white text-sm font-medium rounded-lg disabled:opacity-50">
|
||||||
|
{inviting ? "Sending..." : "Send Invite"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-gray-500">
|
||||||
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-dark-surface border border-dark-border rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-dark-border text-xs text-gunmetal-light uppercase tracking-wider">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">User</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Last Login</th>
|
||||||
|
{isAdmin && <th className="text-right px-4 py-3 font-medium">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-dark-border/50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gunmetal flex items-center justify-center text-fde text-xs font-bold">
|
||||||
|
{u.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-200 font-medium">{u.name}</p>
|
||||||
|
<p className="text-xs text-gunmetal-light">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isAdmin && u.role !== "owner" && u.id !== currentUser?.id ? (
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={(e) => handleRoleChange(u.id, e.target.value)}
|
||||||
|
className="px-2 py-1 bg-dark-bg border border-dark-border rounded text-xs text-gray-200 focus:outline-none focus:border-od-green"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="agent">Agent</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] || ""}`}>
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{u.role.charAt(0).toUpperCase() + u.role.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${u.isActive ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{u.isActive ? <UserCheck className="w-3.5 h-3.5" /> : <UserX className="w-3.5 h-3.5" />}
|
||||||
|
{u.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs text-gunmetal-light">{formatDate(u.lastLoginAt)}</span>
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{u.role !== "owner" && u.id !== currentUser?.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(u.id, u.isActive)}
|
||||||
|
className={`text-xs px-3 py-1 rounded border ${
|
||||||
|
u.isActive
|
||||||
|
? "border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
|
: "border-green-500/30 text-green-400 hover:bg-green-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import axios, { AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
const API_BASE = "/api/v1";
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private refreshing: Promise<string> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor: attach access token
|
||||||
|
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor: handle 401 with token refresh
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await this.refreshToken();
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return this.client(originalRequest);
|
||||||
|
} catch {
|
||||||
|
// Refresh failed, clear tokens and redirect to login
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
localStorage.removeItem("refreshToken");
|
||||||
|
window.location.href = "/login";
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshToken(): Promise<string> {
|
||||||
|
// Deduplicate concurrent refresh requests
|
||||||
|
if (this.refreshing) return this.refreshing;
|
||||||
|
|
||||||
|
this.refreshing = (async () => {
|
||||||
|
const refreshToken = localStorage.getItem("refreshToken");
|
||||||
|
if (!refreshToken) throw new Error("No refresh token");
|
||||||
|
|
||||||
|
const { data } = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken });
|
||||||
|
localStorage.setItem("accessToken", data.accessToken);
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken);
|
||||||
|
this.refreshing = null;
|
||||||
|
return data.accessToken;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.refreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const { data } = await this.client.post("/auth/login", { email, password });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(tenantName: string, email: string, password: string, name: string) {
|
||||||
|
const { data } = await this.client.post("/auth/register", { tenantName, email, password, name });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe() {
|
||||||
|
const { data } = await this.client.get("/auth/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invite(email: string, name: string, role: string) {
|
||||||
|
const { data } = await this.client.post("/auth/invite", { email, name, role });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tickets
|
||||||
|
async getTickets(params?: Record<string, string>) {
|
||||||
|
const { data } = await this.client.get("/tickets", { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicket(id: string) {
|
||||||
|
const { data } = await this.client.get(`/tickets/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTicket(body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.post("/tickets", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTicket(id: string, body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.patch(`/tickets/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async replyToTicket(id: string, body: string, bodyHtml?: string) {
|
||||||
|
const { data } = await this.client.post(`/tickets/${id}/reply`, { body, bodyHtml });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicketMessages(id: string) {
|
||||||
|
const { data } = await this.client.get(`/tickets/${id}/messages`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email Accounts
|
||||||
|
async getEmailAccounts() {
|
||||||
|
const { data } = await this.client.get("/email-accounts");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmailAccount(id: string) {
|
||||||
|
const { data } = await this.client.get(`/email-accounts/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmailAccount(body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.post("/email-accounts", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmailAccount(id: string, body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.patch(`/email-accounts/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmailAccount(id: string) {
|
||||||
|
const { data } = await this.client.delete(`/email-accounts/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async testEmailAccount(id: string) {
|
||||||
|
const { data } = await this.client.post(`/email-accounts/${id}/test`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
async getSettings() {
|
||||||
|
const { data } = await this.client.get("/settings");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.patch("/settings", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users
|
||||||
|
async getUsers() {
|
||||||
|
const { data } = await this.client.get("/users");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(id: string, body: Record<string, unknown>) {
|
||||||
|
const { data } = await this.client.patch(`/users/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: string) {
|
||||||
|
const { data } = await this.client.delete(`/users/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-od-green: #4A5D23;
|
||||||
|
--color-od-green-light: #5A7A2D;
|
||||||
|
--color-od-green-dark: #3A4A1B;
|
||||||
|
--color-fde: #C3A76B;
|
||||||
|
--color-fde-light: #D4BE8E;
|
||||||
|
--color-fde-dark: #A68D52;
|
||||||
|
--color-gunmetal: #51555C;
|
||||||
|
--color-gunmetal-light: #6B7280;
|
||||||
|
--color-dark-bg: #1A1D21;
|
||||||
|
--color-dark-surface: #22262B;
|
||||||
|
--color-dark-border: #2D3239;
|
||||||
|
--color-dark-hover: #323840;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1A1D21;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #2D3239;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #51555C;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(74, 93, 35, 0.4);
|
||||||
|
color: #C3A76B;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ["tickets.jfamily.io", ".jfamily.io"],
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@forward-assist/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Forward Assist — Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const TICKET_STATUSES = {
|
||||||
|
INCOMING: 'incoming',
|
||||||
|
IN_THE_CHAMBER: 'in_the_chamber',
|
||||||
|
CLEARED_HOT: 'cleared_hot',
|
||||||
|
MISFIRE: 'misfire',
|
||||||
|
STANDBY: 'standby',
|
||||||
|
RESOLVED: 'resolved',
|
||||||
|
ARCHIVED: 'archived',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TICKET_STATUS_LABELS: Record<string, string> = {
|
||||||
|
incoming: 'Incoming!',
|
||||||
|
in_the_chamber: 'In The Chamber',
|
||||||
|
cleared_hot: 'Cleared Hot',
|
||||||
|
misfire: 'Misfire',
|
||||||
|
standby: 'Standby',
|
||||||
|
resolved: 'Resolved',
|
||||||
|
archived: 'Archived',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TICKET_STATUS_COLORS: Record<string, string> = {
|
||||||
|
incoming: '#EF4444', // red
|
||||||
|
in_the_chamber: '#F59E0B', // amber
|
||||||
|
cleared_hot: '#4A5D23', // OD green
|
||||||
|
misfire: '#DC2626', // dark red
|
||||||
|
standby: '#6B7280', // gray
|
||||||
|
resolved: '#10B981', // emerald
|
||||||
|
archived: '#374151', // dark gray
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TICKET_PRIORITIES = {
|
||||||
|
LOW: 'low',
|
||||||
|
MEDIUM: 'medium',
|
||||||
|
HIGH: 'high',
|
||||||
|
CRITICAL: 'critical',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
low: 'Low',
|
||||||
|
medium: 'Medium',
|
||||||
|
high: 'High',
|
||||||
|
critical: 'Critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
low: '#6B7280',
|
||||||
|
medium: '#3B82F6',
|
||||||
|
high: '#F59E0B',
|
||||||
|
critical: '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
OWNER: 'owner',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
AGENT: 'agent',
|
||||||
|
VIEWER: 'viewer',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<string, string> = {
|
||||||
|
owner: 'Owner',
|
||||||
|
admin: 'Admin',
|
||||||
|
agent: 'Agent',
|
||||||
|
viewer: 'Viewer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAN_SLUGS = {
|
||||||
|
TRIAL: 'trial',
|
||||||
|
STARTER: 'starter',
|
||||||
|
PRO: 'pro',
|
||||||
|
ENTERPRISE: 'enterprise',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
export const BRAND = {
|
||||||
|
NAME: 'Forward Assist',
|
||||||
|
TAGLINE: 'AI-Powered Support, Mission Ready',
|
||||||
|
DASHBOARD_NAME: 'The Armory',
|
||||||
|
APPROVAL_QUEUE: 'The Chamber',
|
||||||
|
SETTINGS_NAME: 'Field Strip',
|
||||||
|
TICKET_PREFIX: 'FA',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Theme colors
|
||||||
|
export const COLORS = {
|
||||||
|
OD_GREEN: '#4A5D23',
|
||||||
|
OD_GREEN_LIGHT: '#5A7A2D',
|
||||||
|
FDE: '#C3A76B',
|
||||||
|
FDE_LIGHT: '#D4BE8E',
|
||||||
|
GUNMETAL: '#51555C',
|
||||||
|
GUNMETAL_LIGHT: '#6B7280',
|
||||||
|
DARK_BG: '#1A1D21',
|
||||||
|
DARK_SURFACE: '#22262B',
|
||||||
|
DARK_BORDER: '#2D3239',
|
||||||
|
DARK_HOVER: '#323840',
|
||||||
|
TEXT_PRIMARY: '#E5E7EB',
|
||||||
|
TEXT_SECONDARY: '#9CA3AF',
|
||||||
|
TEXT_MUTED: '#6B7280',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// API
|
||||||
|
export const API_DEFAULTS = {
|
||||||
|
PAGE_SIZE: 25,
|
||||||
|
MAX_PAGE_SIZE: 100,
|
||||||
|
RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
|
||||||
|
RATE_LIMIT_MAX: 100,
|
||||||
|
JWT_ACCESS_EXPIRY: '15m',
|
||||||
|
JWT_REFRESH_EXPIRY: '7d',
|
||||||
|
IMAP_POLL_INTERVAL: 60, // seconds
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Forward Assist — Shared Type Definitions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
planId: string;
|
||||||
|
plan?: Plan;
|
||||||
|
settings: TenantSettings;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantSettings {
|
||||||
|
timezone: string;
|
||||||
|
businessHours: BusinessHours;
|
||||||
|
autoReplyEnabled: boolean;
|
||||||
|
autoReplyMessage: string;
|
||||||
|
aiDraftEnabled: boolean;
|
||||||
|
aiTone: 'professional' | 'friendly' | 'technical';
|
||||||
|
ticketPrefix: string;
|
||||||
|
maxTicketsPerDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessHours {
|
||||||
|
monday: DayHours;
|
||||||
|
tuesday: DayHours;
|
||||||
|
wednesday: DayHours;
|
||||||
|
thursday: DayHours;
|
||||||
|
friday: DayHours;
|
||||||
|
saturday: DayHours;
|
||||||
|
sunday: DayHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayHours {
|
||||||
|
enabled: boolean;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
maxUsers: number;
|
||||||
|
maxEmailAccounts: number;
|
||||||
|
maxTicketsPerMonth: number;
|
||||||
|
aiDraftsEnabled: boolean;
|
||||||
|
knowledgeBaseEnabled: boolean;
|
||||||
|
priceMonthly: number;
|
||||||
|
priceYearly: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatarUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'owner' | 'admin' | 'agent' | 'viewer';
|
||||||
|
|
||||||
|
export interface EmailAccount {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
emailAddress: string;
|
||||||
|
imapHost: string;
|
||||||
|
imapPort: number;
|
||||||
|
imapUser: string;
|
||||||
|
imapPassword: string;
|
||||||
|
imapTls: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUser: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpTls: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
lastPollAt?: Date;
|
||||||
|
lastError?: string;
|
||||||
|
pollIntervalSeconds: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
emailAccountId: string;
|
||||||
|
subject: string;
|
||||||
|
status: TicketStatus;
|
||||||
|
priority: TicketPriority;
|
||||||
|
assigneeId?: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerProfileId?: string;
|
||||||
|
tags: string[];
|
||||||
|
messageCount: number;
|
||||||
|
lastMessageAt: Date;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
messages?: Message[];
|
||||||
|
assignee?: User;
|
||||||
|
emailAccount?: EmailAccount;
|
||||||
|
customerProfile?: CustomerProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketStatus =
|
||||||
|
| 'incoming'
|
||||||
|
| 'in_the_chamber'
|
||||||
|
| 'cleared_hot'
|
||||||
|
| 'misfire'
|
||||||
|
| 'standby'
|
||||||
|
| 'resolved'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
|
export type TicketPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
ticketId: string;
|
||||||
|
tenantId: string;
|
||||||
|
direction: MessageDirection;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName?: string;
|
||||||
|
toEmail: string;
|
||||||
|
subject: string;
|
||||||
|
bodyText: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
messageId?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
references?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
sentAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageDirection = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
messageId: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
storageKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiDraft {
|
||||||
|
id: string;
|
||||||
|
ticketId: string;
|
||||||
|
tenantId: string;
|
||||||
|
messageId: string;
|
||||||
|
draftBody: string;
|
||||||
|
confidence: number;
|
||||||
|
model: string;
|
||||||
|
tokensUsed: number;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||||
|
editedBody?: string;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBase {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
embedding?: number[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId?: string;
|
||||||
|
action: string;
|
||||||
|
entity: string;
|
||||||
|
entityId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerProfile {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags: string[];
|
||||||
|
ticketCount: number;
|
||||||
|
firstContactAt: Date;
|
||||||
|
lastContactAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CannedResponse {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
category: string;
|
||||||
|
shortcut?: string;
|
||||||
|
createdBy: string;
|
||||||
|
isShared: boolean;
|
||||||
|
usageCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreference {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
tenantId: string;
|
||||||
|
newTicket: boolean;
|
||||||
|
ticketAssigned: boolean;
|
||||||
|
ticketReply: boolean;
|
||||||
|
aiDraftReady: boolean;
|
||||||
|
dailyDigest: boolean;
|
||||||
|
emailNotifications: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Request/Response types
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
tenantName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: Omit<User, 'tenantId'>;
|
||||||
|
tenant: Pick<Tenant, 'id' | 'name' | 'slug'>;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshRequest {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteRequest {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTicketRequest {
|
||||||
|
emailAccountId: string;
|
||||||
|
subject: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerName?: string;
|
||||||
|
body: string;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTicketRequest {
|
||||||
|
status?: TicketStatus;
|
||||||
|
priority?: TicketPriority;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendReplyRequest {
|
||||||
|
body: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmailAccountRequest {
|
||||||
|
name: string;
|
||||||
|
emailAddress: string;
|
||||||
|
imapHost: string;
|
||||||
|
imapPort: number;
|
||||||
|
imapUser: string;
|
||||||
|
imapPassword: string;
|
||||||
|
imapTls: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUser: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpTls: boolean;
|
||||||
|
pollIntervalSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketFilters {
|
||||||
|
status?: TicketStatus | TicketStatus[];
|
||||||
|
priority?: TicketPriority | TicketPriority[];
|
||||||
|
assigneeId?: string;
|
||||||
|
emailAccountId?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Forward Assist — Shared Utilities
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { BRAND } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a ticket number like FA-0001
|
||||||
|
*/
|
||||||
|
export function generateTicketNumber(sequenceNum: number, prefix?: string): string {
|
||||||
|
const p = prefix || BRAND.TICKET_PREFIX;
|
||||||
|
return `${p}-${String(sequenceNum).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a URL-safe slug from a string
|
||||||
|
*/
|
||||||
|
export function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.substring(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text to a max length with ellipsis
|
||||||
|
*/
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML tags from a string
|
||||||
|
*/
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date with time
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative time (e.g., "2 hours ago")
|
||||||
|
*/
|
||||||
|
export function timeAgo(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHour < 24) return `${diffHour}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return formatDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return re.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract name from email (e.g., "john.doe@example.com" -> "John Doe")
|
||||||
|
*/
|
||||||
|
export function nameFromEmail(email: string): string {
|
||||||
|
const local = email.split('@')[0];
|
||||||
|
return local
|
||||||
|
.replace(/[._-]/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize user input for safe display
|
||||||
|
*/
|
||||||
|
export function sanitize(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@forward-assist/worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@forward-assist/shared": "*",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"imapflow": "^1.0.160",
|
||||||
|
"mailparser": "^3.7.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/nodemailer": "^6.4.16",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config({ path: "../../.env" });
|
||||||
|
|
||||||
|
import { Worker, Queue, QueueScheduler } from "bullmq";
|
||||||
|
import IORedis from "ioredis";
|
||||||
|
import { imapPollProcessor } from "./jobs/imapPoll";
|
||||||
|
import { smtpSendProcessor } from "./jobs/smtpSend";
|
||||||
|
import { schedulerService } from "./services/scheduler";
|
||||||
|
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||||
|
|
||||||
|
const connection = new IORedis(REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queues
|
||||||
|
export const imapQueue = new Queue("imap-poll", { connection });
|
||||||
|
export const smtpQueue = new Queue("smtp-send", { connection });
|
||||||
|
|
||||||
|
// Workers
|
||||||
|
const imapWorker = new Worker("imap-poll", imapPollProcessor, {
|
||||||
|
connection,
|
||||||
|
concurrency: 5,
|
||||||
|
limiter: {
|
||||||
|
max: 10,
|
||||||
|
duration: 60000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const smtpWorker = new Worker("smtp-send", smtpSendProcessor, {
|
||||||
|
connection,
|
||||||
|
concurrency: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
imapWorker.on("completed", (job) => {
|
||||||
|
console.log(`[IMAP Worker] Job ${job.id} completed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
imapWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[IMAP Worker] Job ${job?.id} failed:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
smtpWorker.on("completed", (job) => {
|
||||||
|
console.log(`[SMTP Worker] Job ${job.id} completed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
smtpWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[SMTP Worker] Job ${job?.id} failed:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scheduler
|
||||||
|
schedulerService.start(imapQueue).then(() => {
|
||||||
|
console.log("[Forward Assist Worker] All workers and schedulers running");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
async function shutdown() {
|
||||||
|
console.log("[Worker] Shutting down...");
|
||||||
|
await imapWorker.close();
|
||||||
|
await smtpWorker.close();
|
||||||
|
await schedulerService.stop();
|
||||||
|
await connection.quit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
|
console.log("[Forward Assist Worker] Starting workers...");
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import { ImapFlow } from "imapflow";
|
||||||
|
import { simpleParser, ParsedMail } from "mailparser";
|
||||||
|
import { prisma } from "../services/prisma";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
interface ImapPollData {
|
||||||
|
emailAccountId: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function imapPollProcessor(job: Job<ImapPollData>): Promise<void> {
|
||||||
|
const { emailAccountId, tenantId } = job.data;
|
||||||
|
|
||||||
|
const account = await prisma.emailAccount.findUnique({
|
||||||
|
where: { id: emailAccountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account || !account.isActive) {
|
||||||
|
console.log(`[IMAP] Account ${emailAccountId} not found or inactive, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IMAP] Polling ${account.emailAddress} for tenant ${tenantId}`);
|
||||||
|
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: account.imapHost,
|
||||||
|
port: account.imapPort,
|
||||||
|
secure: account.imapTls,
|
||||||
|
auth: {
|
||||||
|
user: account.imapUser,
|
||||||
|
pass: account.imapPassword,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const lock = await client.getMailboxLock("INBOX");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch unseen messages
|
||||||
|
const messages: ParsedMail[] = [];
|
||||||
|
for await (const msg of client.fetch(
|
||||||
|
{ seen: false },
|
||||||
|
{ source: true, envelope: true, flags: true }
|
||||||
|
)) {
|
||||||
|
if (msg.source) {
|
||||||
|
const parsed = await simpleParser(msg.source);
|
||||||
|
messages.push(parsed);
|
||||||
|
|
||||||
|
// Mark as seen
|
||||||
|
await client.messageFlagsAdd(msg.seq, ["\\Seen"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IMAP] Found ${messages.length} new messages for ${account.emailAddress}`);
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for (const mail of messages) {
|
||||||
|
await processIncomingEmail(mail, account, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last poll time
|
||||||
|
await prisma.emailAccount.update({
|
||||||
|
where: { id: emailAccountId },
|
||||||
|
data: { lastPollAt: new Date(), lastError: null },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.logout();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[IMAP] Error polling ${account.emailAddress}:`, error.message);
|
||||||
|
|
||||||
|
// Store the error on the account
|
||||||
|
await prisma.emailAccount.update({
|
||||||
|
where: { id: emailAccountId },
|
||||||
|
data: {
|
||||||
|
lastPollAt: new Date(),
|
||||||
|
lastError: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error; // Let BullMQ handle retries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processIncomingEmail(
|
||||||
|
mail: ParsedMail,
|
||||||
|
account: any,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const fromEmail = mail.from?.value?.[0]?.address || "unknown@unknown.com";
|
||||||
|
const fromName = mail.from?.value?.[0]?.name || undefined;
|
||||||
|
const subject = mail.subject || "(No Subject)";
|
||||||
|
const bodyText = mail.text || "";
|
||||||
|
const bodyHtml = mail.html || undefined;
|
||||||
|
const messageId = mail.messageId || undefined;
|
||||||
|
const inReplyTo = mail.inReplyTo || undefined;
|
||||||
|
const references = Array.isArray(mail.references)
|
||||||
|
? mail.references.join(" ")
|
||||||
|
: mail.references || undefined;
|
||||||
|
|
||||||
|
// Thread detection: check if this is a reply to an existing ticket
|
||||||
|
let existingTicket = null;
|
||||||
|
|
||||||
|
// Strategy 1: Match by In-Reply-To or References headers
|
||||||
|
if (inReplyTo || references) {
|
||||||
|
const refIds = [inReplyTo, ...(references?.split(/\s+/) || [])].filter(Boolean);
|
||||||
|
|
||||||
|
if (refIds.length > 0) {
|
||||||
|
const existingMessage = await prisma.message.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
messageId: { in: refIds as string[] },
|
||||||
|
},
|
||||||
|
include: { ticket: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMessage) {
|
||||||
|
existingTicket = existingMessage.ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Match by subject line (strip Re:/Fwd: prefixes)
|
||||||
|
if (!existingTicket) {
|
||||||
|
const cleanSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
|
||||||
|
existingTicket = await prisma.ticket.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
customerEmail: fromEmail,
|
||||||
|
subject: { contains: cleanSubject, mode: "insensitive" },
|
||||||
|
status: { notIn: ["archived", "resolved"] },
|
||||||
|
},
|
||||||
|
orderBy: { lastMessageAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTicket) {
|
||||||
|
// Add message to existing ticket
|
||||||
|
await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketId: existingTicket.id,
|
||||||
|
direction: "inbound",
|
||||||
|
fromEmail,
|
||||||
|
fromName,
|
||||||
|
toEmail: account.emailAddress,
|
||||||
|
subject,
|
||||||
|
bodyText,
|
||||||
|
bodyHtml: bodyHtml || null,
|
||||||
|
messageId,
|
||||||
|
inReplyTo,
|
||||||
|
references,
|
||||||
|
sentAt: mail.date || new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update ticket
|
||||||
|
await prisma.ticket.update({
|
||||||
|
where: { id: existingTicket.id },
|
||||||
|
data: {
|
||||||
|
status: "incoming",
|
||||||
|
messageCount: { increment: 1 },
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[IMAP] Added message to existing ticket ${existingTicket.ticketNumber}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new ticket
|
||||||
|
const ticketNumber = await generateNextTicketNumber(tenantId);
|
||||||
|
|
||||||
|
// Find or create customer profile
|
||||||
|
let profile = await prisma.customerProfile.findUnique({
|
||||||
|
where: { tenantId_email: { tenantId, email: fromEmail } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
profile = await prisma.customerProfile.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
email: fromEmail,
|
||||||
|
name: fromName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketNumber,
|
||||||
|
emailAccountId: account.id,
|
||||||
|
subject,
|
||||||
|
status: "incoming",
|
||||||
|
priority: "medium",
|
||||||
|
customerEmail: fromEmail,
|
||||||
|
customerName: fromName,
|
||||||
|
customerProfileId: profile.id,
|
||||||
|
messageCount: 1,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
tenantId,
|
||||||
|
direction: "inbound",
|
||||||
|
fromEmail,
|
||||||
|
fromName,
|
||||||
|
toEmail: account.emailAddress,
|
||||||
|
subject,
|
||||||
|
bodyText,
|
||||||
|
bodyHtml: bodyHtml || null,
|
||||||
|
messageId,
|
||||||
|
inReplyTo,
|
||||||
|
references,
|
||||||
|
sentAt: mail.date || new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update customer profile
|
||||||
|
await prisma.customerProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: {
|
||||||
|
ticketCount: { increment: 1 },
|
||||||
|
lastContactAt: new Date(),
|
||||||
|
name: profile.name || fromName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[IMAP] Created new ticket ${ticketNumber} from ${fromEmail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateNextTicketNumber(tenantId: string): Promise<string> {
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||||
|
const settings = (tenant?.settings as any) || {};
|
||||||
|
const prefix = settings.ticketPrefix || "FA";
|
||||||
|
|
||||||
|
const lastTicket = await prisma.ticket.findFirst({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { ticketNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextNum = 1;
|
||||||
|
if (lastTicket) {
|
||||||
|
const match = lastTicket.ticketNumber.match(/(\d+)$/);
|
||||||
|
if (match) nextNum = parseInt(match[1], 10) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}-${String(nextNum).padStart(4, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { prisma } from "../services/prisma";
|
||||||
|
|
||||||
|
interface SmtpSendData {
|
||||||
|
messageId: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function smtpSendProcessor(job: Job<SmtpSendData>): Promise<void> {
|
||||||
|
const { messageId, tenantId } = job.data;
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id: messageId },
|
||||||
|
include: {
|
||||||
|
ticket: {
|
||||||
|
include: {
|
||||||
|
emailAccount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
console.error(`[SMTP] Message ${messageId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = message.ticket.emailAccount;
|
||||||
|
if (!account) {
|
||||||
|
console.error(`[SMTP] No email account for ticket ${message.ticket.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SMTP] Sending reply for ticket ${message.ticket.ticketNumber} via ${account.emailAddress}`);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: account.smtpHost,
|
||||||
|
port: account.smtpPort,
|
||||||
|
secure: account.smtpTls && account.smtpPort === 465,
|
||||||
|
auth: {
|
||||||
|
user: account.smtpUser,
|
||||||
|
pass: account.smtpPassword,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the original inbound message to get its Message-ID for threading
|
||||||
|
const originalMessage = await prisma.message.findFirst({
|
||||||
|
where: {
|
||||||
|
ticketId: message.ticketId,
|
||||||
|
direction: "inbound",
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mailOptions: nodemailer.SendMailOptions = {
|
||||||
|
from: {
|
||||||
|
name: message.fromName || account.name,
|
||||||
|
address: account.emailAddress,
|
||||||
|
},
|
||||||
|
to: message.toEmail,
|
||||||
|
subject: message.subject,
|
||||||
|
text: message.bodyText,
|
||||||
|
html: message.bodyHtml || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add threading headers
|
||||||
|
if (originalMessage?.messageId) {
|
||||||
|
mailOptions.inReplyTo = originalMessage.messageId;
|
||||||
|
mailOptions.references = originalMessage.messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
|
// Update message with the sent Message-ID
|
||||||
|
await prisma.message.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: {
|
||||||
|
messageId: result.messageId,
|
||||||
|
sentAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SMTP] Sent successfully: ${result.messageId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SMTP] Send failed for message ${messageId}:`, error.message);
|
||||||
|
throw error; // Let BullMQ retry
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
class SchedulerService {
|
||||||
|
private intervalHandle: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async start(imapQueue: Queue): Promise<void> {
|
||||||
|
console.log("[Scheduler] Starting IMAP poll scheduler");
|
||||||
|
|
||||||
|
// Schedule initial jobs
|
||||||
|
await this.schedulePolls(imapQueue);
|
||||||
|
|
||||||
|
// Re-check every 60 seconds for new/changed email accounts
|
||||||
|
this.intervalHandle = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this.schedulePolls(imapQueue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Scheduler] Error scheduling polls:", error);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.intervalHandle) {
|
||||||
|
clearInterval(this.intervalHandle);
|
||||||
|
this.intervalHandle = null;
|
||||||
|
}
|
||||||
|
console.log("[Scheduler] Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schedulePolls(imapQueue: Queue): Promise<void> {
|
||||||
|
// Get all active email accounts
|
||||||
|
const accounts = await prisma.emailAccount.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
emailAddress: true,
|
||||||
|
pollIntervalSeconds: true,
|
||||||
|
lastPollAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// Check if it is time to poll
|
||||||
|
const lastPoll = account.lastPollAt ? new Date(account.lastPollAt) : new Date(0);
|
||||||
|
const elapsed = (now.getTime() - lastPoll.getTime()) / 1000;
|
||||||
|
|
||||||
|
if (elapsed >= account.pollIntervalSeconds) {
|
||||||
|
// Add job with deduplication key to prevent duplicates
|
||||||
|
const jobId = `imap-poll-${account.id}`;
|
||||||
|
try {
|
||||||
|
await imapQueue.add(
|
||||||
|
"poll",
|
||||||
|
{
|
||||||
|
emailAccountId: account.id,
|
||||||
|
tenantId: account.tenantId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId,
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 50,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 5000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Job with this ID may already exist, that is fine
|
||||||
|
if (!error.message?.includes("already exists")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedulerService = new SchedulerService();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user