Phase 1: Forward Assist initial build

Multi-tenant AI help desk SaaS for the firearms industry.
Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind).
PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation.
Dark Armory theme with tactical branding throughout.

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