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,14 @@
|
||||
{
|
||||
"name": "@forward-assist/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// ============================================================
|
||||
// Forward Assist — Constants
|
||||
// ============================================================
|
||||
|
||||
export const TICKET_STATUSES = {
|
||||
INCOMING: 'incoming',
|
||||
IN_THE_CHAMBER: 'in_the_chamber',
|
||||
CLEARED_HOT: 'cleared_hot',
|
||||
MISFIRE: 'misfire',
|
||||
STANDBY: 'standby',
|
||||
RESOLVED: 'resolved',
|
||||
ARCHIVED: 'archived',
|
||||
} as const;
|
||||
|
||||
export const TICKET_STATUS_LABELS: Record<string, string> = {
|
||||
incoming: 'Incoming!',
|
||||
in_the_chamber: 'In The Chamber',
|
||||
cleared_hot: 'Cleared Hot',
|
||||
misfire: 'Misfire',
|
||||
standby: 'Standby',
|
||||
resolved: 'Resolved',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
export const TICKET_STATUS_COLORS: Record<string, string> = {
|
||||
incoming: '#EF4444', // red
|
||||
in_the_chamber: '#F59E0B', // amber
|
||||
cleared_hot: '#4A5D23', // OD green
|
||||
misfire: '#DC2626', // dark red
|
||||
standby: '#6B7280', // gray
|
||||
resolved: '#10B981', // emerald
|
||||
archived: '#374151', // dark gray
|
||||
};
|
||||
|
||||
export const TICKET_PRIORITIES = {
|
||||
LOW: 'low',
|
||||
MEDIUM: 'medium',
|
||||
HIGH: 'high',
|
||||
CRITICAL: 'critical',
|
||||
} as const;
|
||||
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
critical: 'Critical',
|
||||
};
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: '#6B7280',
|
||||
medium: '#3B82F6',
|
||||
high: '#F59E0B',
|
||||
critical: '#EF4444',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
AGENT: 'agent',
|
||||
VIEWER: 'viewer',
|
||||
} as const;
|
||||
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
agent: 'Agent',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
export const PLAN_SLUGS = {
|
||||
TRIAL: 'trial',
|
||||
STARTER: 'starter',
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
} as const;
|
||||
|
||||
// Branding
|
||||
export const BRAND = {
|
||||
NAME: 'Forward Assist',
|
||||
TAGLINE: 'AI-Powered Support, Mission Ready',
|
||||
DASHBOARD_NAME: 'The Armory',
|
||||
APPROVAL_QUEUE: 'The Chamber',
|
||||
SETTINGS_NAME: 'Field Strip',
|
||||
TICKET_PREFIX: 'FA',
|
||||
} as const;
|
||||
|
||||
// Theme colors
|
||||
export const COLORS = {
|
||||
OD_GREEN: '#4A5D23',
|
||||
OD_GREEN_LIGHT: '#5A7A2D',
|
||||
FDE: '#C3A76B',
|
||||
FDE_LIGHT: '#D4BE8E',
|
||||
GUNMETAL: '#51555C',
|
||||
GUNMETAL_LIGHT: '#6B7280',
|
||||
DARK_BG: '#1A1D21',
|
||||
DARK_SURFACE: '#22262B',
|
||||
DARK_BORDER: '#2D3239',
|
||||
DARK_HOVER: '#323840',
|
||||
TEXT_PRIMARY: '#E5E7EB',
|
||||
TEXT_SECONDARY: '#9CA3AF',
|
||||
TEXT_MUTED: '#6B7280',
|
||||
} as const;
|
||||
|
||||
// API
|
||||
export const API_DEFAULTS = {
|
||||
PAGE_SIZE: 25,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
|
||||
RATE_LIMIT_MAX: 100,
|
||||
JWT_ACCESS_EXPIRY: '15m',
|
||||
JWT_REFRESH_EXPIRY: '7d',
|
||||
IMAP_POLL_INTERVAL: 60, // seconds
|
||||
} as const;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,338 @@
|
||||
// ============================================================
|
||||
// Forward Assist — Shared Type Definitions
|
||||
// ============================================================
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
planId: string;
|
||||
plan?: Plan;
|
||||
settings: TenantSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TenantSettings {
|
||||
timezone: string;
|
||||
businessHours: BusinessHours;
|
||||
autoReplyEnabled: boolean;
|
||||
autoReplyMessage: string;
|
||||
aiDraftEnabled: boolean;
|
||||
aiTone: 'professional' | 'friendly' | 'technical';
|
||||
ticketPrefix: string;
|
||||
maxTicketsPerDay: number;
|
||||
}
|
||||
|
||||
export interface BusinessHours {
|
||||
monday: DayHours;
|
||||
tuesday: DayHours;
|
||||
wednesday: DayHours;
|
||||
thursday: DayHours;
|
||||
friday: DayHours;
|
||||
saturday: DayHours;
|
||||
sunday: DayHours;
|
||||
}
|
||||
|
||||
export interface DayHours {
|
||||
enabled: boolean;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
maxUsers: number;
|
||||
maxEmailAccounts: number;
|
||||
maxTicketsPerMonth: number;
|
||||
aiDraftsEnabled: boolean;
|
||||
knowledgeBaseEnabled: boolean;
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatarUrl?: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type UserRole = 'owner' | 'admin' | 'agent' | 'viewer';
|
||||
|
||||
export interface EmailAccount {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
emailAddress: string;
|
||||
imapHost: string;
|
||||
imapPort: number;
|
||||
imapUser: string;
|
||||
imapPassword: string;
|
||||
imapTls: boolean;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
smtpTls: boolean;
|
||||
isActive: boolean;
|
||||
lastPollAt?: Date;
|
||||
lastError?: string;
|
||||
pollIntervalSeconds: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
ticketNumber: string;
|
||||
emailAccountId: string;
|
||||
subject: string;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assigneeId?: string;
|
||||
customerEmail: string;
|
||||
customerName?: string;
|
||||
customerProfileId?: string;
|
||||
tags: string[];
|
||||
messageCount: number;
|
||||
lastMessageAt: Date;
|
||||
resolvedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
messages?: Message[];
|
||||
assignee?: User;
|
||||
emailAccount?: EmailAccount;
|
||||
customerProfile?: CustomerProfile;
|
||||
}
|
||||
|
||||
export type TicketStatus =
|
||||
| 'incoming'
|
||||
| 'in_the_chamber'
|
||||
| 'cleared_hot'
|
||||
| 'misfire'
|
||||
| 'standby'
|
||||
| 'resolved'
|
||||
| 'archived';
|
||||
|
||||
export type TicketPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
tenantId: string;
|
||||
direction: MessageDirection;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
toEmail: string;
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
bodyHtml?: string;
|
||||
messageId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: Attachment[];
|
||||
sentAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type MessageDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export interface AiDraft {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
tenantId: string;
|
||||
messageId: string;
|
||||
draftBody: string;
|
||||
confidence: number;
|
||||
model: string;
|
||||
tokensUsed: number;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||
editedBody?: string;
|
||||
reviewedBy?: string;
|
||||
reviewedAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
embedding?: number[];
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId?: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CustomerProfile {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
ticketCount: number;
|
||||
firstContactAt: Date;
|
||||
lastContactAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CannedResponse {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
shortcut?: string;
|
||||
createdBy: string;
|
||||
isShared: boolean;
|
||||
usageCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
newTicket: boolean;
|
||||
ticketAssigned: boolean;
|
||||
ticketReply: boolean;
|
||||
aiDraftReady: boolean;
|
||||
dailyDigest: boolean;
|
||||
emailNotifications: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// API Request/Response types
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
tenantName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: Omit<User, 'tenantId'>;
|
||||
tenant: Pick<Tenant, 'id' | 'name' | 'slug'>;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface InviteRequest {
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
emailAccountId: string;
|
||||
subject: string;
|
||||
customerEmail: string;
|
||||
customerName?: string;
|
||||
body: string;
|
||||
priority?: TicketPriority;
|
||||
}
|
||||
|
||||
export interface UpdateTicketRequest {
|
||||
status?: TicketStatus;
|
||||
priority?: TicketPriority;
|
||||
assigneeId?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SendReplyRequest {
|
||||
body: string;
|
||||
bodyHtml?: string;
|
||||
}
|
||||
|
||||
export interface CreateEmailAccountRequest {
|
||||
name: string;
|
||||
emailAddress: string;
|
||||
imapHost: string;
|
||||
imapPort: number;
|
||||
imapUser: string;
|
||||
imapPassword: string;
|
||||
imapTls: boolean;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
smtpTls: boolean;
|
||||
pollIntervalSeconds?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: TicketStatus | TicketStatus[];
|
||||
priority?: TicketPriority | TicketPriority[];
|
||||
assigneeId?: string;
|
||||
emailAccountId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// ============================================================
|
||||
// Forward Assist — Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
import { BRAND } from '../constants';
|
||||
|
||||
/**
|
||||
* Generate a ticket number like FA-0001
|
||||
*/
|
||||
export function generateTicketNumber(sequenceNum: number, prefix?: string): string {
|
||||
const p = prefix || BRAND.TICKET_PREFIX;
|
||||
return `${p}-${String(sequenceNum).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URL-safe slug from a string
|
||||
*/
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a max length with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from a string
|
||||
*/
|
||||
export function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display
|
||||
*/
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date with time
|
||||
*/
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export function timeAgo(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHour < 24) return `${diffHour}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract name from email (e.g., "john.doe@example.com" -> "John Doe")
|
||||
*/
|
||||
export function nameFromEmail(email: string): string {
|
||||
const local = email.split('@')[0];
|
||||
return local
|
||||
.replace(/[._-]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize user input for safe display
|
||||
*/
|
||||
export function sanitize(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user