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,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