Phase 1: Forward Assist initial build
Multi-tenant AI help desk SaaS for the firearms industry. Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind). PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation. Dark Armory theme with tactical branding throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@forward-assist/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@forward-assist/shared": "*",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.0.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "plans" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 3,
|
||||
"maxEmailAccounts" INTEGER NOT NULL DEFAULT 1,
|
||||
"maxTicketsPerMonth" INTEGER NOT NULL DEFAULT 500,
|
||||
"aiDraftsEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"knowledgeBaseEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"priceMonthly" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
"priceYearly" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "plans_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenants" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"plan_id" TEXT NOT NULL,
|
||||
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'agent',
|
||||
"avatar_url" TEXT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"last_login_at" TIMESTAMP(3),
|
||||
"refresh_token" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "email_accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email_address" TEXT NOT NULL,
|
||||
"imap_host" TEXT NOT NULL,
|
||||
"imap_port" INTEGER NOT NULL DEFAULT 993,
|
||||
"imap_user" TEXT NOT NULL,
|
||||
"imap_password" TEXT NOT NULL,
|
||||
"imap_tls" BOOLEAN NOT NULL DEFAULT true,
|
||||
"smtp_host" TEXT NOT NULL,
|
||||
"smtp_port" INTEGER NOT NULL DEFAULT 587,
|
||||
"smtp_user" TEXT NOT NULL,
|
||||
"smtp_password" TEXT NOT NULL,
|
||||
"smtp_tls" BOOLEAN NOT NULL DEFAULT true,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"last_poll_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"poll_interval_seconds" INTEGER NOT NULL DEFAULT 60,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "email_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tickets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"ticket_number" TEXT NOT NULL,
|
||||
"email_account_id" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'incoming',
|
||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||
"assignee_id" TEXT,
|
||||
"customer_email" TEXT NOT NULL,
|
||||
"customer_name" TEXT,
|
||||
"customer_profile_id" TEXT,
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"message_count" INTEGER NOT NULL DEFAULT 1,
|
||||
"last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"resolved_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticket_id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"direction" TEXT NOT NULL,
|
||||
"from_email" TEXT NOT NULL,
|
||||
"from_name" TEXT,
|
||||
"to_email" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"body_text" TEXT NOT NULL,
|
||||
"body_html" TEXT,
|
||||
"message_id" TEXT,
|
||||
"in_reply_to" TEXT,
|
||||
"references" TEXT,
|
||||
"headers" JSONB,
|
||||
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"message_id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"content_type" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"storage_key" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ai_drafts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticket_id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"message_id" TEXT NOT NULL,
|
||||
"draft_body" TEXT NOT NULL,
|
||||
"confidence" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"model" TEXT NOT NULL DEFAULT 'gpt-4o',
|
||||
"tokens_used" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"edited_body" TEXT,
|
||||
"reviewed_by" TEXT,
|
||||
"reviewed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ai_drafts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "knowledge_base" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'general',
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "knowledge_base_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entity_id" TEXT,
|
||||
"details" JSONB,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customer_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"phone" TEXT,
|
||||
"company" TEXT,
|
||||
"notes" TEXT,
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"ticket_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"first_contact_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_contact_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customer_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "canned_responses" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL DEFAULT 'general',
|
||||
"shortcut" TEXT,
|
||||
"created_by" TEXT NOT NULL,
|
||||
"is_shared" BOOLEAN NOT NULL DEFAULT true,
|
||||
"usage_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "canned_responses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"new_ticket" BOOLEAN NOT NULL DEFAULT true,
|
||||
"ticket_assigned" BOOLEAN NOT NULL DEFAULT true,
|
||||
"ticket_reply" BOOLEAN NOT NULL DEFAULT true,
|
||||
"ai_draft_ready" BOOLEAN NOT NULL DEFAULT true,
|
||||
"daily_digest" BOOLEAN NOT NULL DEFAULT false,
|
||||
"email_notifications" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "plans_slug_key" ON "plans"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_tenant_id_idx" ON "users"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_tenant_id_email_key" ON "users"("tenant_id", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "email_accounts_tenant_id_idx" ON "email_accounts"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "email_accounts_tenant_id_email_address_key" ON "email_accounts"("tenant_id", "email_address");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_tenant_id_status_idx" ON "tickets"("tenant_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_tenant_id_assignee_id_idx" ON "tickets"("tenant_id", "assignee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_tenant_id_customer_email_idx" ON "tickets"("tenant_id", "customer_email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_tenant_id_created_at_idx" ON "tickets"("tenant_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tickets_tenant_id_ticket_number_key" ON "tickets"("tenant_id", "ticket_number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "messages_ticket_id_idx" ON "messages"("ticket_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "messages_tenant_id_idx" ON "messages"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "messages_message_id_idx" ON "messages"("message_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ai_drafts_ticket_id_idx" ON "ai_drafts"("ticket_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ai_drafts_tenant_id_idx" ON "ai_drafts"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "knowledge_base_tenant_id_idx" ON "knowledge_base"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_tenant_id_created_at_idx" ON "audit_log"("tenant_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_tenant_id_entity_idx" ON "audit_log"("tenant_id", "entity");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "customer_profiles_tenant_id_idx" ON "customer_profiles"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customer_profiles_tenant_id_email_key" ON "customer_profiles"("tenant_id", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "canned_responses_tenant_id_idx" ON "canned_responses"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notification_preferences_user_id_key" ON "notification_preferences"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notification_preferences_tenant_id_idx" ON "notification_preferences"("tenant_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_email_account_id_fkey" FOREIGN KEY ("email_account_id") REFERENCES "email_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_customer_profile_id_fkey" FOREIGN KEY ("customer_profile_id") REFERENCES "customer_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_ticket_id_fkey" FOREIGN KEY ("ticket_id") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ai_drafts" ADD CONSTRAINT "ai_drafts_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "knowledge_base" ADD CONSTRAINT "knowledge_base_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customer_profiles" ADD CONSTRAINT "customer_profiles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "canned_responses" ADD CONSTRAINT "canned_responses_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "canned_responses" ADD CONSTRAINT "canned_responses_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,48 @@
|
||||
-- ============================================================
|
||||
-- Forward Assist — Row Level Security Setup
|
||||
-- Run after Prisma migrations to enable tenant isolation
|
||||
-- ============================================================
|
||||
|
||||
-- Enable RLS on tenant-scoped tables
|
||||
-- Note: Prisma handles the actual filtering via middleware,
|
||||
-- but these policies provide defense-in-depth at the DB level.
|
||||
|
||||
-- We use a session variable app.current_tenant_id set per connection.
|
||||
|
||||
-- Function to get current tenant
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
|
||||
SELECT current_setting('app.current_tenant_id', true);
|
||||
$$ LANGUAGE sql STABLE;
|
||||
|
||||
-- Enable RLS on all tenant-scoped tables
|
||||
DO $$
|
||||
DECLARE
|
||||
t TEXT;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT unnest(ARRAY[
|
||||
'users', 'email_accounts', 'tickets', 'messages',
|
||||
'ai_drafts', 'knowledge_base', 'audit_log',
|
||||
'customer_profiles', 'canned_responses', 'notification_preferences'
|
||||
])
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||
|
||||
-- Policy: tenant isolation
|
||||
EXECUTE format(
|
||||
'CREATE POLICY IF NOT EXISTS tenant_isolation ON %I
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id())',
|
||||
t
|
||||
);
|
||||
|
||||
-- Allow the app user to bypass RLS (Prisma uses this role)
|
||||
EXECUTE format(
|
||||
'ALTER TABLE %I FORCE ROW LEVEL SECURITY', t
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Grant the application user the ability to set tenant context
|
||||
-- The Prisma middleware will SET app.current_tenant_id before each query
|
||||
@@ -0,0 +1,343 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Plans
|
||||
// ============================================================
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
maxUsers Int @default(3)
|
||||
maxEmailAccounts Int @default(1)
|
||||
maxTicketsPerMonth Int @default(500)
|
||||
aiDraftsEnabled Boolean @default(false)
|
||||
knowledgeBaseEnabled Boolean @default(false)
|
||||
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
|
||||
priceYearly Decimal @default(0) @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenants Tenant[]
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tenants (Multi-tenant root)
|
||||
// ============================================================
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
planId String @map("plan_id")
|
||||
settings Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
users User[]
|
||||
emailAccounts EmailAccount[]
|
||||
tickets Ticket[]
|
||||
messages Message[]
|
||||
aiDrafts AiDraft[]
|
||||
knowledgeBase KnowledgeBaseEntry[]
|
||||
auditLogs AuditLog[]
|
||||
customerProfiles CustomerProfile[]
|
||||
cannedResponses CannedResponse[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Users
|
||||
// ============================================================
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
email String
|
||||
passwordHash String @map("password_hash")
|
||||
name String
|
||||
role String @default("agent")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
refreshToken String? @map("refresh_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||
auditLogs AuditLog[]
|
||||
notificationPreference NotificationPreference?
|
||||
createdCannedResponses CannedResponse[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Email Accounts
|
||||
// ============================================================
|
||||
model EmailAccount {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
name String
|
||||
emailAddress String @map("email_address")
|
||||
imapHost String @map("imap_host")
|
||||
imapPort Int @default(993) @map("imap_port")
|
||||
imapUser String @map("imap_user")
|
||||
imapPassword String @map("imap_password")
|
||||
imapTls Boolean @default(true) @map("imap_tls")
|
||||
smtpHost String @map("smtp_host")
|
||||
smtpPort Int @default(587) @map("smtp_port")
|
||||
smtpUser String @map("smtp_user")
|
||||
smtpPassword String @map("smtp_password")
|
||||
smtpTls Boolean @default(true) @map("smtp_tls")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastPollAt DateTime? @map("last_poll_at")
|
||||
lastError String? @map("last_error")
|
||||
pollIntervalSeconds Int @default(60) @map("poll_interval_seconds")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([tenantId, emailAddress])
|
||||
@@index([tenantId])
|
||||
@@map("email_accounts")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tickets
|
||||
// ============================================================
|
||||
model Ticket {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
ticketNumber String @map("ticket_number")
|
||||
emailAccountId String @map("email_account_id")
|
||||
subject String
|
||||
status String @default("incoming")
|
||||
priority String @default("medium")
|
||||
assigneeId String? @map("assignee_id")
|
||||
customerEmail String @map("customer_email")
|
||||
customerName String? @map("customer_name")
|
||||
customerProfileId String? @map("customer_profile_id")
|
||||
tags String[] @default([])
|
||||
messageCount Int @default(1) @map("message_count")
|
||||
lastMessageAt DateTime @default(now()) @map("last_message_at")
|
||||
resolvedAt DateTime? @map("resolved_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id])
|
||||
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||
customerProfile CustomerProfile? @relation(fields: [customerProfileId], references: [id])
|
||||
messages Message[]
|
||||
aiDrafts AiDraft[]
|
||||
|
||||
@@unique([tenantId, ticketNumber])
|
||||
@@index([tenantId, status])
|
||||
@@index([tenantId, assigneeId])
|
||||
@@index([tenantId, customerEmail])
|
||||
@@index([tenantId, createdAt])
|
||||
@@map("tickets")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Messages
|
||||
// ============================================================
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
ticketId String @map("ticket_id")
|
||||
tenantId String @map("tenant_id")
|
||||
direction String // 'inbound' | 'outbound'
|
||||
fromEmail String @map("from_email")
|
||||
fromName String? @map("from_name")
|
||||
toEmail String @map("to_email")
|
||||
subject String
|
||||
bodyText String @map("body_text")
|
||||
bodyHtml String? @map("body_html")
|
||||
messageId String? @map("message_id")
|
||||
inReplyTo String? @map("in_reply_to")
|
||||
references String?
|
||||
headers Json?
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
attachments Attachment[]
|
||||
aiDrafts AiDraft[]
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([tenantId])
|
||||
@@index([messageId])
|
||||
@@map("messages")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Attachments
|
||||
// ============================================================
|
||||
model Attachment {
|
||||
id String @id @default(uuid())
|
||||
messageId String @map("message_id")
|
||||
filename String
|
||||
contentType String @map("content_type")
|
||||
size Int
|
||||
storageKey String @map("storage_key")
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("attachments")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AI Drafts
|
||||
// ============================================================
|
||||
model AiDraft {
|
||||
id String @id @default(uuid())
|
||||
ticketId String @map("ticket_id")
|
||||
tenantId String @map("tenant_id")
|
||||
messageId String @map("message_id")
|
||||
draftBody String @map("draft_body")
|
||||
confidence Float @default(0)
|
||||
model String @default("gpt-4o")
|
||||
tokensUsed Int @default(0) @map("tokens_used")
|
||||
status String @default("pending")
|
||||
editedBody String? @map("edited_body")
|
||||
reviewedBy String? @map("reviewed_by")
|
||||
reviewedAt DateTime? @map("reviewed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([tenantId])
|
||||
@@map("ai_drafts")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Knowledge Base
|
||||
// ============================================================
|
||||
model KnowledgeBaseEntry {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
title String
|
||||
content String
|
||||
category String @default("general")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("knowledge_base")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Audit Log
|
||||
// ============================================================
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
userId String? @map("user_id")
|
||||
action String
|
||||
entity String
|
||||
entityId String? @map("entity_id")
|
||||
details Json?
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([tenantId, createdAt])
|
||||
@@index([tenantId, entity])
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Customer Profiles
|
||||
// ============================================================
|
||||
model CustomerProfile {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
email String
|
||||
name String?
|
||||
phone String?
|
||||
company String?
|
||||
notes String?
|
||||
tags String[] @default([])
|
||||
ticketCount Int @default(0) @map("ticket_count")
|
||||
firstContactAt DateTime @default(now()) @map("first_contact_at")
|
||||
lastContactAt DateTime @default(now()) @map("last_contact_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@map("customer_profiles")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Canned Responses
|
||||
// ============================================================
|
||||
model CannedResponse {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
title String
|
||||
body String
|
||||
category String @default("general")
|
||||
shortcut String?
|
||||
createdBy String @map("created_by")
|
||||
isShared Boolean @default(true) @map("is_shared")
|
||||
usageCount Int @default(0) @map("usage_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("canned_responses")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Notification Preferences
|
||||
// ============================================================
|
||||
model NotificationPreference {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique @map("user_id")
|
||||
tenantId String @map("tenant_id")
|
||||
newTicket Boolean @default(true) @map("new_ticket")
|
||||
ticketAssigned Boolean @default(true) @map("ticket_assigned")
|
||||
ticketReply Boolean @default(true) @map("ticket_reply")
|
||||
aiDraftReady Boolean @default(true) @map("ai_draft_ready")
|
||||
dailyDigest Boolean @default(false) @map("daily_digest")
|
||||
emailNotifications Boolean @default(true) @map("email_notifications")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tenantId])
|
||||
@@map("notification_preferences")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding Forward Assist database...');
|
||||
|
||||
// Create plans
|
||||
const trialPlan = await prisma.plan.upsert({
|
||||
where: { slug: 'trial' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Trial',
|
||||
slug: 'trial',
|
||||
maxUsers: 2,
|
||||
maxEmailAccounts: 1,
|
||||
maxTicketsPerMonth: 100,
|
||||
aiDraftsEnabled: true,
|
||||
knowledgeBaseEnabled: false,
|
||||
priceMonthly: 0,
|
||||
priceYearly: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.plan.upsert({
|
||||
where: { slug: 'starter' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Starter',
|
||||
slug: 'starter',
|
||||
maxUsers: 5,
|
||||
maxEmailAccounts: 2,
|
||||
maxTicketsPerMonth: 1000,
|
||||
aiDraftsEnabled: true,
|
||||
knowledgeBaseEnabled: false,
|
||||
priceMonthly: 49,
|
||||
priceYearly: 470,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.plan.upsert({
|
||||
where: { slug: 'pro' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Pro',
|
||||
slug: 'pro',
|
||||
maxUsers: 20,
|
||||
maxEmailAccounts: 10,
|
||||
maxTicketsPerMonth: 10000,
|
||||
aiDraftsEnabled: true,
|
||||
knowledgeBaseEnabled: true,
|
||||
priceMonthly: 149,
|
||||
priceYearly: 1430,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.plan.upsert({
|
||||
where: { slug: 'enterprise' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Enterprise',
|
||||
slug: 'enterprise',
|
||||
maxUsers: 999,
|
||||
maxEmailAccounts: 50,
|
||||
maxTicketsPerMonth: 999999,
|
||||
aiDraftsEnabled: true,
|
||||
knowledgeBaseEnabled: true,
|
||||
priceMonthly: 499,
|
||||
priceYearly: 4790,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test tenant
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { slug: 'demo-armory' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Demo Armory',
|
||||
slug: 'demo-armory',
|
||||
planId: trialPlan.id,
|
||||
settings: {
|
||||
timezone: 'America/Chicago',
|
||||
businessHours: {
|
||||
monday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
tuesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
wednesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
thursday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
friday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
saturday: { enabled: false, start: '10:00', end: '14:00' },
|
||||
sunday: { enabled: false, start: '10:00', end: '14:00' },
|
||||
},
|
||||
autoReplyEnabled: false,
|
||||
autoReplyMessage: 'Thank you for contacting us. We will respond within 24 hours.',
|
||||
aiDraftEnabled: false,
|
||||
aiTone: 'professional',
|
||||
ticketPrefix: 'FA',
|
||||
maxTicketsPerDay: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create admin user (password: "admin123")
|
||||
const passwordHash = await bcrypt.hash('admin123', 12);
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: 'admin@demo-armory.com' } },
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
email: 'admin@demo-armory.com',
|
||||
passwordHash,
|
||||
name: 'Range Master',
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification preferences for admin
|
||||
await prisma.notificationPreference.upsert({
|
||||
where: { userId: adminUser.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: adminUser.id,
|
||||
tenantId: tenant.id,
|
||||
newTicket: true,
|
||||
ticketAssigned: true,
|
||||
ticketReply: true,
|
||||
aiDraftReady: true,
|
||||
dailyDigest: false,
|
||||
emailNotifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed complete!');
|
||||
console.log(` Plans: trial, starter, pro, enterprise`);
|
||||
console.log(` Tenant: ${tenant.name} (${tenant.slug})`);
|
||||
console.log(` Admin: ${adminUser.email} / admin123`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({ path: '../../.env' });
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { rateLimiter } from './middleware/rateLimiter';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { ticketRouter } from './routes/tickets';
|
||||
import { emailAccountRouter } from './routes/emailAccounts';
|
||||
import { settingsRouter } from './routes/settings';
|
||||
import { userRouter } from './routes/users';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.API_PORT || 3001;
|
||||
|
||||
// Global middleware
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(morgan('short'));
|
||||
app.use(rateLimiter);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'operational', service: 'forward-assist-api', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/tickets', ticketRouter);
|
||||
app.use('/api/v1/email-accounts', emailAccountRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
app.use('/api/v1/users', userRouter);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Forward Assist API] Server running on port ${PORT}`);
|
||||
console.log(`[Forward Assist API] Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyAccessToken, TokenPayload } from '../services/jwt';
|
||||
|
||||
// Extend Express Request
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: TokenPayload;
|
||||
tenantId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Missing or invalid authorization header', statusCode: 401 });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const payload = verifyAccessToken(token);
|
||||
req.user = payload;
|
||||
req.tenantId = payload.tenantId;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token', statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRole(...roles: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Unauthorized', message: 'Not authenticated', statusCode: 401 });
|
||||
return;
|
||||
}
|
||||
if (!roles.includes(req.user.role)) {
|
||||
res.status(403).json({ error: 'Forbidden', message: 'Insufficient permissions', statusCode: 403 });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export class AppError extends Error {
|
||||
statusCode: number;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, statusCode: number = 500, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||
console.error('[Error]', err.message);
|
||||
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.statusCode).json({
|
||||
error: err.name,
|
||||
message: err.message,
|
||||
statusCode: err.statusCode,
|
||||
details: err.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prisma errors
|
||||
if (err.name === 'PrismaClientKnownRequestError') {
|
||||
const prismaErr = err as any;
|
||||
if (prismaErr.code === 'P2002') {
|
||||
res.status(409).json({
|
||||
error: 'Conflict',
|
||||
message: 'A record with that value already exists',
|
||||
statusCode: 409,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (prismaErr.code === 'P2025') {
|
||||
res.status(404).json({
|
||||
error: 'NotFound',
|
||||
message: 'Record not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired token',
|
||||
statusCode: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
res.status(500).json({
|
||||
error: 'InternalServerError',
|
||||
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : err.message,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
export const rateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200, // limit each IP to 200 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
error: 'TooManyRequests',
|
||||
message: 'Too many requests, please try again later',
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
|
||||
export const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20, // stricter for auth endpoints
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
error: 'TooManyRequests',
|
||||
message: 'Too many authentication attempts, please try again later',
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../services/prisma';
|
||||
import { generateTokenPair, verifyRefreshToken, TokenPayload } from '../services/jwt';
|
||||
import { logAudit } from '../services/audit';
|
||||
import { authenticate, requireRole } from '../middleware/auth';
|
||||
import { authRateLimiter } from '../middleware/rateLimiter';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = z.object({
|
||||
tenantName: z.string().min(2).max(100),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
role: z.enum(['admin', 'agent', 'viewer']),
|
||||
});
|
||||
|
||||
// Slugify helper
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 64);
|
||||
}
|
||||
|
||||
// POST /api/auth/register — Create tenant + owner account
|
||||
authRouter.post('/register', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = registerSchema.parse(req.body);
|
||||
|
||||
// Get trial plan
|
||||
const trialPlan = await prisma.plan.findUnique({ where: { slug: 'trial' } });
|
||||
if (!trialPlan) throw new AppError('Trial plan not configured', 500);
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = await prisma.user.findFirst({ where: { email: body.email } });
|
||||
if (existingUser) throw new AppError('Email already registered', 409);
|
||||
|
||||
// Create tenant + user in transaction
|
||||
const slug = slugify(body.tenantName);
|
||||
const passwordHash = await bcrypt.hash(body.password, 12);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const tenant = await tx.tenant.create({
|
||||
data: {
|
||||
name: body.tenantName,
|
||||
slug: slug + '-' + uuidv4().substring(0, 6),
|
||||
planId: trialPlan.id,
|
||||
settings: {
|
||||
timezone: 'America/New_York',
|
||||
businessHours: {
|
||||
monday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
tuesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
wednesday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
thursday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
friday: { enabled: true, start: '09:00', end: '17:00' },
|
||||
saturday: { enabled: false, start: '10:00', end: '14:00' },
|
||||
sunday: { enabled: false, start: '10:00', end: '14:00' },
|
||||
},
|
||||
autoReplyEnabled: false,
|
||||
autoReplyMessage: 'Thank you for contacting us. We will respond within 24 hours.',
|
||||
aiDraftEnabled: false,
|
||||
aiTone: 'professional',
|
||||
ticketPrefix: 'FA',
|
||||
maxTicketsPerDay: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: body.email,
|
||||
passwordHash,
|
||||
name: body.name,
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create default notification preferences
|
||||
await tx.notificationPreference.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, user };
|
||||
});
|
||||
|
||||
const tokenPayload: TokenPayload = {
|
||||
userId: result.user.id,
|
||||
tenantId: result.tenant.id,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
};
|
||||
|
||||
const tokens = generateTokenPair(tokenPayload);
|
||||
|
||||
// Store refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: result.user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: result.tenant.id,
|
||||
userId: result.user.id,
|
||||
action: 'register',
|
||||
entity: 'tenant',
|
||||
entityId: result.tenant.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
role: result.user.role,
|
||||
isActive: result.user.isActive,
|
||||
createdAt: result.user.createdAt,
|
||||
updatedAt: result.user.updatedAt,
|
||||
},
|
||||
tenant: {
|
||||
id: result.tenant.id,
|
||||
name: result.tenant.name,
|
||||
slug: result.tenant.slug,
|
||||
},
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { email: body.email, isActive: true },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
if (!user) throw new AppError('Invalid email or password', 401);
|
||||
|
||||
const validPassword = await bcrypt.compare(body.password, user.passwordHash);
|
||||
if (!validPassword) throw new AppError('Invalid email or password', 401);
|
||||
|
||||
const tokenPayload: TokenPayload = {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const tokens = generateTokenPair(tokenPayload);
|
||||
|
||||
// Update refresh token and last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
refreshToken: tokens.refreshToken,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: user.tenantId,
|
||||
userId: user.id,
|
||||
action: 'login',
|
||||
entity: 'user',
|
||||
entityId: user.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
avatarUrl: user.avatarUrl,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
},
|
||||
tenant: {
|
||||
id: user.tenant.id,
|
||||
name: user.tenant.name,
|
||||
slug: user.tenant.slug,
|
||||
},
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh
|
||||
authRouter.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = refreshSchema.parse(req.body);
|
||||
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = verifyRefreshToken(body.refreshToken);
|
||||
} catch {
|
||||
throw new AppError('Invalid refresh token', 401);
|
||||
}
|
||||
|
||||
// Verify token matches stored token (rotation)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive || user.refreshToken !== body.refreshToken) {
|
||||
throw new AppError('Invalid refresh token', 401);
|
||||
}
|
||||
|
||||
const newPayload: TokenPayload = {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const tokens = generateTokenPair(newPayload);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/invite — Invite a user (owner/admin only)
|
||||
authRouter.post('/invite', authenticate, requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = inviteSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { tenantId_email: { tenantId, email: body.email } },
|
||||
});
|
||||
if (existing) throw new AppError('User already exists in this tenant', 409);
|
||||
|
||||
// Check plan limits
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: { plan: true, users: { where: { isActive: true } } },
|
||||
});
|
||||
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||
if (tenant.users.length >= tenant.plan.maxUsers) {
|
||||
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxUsers} users on ${tenant.plan.name} plan.`, 403);
|
||||
}
|
||||
|
||||
// Create user with temporary password
|
||||
const tempPassword = uuidv4().substring(0, 12);
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: body.email,
|
||||
passwordHash,
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.notificationPreference.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'invite_user',
|
||||
entity: 'user',
|
||||
entityId: user.id,
|
||||
details: { email: body.email, role: body.role },
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
temporaryPassword: tempPassword, // In production, this would be emailed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me — Get current user
|
||||
authRouter.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
include: { tenant: { include: { plan: true } } },
|
||||
});
|
||||
if (!user) throw new AppError('User not found', 404);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
avatarUrl: user.avatarUrl,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
},
|
||||
tenant: {
|
||||
id: user.tenant.id,
|
||||
name: user.tenant.name,
|
||||
slug: user.tenant.slug,
|
||||
plan: {
|
||||
name: user.tenant.plan.name,
|
||||
slug: user.tenant.plan.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../services/prisma';
|
||||
import { logAudit } from '../services/audit';
|
||||
import { authenticate, requireRole } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
export const emailAccountRouter = Router();
|
||||
emailAccountRouter.use(authenticate);
|
||||
|
||||
const emailAccountSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
emailAddress: z.string().email(),
|
||||
imapHost: z.string().min(1),
|
||||
imapPort: z.number().int().min(1).max(65535),
|
||||
imapUser: z.string().min(1),
|
||||
imapPassword: z.string().min(1),
|
||||
imapTls: z.boolean().default(true),
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpUser: z.string().min(1),
|
||||
smtpPassword: z.string().min(1),
|
||||
smtpTls: z.boolean().default(true),
|
||||
pollIntervalSeconds: z.number().int().min(30).max(3600).optional(),
|
||||
});
|
||||
|
||||
const updateEmailAccountSchema = emailAccountSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// GET /api/email-accounts — List all email accounts for tenant
|
||||
emailAccountRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const accounts = await prisma.emailAccount.findMany({
|
||||
where: { tenantId: req.tenantId! },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
emailAddress: true,
|
||||
imapHost: true,
|
||||
imapPort: true,
|
||||
imapUser: true,
|
||||
imapTls: true,
|
||||
smtpHost: true,
|
||||
smtpPort: true,
|
||||
smtpUser: true,
|
||||
smtpTls: true,
|
||||
isActive: true,
|
||||
lastPollAt: true,
|
||||
lastError: true,
|
||||
pollIntervalSeconds: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
// Deliberately omit passwords
|
||||
},
|
||||
});
|
||||
|
||||
res.json(accounts);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/email-accounts/:id
|
||||
emailAccountRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const account = await prisma.emailAccount.findFirst({
|
||||
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
emailAddress: true,
|
||||
imapHost: true,
|
||||
imapPort: true,
|
||||
imapUser: true,
|
||||
imapTls: true,
|
||||
smtpHost: true,
|
||||
smtpPort: true,
|
||||
smtpUser: true,
|
||||
smtpTls: true,
|
||||
isActive: true,
|
||||
lastPollAt: true,
|
||||
lastError: true,
|
||||
pollIntervalSeconds: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) throw new AppError('Email account not found', 404);
|
||||
res.json(account);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/email-accounts — Create email account
|
||||
emailAccountRouter.post('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = emailAccountSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
// Check plan limits
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
include: { plan: true, emailAccounts: { where: { isActive: true } } },
|
||||
});
|
||||
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||
if (tenant.emailAccounts.length >= tenant.plan.maxEmailAccounts) {
|
||||
throw new AppError(`Plan limit reached. Maximum ${tenant.plan.maxEmailAccounts} email accounts on ${tenant.plan.name} plan.`, 403);
|
||||
}
|
||||
|
||||
const account = await prisma.emailAccount.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: body.name,
|
||||
emailAddress: body.emailAddress,
|
||||
imapHost: body.imapHost,
|
||||
imapPort: body.imapPort,
|
||||
imapUser: body.imapUser,
|
||||
imapPassword: body.imapPassword,
|
||||
imapTls: body.imapTls,
|
||||
smtpHost: body.smtpHost,
|
||||
smtpPort: body.smtpPort,
|
||||
smtpUser: body.smtpUser,
|
||||
smtpPassword: body.smtpPassword,
|
||||
smtpTls: body.smtpTls,
|
||||
pollIntervalSeconds: body.pollIntervalSeconds || 60,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'create_email_account',
|
||||
entity: 'email_account',
|
||||
entityId: account.id,
|
||||
details: { emailAddress: body.emailAddress },
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
emailAddress: account.emailAddress,
|
||||
imapHost: account.imapHost,
|
||||
imapPort: account.imapPort,
|
||||
imapUser: account.imapUser,
|
||||
imapTls: account.imapTls,
|
||||
smtpHost: account.smtpHost,
|
||||
smtpPort: account.smtpPort,
|
||||
smtpUser: account.smtpUser,
|
||||
smtpTls: account.smtpTls,
|
||||
isActive: account.isActive,
|
||||
pollIntervalSeconds: account.pollIntervalSeconds,
|
||||
createdAt: account.createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/email-accounts/:id — Update email account
|
||||
emailAccountRouter.patch('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = updateEmailAccountSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const existing = await prisma.emailAccount.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
});
|
||||
if (!existing) throw new AppError('Email account not found', 404);
|
||||
|
||||
const account = await prisma.emailAccount.update({
|
||||
where: { id: req.params.id },
|
||||
data: body,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
emailAddress: true,
|
||||
imapHost: true,
|
||||
imapPort: true,
|
||||
imapUser: true,
|
||||
imapTls: true,
|
||||
smtpHost: true,
|
||||
smtpPort: true,
|
||||
smtpUser: true,
|
||||
smtpTls: true,
|
||||
isActive: true,
|
||||
lastPollAt: true,
|
||||
lastError: true,
|
||||
pollIntervalSeconds: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'update_email_account',
|
||||
entity: 'email_account',
|
||||
entityId: account.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json(account);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/email-accounts/:id
|
||||
emailAccountRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const existing = await prisma.emailAccount.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
});
|
||||
if (!existing) throw new AppError('Email account not found', 404);
|
||||
|
||||
// Soft delete — just deactivate
|
||||
await prisma.emailAccount.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'delete_email_account',
|
||||
entity: 'email_account',
|
||||
entityId: req.params.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ message: 'Email account deactivated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/email-accounts/:id/test — Test connection
|
||||
emailAccountRouter.post('/:id/test', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const account = await prisma.emailAccount.findFirst({
|
||||
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||
});
|
||||
if (!account) throw new AppError('Email account not found', 404);
|
||||
|
||||
// In Phase 1, we just return a success stub.
|
||||
// Phase 2 will actually test IMAP/SMTP connections.
|
||||
res.json({
|
||||
imap: { success: true, message: 'IMAP connection test not yet implemented (Phase 2)' },
|
||||
smtp: { success: true, message: 'SMTP connection test not yet implemented (Phase 2)' },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../services/prisma';
|
||||
import { logAudit } from '../services/audit';
|
||||
import { authenticate, requireRole } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
settingsRouter.use(authenticate);
|
||||
|
||||
const dayHoursSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
start: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
end: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
});
|
||||
|
||||
const settingsSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
businessHours: z.object({
|
||||
monday: dayHoursSchema,
|
||||
tuesday: dayHoursSchema,
|
||||
wednesday: dayHoursSchema,
|
||||
thursday: dayHoursSchema,
|
||||
friday: dayHoursSchema,
|
||||
saturday: dayHoursSchema,
|
||||
sunday: dayHoursSchema,
|
||||
}).optional(),
|
||||
autoReplyEnabled: z.boolean().optional(),
|
||||
autoReplyMessage: z.string().max(1000).optional(),
|
||||
aiDraftEnabled: z.boolean().optional(),
|
||||
aiTone: z.enum(['professional', 'friendly', 'technical']).optional(),
|
||||
ticketPrefix: z.string().min(1).max(5).optional(),
|
||||
maxTicketsPerDay: z.number().int().min(1).max(10000).optional(),
|
||||
}).partial();
|
||||
|
||||
// GET /api/settings — Get tenant settings
|
||||
settingsRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.tenantId! },
|
||||
include: { plan: true },
|
||||
});
|
||||
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||
|
||||
res.json({
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
createdAt: tenant.createdAt,
|
||||
},
|
||||
plan: {
|
||||
name: tenant.plan.name,
|
||||
slug: tenant.plan.slug,
|
||||
maxUsers: tenant.plan.maxUsers,
|
||||
maxEmailAccounts: tenant.plan.maxEmailAccounts,
|
||||
maxTicketsPerMonth: tenant.plan.maxTicketsPerMonth,
|
||||
aiDraftsEnabled: tenant.plan.aiDraftsEnabled,
|
||||
knowledgeBaseEnabled: tenant.plan.knowledgeBaseEnabled,
|
||||
},
|
||||
settings: tenant.settings,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/settings — Update tenant settings
|
||||
settingsRouter.patch('/', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = settingsSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
if (!tenant) throw new AppError('Tenant not found', 404);
|
||||
|
||||
// Merge with existing settings
|
||||
const currentSettings = (tenant.settings as Record<string, unknown>) || {};
|
||||
const newSettings = { ...currentSettings, ...body };
|
||||
|
||||
const updated = await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: { settings: newSettings },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'update_settings',
|
||||
entity: 'tenant',
|
||||
entityId: tenantId,
|
||||
details: body,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ settings: updated.settings });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../services/prisma';
|
||||
import { logAudit } from '../services/audit';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
export const ticketRouter = Router();
|
||||
ticketRouter.use(authenticate);
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
emailAccountId: z.string().uuid(),
|
||||
subject: z.string().min(1).max(500),
|
||||
customerEmail: z.string().email(),
|
||||
customerName: z.string().optional(),
|
||||
body: z.string().min(1),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
});
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['incoming', 'in_the_chamber', 'cleared_hot', 'misfire', 'standby', 'resolved', 'archived']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
assigneeId: z.string().uuid().nullable().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const replySchema = z.object({
|
||||
body: z.string().min(1),
|
||||
bodyHtml: z.string().optional(),
|
||||
});
|
||||
|
||||
// Helper to generate next ticket number
|
||||
async function nextTicketNumber(tenantId: string): Promise<string> {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
||||
const settings = (tenant?.settings as any) || {};
|
||||
const prefix = settings.ticketPrefix || 'FA';
|
||||
|
||||
const lastTicket = await prisma.ticket.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { ticketNumber: true },
|
||||
});
|
||||
|
||||
let nextNum = 1;
|
||||
if (lastTicket) {
|
||||
const match = lastTicket.ticketNumber.match(/(\d+)$/);
|
||||
if (match) nextNum = parseInt(match[1], 10) + 1;
|
||||
}
|
||||
|
||||
return `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
// GET /api/tickets — List tickets with filters
|
||||
ticketRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!;
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 25));
|
||||
const sortBy = (req.query.sortBy as string) || 'lastMessageAt';
|
||||
const sortOrder = (req.query.sortOrder as string) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const where: any = { tenantId };
|
||||
|
||||
// Status filter
|
||||
if (req.query.status) {
|
||||
const statuses = (req.query.status as string).split(',');
|
||||
where.status = { in: statuses };
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (req.query.priority) {
|
||||
const priorities = (req.query.priority as string).split(',');
|
||||
where.priority = { in: priorities };
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (req.query.assigneeId) {
|
||||
where.assigneeId = req.query.assigneeId;
|
||||
}
|
||||
|
||||
// Email account filter
|
||||
if (req.query.emailAccountId) {
|
||||
where.emailAccountId = req.query.emailAccountId;
|
||||
}
|
||||
|
||||
// Search
|
||||
if (req.query.search) {
|
||||
const search = req.query.search as string;
|
||||
where.OR = [
|
||||
{ subject: { contains: search, mode: 'insensitive' } },
|
||||
{ customerEmail: { contains: search, mode: 'insensitive' } },
|
||||
{ customerName: { contains: search, mode: 'insensitive' } },
|
||||
{ ticketNumber: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.ticket.findMany({
|
||||
where,
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||
},
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.ticket.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id — Get single ticket with messages
|
||||
ticketRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { attachments: true },
|
||||
},
|
||||
assignee: { select: { id: true, name: true, email: true, avatarUrl: true } },
|
||||
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||
customerProfile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tickets — Create a manual ticket
|
||||
ticketRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = createTicketSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
// Verify email account belongs to tenant
|
||||
const emailAccount = await prisma.emailAccount.findFirst({
|
||||
where: { id: body.emailAccountId, tenantId },
|
||||
});
|
||||
if (!emailAccount) throw new AppError('Email account not found', 404);
|
||||
|
||||
const ticketNumber = await nextTicketNumber(tenantId);
|
||||
|
||||
// Find or create customer profile
|
||||
let customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { tenantId_email: { tenantId, email: body.customerEmail } },
|
||||
});
|
||||
if (!customerProfile) {
|
||||
customerProfile = await prisma.customerProfile.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email: body.customerEmail,
|
||||
name: body.customerName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ticketNumber,
|
||||
emailAccountId: body.emailAccountId,
|
||||
subject: body.subject,
|
||||
status: 'incoming',
|
||||
priority: body.priority || 'medium',
|
||||
customerEmail: body.customerEmail,
|
||||
customerName: body.customerName,
|
||||
customerProfileId: customerProfile.id,
|
||||
messageCount: 1,
|
||||
lastMessageAt: new Date(),
|
||||
messages: {
|
||||
create: {
|
||||
tenantId,
|
||||
direction: 'inbound',
|
||||
fromEmail: body.customerEmail,
|
||||
fromName: body.customerName,
|
||||
toEmail: emailAccount.emailAddress,
|
||||
subject: body.subject,
|
||||
bodyText: body.body,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
messages: true,
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Update customer profile ticket count
|
||||
await prisma.customerProfile.update({
|
||||
where: { id: customerProfile.id },
|
||||
data: {
|
||||
ticketCount: { increment: 1 },
|
||||
lastContactAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'create_ticket',
|
||||
entity: 'ticket',
|
||||
entityId: ticket.id,
|
||||
details: { ticketNumber, subject: body.subject },
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json(ticket);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/tickets/:id — Update ticket
|
||||
ticketRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = updateTicketSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const existing = await prisma.ticket.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
});
|
||||
if (!existing) throw new AppError('Ticket not found', 404);
|
||||
|
||||
const updateData: any = {};
|
||||
if (body.status !== undefined) {
|
||||
updateData.status = body.status;
|
||||
if (body.status === 'resolved') updateData.resolvedAt = new Date();
|
||||
}
|
||||
if (body.priority !== undefined) updateData.priority = body.priority;
|
||||
if (body.assigneeId !== undefined) updateData.assigneeId = body.assigneeId;
|
||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||
|
||||
const ticket = await prisma.ticket.update({
|
||||
where: { id: req.params.id },
|
||||
data: updateData,
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
emailAccount: { select: { id: true, name: true, emailAddress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'update_ticket',
|
||||
entity: 'ticket',
|
||||
entityId: ticket.id,
|
||||
details: body,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json(ticket);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tickets/:id/reply — Send a reply
|
||||
ticketRouter.post('/:id/reply', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = replySchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
include: { emailAccount: true },
|
||||
});
|
||||
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
||||
|
||||
const message = await prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ticketId: ticket.id,
|
||||
direction: 'outbound',
|
||||
fromEmail: ticket.emailAccount.emailAddress,
|
||||
fromName: user?.name,
|
||||
toEmail: ticket.customerEmail,
|
||||
subject: `Re: ${ticket.subject}`,
|
||||
bodyText: body.body,
|
||||
bodyHtml: body.bodyHtml,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update ticket
|
||||
await prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: 'cleared_hot',
|
||||
messageCount: { increment: 1 },
|
||||
lastMessageAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'reply_ticket',
|
||||
entity: 'message',
|
||||
entityId: message.id,
|
||||
details: { ticketId: ticket.id, ticketNumber: ticket.ticketNumber },
|
||||
req,
|
||||
});
|
||||
|
||||
res.status(201).json(message);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tickets/:id/messages — Get messages for a ticket
|
||||
ticketRouter.get('/:id/messages', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findFirst({
|
||||
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||
});
|
||||
if (!ticket) throw new AppError('Ticket not found', 404);
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { ticketId: ticket.id },
|
||||
include: { attachments: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
res.json(messages);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '../services/prisma';
|
||||
import { logAudit } from '../services/audit';
|
||||
import { authenticate, requireRole } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
export const userRouter = Router();
|
||||
userRouter.use(authenticate);
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
role: z.enum(['admin', 'agent', 'viewer']).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
// GET /api/users — List users in tenant
|
||||
userRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { tenantId: req.tenantId! },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
avatarUrl: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/users/:id
|
||||
userRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: req.params.id, tenantId: req.tenantId! },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
avatarUrl: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new AppError('User not found', 404);
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/users/:id — Update user (owner/admin only, or self for name)
|
||||
userRouter.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = updateUserSchema.parse(req.body);
|
||||
const tenantId = req.tenantId!;
|
||||
const requestingUser = req.user!;
|
||||
|
||||
const target = await prisma.user.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
});
|
||||
if (!target) throw new AppError('User not found', 404);
|
||||
|
||||
// Permission check
|
||||
const isSelf = requestingUser.userId === target.id;
|
||||
const isAdmin = ['owner', 'admin'].includes(requestingUser.role);
|
||||
|
||||
if (!isSelf && !isAdmin) {
|
||||
throw new AppError('Forbidden', 403);
|
||||
}
|
||||
|
||||
// Non-admins can only update their own name
|
||||
if (!isAdmin && (body.role || body.isActive !== undefined)) {
|
||||
throw new AppError('Only admins can change roles or status', 403);
|
||||
}
|
||||
|
||||
// Cannot change owner role
|
||||
if (target.role === 'owner' && body.role && body.role !== 'owner') {
|
||||
throw new AppError('Cannot change owner role', 403);
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: body,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: requestingUser.userId,
|
||||
action: 'update_user',
|
||||
entity: 'user',
|
||||
entityId: target.id,
|
||||
details: body,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/users/:id/change-password
|
||||
userRouter.post('/:id/change-password', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = changePasswordSchema.parse(req.body);
|
||||
const requestingUser = req.user!;
|
||||
|
||||
// Can only change own password
|
||||
if (requestingUser.userId !== req.params.id) {
|
||||
throw new AppError('Can only change your own password', 403);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) throw new AppError('User not found', 404);
|
||||
|
||||
const valid = await bcrypt.compare(body.currentPassword, user.passwordHash);
|
||||
if (!valid) throw new AppError('Current password is incorrect', 400);
|
||||
|
||||
const newHash = await bcrypt.hash(body.newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { passwordHash: newHash, refreshToken: null },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId: req.tenantId!,
|
||||
userId: requestingUser.userId,
|
||||
action: 'change_password',
|
||||
entity: 'user',
|
||||
entityId: req.params.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — Deactivate user
|
||||
userRouter.delete('/:id', requireRole('owner', 'admin'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!;
|
||||
|
||||
const target = await prisma.user.findFirst({
|
||||
where: { id: req.params.id, tenantId },
|
||||
});
|
||||
if (!target) throw new AppError('User not found', 404);
|
||||
if (target.role === 'owner') throw new AppError('Cannot delete the owner account', 403);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isActive: false, refreshToken: null },
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
tenantId,
|
||||
userId: req.user!.userId,
|
||||
action: 'delete_user',
|
||||
entity: 'user',
|
||||
entityId: req.params.id,
|
||||
req,
|
||||
});
|
||||
|
||||
res.json({ message: 'User deactivated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { prisma } from './prisma';
|
||||
import { Request } from 'express';
|
||||
|
||||
interface AuditEntry {
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: entry.tenantId,
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
entity: entry.entity,
|
||||
entityId: entry.entityId,
|
||||
details: entry.details || {},
|
||||
ipAddress: entry.req?.ip || entry.req?.socket.remoteAddress,
|
||||
userAgent: entry.req?.headers['user-agent'],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Audit logging should never crash the app
|
||||
console.error('[Audit] Failed to log:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'dev-access-secret-minimum-32-characters';
|
||||
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-minimum-32-characters';
|
||||
const ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m';
|
||||
const REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d';
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function signAccessToken(payload: TokenPayload): string {
|
||||
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRY });
|
||||
}
|
||||
|
||||
export function signRefreshToken(payload: TokenPayload): string {
|
||||
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRY });
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): TokenPayload {
|
||||
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token: string): TokenPayload {
|
||||
return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
|
||||
}
|
||||
|
||||
export function generateTokenPair(payload: TokenPayload) {
|
||||
return {
|
||||
accessToken: signAccessToken(payload),
|
||||
refreshToken: signRefreshToken(payload),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"paths": {
|
||||
"@forward-assist/shared": ["../shared/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user