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:
Eric Jungbauer
2026-03-20 01:45:13 +00:00
parent 0bae347e65
commit 05aad75272
56 changed files with 11815 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Database
DATABASE_URL=postgresql://forward_assist:fa_dev_password_2024@localhost:5432/forward_assist
# Redis
REDIS_URL=redis://localhost:6379
# JWT
JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars
JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Server
API_PORT=3001
FRONTEND_URL=http://localhost:5173
NODE_ENV=development
# Email (defaults for dev)
DEFAULT_IMAP_POLL_INTERVAL=60
# AI (Phase 2+)
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
# Logging
LOG_LEVEL=debug
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.env
*.log
.pm2/
packages/shared/dist/
packages/api/node_modules/
packages/worker/node_modules/
packages/frontend/node_modules/
+40
View File
@@ -0,0 +1,40 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: fa-postgres
restart: unless-stopped
ports:
- '5432:5432'
environment:
POSTGRES_USER: forward_assist
POSTGRES_PASSWORD: fa_dev_password_2024
POSTGRES_DB: forward_assist
volumes:
- pgdata:/var/lib/postgresql/data
- ./packages/api/prisma/rls-setup.sql:/docker-entrypoint-initdb.d/99-rls-setup.sql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U forward_assist']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: fa-redis
restart: unless-stopped
ports:
- '6379:6379'
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
redisdata:
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
apps: [
{
name: 'fa-api',
script: 'npx',
args: 'tsx packages/api/src/index.ts',
cwd: '/opt/forward-assist',
env: { NODE_ENV: 'development' },
},
{
name: 'fa-worker',
script: 'npx',
args: 'tsx packages/worker/src/index.ts',
cwd: '/opt/forward-assist',
env: { NODE_ENV: 'development' },
},
{
name: 'fa-frontend',
script: 'npx',
args: 'vite --host 0.0.0.0',
cwd: '/opt/forward-assist/packages/frontend',
env: { NODE_ENV: 'development' },
},
],
};
+5907
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "forward-assist",
"version": "0.1.0",
"private": true,
"description": "AI-powered help desk for the firearms industry",
"workspaces": [
"packages/shared",
"packages/api",
"packages/worker",
"packages/frontend"
],
"scripts": {
"dev:api": "npm run dev -w packages/api",
"dev:worker": "npm run dev -w packages/worker",
"dev:frontend": "npm run dev -w packages/frontend",
"build": "npm run build -ws",
"db:generate": "npm run prisma:generate -w packages/api",
"db:migrate": "npm run prisma:migrate -w packages/api",
"db:seed": "npm run prisma:seed -w packages/api",
"db:studio": "npx prisma studio --schema packages/api/prisma/schema.prisma",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down"
}
}
+46
View File
@@ -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"
+48
View File
@@ -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
+343
View File
@@ -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")
}
+147
View File
@@ -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();
});
+52
View File
@@ -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;
+44
View File
@@ -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,
},
});
+376
View File
@@ -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);
}
});
+261
View File
@@ -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);
}
});
+100
View File
@@ -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);
}
});
+350
View File
@@ -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);
}
});
+199
View File
@@ -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);
}
});
+32
View File
@@ -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);
}
}
+36
View File
@@ -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),
};
}
+13
View File
@@ -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;
}
+21
View File
@@ -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"]
}
+14
View File
@@ -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>
+29
View File
@@ -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"
}
}
+4
View File
@@ -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

+58
View File
@@ -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;
}
+16
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+250
View File
@@ -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>
);
}
+180
View File
@@ -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();
+38
View File
@@ -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;
}
+22
View File
@@ -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"]
}
+23
View File
@@ -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,
},
},
},
});
+14
View File
@@ -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"
}
}
+113
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export * from './types';
export * from './constants';
export * from './utils';
+338
View File
@@ -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>;
}
+115
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+19
View File
@@ -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"]
}
+28
View File
@@ -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"
}
}
+70
View File
@@ -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...");
+258
View File
@@ -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")}`;
}
+93
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
+83
View File
@@ -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();
+18
View File
@@ -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"]
}