Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
1473 lines
45 KiB
TypeScript
1473 lines
45 KiB
TypeScript
/**
|
|
* Shared Ticket Model - Core business logic for ticket operations
|
|
* This model contains the essential ticket business logic extracted from
|
|
* server actions and used by both server actions and workflow actions.
|
|
*/
|
|
|
|
import { Knex } from 'knex';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { z } from 'zod';
|
|
import type { IEventPublisher } from '@alga-psa/types';
|
|
import { applyMatchingChecklistTemplates } from '../lib/ticketChecklists';
|
|
|
|
const TICKET_ORIGINS = {
|
|
INTERNAL: 'internal',
|
|
CLIENT_PORTAL: 'client_portal',
|
|
INBOUND_EMAIL: 'inbound_email',
|
|
API: 'api',
|
|
} as const;
|
|
|
|
// =============================================================================
|
|
// VALIDATION SCHEMAS
|
|
// =============================================================================
|
|
|
|
// Core ticket form validation schema extracted from server actions
|
|
export const ticketFormSchema = z.object({
|
|
title: z.string().min(1, 'Title is required'),
|
|
board_id: z.string().uuid('Board ID must be a valid UUID'),
|
|
client_id: z.string().uuid('Client ID must be a valid UUID'),
|
|
location_id: z.string().uuid('Location ID must be a valid UUID').nullable().optional(),
|
|
contact_name_id: z.string().uuid('Contact ID must be a valid UUID').nullable(),
|
|
status_id: z.string().uuid('Status ID must be a valid UUID'),
|
|
assigned_to: z.string().uuid('Assigned to must be a valid UUID').nullable(),
|
|
priority_id: z.string().uuid('Priority ID must be a valid UUID').nullable(), // Required - used for both custom and ITIL priorities
|
|
description: z.string(),
|
|
category_id: z.string().uuid('Category ID must be a valid UUID').nullable(),
|
|
subcategory_id: z.string().uuid('Subcategory ID must be a valid UUID').nullable(),
|
|
// ITIL-specific fields (for UI calculation only)
|
|
itil_impact: z.number().int().min(1).max(5).optional(),
|
|
itil_urgency: z.number().int().min(1).max(5).optional(),
|
|
});
|
|
|
|
// Ticket creation from asset schema
|
|
export const createTicketFromAssetSchema = z.object({
|
|
title: z.string().min(1, 'Title is required'),
|
|
description: z.string(),
|
|
priority_id: z.string().uuid('Priority ID must be a valid UUID'),
|
|
status_id: z.string().uuid('Status ID must be a valid UUID'),
|
|
board_id: z.string().uuid('Board ID must be a valid UUID'),
|
|
asset_id: z.string().uuid('Asset ID must be a valid UUID'),
|
|
client_id: z.string().uuid('Client ID must be a valid UUID')
|
|
});
|
|
|
|
// Complete ticket schema for validation
|
|
export const ticketSchema = z.object({
|
|
tenant: z.string().uuid().optional(),
|
|
ticket_id: z.string().uuid(),
|
|
ticket_number: z.string(),
|
|
title: z.string(),
|
|
url: z.string().nullable(),
|
|
board_id: z.string().uuid(),
|
|
client_id: z.string().uuid(),
|
|
location_id: z.string().uuid().nullable().optional(),
|
|
contact_name_id: z.string().uuid().nullable(),
|
|
status_id: z.string().uuid(),
|
|
category_id: z.string().uuid().nullable(),
|
|
subcategory_id: z.string().uuid().nullable(),
|
|
entered_by: z.string().uuid().nullable(),
|
|
updated_by: z.string().uuid().nullable(),
|
|
closed_by: z.string().uuid().nullable(),
|
|
assigned_to: z.string().uuid().nullable(),
|
|
assigned_team_id: z.string().uuid().nullable().optional(),
|
|
entered_at: z.string().nullable(),
|
|
updated_at: z.string().nullable(),
|
|
closed_at: z.string().nullable(),
|
|
due_date: z.string().nullable().optional(),
|
|
source: z.string().nullable().optional(),
|
|
ticket_origin: z.string().nullable().optional(),
|
|
attributes: z.record(z.unknown()).nullable(),
|
|
email_metadata: z.unknown().nullable().optional(),
|
|
priority_id: z.string().uuid().nullable().optional(), // Optional for ITIL tickets
|
|
// ITIL-specific fields
|
|
itil_impact: z.number().int().min(1).max(5).nullable().optional(),
|
|
itil_urgency: z.number().int().min(1).max(5).nullable().optional(),
|
|
itil_priority_level: z.number().int().min(1).max(5).nullable().optional(),
|
|
itil_category: z.string().nullable().optional(),
|
|
itil_subcategory: z.string().nullable().optional()
|
|
});
|
|
|
|
// Ticket update schema
|
|
export const ticketUpdateSchema = ticketSchema.partial().omit({
|
|
tenant: true,
|
|
ticket_id: true,
|
|
ticket_number: true,
|
|
entered_by: true,
|
|
entered_at: true
|
|
});
|
|
|
|
// Comment validation schema
|
|
export const createCommentSchema = z.object({
|
|
ticket_id: z.string().uuid('Ticket ID must be a valid UUID'),
|
|
content: z.string().min(1, 'Comment content is required'),
|
|
parent_comment_id: z.string().uuid('Parent comment ID must be a valid UUID').optional(),
|
|
is_internal: z.boolean().optional(),
|
|
is_resolution: z.boolean().optional(),
|
|
author_type: z.enum(['internal', 'contact', 'system']).optional(),
|
|
author_id: z.string().uuid('Author ID must be a valid UUID').optional(),
|
|
contact_id: z.string().uuid('Contact ID must be a valid UUID').optional(),
|
|
metadata: z.record(z.unknown()).optional()
|
|
});
|
|
|
|
// =============================================================================
|
|
// INTERFACES
|
|
// =============================================================================
|
|
|
|
export interface CreateTicketInput {
|
|
title: string;
|
|
description?: string;
|
|
client_id?: string;
|
|
contact_id?: string; // Note: Maps to contact_name_id in database
|
|
location_id?: string;
|
|
status_id?: string;
|
|
assigned_to?: string;
|
|
assigned_team_id?: string;
|
|
priority_id?: string;
|
|
category_id?: string;
|
|
subcategory_id?: string;
|
|
board_id?: string;
|
|
source?: string;
|
|
ticket_origin?: string;
|
|
entered_by?: string;
|
|
email_metadata?: any;
|
|
attributes?: Record<string, any>;
|
|
// Additional fields for server compatibility
|
|
url?: string;
|
|
severity_id?: string;
|
|
urgency_id?: string;
|
|
impact_id?: string;
|
|
updated_by?: string;
|
|
closed_by?: string;
|
|
// ITIL-specific fields (for UI calculation only)
|
|
itil_impact?: number;
|
|
itil_urgency?: number;
|
|
closed_at?: string;
|
|
is_closed?: boolean;
|
|
due_date?: string;
|
|
}
|
|
|
|
export interface CreateTicketFromAssetInput {
|
|
title: string;
|
|
description: string;
|
|
priority_id: string;
|
|
status_id: string;
|
|
board_id: string;
|
|
asset_id: string;
|
|
client_id: string;
|
|
}
|
|
|
|
export interface UpdateTicketInput {
|
|
title?: string;
|
|
url?: string;
|
|
client_id?: string;
|
|
location_id?: string | null;
|
|
contact_name_id?: string | null;
|
|
status_id?: string;
|
|
board_id?: string;
|
|
category_id?: string | null;
|
|
subcategory_id?: string | null;
|
|
updated_by?: string;
|
|
closed_by?: string;
|
|
assigned_to?: string | null;
|
|
assigned_team_id?: string | null;
|
|
updated_at?: string;
|
|
closed_at?: string;
|
|
due_date?: string | null;
|
|
attributes?: Record<string, any>;
|
|
priority_id?: string;
|
|
}
|
|
|
|
export interface ValidationOptions {
|
|
skipLocationValidation?: boolean;
|
|
skipCategoryValidation?: boolean;
|
|
skipSubcategoryValidation?: boolean;
|
|
skipStatusBoardValidation?: boolean;
|
|
allowEmptyFields?: boolean;
|
|
}
|
|
|
|
export interface BusinessRuleResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface TicketValidationResult {
|
|
valid: boolean;
|
|
data?: any;
|
|
errors?: string[];
|
|
}
|
|
|
|
export interface CreateCommentValidationInput {
|
|
ticket_id: string;
|
|
content: string;
|
|
parent_comment_id?: string;
|
|
is_internal?: boolean;
|
|
is_resolution?: boolean;
|
|
author_type?: 'internal' | 'contact' | 'system';
|
|
author_id?: string;
|
|
contact_id?: string;
|
|
}
|
|
|
|
export interface CreateTicketOutput {
|
|
ticket_id: string;
|
|
ticket_number: string;
|
|
title: string;
|
|
client_id?: string;
|
|
contact_id?: string; // Note: Mapped from contact_name_id
|
|
status_id?: string;
|
|
priority_id?: string;
|
|
board_id?: string;
|
|
entered_at: string;
|
|
tenant: string;
|
|
}
|
|
|
|
export interface CreateCommentInput {
|
|
ticket_id: string;
|
|
content: string;
|
|
parent_comment_id?: string;
|
|
is_internal?: boolean;
|
|
is_resolution?: boolean;
|
|
author_type?: 'internal' | 'contact' | 'system';
|
|
author_id?: string;
|
|
contact_id?: string;
|
|
metadata?: Record<string, any>;
|
|
}
|
|
|
|
export interface CreateCommentOutput {
|
|
comment_id: string;
|
|
ticket_id: string;
|
|
content: string;
|
|
author_type: string;
|
|
created_at: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// DEPENDENCY INJECTION INTERFACES
|
|
// =============================================================================
|
|
|
|
// IEventPublisher is imported from @alga-psa/types
|
|
|
|
/**
|
|
* Interface for analytics tracking using dependency injection pattern
|
|
* This allows different contexts to provide their own analytics implementations
|
|
*/
|
|
export interface IAnalyticsTracker {
|
|
trackTicketCreated(data: {
|
|
ticket_type: string;
|
|
priority_id?: string;
|
|
has_description: boolean;
|
|
has_category: boolean;
|
|
has_subcategory: boolean;
|
|
is_assigned: boolean;
|
|
board_id?: string;
|
|
created_via: string;
|
|
has_asset?: boolean;
|
|
metadata?: Record<string, any>;
|
|
}, userId?: string): Promise<void>;
|
|
|
|
trackTicketUpdated(data: {
|
|
ticket_id: string;
|
|
changes: string[];
|
|
updated_via: string;
|
|
metadata?: Record<string, any>;
|
|
}, userId?: string): Promise<void>;
|
|
|
|
trackCommentCreated(data: {
|
|
ticket_id: string;
|
|
is_internal: boolean;
|
|
is_resolution: boolean;
|
|
author_type: string;
|
|
created_via: string;
|
|
metadata?: Record<string, any>;
|
|
}, userId?: string): Promise<void>;
|
|
|
|
trackFeatureUsage(feature: string, userId?: string, metadata?: Record<string, any>): Promise<void>;
|
|
}
|
|
|
|
// =============================================================================
|
|
// RETRY LOGIC FOR DEADLOCK HANDLING
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Retry function for handling database deadlocks
|
|
* This matches the pattern used in server actions
|
|
*/
|
|
export async function withRetry<T>(
|
|
operation: () => Promise<T>,
|
|
maxRetries: number = 3,
|
|
delayMs: number = 100
|
|
): Promise<T> {
|
|
let lastError: Error | undefined;
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
|
|
// Check if it's a deadlock error
|
|
const isDeadlock = lastError.message.includes('deadlock') ||
|
|
lastError.message.includes('Deadlock') ||
|
|
(lastError as any).code === 'ER_LOCK_DEADLOCK' ||
|
|
(lastError as any).code === '40P01'; // PostgreSQL deadlock code
|
|
|
|
if (!isDeadlock || attempt === maxRetries - 1) {
|
|
throw lastError;
|
|
}
|
|
|
|
// Wait before retrying with exponential backoff
|
|
const delay = delayMs * Math.pow(2, attempt);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
|
|
console.warn(`Retrying operation due to deadlock, attempt ${attempt + 1}/${maxRetries}`);
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Retry operation failed');
|
|
}
|
|
|
|
// =============================================================================
|
|
// VALIDATION HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Validates form data using the provided schema
|
|
*/
|
|
export function validateData<T>(schema: z.ZodSchema<T>, data: unknown): T {
|
|
try {
|
|
return schema.parse(data);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
const errorMessages = error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ');
|
|
throw new Error(`Validation failed: ${errorMessages}`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleans empty string values to null for nullable fields
|
|
*/
|
|
export function cleanNullableFields(data: Record<string, any>): Record<string, any> {
|
|
const cleaned = { ...data };
|
|
const nullableFields = ['contact_name_id', 'category_id', 'subcategory_id', 'location_id', 'assigned_to', 'assigned_team_id'];
|
|
|
|
for (const field of nullableFields) {
|
|
if (cleaned[field] === '') {
|
|
cleaned[field] = null;
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
// =============================================================================
|
|
// CORE TICKET MODEL
|
|
// =============================================================================
|
|
|
|
export class TicketModel {
|
|
/**
|
|
* Validates ticket creation input using extracted validation logic
|
|
*/
|
|
static validateCreateTicketInput(input: CreateTicketInput): TicketValidationResult {
|
|
try {
|
|
// Basic required field validation
|
|
if (!input.title || input.title.trim() === '') {
|
|
return { valid: false, errors: ['Ticket title is required'] };
|
|
}
|
|
|
|
// Clean nullable fields (convert empty strings to null)
|
|
const cleanedInput = cleanNullableFields(input);
|
|
|
|
return { valid: true, data: cleanedInput };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
errors: [error instanceof Error ? error.message : 'Validation failed']
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates ticket form data using server action validation logic
|
|
*/
|
|
static validateTicketFormData(formData: Record<string, any>): TicketValidationResult {
|
|
try {
|
|
const validatedData = validateData(ticketFormSchema, formData);
|
|
return { valid: true, data: validatedData };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
errors: [error instanceof Error ? error.message : 'Form validation failed']
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates ticket creation from asset using server action validation logic
|
|
*/
|
|
static validateCreateTicketFromAssetData(data: CreateTicketFromAssetInput): TicketValidationResult {
|
|
try {
|
|
const validatedData = validateData(createTicketFromAssetSchema, data);
|
|
return { valid: true, data: validatedData };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
errors: [error instanceof Error ? error.message : 'Asset ticket validation failed']
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates ticket update data using server action validation logic
|
|
*/
|
|
static validateUpdateTicketData(data: UpdateTicketInput): TicketValidationResult {
|
|
try {
|
|
const validatedData = validateData(ticketUpdateSchema, data);
|
|
return { valid: true, data: validatedData };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
errors: [error instanceof Error ? error.message : 'Update validation failed']
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that a location belongs to the specified client
|
|
*/
|
|
static async validateLocationBelongsToClient(
|
|
locationId: string,
|
|
clientId: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<BusinessRuleResult> {
|
|
try {
|
|
const location = await trx('client_locations')
|
|
.where({
|
|
location_id: locationId,
|
|
client_id: clientId,
|
|
tenant: tenant
|
|
})
|
|
.first();
|
|
|
|
if (!location) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid location: Location does not belong to the selected client'
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
error: `Location validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that a subcategory belongs to the specified parent category
|
|
*/
|
|
static async validateCategorySubcategoryRelationship(
|
|
subcategoryId: string,
|
|
categoryId: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<BusinessRuleResult> {
|
|
try {
|
|
const subcategory = await trx('categories')
|
|
.where({ category_id: subcategoryId, tenant: tenant })
|
|
.first();
|
|
|
|
if (subcategory && subcategory.parent_category !== categoryId) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid category combination: subcategory must belong to the selected parent category'
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
error: `Category validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that a ticket status belongs to the selected board
|
|
*/
|
|
static async validateStatusBelongsToBoard(
|
|
statusId: string,
|
|
boardId: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<BusinessRuleResult> {
|
|
try {
|
|
const status = await trx('statuses')
|
|
.where({
|
|
status_id: statusId,
|
|
board_id: boardId,
|
|
tenant,
|
|
status_type: 'ticket'
|
|
})
|
|
.first();
|
|
|
|
if (!status) {
|
|
return {
|
|
valid: false,
|
|
error: 'Invalid status: selected status does not belong to the selected board'
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
error: `Status validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs comprehensive business rule validation for ticket creation
|
|
*/
|
|
static async validateBusinessRules(
|
|
input: CreateTicketInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
options: ValidationOptions = {}
|
|
): Promise<BusinessRuleResult> {
|
|
const errors: string[] = [];
|
|
|
|
try {
|
|
// Validate location belongs to client if both are provided
|
|
if (!options.skipLocationValidation && input.location_id && input.client_id) {
|
|
const locationResult = await this.validateLocationBelongsToClient(
|
|
input.location_id,
|
|
input.client_id,
|
|
tenant,
|
|
trx
|
|
);
|
|
if (!locationResult.valid && locationResult.error) {
|
|
errors.push(locationResult.error);
|
|
}
|
|
}
|
|
|
|
// Validate category/subcategory compatibility if both are provided
|
|
if (!options.skipCategoryValidation && input.subcategory_id && input.category_id) {
|
|
const categoryResult = await this.validateCategorySubcategoryRelationship(
|
|
input.subcategory_id,
|
|
input.category_id,
|
|
tenant,
|
|
trx
|
|
);
|
|
if (!categoryResult.valid && categoryResult.error) {
|
|
errors.push(categoryResult.error);
|
|
}
|
|
}
|
|
|
|
if (!options.skipStatusBoardValidation && input.status_id && input.board_id) {
|
|
const statusResult = await this.validateStatusBelongsToBoard(
|
|
input.status_id,
|
|
input.board_id,
|
|
tenant,
|
|
trx
|
|
);
|
|
if (!statusResult.valid && statusResult.error) {
|
|
errors.push(statusResult.error);
|
|
}
|
|
} else if (input.status_id && !input.board_id) {
|
|
errors.push('Invalid status: board_id is required when selecting a ticket status');
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: errors.join('; ')
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
error: `Business rule validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new ticket with retry logic for deadlock handling
|
|
*/
|
|
static async createTicketWithRetry(
|
|
input: CreateTicketInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
validationOptions: ValidationOptions = {},
|
|
eventPublisher?: IEventPublisher,
|
|
analyticsTracker?: IAnalyticsTracker,
|
|
userId?: string,
|
|
maxRetries: number = 3
|
|
): Promise<CreateTicketOutput> {
|
|
return withRetry(
|
|
() => this.createTicket(input, tenant, trx, validationOptions, eventPublisher, analyticsTracker, userId),
|
|
maxRetries
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a new ticket with complete validation and business rule checking
|
|
*/
|
|
static async createTicket(
|
|
input: CreateTicketInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
validationOptions: ValidationOptions = {},
|
|
eventPublisher?: IEventPublisher,
|
|
analyticsTracker?: IAnalyticsTracker,
|
|
userId?: string
|
|
): Promise<CreateTicketOutput> {
|
|
// Validate required tenant
|
|
if (!tenant) {
|
|
throw new Error('Tenant is required');
|
|
}
|
|
|
|
// Perform input validation
|
|
const inputValidation = this.validateCreateTicketInput(input);
|
|
if (!inputValidation.valid) {
|
|
throw new Error(`Input validation failed: ${inputValidation.errors?.join('; ')}`);
|
|
}
|
|
|
|
const cleanedInput = {
|
|
...(inputValidation.data || input)
|
|
};
|
|
|
|
if (!cleanedInput.status_id && cleanedInput.board_id) {
|
|
cleanedInput.status_id = await this.getDefaultStatusId(tenant, trx, cleanedInput.board_id);
|
|
}
|
|
|
|
if (!cleanedInput.status_id) {
|
|
throw new Error(
|
|
cleanedInput.board_id
|
|
? 'No default ticket status configured for the selected board'
|
|
: 'Validation failed: status_id is required'
|
|
);
|
|
}
|
|
|
|
// Perform business rule validation
|
|
const businessRuleValidation = await this.validateBusinessRules(
|
|
cleanedInput,
|
|
tenant,
|
|
trx,
|
|
validationOptions
|
|
);
|
|
if (!businessRuleValidation.valid && businessRuleValidation.error) {
|
|
throw new Error(businessRuleValidation.error);
|
|
}
|
|
|
|
// Generate ticket number using the database function
|
|
const numberResult = await trx.raw(
|
|
'SELECT generate_next_number(?::uuid, ?::text) as number',
|
|
[tenant, 'TICKET']
|
|
);
|
|
|
|
const ticketNumber = numberResult?.rows?.[0]?.number;
|
|
if (!ticketNumber) {
|
|
throw new Error('Failed to generate ticket number');
|
|
}
|
|
|
|
const ticketId = uuidv4();
|
|
const now = new Date();
|
|
|
|
// Prepare attributes object - description goes into attributes.description
|
|
const attributes = { ...cleanedInput.attributes };
|
|
if (cleanedInput.description) {
|
|
attributes.description = cleanedInput.description;
|
|
}
|
|
|
|
// Prepare ticket data
|
|
const ticketData = {
|
|
ticket_id: ticketId,
|
|
tenant,
|
|
title: cleanedInput.title,
|
|
ticket_number: ticketNumber,
|
|
client_id: cleanedInput.client_id || null,
|
|
contact_name_id: cleanedInput.contact_id || null, // Map contact_id to contact_name_id
|
|
location_id: cleanedInput.location_id || null,
|
|
status_id: cleanedInput.status_id || null,
|
|
assigned_to: cleanedInput.assigned_to || null,
|
|
assigned_team_id: cleanedInput.assigned_team_id || null,
|
|
priority_id: cleanedInput.priority_id || null,
|
|
category_id: cleanedInput.category_id || null,
|
|
subcategory_id: cleanedInput.subcategory_id || null,
|
|
board_id: cleanedInput.board_id || null,
|
|
source: cleanedInput.source || null,
|
|
ticket_origin: cleanedInput.ticket_origin || TICKET_ORIGINS.INTERNAL,
|
|
entered_by: cleanedInput.entered_by || null,
|
|
entered_at: now.toISOString(),
|
|
updated_at: now.toISOString(),
|
|
due_date: cleanedInput.due_date || null,
|
|
// ITIL-specific fields (for priority calculation)
|
|
itil_impact: cleanedInput.itil_impact || null,
|
|
itil_urgency: cleanedInput.itil_urgency || null,
|
|
// Store attributes and email_metadata as JSON
|
|
attributes: Object.keys(attributes).length > 0 ? JSON.stringify(attributes) : null,
|
|
email_metadata: cleanedInput.email_metadata ? JSON.stringify(cleanedInput.email_metadata) : null
|
|
};
|
|
|
|
// Create validation data with object attributes
|
|
const validationData = {
|
|
...ticketData,
|
|
attributes: Object.keys(attributes).length > 0 ? attributes : null
|
|
};
|
|
|
|
// Custom validation: priority_id is required for all tickets (unified system)
|
|
if (!validationData.priority_id) {
|
|
throw new Error('Validation failed: priority_id is required for all tickets');
|
|
}
|
|
|
|
// Final validation of complete ticket data using the database schema
|
|
// We use the database schema (which is more permissive) rather than the form schema
|
|
const completeValidation = validateData(ticketSchema.partial(), validationData);
|
|
|
|
// Prepare data for database insertion with stringified attributes
|
|
const dbData = {
|
|
...completeValidation,
|
|
attributes: completeValidation.attributes ? JSON.stringify(completeValidation.attributes) : null
|
|
};
|
|
|
|
// Insert the ticket
|
|
await trx('tickets').insert(dbData);
|
|
|
|
// Auto-apply matching checklist templates (idempotent; null matcher =
|
|
// match any). Failure here must not break ticket creation.
|
|
try {
|
|
await applyMatchingChecklistTemplates(trx, tenant, {
|
|
ticket_id: ticketId,
|
|
board_id: cleanedInput.board_id,
|
|
category_id: cleanedInput.category_id ?? null,
|
|
subcategory_id: cleanedInput.subcategory_id ?? null,
|
|
priority_id: cleanedInput.priority_id ?? null,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to auto-apply checklist templates:', error);
|
|
}
|
|
|
|
// Publish event if publisher provided
|
|
if (eventPublisher) {
|
|
try {
|
|
await eventPublisher.publishTicketCreated({
|
|
tenantId: tenant,
|
|
ticketId: ticketId,
|
|
userId: userId,
|
|
metadata: {
|
|
source: cleanedInput.source,
|
|
board_id: cleanedInput.board_id,
|
|
priority_id: cleanedInput.priority_id,
|
|
client_id: cleanedInput.client_id
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to publish ticket created event:', error);
|
|
// Don't throw - event publishing failure shouldn't break ticket creation
|
|
}
|
|
}
|
|
|
|
// Track analytics if tracker provided
|
|
if (analyticsTracker) {
|
|
try {
|
|
await analyticsTracker.trackTicketCreated({
|
|
ticket_type: cleanedInput.source === 'email' ? 'from_email' : 'manual',
|
|
priority_id: cleanedInput.priority_id,
|
|
has_description: !!cleanedInput.description,
|
|
has_category: !!cleanedInput.category_id,
|
|
has_subcategory: !!cleanedInput.subcategory_id,
|
|
is_assigned: !!cleanedInput.assigned_to,
|
|
board_id: cleanedInput.board_id,
|
|
created_via: cleanedInput.source || 'unknown',
|
|
has_asset: false
|
|
}, userId);
|
|
|
|
await analyticsTracker.trackFeatureUsage('ticket_creation', userId, {
|
|
ticket_source: cleanedInput.source || 'manual',
|
|
used_template: false,
|
|
automation_triggered: !!cleanedInput.email_metadata
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to track ticket creation analytics:', error);
|
|
// Don't throw - analytics failure shouldn't break ticket creation
|
|
}
|
|
}
|
|
|
|
return {
|
|
ticket_id: ticketId,
|
|
ticket_number: ticketNumber,
|
|
title: cleanedInput.title,
|
|
client_id: cleanedInput.client_id,
|
|
contact_id: cleanedInput.contact_id,
|
|
status_id: cleanedInput.status_id,
|
|
priority_id: cleanedInput.priority_id,
|
|
board_id: cleanedInput.board_id,
|
|
entered_at: now.toISOString(),
|
|
tenant
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a ticket from asset with full validation
|
|
*/
|
|
static async createTicketFromAsset(
|
|
input: CreateTicketFromAssetInput,
|
|
enteredBy: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
eventPublisher?: IEventPublisher,
|
|
analyticsTracker?: IAnalyticsTracker
|
|
): Promise<CreateTicketOutput> {
|
|
// Validate input data
|
|
const validation = this.validateCreateTicketFromAssetData(input);
|
|
if (!validation.valid) {
|
|
throw new Error(`Asset ticket validation failed: ${validation.errors?.join('; ')}`);
|
|
}
|
|
|
|
const validatedData = validation.data;
|
|
|
|
// Convert to CreateTicketInput format using the provided status_id and board_id
|
|
const createTicketInput: CreateTicketInput = {
|
|
title: validatedData.title,
|
|
description: validatedData.description,
|
|
priority_id: validatedData.priority_id,
|
|
client_id: validatedData.client_id,
|
|
status_id: validatedData.status_id,
|
|
board_id: validatedData.board_id,
|
|
entered_by: enteredBy,
|
|
attributes: {
|
|
created_from_asset: validatedData.asset_id
|
|
}
|
|
};
|
|
|
|
// Track asset-specific analytics if tracker provided
|
|
if (analyticsTracker) {
|
|
try {
|
|
await analyticsTracker.trackTicketCreated({
|
|
ticket_type: 'from_asset',
|
|
priority_id: validatedData.priority_id,
|
|
has_description: !!validatedData.description,
|
|
has_category: false,
|
|
has_subcategory: false,
|
|
is_assigned: false,
|
|
created_via: 'asset_page',
|
|
has_asset: true
|
|
}, enteredBy);
|
|
} catch (error) {
|
|
console.error('Failed to track asset ticket analytics:', error);
|
|
}
|
|
}
|
|
|
|
return this.createTicket(createTicketInput, tenant, trx, {}, eventPublisher, analyticsTracker, enteredBy);
|
|
}
|
|
|
|
/**
|
|
* Update ticket with validation and business rule checking
|
|
*/
|
|
static async updateTicket(
|
|
ticketId: string,
|
|
input: UpdateTicketInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
validationOptions: ValidationOptions = {},
|
|
eventPublisher?: IEventPublisher,
|
|
analyticsTracker?: IAnalyticsTracker,
|
|
userId?: string
|
|
): Promise<any> {
|
|
// Validate required parameters
|
|
if (!ticketId) {
|
|
throw new Error('Ticket ID is required');
|
|
}
|
|
if (!tenant) {
|
|
throw new Error('Tenant is required');
|
|
}
|
|
|
|
// Validate input data
|
|
const validation = this.validateUpdateTicketData(input);
|
|
if (!validation.valid) {
|
|
throw new Error(`Update validation failed: ${validation.errors?.join('; ')}`);
|
|
}
|
|
|
|
const validatedData = validation.data;
|
|
|
|
// Get current ticket state
|
|
const currentTicket = await trx('tickets')
|
|
.where({ ticket_id: ticketId, tenant: tenant })
|
|
.first();
|
|
|
|
if (!currentTicket) {
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
// Clean up the data before update
|
|
const updateData = cleanNullableFields({ ...validatedData });
|
|
const effectiveBoardId = updateData.board_id || currentTicket.board_id;
|
|
const isBoardChange =
|
|
'board_id' in updateData &&
|
|
!!updateData.board_id &&
|
|
updateData.board_id !== currentTicket.board_id;
|
|
|
|
if (isBoardChange && !updateData.status_id) {
|
|
throw new Error('Changing the board requires selecting a status for the destination board');
|
|
}
|
|
|
|
if (updateData.status_id) {
|
|
const statusResult = effectiveBoardId
|
|
? await this.validateStatusBelongsToBoard(
|
|
updateData.status_id,
|
|
effectiveBoardId,
|
|
tenant,
|
|
trx
|
|
)
|
|
: {
|
|
valid: false,
|
|
error: 'Invalid status: board_id is required when selecting a ticket status'
|
|
};
|
|
|
|
if (!statusResult.valid && statusResult.error) {
|
|
throw new Error(statusResult.error);
|
|
}
|
|
}
|
|
|
|
// Validate location belongs to the client if provided
|
|
if (!validationOptions.skipLocationValidation && 'location_id' in updateData && updateData.location_id) {
|
|
const clientId = 'client_id' in updateData ? updateData.client_id : currentTicket.client_id;
|
|
const locationResult = await this.validateLocationBelongsToClient(
|
|
updateData.location_id,
|
|
clientId,
|
|
tenant,
|
|
trx
|
|
);
|
|
if (!locationResult.valid && locationResult.error) {
|
|
throw new Error(locationResult.error);
|
|
}
|
|
}
|
|
|
|
// If updating category or subcategory, ensure they are compatible
|
|
if (!validationOptions.skipCategoryValidation && ('subcategory_id' in updateData || 'category_id' in updateData)) {
|
|
const newSubcategoryId = updateData.subcategory_id;
|
|
const newCategoryId = updateData.category_id || currentTicket?.category_id;
|
|
|
|
if (newSubcategoryId) {
|
|
const categoryResult = await this.validateCategorySubcategoryRelationship(
|
|
newSubcategoryId,
|
|
newCategoryId,
|
|
tenant,
|
|
trx
|
|
);
|
|
if (!categoryResult.valid && categoryResult.error) {
|
|
throw new Error(categoryResult.error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the ticket
|
|
const [updatedTicket] = await trx('tickets')
|
|
.where({ ticket_id: ticketId, tenant: tenant })
|
|
.update({
|
|
...updateData,
|
|
updated_at: new Date()
|
|
})
|
|
.returning('*');
|
|
|
|
if (!updatedTicket) {
|
|
throw new Error('Ticket not found or update failed');
|
|
}
|
|
|
|
// Publish update event if publisher provided
|
|
if (eventPublisher) {
|
|
try {
|
|
await eventPublisher.publishTicketUpdated({
|
|
tenantId: tenant,
|
|
ticketId: ticketId,
|
|
userId: userId,
|
|
changes: updateData,
|
|
metadata: {
|
|
updated_fields: Object.keys(updateData)
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to publish ticket updated event:', error);
|
|
}
|
|
}
|
|
|
|
// Track analytics if tracker provided
|
|
if (analyticsTracker) {
|
|
try {
|
|
await analyticsTracker.trackTicketUpdated({
|
|
ticket_id: ticketId,
|
|
changes: Object.keys(updateData),
|
|
updated_via: 'manual',
|
|
metadata: {
|
|
is_assignment_change: 'assigned_to' in updateData,
|
|
is_status_change: 'status_id' in updateData,
|
|
is_priority_change: 'priority_id' in updateData
|
|
}
|
|
}, userId);
|
|
} catch (error) {
|
|
console.error('Failed to track ticket update analytics:', error);
|
|
}
|
|
}
|
|
|
|
return updatedTicket;
|
|
}
|
|
|
|
/**
|
|
* Handle assignment changes with ticket_resources table management
|
|
* This is the complex assignment logic extracted from server actions
|
|
*/
|
|
static async updateTicketWithAssignmentChange(
|
|
ticketId: string,
|
|
updateData: UpdateTicketInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<any> {
|
|
// Get current ticket state
|
|
const currentTicket = await trx('tickets')
|
|
.where({ ticket_id: ticketId, tenant: tenant })
|
|
.first();
|
|
|
|
if (!currentTicket) {
|
|
throw new Error('Ticket not found');
|
|
}
|
|
|
|
// Check if we're updating the assigned_to field
|
|
const isChangingAssignment = 'assigned_to' in updateData &&
|
|
updateData.assigned_to !== currentTicket.assigned_to;
|
|
|
|
if (!isChangingAssignment) {
|
|
// Regular update without changing assignment
|
|
return this.updateTicket(ticketId, updateData, tenant, trx);
|
|
}
|
|
|
|
// Handle the complex assignment change logic from server actions
|
|
// Step 1: Delete any ticket_resources where the new assigned_to is an additional_user_id
|
|
// to avoid constraint violations after the update
|
|
await trx('ticket_resources')
|
|
.where({
|
|
tenant: tenant,
|
|
ticket_id: ticketId,
|
|
additional_user_id: updateData.assigned_to
|
|
})
|
|
.delete();
|
|
|
|
// Step 2: Get existing resources with the old assigned_to value
|
|
const existingResources = await trx('ticket_resources')
|
|
.where({
|
|
tenant: tenant,
|
|
ticket_id: ticketId,
|
|
assigned_to: currentTicket.assigned_to
|
|
})
|
|
.select('*');
|
|
|
|
// Step 3: Store resources for recreation, excluding those that would violate constraints
|
|
// Explicitly type to avoid never[] inference
|
|
const resourcesToRecreate: any[] = [];
|
|
for (const resource of existingResources) {
|
|
// Skip resources where additional_user_id would equal the new assigned_to
|
|
if (resource.additional_user_id !== updateData.assigned_to) {
|
|
// Clone the resource but exclude the primary key fields
|
|
const { assignment_id, ...resourceData } = resource;
|
|
resourcesToRecreate.push(resourceData);
|
|
}
|
|
}
|
|
|
|
// Step 4: Delete the existing resources with the old assigned_to
|
|
if (existingResources.length > 0) {
|
|
await trx('ticket_resources')
|
|
.where({
|
|
tenant: tenant,
|
|
ticket_id: ticketId,
|
|
assigned_to: currentTicket.assigned_to
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
// Step 5: Update the ticket with the new assigned_to
|
|
const [updatedTicket] = await trx('tickets')
|
|
.where({ ticket_id: ticketId, tenant: tenant })
|
|
.update({
|
|
...updateData,
|
|
updated_at: new Date()
|
|
})
|
|
.returning('*');
|
|
|
|
// Step 6: Re-create the resources with the new assigned_to
|
|
for (const resourceData of resourcesToRecreate) {
|
|
await trx('ticket_resources').insert({
|
|
...resourceData,
|
|
assigned_to: updateData.assigned_to
|
|
});
|
|
}
|
|
|
|
return updatedTicket;
|
|
}
|
|
|
|
/**
|
|
* Validates comment creation input
|
|
*/
|
|
static validateCreateCommentInput(input: CreateCommentValidationInput): TicketValidationResult {
|
|
try {
|
|
const validatedData = validateData(createCommentSchema, input);
|
|
return { valid: true, data: validatedData };
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
errors: [error instanceof Error ? error.message : 'Comment validation failed']
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a comment for a ticket with validation
|
|
*/
|
|
static async createComment(
|
|
input: CreateCommentInput,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
eventPublisher?: IEventPublisher,
|
|
analyticsTracker?: IAnalyticsTracker,
|
|
userId?: string
|
|
): Promise<CreateCommentOutput> {
|
|
// Validate required tenant
|
|
if (!tenant) {
|
|
throw new Error('Tenant is required');
|
|
}
|
|
|
|
// Validate input data
|
|
const validation = this.validateCreateCommentInput(input);
|
|
if (!validation.valid) {
|
|
throw new Error(`Comment validation failed: ${validation.errors?.join('; ')}`);
|
|
}
|
|
|
|
const validatedData = validation.data;
|
|
|
|
// Verify ticket exists and belongs to tenant
|
|
const ticket = await trx('tickets')
|
|
.where({
|
|
ticket_id: validatedData.ticket_id,
|
|
tenant: tenant
|
|
})
|
|
.first();
|
|
|
|
if (!ticket) {
|
|
throw new Error('Ticket not found or does not belong to tenant');
|
|
}
|
|
|
|
if (validatedData.contact_id) {
|
|
const contact = await trx('contacts')
|
|
.where({
|
|
tenant,
|
|
contact_name_id: validatedData.contact_id,
|
|
})
|
|
.first();
|
|
|
|
if (!contact) {
|
|
throw new Error('Contact not found or does not belong to tenant');
|
|
}
|
|
}
|
|
|
|
const commentId = uuidv4();
|
|
const parentCommentId = validatedData.parent_comment_id || null;
|
|
let threadId = uuidv4();
|
|
let commentIsInternal = validatedData.is_internal || false;
|
|
const now = new Date();
|
|
|
|
// Map legacy/alias author types to current enum: internal | client | unknown
|
|
// Map to DB enum/text values that align with comment_author_type_new
|
|
const dbAuthorType = (() => {
|
|
switch (validatedData.author_type) {
|
|
case 'internal':
|
|
case 'system':
|
|
return 'internal';
|
|
case 'contact':
|
|
return 'client';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
})();
|
|
|
|
if (parentCommentId) {
|
|
const parent = await trx('comments as parent')
|
|
.join('comment_threads as thread', function() {
|
|
this.on('parent.tenant', 'thread.tenant')
|
|
.andOn('parent.thread_id', 'thread.thread_id');
|
|
})
|
|
.select(
|
|
'parent.comment_id',
|
|
'parent.ticket_id',
|
|
'parent.thread_id',
|
|
'parent.deleted_at',
|
|
'thread.is_internal as thread_is_internal'
|
|
)
|
|
.where('parent.tenant', tenant)
|
|
.where('parent.comment_id', parentCommentId)
|
|
.first();
|
|
|
|
if (!parent) {
|
|
throw new Error('Parent comment not found');
|
|
}
|
|
|
|
if (parent.ticket_id !== validatedData.ticket_id) {
|
|
throw new Error('Parent comment must belong to the same ticket');
|
|
}
|
|
|
|
if (parent.deleted_at) {
|
|
throw new Error('Cannot reply to a deleted comment');
|
|
}
|
|
|
|
threadId = parent.thread_id;
|
|
commentIsInternal = Boolean(parent.thread_is_internal);
|
|
}
|
|
|
|
const baseCommentData: any = {
|
|
comment_id: commentId,
|
|
tenant,
|
|
ticket_id: validatedData.ticket_id,
|
|
note: validatedData.content,
|
|
is_internal: commentIsInternal,
|
|
is_resolution: validatedData.is_resolution || false,
|
|
author_type: dbAuthorType as any,
|
|
user_id: validatedData.author_id || null,
|
|
contact_id: validatedData.contact_id || null,
|
|
metadata: validatedData.metadata ? JSON.stringify(validatedData.metadata) : null,
|
|
thread_id: threadId,
|
|
parent_comment_id: parentCommentId,
|
|
created_at: now,
|
|
updated_at: now
|
|
};
|
|
|
|
if (!parentCommentId) {
|
|
await trx('comment_threads').insert({
|
|
tenant,
|
|
thread_id: threadId,
|
|
ticket_id: validatedData.ticket_id,
|
|
project_task_id: null,
|
|
root_comment_id: commentId,
|
|
is_internal: commentIsInternal,
|
|
reply_count: 0,
|
|
last_activity_at: now,
|
|
created_at: now,
|
|
created_by: validatedData.author_id || null,
|
|
});
|
|
}
|
|
|
|
await trx('comments').insert(baseCommentData);
|
|
|
|
if (parentCommentId) {
|
|
await trx('comment_threads')
|
|
.where({ tenant, thread_id: threadId })
|
|
.update({
|
|
reply_count: trx.raw('reply_count + 1'),
|
|
last_activity_at: now,
|
|
});
|
|
}
|
|
|
|
if (!validatedData.is_internal && validatedData.author_type === 'contact') {
|
|
// Only update response state if tracking is enabled for this tenant
|
|
const tenantSettingsRow = await trx('tenant_settings')
|
|
.select('ticket_display_settings')
|
|
.where({ tenant })
|
|
.first();
|
|
const responseStateEnabled = (tenantSettingsRow?.ticket_display_settings as any)?.responseStateTrackingEnabled ?? true;
|
|
|
|
if (responseStateEnabled) {
|
|
await trx('tickets')
|
|
.where({
|
|
ticket_id: validatedData.ticket_id,
|
|
tenant,
|
|
})
|
|
.update({
|
|
response_state: 'awaiting_internal',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Publish comment event if publisher provided
|
|
if (eventPublisher) {
|
|
try {
|
|
// Resolve a display name so notification templates render the author/body.
|
|
let authorName: string | undefined;
|
|
if (validatedData.contact_id) {
|
|
const c = await trx('contacts')
|
|
.select('full_name')
|
|
.where({ tenant, contact_name_id: validatedData.contact_id })
|
|
.first();
|
|
authorName = c?.full_name || undefined;
|
|
} else if (validatedData.author_id) {
|
|
const u = await trx('users')
|
|
.select('first_name', 'last_name')
|
|
.where({ tenant, user_id: validatedData.author_id })
|
|
.first();
|
|
authorName = u ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() || undefined : undefined;
|
|
}
|
|
|
|
await eventPublisher.publishCommentCreated({
|
|
tenantId: tenant,
|
|
ticketId: validatedData.ticket_id,
|
|
commentId: commentId,
|
|
userId: userId,
|
|
metadata: {
|
|
author_type: dbAuthorType,
|
|
is_internal: validatedData.is_internal,
|
|
isInternal: commentIsInternal,
|
|
is_resolution: validatedData.is_resolution,
|
|
content: validatedData.content,
|
|
author: authorName
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to publish comment created event:', error);
|
|
}
|
|
}
|
|
|
|
// Track analytics if tracker provided
|
|
if (analyticsTracker) {
|
|
try {
|
|
await analyticsTracker.trackCommentCreated({
|
|
ticket_id: validatedData.ticket_id,
|
|
is_internal: validatedData.is_internal || false,
|
|
is_resolution: validatedData.is_resolution || false,
|
|
author_type: dbAuthorType,
|
|
created_via: 'manual'
|
|
}, userId);
|
|
} catch (error) {
|
|
console.error('Failed to track comment creation analytics:', error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
comment_id: commentId,
|
|
ticket_id: validatedData.ticket_id,
|
|
content: validatedData.content,
|
|
author_type: validatedData.author_type || 'system',
|
|
created_at: now.toISOString()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get default status ID for tickets
|
|
* Falls back to the first available ticket status if no default is explicitly set
|
|
*/
|
|
static async getDefaultStatusId(
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
boardId?: string | null
|
|
): Promise<string | null> {
|
|
if (!boardId) {
|
|
return null;
|
|
}
|
|
|
|
// First try to find an explicitly marked default status
|
|
const defaultStatus = await trx('statuses')
|
|
.where({
|
|
tenant,
|
|
is_default: true,
|
|
status_type: 'ticket',
|
|
board_id: boardId
|
|
})
|
|
.first();
|
|
|
|
if (defaultStatus?.status_id) {
|
|
return defaultStatus.status_id;
|
|
}
|
|
|
|
// Fall back to the first ticket status ordered by order_number
|
|
const firstStatus = await trx('statuses')
|
|
.where({
|
|
tenant,
|
|
status_type: 'ticket',
|
|
board_id: boardId
|
|
})
|
|
.orderBy('order_number', 'asc')
|
|
.first();
|
|
|
|
return firstStatus?.status_id || null;
|
|
}
|
|
|
|
/**
|
|
* Find or create a board by name
|
|
*/
|
|
static async findOrCreateBoard(
|
|
boardName: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction,
|
|
description?: string
|
|
): Promise<string> {
|
|
// Try to find existing board
|
|
const existingBoard = await trx('boards')
|
|
.where({
|
|
board_name: boardName,
|
|
tenant: tenant
|
|
})
|
|
.first();
|
|
|
|
if (existingBoard) {
|
|
return existingBoard.board_id;
|
|
}
|
|
|
|
// Create new board
|
|
const boardId = uuidv4();
|
|
const now = new Date();
|
|
|
|
await trx('boards').insert({
|
|
board_id: boardId,
|
|
tenant,
|
|
board_name: boardName,
|
|
description: description || '',
|
|
is_default: false,
|
|
is_active: true,
|
|
created_at: now,
|
|
updated_at: now
|
|
});
|
|
|
|
return boardId;
|
|
}
|
|
|
|
/**
|
|
* Find status by name and type
|
|
*/
|
|
static async findStatusByName(
|
|
statusName: string,
|
|
itemType: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<string | null> {
|
|
const status = await trx('statuses')
|
|
.where({
|
|
name: statusName,
|
|
item_type: itemType,
|
|
tenant: tenant
|
|
})
|
|
.first();
|
|
|
|
return status?.status_id || null;
|
|
}
|
|
|
|
/**
|
|
* Find priority by name
|
|
*/
|
|
static async findPriorityByName(
|
|
priorityName: string,
|
|
tenant: string,
|
|
trx: Knex.Transaction
|
|
): Promise<string | null> {
|
|
const priority = await trx('priorities')
|
|
.where({
|
|
priority_name: priorityName,
|
|
tenant: tenant
|
|
})
|
|
.first();
|
|
|
|
return priority?.priority_id || null;
|
|
}
|
|
}
|