// Phoenix Rebuild: RecordHandler Implementation (GREEN Phase)
// Contract: RECORD-001 to RECORD-006
// TDD Phase: GREEN (minimal code to pass tests)
// Test-First-Hook: test/unit/operations/record-handler.test.ts (12 RED tests written first)
import type { SmartSuiteClient } from '../smartsuite-client.js';
/**
* Operation context for record mutations
* Mirrors test contract expectations
*/
export interface RecordContext {
operation: 'create' | 'update' | 'delete';
tableId: string;
recordId?: string | undefined;
data?: Record<string, unknown>;
dryRun: boolean;
validated?: boolean; // For testing - indicates prior validation
}
/**
* Dry-run validation result structure
* Tests expect dryRun/operation/tableId to be echoed back
*/
export interface DryRunResult {
dryRun: true;
operation: string;
tableId: string;
recordId?: string | undefined;
validated: boolean;
preview?: Record<string, unknown>;
errors?: string[];
message?: string;
}
/**
* Record operation result structure
*/
export interface RecordResult {
id?: string;
deleted?: boolean;
recordId?: string | undefined;
[key: string]: unknown;
}
/**
* RecordHandler - Implementation for RECORD-001 to RECORD-006 contracts
*
* Architecture: RecordHandler → SmartSuiteClient → SmartSuite API
*
* Responsibilities:
* - Validate field formats (SmartDoc, linked records, status codes)
* - Perform dry-run simulation with connectivity checks
* - Execute operations directly (validation gate removed per UX research)
* - Delegate all API communication to SmartSuiteClient
*
*
* Design Change: Validation requirement removed per MINIMAL_INTERVENTION_PRINCIPLE
* Reference: coordination/reports/851-REPORT-MCP-TOOL-UX-INVESTIGATION.md
* Rationale: MCP protocol is stateless - validation state cannot persist between calls
* Critical Format Requirements (from CRITICAL-FORMATS-TRUTH.md):
* 1. Rich text fields MUST use SmartDoc structure (not plain strings)
* 2. Linked records MUST be arrays (even single values)
* 3. Status fields MUST use option codes (not display labels)
*/
export class RecordHandler {
private client: SmartSuiteClient | null = null;
/**
* Execute record operation based on context
* Minimal implementation to satisfy test contracts
*/
async execute(context: RecordContext): Promise<DryRunResult | RecordResult> {
const { operation, tableId, recordId, data, dryRun } = context;
// Validate required parameters
if (!tableId || typeof tableId !== 'string' || tableId.trim() === '') {
throw new Error('Invalid table ID');
}
// Validate recordId for update/delete operations
if ((operation === 'update' || operation === 'delete') && !recordId) {
throw new Error('Record ID required for ' + operation + ' operation');
}
// Lazy load client (allows tests to run without real auth)
if (!this.client) {
throw new Error('SmartSuiteClient not initialized');
}
// DRY-RUN MODE: Validate without execution
if (dryRun) {
return this.performDryRun(operation, tableId, recordId, data);
}
// EXECUTE MODE: Perform operation directly
// Note: Validation gate removed per MINIMAL_INTERVENTION_PRINCIPLE
// MCP protocol is stateless - validation state cannot persist between calls
// Format validation still available via performDryRun (optional preview)
// Reference: coordination/reports/851-REPORT-MCP-TOOL-UX-INVESTIGATION.md
// Route to appropriate operation
switch (operation) {
case 'create':
return this.handleCreate(tableId, data!);
case 'update':
return this.handleUpdate(tableId, recordId!, data!);
case 'delete':
return this.handleDelete(tableId, recordId!);
default:
throw new Error(`Unknown record operation: ${String(operation)}`);
}
}
/**
* Perform dry-run validation with connectivity and format checks
* Implements validation without actual mutation
*/
private async performDryRun(
operation: string,
tableId: string,
recordId: string | undefined,
data: Record<string, unknown> | undefined,
): Promise<DryRunResult> {
const errors: string[] = [];
// Phase 1: Connectivity check
try {
// Minimal list query to test connectivity and auth
await this.client!.listRecords(tableId, { limit: 1 });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`API connectivity check failed: ${errorMessage}`);
return {
dryRun: true,
operation,
tableId,
recordId,
validated: false,
errors,
message: 'DRY-RUN FAILED: Cannot validate operation due to API connectivity issues',
};
}
// Phase 2: Schema-based field format validation (only for create/update with data)
if ((operation === 'create' || operation === 'update') && data) {
try {
const schema = await this.client!.getSchema(tableId);
const formatErrors = this.validateFieldFormats(data, schema);
errors.push(...formatErrors);
} catch (_error) {
// const errorMessage = error instanceof Error ? error.message : String(error);
// Schema validation failure is not fatal - just log warning
// Tests may proceed without schema if connectivity passed
// console.warn(`Schema validation skipped: ${String(_error)}`); // Disabled for production
}
}
// Determine validation result
const validated = errors.length === 0;
return {
dryRun: true,
operation,
tableId,
recordId,
validated,
...(data && { preview: data }),
...(errors.length > 0 && { errors }),
message: validated
? 'DRY-RUN PASSED: Operation validated successfully. You may now execute with dryRun: false.'
: 'DRY-RUN FAILED: Operation validation failed. See errors for details.',
};
}
/**
* Validate field formats against SmartSuite critical requirements
* Reference: coordination/smartsuite-truth/CRITICAL-FORMATS-TRUTH.md
*/
private validateFieldFormats(
data: Record<string, unknown>,
schema: unknown,
): string[] {
const errors: string[] = [];
// Extract schema structure
if (!schema || typeof schema !== 'object' || !('structure' in schema)) {
return []; // Cannot validate without schema
}
const structure = (schema as Record<string, unknown>).structure;
if (!Array.isArray(structure)) {
return [];
}
// Build field type map
const fieldTypeMap = new Map<string, string>();
for (const field of structure) {
if (typeof field === 'object' && field !== null) {
const fieldObj = field as Record<string, unknown>;
const slug = fieldObj.slug as string | undefined;
const fieldType = fieldObj.field_type as string | undefined;
if (slug && fieldType) {
fieldTypeMap.set(slug, fieldType);
}
}
}
// Validate each field in data
for (const [fieldSlug, value] of Object.entries(data)) {
const fieldType = fieldTypeMap.get(fieldSlug);
if (!fieldType) {
continue; // Unknown field - skip (might be handled by API)
}
// CRITICAL VALIDATION 1: Rich text fields MUST use SmartDoc format
if (fieldType === 'richtextareafield' && value !== null && value !== undefined) {
if (typeof value === 'string') {
errors.push(
`Field '${fieldSlug}' is a rich text field and requires SmartDoc format structure, not a plain string. ` +
'Plain strings will cause silent failure (API accepts but does not save properly).',
);
} else if (typeof value === 'object') {
// Check for SmartDoc structure
const valueObj = value as Record<string, unknown>;
if (!valueObj.data || !valueObj.html || !valueObj.preview) {
errors.push(
`Field '${fieldSlug}' requires complete SmartDoc format with data, html, and preview properties.`,
);
}
}
}
// CRITICAL VALIDATION 2: Linked records MUST be arrays
if (fieldType === 'linkedrecordfield' && value !== null && value !== undefined) {
if (!Array.isArray(value)) {
errors.push(
`Field '${fieldSlug}' is a linked record field and requires an array format (even for single values). ` +
`Current value is ${typeof value}. This will cause type errors.`,
);
}
}
// Note: Status field validation (option codes vs display labels) is not enforced here
// as it requires runtime knowledge of valid option codes from schema
// The API will reject invalid option codes with clear error messages
}
return errors;
}
/**
* Handle create operation - save new record
*/
private async handleCreate(tableId: string, data: Record<string, unknown>): Promise<RecordResult> {
const result = await this.client!.createRecord(tableId, data);
return result as RecordResult;
}
/**
* Handle update operation - modify existing record
*/
private async handleUpdate(
tableId: string,
recordId: string,
data: Record<string, unknown>,
): Promise<RecordResult> {
const result = await this.client!.updateRecord(tableId, recordId, data);
return result as RecordResult;
}
/**
* Handle delete operation - remove record
*/
private async handleDelete(tableId: string, recordId: string): Promise<RecordResult> {
await this.client!.deleteRecord(tableId, recordId);
return {
deleted: true,
recordId,
};
}
/**
* Set SmartSuite client (for dependency injection)
* Allows tests to inject mocks
*/
setClient(client: SmartSuiteClient): void {
this.client = client;
}
}