PSA/shared/models/tagModel.ts
Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

487 lines
13 KiB
TypeScript

/**
* Shared Tag Model - Core business logic for tag operations
* This model contains the essential tag 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 {
TaggedEntityType,
TagDefinition,
TagMapping,
CreateTagInput,
CreateTagOutput
} from '../interfaces/tag.interfaces';
import { ValidationResult } from '../interfaces/validation.interfaces';
// =============================================================================
// VALIDATION SCHEMAS
// =============================================================================
// Core tag validation schema
export const tagFormSchema = z.object({
tag_text: z.string().min(1, 'Tag text is required').max(50, 'Tag text too long (max 50 characters)'),
tagged_id: z.string().uuid('Tagged ID must be a valid UUID'),
tagged_type: z.enum(['client', 'contact', 'project_task', 'document', 'knowledge_base_article']),
board_id: z.string().uuid().optional().nullable(),
background_color: z.string().regex(/^#[0-9A-F]{6}$/i).optional().nullable(),
text_color: z.string().regex(/^#[0-9A-F]{6}$/i).optional().nullable()
});
// Tag definition schema
export const tagDefinitionSchema = z.object({
tag_id: z.string().uuid(),
tenant: z.string().uuid(),
tag_text: z.string(),
tagged_type: z.enum(['client', 'contact', 'project_task', 'document', 'knowledge_base_article']),
board_id: z.string().uuid().nullable(),
background_color: z.string().nullable(),
text_color: z.string().nullable(),
created_at: z.string()
});
// Tag mapping schema
export const tagMappingSchema = z.object({
mapping_id: z.string().uuid(),
tenant: z.string().uuid(),
tag_id: z.string().uuid(),
tagged_id: z.string().uuid(),
tagged_type: z.enum(['client', 'contact', 'project_task', 'document', 'knowledge_base_article']),
created_by: z.string().uuid().nullable(),
created_at: z.string()
});
// =============================================================================
// Re-export interfaces for backward compatibility
// =============================================================================
export type {
TaggedEntityType,
TagDefinition,
TagMapping,
CreateTagInput,
CreateTagOutput
} from '../interfaces/tag.interfaces';
export type { ValidationResult } from '../interfaces/validation.interfaces';
// =============================================================================
// COLOR GENERATION
// =============================================================================
/**
* Generate colors for a tag based on its text
* Extracted from server/src/utils/colorUtils.ts logic
*/
export function generateTagColors(text: string): { background: string; text: string } {
// Simple hash function to generate consistent colors
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
// Generate background color
const hue = Math.abs(hash) % 360;
const saturation = 70; // Fixed saturation for consistency
const lightness = 85; // Light background
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Convert to hex for storage
const backgroundHex = hslToHex(hue, saturation, lightness);
// Text color should be dark for light backgrounds
const textHex = '#2C3E50'; // Dark gray for readability
return {
background: backgroundHex,
text: textHex
};
}
/**
* Convert HSL to Hex color
*/
function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
}
// =============================================================================
// 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;
}
}
/**
* Validate tag text format
*/
export function validateTagText(tagText: string): ValidationResult {
if (!tagText || !tagText.trim()) {
return { valid: false, errors: ['Tag text is required'] };
}
const trimmedText = tagText.trim();
if (trimmedText.length > 50) {
return { valid: false, errors: ['Tag text too long (max 50 characters)'] };
}
// Allow letters, numbers, spaces, and common punctuation
if (!/^[a-zA-Z0-9\-_\s!@#$%^&*()+=\[\]{};':",./<>?]+$/.test(trimmedText)) {
return { valid: false, errors: ['Tag text contains invalid characters'] };
}
return { valid: true, data: trimmedText };
}
// =============================================================================
// CORE TAG MODEL
// =============================================================================
export class TagModel {
/**
* Validates tag creation input
*/
static validateCreateTagInput(input: CreateTagInput): ValidationResult {
try {
// Validate tag text
const textValidation = validateTagText(input.tag_text);
if (!textValidation.valid) {
return textValidation;
}
// Validate with schema
const validatedData = validateData(tagFormSchema, {
...input,
tag_text: textValidation.data
});
return { valid: true, data: validatedData };
} catch (error) {
return {
valid: false,
errors: [error instanceof Error ? error.message : 'Validation failed']
};
}
}
/**
* Get or create tag definition
*/
static async getOrCreateTagDefinition(
tagText: string,
taggedType: TaggedEntityType,
tenant: string,
trx: Knex.Transaction,
options?: {
board_id?: string;
background_color?: string | null;
text_color?: string | null;
}
): Promise<TagDefinition> {
// Check if definition already exists
const existing = await trx('tag_definitions')
.where({
tag_text: tagText,
tagged_type: taggedType,
tenant
})
.first();
if (existing) {
return existing;
}
// Generate colors if not provided
let backgroundColor = options?.background_color;
let textColor = options?.text_color;
if (!backgroundColor || !textColor) {
const colors = generateTagColors(tagText);
backgroundColor = backgroundColor || colors.background;
textColor = textColor || colors.text;
}
// Create new definition
const tagId = uuidv4();
const now = new Date().toISOString();
const definition: TagDefinition = {
tag_id: tagId,
tenant,
tag_text: tagText,
tagged_type: taggedType,
board_id: options?.board_id || null,
background_color: backgroundColor,
text_color: textColor,
created_at: now
};
await trx('tag_definitions').insert(definition);
return definition;
}
/**
* Create tag mapping
*/
static async createTagMapping(
tagId: string,
taggedId: string,
taggedType: TaggedEntityType,
tenant: string,
trx: Knex.Transaction,
createdBy?: string
): Promise<TagMapping> {
const mappingId = uuidv4();
const now = new Date().toISOString();
const mapping: TagMapping = {
mapping_id: mappingId,
tenant,
tag_id: tagId,
tagged_id: taggedId,
tagged_type: taggedType,
created_by: createdBy || null,
created_at: now
};
await trx('tag_mappings').insert(mapping);
return mapping;
}
/**
* Create a new tag with complete validation
*/
static async createTag(
input: CreateTagInput,
tenant: string,
trx: Knex.Transaction
): Promise<CreateTagOutput> {
// Validate input
const validation = this.validateCreateTagInput(input);
if (!validation.valid) {
throw new Error(`Tag validation failed: ${validation.errors?.join('; ')}`);
}
const tagData = validation.data;
// Get or create tag definition
const definition = await this.getOrCreateTagDefinition(
tagData.tag_text,
tagData.tagged_type,
tenant,
trx,
{
board_id: tagData.board_id,
background_color: tagData.background_color,
text_color: tagData.text_color
}
);
// Check if mapping already exists
const existingMapping = await trx('tag_mappings')
.where({
tag_id: definition.tag_id,
tagged_id: tagData.tagged_id,
tagged_type: tagData.tagged_type,
tenant
})
.first();
if (existingMapping) {
return {
tag_id: definition.tag_id,
mapping_id: existingMapping.mapping_id,
tag_text: definition.tag_text,
tagged_id: tagData.tagged_id,
tagged_type: tagData.tagged_type,
tenant,
created_at: existingMapping.created_at
};
}
// Create mapping
const mapping = await this.createTagMapping(
definition.tag_id,
tagData.tagged_id,
tagData.tagged_type,
tenant,
trx,
tagData.created_by
);
return {
tag_id: definition.tag_id,
mapping_id: mapping.mapping_id,
tag_text: definition.tag_text,
tagged_id: tagData.tagged_id,
tagged_type: tagData.tagged_type,
tenant,
created_at: mapping.created_at
};
}
/**
* Get all tag definitions by type
*/
static async getTagDefinitionsByType(
taggedType: TaggedEntityType,
tenant: string,
trx: Knex.Transaction
): Promise<TagDefinition[]> {
return await trx('tag_definitions')
.where({
tagged_type: taggedType,
tenant
})
.orderBy('tag_text', 'asc');
}
/**
* Get tags by entity
*/
static async getTagsByEntity(
entityId: string,
entityType: TaggedEntityType,
tenant: string,
trx: Knex.Transaction
): Promise<any[]> {
return await trx('tag_mappings as tm')
.join('tag_definitions as td', function() {
this.on('tm.tenant', '=', 'td.tenant')
.andOn('tm.tag_id', '=', 'td.tag_id');
})
.where({
'tm.tagged_id': entityId,
'tm.tagged_type': entityType,
'tm.tenant': tenant
})
.select(
'tm.mapping_id',
'td.tag_id',
'td.tag_text',
'td.background_color',
'td.text_color',
'td.board_id',
'tm.tagged_id',
'tm.tagged_type',
'tm.created_by',
'tm.created_at'
);
}
/**
* Delete tag mapping
*/
static async deleteTagMapping(
mappingId: string,
tenant: string,
trx: Knex.Transaction
): Promise<void> {
await trx('tag_mappings')
.where({
mapping_id: mappingId,
tenant
})
.delete();
}
/**
* Update tag definition
*/
static async updateTagDefinition(
tagId: string,
updates: {
tag_text?: string;
background_color?: string | null;
text_color?: string | null;
board_id?: string | null;
},
tenant: string,
trx: Knex.Transaction
): Promise<void> {
const now = new Date().toISOString();
await trx('tag_definitions')
.where({
tag_id: tagId,
tenant
})
.update({
...updates,
updated_at: now
});
}
/**
* Check if tag exists for entity
*/
static async tagExistsForEntity(
tagText: string,
entityId: string,
entityType: TaggedEntityType,
tenant: string,
trx: Knex.Transaction
): Promise<boolean> {
const result = await trx('tag_mappings as tm')
.join('tag_definitions as td', function() {
this.on('tm.tenant', '=', 'td.tenant')
.andOn('tm.tag_id', '=', 'td.tag_id');
})
.where({
'td.tag_text': tagText,
'tm.tagged_id': entityId,
'tm.tagged_type': entityType,
'tm.tenant': tenant
})
.count('* as count')
.first();
return parseInt(String(result?.count || 0), 10) > 0;
}
/**
* Get or create a tag for PSA Customer tracking
*/
static async ensurePSACustomerTag(
clientId: string,
tenant: string,
trx: Knex.Transaction,
createdBy?: string
): Promise<CreateTagOutput> {
return await this.createTag(
{
tag_text: 'PSA Customer',
tagged_id: clientId,
tagged_type: 'client',
created_by: createdBy || 'system'
},
tenant,
trx
);
}
}