Skip to content

AI Prompt: Forma3D.Connect — Phase 5e: Typed JSON Column Schemas

Purpose: This prompt instructs an AI to add Zod schemas and runtime validation for all JSON columns in the database
Estimated Effort: 3-4 days (~16-24 hours)
Prerequisites: Phase 5d completed (Frontend Test Coverage)
Output: Zod schemas for all JSON columns, validated at read/write boundaries, full type safety
Status: 🟡 PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 5d foundation. Your task is to implement Phase 5e: Typed JSON Column Schemas — specifically addressing TD-003 (Untyped JSON Columns in Database Schema) from the technical debt register.

Why This Matters:

Several database columns use Prisma's Json type without runtime validation, causing:

  1. Runtime Type Errors: Invalid data shapes cause crashes in production
  2. No IDE Support: Developers guess at JSON structure
  3. Inconsistent Validation: Each consumer validates differently
  4. Schema Drift: Frontend/backend expectations diverge over time

Phase 5e delivers:

  • Zod schemas for all JSON columns
  • Typed interfaces exported from libs/domain
  • Validation transformers at Prisma read/write boundaries
  • Full IDE autocomplete for JSON fields
  • Runtime validation with clear error messages

📋 Context: Technical Debt Item

TD-003: Untyped JSON Columns in Database Schema

Attribute Value
Type Code Debt
Priority High
Location prisma/schema.prisma (multiple models)
Interest Rate Medium-High (type errors in production)
Principal (Effort) 3-4 days

Current State

Model JSON Column Description Validated
Order shippingAddress Customer shipping address
ProductMapping defaultPrintProfile Default print settings
AssemblyPart printProfile Part-specific print settings
EventLog metadata Event context data
ProcessedWebhook payload Webhook request body

🛠️ Implementation Phases

Phase 1: Create Zod Schemas (4 hours)

Priority: Critical | Impact: High | Dependencies: None

1. Install Zod (if not present)

pnpm add zod

2. Create Shipping Address Schema

Create libs/domain/src/schemas/shipping-address.schema.ts:

import { z } from 'zod';

/**
 * Schema for Shopify shipping address format.
 * Validates addresses received from Shopify webhooks and stored in Order.shippingAddress.
 */
export const ShippingAddressSchema = z.object({
  /** First line of street address */
  address1: z.string().min(1, 'Address line 1 is required'),
  /** Second line of street address (optional) */
  address2: z.string().nullish(),
  /** City name */
  city: z.string().min(1, 'City is required'),
  /** Province/State (optional for some countries) */
  province: z.string().nullish(),
  /** Province/State code (e.g., "CA", "NY") */
  provinceCode: z.string().nullish(),
  /** Country name */
  country: z.string().min(1, 'Country is required'),
  /** Country code (ISO 3166-1 alpha-2) */
  countryCode: z.string().length(2, 'Country code must be 2 characters').nullish(),
  /** Postal/ZIP code */
  zip: z.string().min(1, 'ZIP/Postal code is required'),
  /** Phone number (optional) */
  phone: z.string().nullish(),
  /** Recipient first name */
  firstName: z.string().nullish(),
  /** Recipient last name */
  lastName: z.string().nullish(),
  /** Company name (optional) */
  company: z.string().nullish(),
  /** Full name (computed by Shopify) */
  name: z.string().nullish(),
});

export type ShippingAddress = z.infer<typeof ShippingAddressSchema>;

/**
 * Parse and validate shipping address data.
 * @throws ZodError if validation fails
 */
export function parseShippingAddress(data: unknown): ShippingAddress {
  return ShippingAddressSchema.parse(data);
}

/**
 * Safely parse shipping address, returning null on failure.
 */
export function safeParseShippingAddress(data: unknown): ShippingAddress | null {
  const result = ShippingAddressSchema.safeParse(data);
  return result.success ? result.data : null;
}

3. Create Print Profile Schema

Create libs/domain/src/schemas/print-profile.schema.ts:

import { z } from 'zod';

/**
 * Schema for 3D print profile settings.
 * Used in ProductMapping.defaultPrintProfile and AssemblyPart.printProfile.
 */
export const PrintProfileSchema = z.object({
  /** Print quality preset (SimplyPrint profile ID or name) */
  qualityPreset: z.string().optional(),
  /** Layer height in millimeters */
  layerHeight: z.number().min(0.05).max(0.5).optional(),
  /** Infill percentage (0-100) */
  infillPercentage: z.number().min(0).max(100).optional(),
  /** Infill pattern type */
  infillPattern: z.enum([
    'grid',
    'lines',
    'triangles',
    'cubic',
    'gyroid',
    'honeycomb',
  ]).optional(),
  /** Print speed in mm/s */
  printSpeed: z.number().min(10).max(500).optional(),
  /** Enable support structures */
  supportEnabled: z.boolean().optional(),
  /** Support pattern type */
  supportType: z.enum(['normal', 'tree', 'organic']).optional(),
  /** Build plate adhesion type */
  adhesionType: z.enum(['none', 'skirt', 'brim', 'raft']).optional(),
  /** Nozzle temperature in Celsius */
  nozzleTemp: z.number().min(150).max(350).optional(),
  /** Bed temperature in Celsius */
  bedTemp: z.number().min(0).max(150).optional(),
  /** Material type override */
  material: z.string().optional(),
  /** Preferred printer ID or group */
  preferredPrinter: z.string().optional(),
  /** Estimated print time in seconds (computed) */
  estimatedDuration: z.number().optional(),
  /** Custom G-code to run before print */
  customStartGcode: z.string().optional(),
  /** Custom G-code to run after print */
  customEndGcode: z.string().optional(),
  /** Additional key-value settings */
  additionalSettings: z.record(z.string(), z.unknown()).optional(),
});

export type PrintProfile = z.infer<typeof PrintProfileSchema>;

/**
 * Parse and validate print profile data.
 * @throws ZodError if validation fails
 */
export function parsePrintProfile(data: unknown): PrintProfile {
  return PrintProfileSchema.parse(data);
}

/**
 * Safely parse print profile, returning null on failure.
 */
export function safeParsePrintProfile(data: unknown): PrintProfile | null {
  const result = PrintProfileSchema.safeParse(data);
  return result.success ? result.data : null;
}

/**
 * Merge two print profiles, with overrides taking precedence.
 */
export function mergePrintProfiles(
  base: PrintProfile | null | undefined,
  overrides: PrintProfile | null | undefined,
): PrintProfile {
  return {
    ...base,
    ...overrides,
    additionalSettings: {
      ...base?.additionalSettings,
      ...overrides?.additionalSettings,
    },
  };
}

4. Create Event Metadata Schema

Create libs/domain/src/schemas/event-metadata.schema.ts:

import { z } from 'zod';

/**
 * Base schema for event log metadata.
 * Extensible via discriminated union based on event type.
 */
export const BaseEventMetadataSchema = z.object({
  /** Correlation ID for request tracing */
  correlationId: z.string().optional(),
  /** Source system that triggered the event */
  source: z.enum(['shopify', 'simplyprint', 'sendcloud', 'internal', 'manual']).optional(),
  /** User or API key that triggered the action */
  triggeredBy: z.string().optional(),
  /** Request ID from external system */
  externalRequestId: z.string().optional(),
  /** Timestamp when event was triggered */
  triggeredAt: z.string().datetime().optional(),
  /** Duration of the operation in milliseconds */
  durationMs: z.number().optional(),
});

/** Metadata for order-related events */
export const OrderEventMetadataSchema = BaseEventMetadataSchema.extend({
  shopifyOrderId: z.string().optional(),
  shopifyOrderNumber: z.string().optional(),
  previousStatus: z.string().optional(),
  newStatus: z.string().optional(),
  lineItemCount: z.number().optional(),
});

/** Metadata for print job events */
export const PrintJobEventMetadataSchema = BaseEventMetadataSchema.extend({
  simplyPrintJobId: z.string().optional(),
  printerId: z.string().optional(),
  printerName: z.string().optional(),
  fileName: z.string().optional(),
  previousStatus: z.string().optional(),
  newStatus: z.string().optional(),
  progress: z.number().min(0).max(100).optional(),
  errorCode: z.string().optional(),
});

/** Metadata for webhook events */
export const WebhookEventMetadataSchema = BaseEventMetadataSchema.extend({
  webhookTopic: z.string().optional(),
  webhookId: z.string().optional(),
  shopDomain: z.string().optional(),
  hmacValid: z.boolean().optional(),
  processingTimeMs: z.number().optional(),
});

/** Metadata for shipping events */
export const ShippingEventMetadataSchema = BaseEventMetadataSchema.extend({
  carrier: z.string().optional(),
  trackingNumber: z.string().optional(),
  labelUrl: z.string().url().optional(),
  shipmentId: z.string().optional(),
});

/** Metadata for error events */
export const ErrorEventMetadataSchema = BaseEventMetadataSchema.extend({
  errorName: z.string().optional(),
  errorMessage: z.string().optional(),
  errorStack: z.string().optional(),
  errorCode: z.string().optional(),
  retryCount: z.number().optional(),
  maxRetries: z.number().optional(),
});

/**
 * Union schema for all event metadata types.
 * Allows flexible metadata while maintaining type safety.
 */
export const EventMetadataSchema = z.union([
  OrderEventMetadataSchema,
  PrintJobEventMetadataSchema,
  WebhookEventMetadataSchema,
  ShippingEventMetadataSchema,
  ErrorEventMetadataSchema,
  BaseEventMetadataSchema,
]);

export type EventMetadata = z.infer<typeof EventMetadataSchema>;
export type OrderEventMetadata = z.infer<typeof OrderEventMetadataSchema>;
export type PrintJobEventMetadata = z.infer<typeof PrintJobEventMetadataSchema>;
export type WebhookEventMetadata = z.infer<typeof WebhookEventMetadataSchema>;
export type ShippingEventMetadata = z.infer<typeof ShippingEventMetadataSchema>;
export type ErrorEventMetadata = z.infer<typeof ErrorEventMetadataSchema>;

/**
 * Parse and validate event metadata.
 * Accepts any metadata shape matching one of the defined schemas.
 */
export function parseEventMetadata(data: unknown): EventMetadata {
  return EventMetadataSchema.parse(data);
}

/**
 * Safely parse event metadata, returning empty object on failure.
 */
export function safeParseEventMetadata(data: unknown): EventMetadata {
  const result = EventMetadataSchema.safeParse(data);
  return result.success ? result.data : {};
}

5. Create Webhook Payload Schema

Create libs/domain/src/schemas/webhook-payload.schema.ts:

import { z } from 'zod';

/**
 * Base schema for stored webhook payloads.
 * The full payload is stored for debugging and replay capability.
 */
export const WebhookPayloadSchema = z.object({
  /** Original webhook topic/type */
  topic: z.string(),
  /** Shop domain for Shopify webhooks */
  shopDomain: z.string().optional(),
  /** API version used */
  apiVersion: z.string().optional(),
  /** Timestamp when webhook was received */
  receivedAt: z.string().datetime(),
  /** Original request headers (filtered for security) */
  headers: z.record(z.string(), z.string()).optional(),
  /** The webhook body (varies by topic) */
  body: z.unknown(),
});

export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;

/**
 * Parse and validate webhook payload.
 */
export function parseWebhookPayload(data: unknown): WebhookPayload {
  return WebhookPayloadSchema.parse(data);
}

/**
 * Safely parse webhook payload.
 */
export function safeParseWebhookPayload(data: unknown): WebhookPayload | null {
  const result = WebhookPayloadSchema.safeParse(data);
  return result.success ? result.data : null;
}

6. Create Index Export

Create libs/domain/src/schemas/index.ts:

// Shipping Address
export {
  ShippingAddressSchema,
  type ShippingAddress,
  parseShippingAddress,
  safeParseShippingAddress,
} from './shipping-address.schema';

// Print Profile
export {
  PrintProfileSchema,
  type PrintProfile,
  parsePrintProfile,
  safeParsePrintProfile,
  mergePrintProfiles,
} from './print-profile.schema';

// Event Metadata
export {
  EventMetadataSchema,
  BaseEventMetadataSchema,
  OrderEventMetadataSchema,
  PrintJobEventMetadataSchema,
  WebhookEventMetadataSchema,
  ShippingEventMetadataSchema,
  ErrorEventMetadataSchema,
  type EventMetadata,
  type OrderEventMetadata,
  type PrintJobEventMetadata,
  type WebhookEventMetadata,
  type ShippingEventMetadata,
  type ErrorEventMetadata,
  parseEventMetadata,
  safeParseEventMetadata,
} from './event-metadata.schema';

// Webhook Payload
export {
  WebhookPayloadSchema,
  type WebhookPayload,
  parseWebhookPayload,
  safeParseWebhookPayload,
} from './webhook-payload.schema';

Update libs/domain/src/index.ts to include schemas:

// ... existing exports
export * from './schemas';

Phase 2: Create Prisma Extensions for Type Safety (4 hours)

Priority: High | Impact: High | Dependencies: Phase 1

1. Create JSON Column Transformer

Create apps/api/src/database/json-transformers.ts:

import {
  ShippingAddressSchema,
  PrintProfileSchema,
  EventMetadataSchema,
  WebhookPayloadSchema,
  type ShippingAddress,
  type PrintProfile,
  type EventMetadata,
  type WebhookPayload,
} from '@forma3d/domain';
import { Logger } from '@nestjs/common';

const logger = new Logger('JsonTransformers');

/**
 * Transform JSON column value with schema validation.
 * Logs warning on validation failure but returns null to prevent crashes.
 */
function safeTransform<T>(
  value: unknown,
  schema: { safeParse: (data: unknown) => { success: boolean; data?: T; error?: unknown } },
  columnName: string,
): T | null {
  if (value === null || value === undefined) {
    return null;
  }

  const result = schema.safeParse(value);
  if (!result.success) {
    logger.warn({
      message: `Invalid JSON in ${columnName}`,
      error: result.error,
      value: typeof value === 'object' ? JSON.stringify(value).slice(0, 200) : String(value),
    });
    return null;
  }

  return result.data as T;
}

/**
 * Transform Order.shippingAddress from JSON to typed object.
 */
export function transformShippingAddress(value: unknown): ShippingAddress | null {
  return safeTransform(value, ShippingAddressSchema, 'shippingAddress');
}

/**
 * Transform ProductMapping.defaultPrintProfile or AssemblyPart.printProfile.
 */
export function transformPrintProfile(value: unknown): PrintProfile | null {
  return safeTransform(value, PrintProfileSchema, 'printProfile');
}

/**
 * Transform EventLog.metadata.
 */
export function transformEventMetadata(value: unknown): EventMetadata | null {
  return safeTransform(value, EventMetadataSchema, 'metadata');
}

/**
 * Transform ProcessedWebhook.payload.
 */
export function transformWebhookPayload(value: unknown): WebhookPayload | null {
  return safeTransform(value, WebhookPayloadSchema, 'payload');
}

/**
 * Validate data before storing in JSON column.
 * @throws Error if validation fails (prevents bad data from being stored)
 */
export function validateForStorage<T>(
  data: T,
  schema: { parse: (data: unknown) => T },
  columnName: string,
): T {
  try {
    return schema.parse(data);
  } catch (error) {
    throw new Error(`Invalid data for ${columnName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
  }
}

2. Create Typed Repository Helpers

Create apps/api/src/database/typed-json.helper.ts:

import { Prisma } from '@prisma/client';
import {
  ShippingAddress,
  PrintProfile,
  EventMetadata,
  WebhookPayload,
  ShippingAddressSchema,
  PrintProfileSchema,
  EventMetadataSchema,
  WebhookPayloadSchema,
} from '@forma3d/domain';

/**
 * Prepare ShippingAddress for database storage.
 * Validates and converts to Prisma JSON input.
 */
export function toShippingAddressJson(
  address: ShippingAddress | null | undefined,
): Prisma.InputJsonValue | null {
  if (!address) return null;
  const validated = ShippingAddressSchema.parse(address);
  return validated as unknown as Prisma.InputJsonValue;
}

/**
 * Prepare PrintProfile for database storage.
 */
export function toPrintProfileJson(
  profile: PrintProfile | null | undefined,
): Prisma.InputJsonValue | null {
  if (!profile) return null;
  const validated = PrintProfileSchema.parse(profile);
  return validated as unknown as Prisma.InputJsonValue;
}

/**
 * Prepare EventMetadata for database storage.
 */
export function toEventMetadataJson(
  metadata: EventMetadata | null | undefined,
): Prisma.InputJsonValue | null {
  if (!metadata) return null;
  const validated = EventMetadataSchema.parse(metadata);
  return validated as unknown as Prisma.InputJsonValue;
}

/**
 * Prepare WebhookPayload for database storage.
 */
export function toWebhookPayloadJson(
  payload: WebhookPayload | null | undefined,
): Prisma.InputJsonValue | null {
  if (!payload) return null;
  const validated = WebhookPayloadSchema.parse(payload);
  return validated as unknown as Prisma.InputJsonValue;
}

Phase 3: Update Repositories to Use Typed JSON (6 hours)

Priority: High | Impact: High | Dependencies: Phase 2

1. Update Orders Repository

Update apps/api/src/orders/orders.repository.ts:

import { transformShippingAddress } from '../database/json-transformers';
import { toShippingAddressJson } from '../database/typed-json.helper';
import { ShippingAddress } from '@forma3d/domain';

// In create method:
async create(data: CreateOrderInput): Promise<OrderWithTypedAddress> {
  const order = await this.prisma.order.create({
    data: {
      ...data,
      shippingAddress: toShippingAddressJson(data.shippingAddress),
    },
  });

  return {
    ...order,
    shippingAddress: transformShippingAddress(order.shippingAddress),
  };
}

// Define return type with typed address
interface OrderWithTypedAddress extends Omit<Order, 'shippingAddress'> {
  shippingAddress: ShippingAddress | null;
}

2. Update ProductMapping Repository

Update apps/api/src/product-mappings/product-mappings.repository.ts:

import { transformPrintProfile } from '../database/json-transformers';
import { toPrintProfileJson } from '../database/typed-json.helper';
import { PrintProfile } from '@forma3d/domain';

// Add type transformation in findOne:
async findOne(id: string): Promise<MappingWithTypedProfile | null> {
  const mapping = await this.prisma.productMapping.findUnique({
    where: { id },
    include: { assemblyParts: true },
  });

  if (!mapping) return null;

  return {
    ...mapping,
    defaultPrintProfile: transformPrintProfile(mapping.defaultPrintProfile),
    assemblyParts: mapping.assemblyParts.map(part => ({
      ...part,
      printProfile: transformPrintProfile(part.printProfile),
    })),
  };
}

3. Update EventLog Repository

Update apps/api/src/event-log/event-log.repository.ts:

import { transformEventMetadata } from '../database/json-transformers';
import { toEventMetadataJson } from '../database/typed-json.helper';
import { EventMetadata } from '@forma3d/domain';

// In create method:
async create(data: CreateEventLogInput): Promise<EventLogWithTypedMetadata> {
  const log = await this.prisma.eventLog.create({
    data: {
      ...data,
      metadata: toEventMetadataJson(data.metadata),
    },
  });

  return {
    ...log,
    metadata: transformEventMetadata(log.metadata),
  };
}

4. Update Webhook Idempotency Repository

Update apps/api/src/shopify/webhook-idempotency.repository.ts:

import { toWebhookPayloadJson } from '../database/typed-json.helper';
import { WebhookPayload } from '@forma3d/domain';

async markProcessed(
  webhookId: string,
  payload: WebhookPayload,
): Promise<void> {
  await this.prisma.processedWebhook.create({
    data: {
      webhookId,
      payload: toWebhookPayloadJson(payload),
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
    },
  });
}

Phase 4: Add Schema Tests (4 hours)

Priority: High | Impact: Medium | Dependencies: Phase 1

1. Test Shipping Address Schema

Create libs/domain/src/schemas/__tests__/shipping-address.schema.test.ts:

import { describe, it, expect } from 'vitest';
import {
  ShippingAddressSchema,
  parseShippingAddress,
  safeParseShippingAddress,
} from '../shipping-address.schema';

describe('ShippingAddressSchema', () => {
  const validAddress = {
    address1: '123 Main Street',
    city: 'Amsterdam',
    country: 'Netherlands',
    countryCode: 'NL',
    zip: '1012 AB',
  };

  describe('validation', () => {
    it('should validate a minimal valid address', () => {
      const result = ShippingAddressSchema.safeParse(validAddress);
      expect(result.success).toBe(true);
    });

    it('should validate a full address', () => {
      const fullAddress = {
        ...validAddress,
        address2: 'Apt 4B',
        province: 'North Holland',
        provinceCode: 'NH',
        phone: '+31 20 123 4567',
        firstName: 'John',
        lastName: 'Doe',
        company: 'Forma3D',
        name: 'John Doe',
      };

      const result = ShippingAddressSchema.safeParse(fullAddress);
      expect(result.success).toBe(true);
    });

    it('should reject missing required fields', () => {
      const invalid = { city: 'Amsterdam' };
      const result = ShippingAddressSchema.safeParse(invalid);
      expect(result.success).toBe(false);
    });

    it('should reject invalid country code length', () => {
      const invalid = { ...validAddress, countryCode: 'NLD' };
      const result = ShippingAddressSchema.safeParse(invalid);
      expect(result.success).toBe(false);
    });
  });

  describe('parseShippingAddress', () => {
    it('should return parsed address for valid input', () => {
      const result = parseShippingAddress(validAddress);
      expect(result.address1).toBe('123 Main Street');
    });

    it('should throw for invalid input', () => {
      expect(() => parseShippingAddress({})).toThrow();
    });
  });

  describe('safeParseShippingAddress', () => {
    it('should return address for valid input', () => {
      const result = safeParseShippingAddress(validAddress);
      expect(result).not.toBeNull();
      expect(result?.city).toBe('Amsterdam');
    });

    it('should return null for invalid input', () => {
      const result = safeParseShippingAddress({});
      expect(result).toBeNull();
    });
  });
});

2. Test Print Profile Schema

Create libs/domain/src/schemas/__tests__/print-profile.schema.test.ts:

import { describe, it, expect } from 'vitest';
import {
  PrintProfileSchema,
  parsePrintProfile,
  mergePrintProfiles,
} from '../print-profile.schema';

describe('PrintProfileSchema', () => {
  describe('validation', () => {
    it('should validate empty profile', () => {
      const result = PrintProfileSchema.safeParse({});
      expect(result.success).toBe(true);
    });

    it('should validate full profile', () => {
      const profile = {
        qualityPreset: 'high',
        layerHeight: 0.2,
        infillPercentage: 20,
        infillPattern: 'gyroid',
        printSpeed: 60,
        supportEnabled: true,
        supportType: 'tree',
        adhesionType: 'brim',
        nozzleTemp: 210,
        bedTemp: 60,
        material: 'PLA',
      };

      const result = PrintProfileSchema.safeParse(profile);
      expect(result.success).toBe(true);
    });

    it('should reject invalid layer height', () => {
      const result = PrintProfileSchema.safeParse({ layerHeight: 1.0 });
      expect(result.success).toBe(false);
    });

    it('should reject invalid infill percentage', () => {
      const result = PrintProfileSchema.safeParse({ infillPercentage: 150 });
      expect(result.success).toBe(false);
    });

    it('should reject invalid infill pattern', () => {
      const result = PrintProfileSchema.safeParse({ infillPattern: 'invalid' });
      expect(result.success).toBe(false);
    });
  });

  describe('mergePrintProfiles', () => {
    it('should merge two profiles', () => {
      const base = { layerHeight: 0.2, infillPercentage: 20 };
      const overrides = { infillPercentage: 40, material: 'PETG' };

      const merged = mergePrintProfiles(base, overrides);

      expect(merged.layerHeight).toBe(0.2);
      expect(merged.infillPercentage).toBe(40);
      expect(merged.material).toBe('PETG');
    });

    it('should handle null base', () => {
      const overrides = { layerHeight: 0.1 };
      const merged = mergePrintProfiles(null, overrides);
      expect(merged.layerHeight).toBe(0.1);
    });

    it('should merge additional settings', () => {
      const base = { additionalSettings: { key1: 'value1' } };
      const overrides = { additionalSettings: { key2: 'value2' } };

      const merged = mergePrintProfiles(base, overrides);

      expect(merged.additionalSettings).toEqual({
        key1: 'value1',
        key2: 'value2',
      });
    });
  });
});

Phase 5: Documentation Updates (30 minutes)

Priority: Medium | Impact: Medium | Dependencies: Phase 4

1. Update Technical Debt Register

Update docs/04-development/techdebt/technical-debt-register.md:

### ~~TD-003: Untyped JSON Columns in Database Schema~~ ✅ RESOLVED

**Type:** Code Debt  
**Status:****Resolved in Phase 5e**  
**Resolution Date:** 2026-XX-XX

#### Resolution

Implemented Zod schemas for all JSON columns with runtime validation:

- **Shipping Address**: Full validation for Shopify address format
- **Print Profile**: Typed 3D print settings with constraints
- **Event Metadata**: Discriminated union for different event types
- **Webhook Payload**: Schema for stored webhook data

**Files Created:**
- `libs/domain/src/schemas/shipping-address.schema.ts`
- `libs/domain/src/schemas/print-profile.schema.ts`
- `libs/domain/src/schemas/event-metadata.schema.ts`
- `libs/domain/src/schemas/webhook-payload.schema.ts`
- `apps/api/src/database/json-transformers.ts`
- `apps/api/src/database/typed-json.helper.ts`

📁 Files to Create/Modify

New Files

libs/domain/src/schemas/
  shipping-address.schema.ts
  print-profile.schema.ts
  event-metadata.schema.ts
  webhook-payload.schema.ts
  index.ts
  __tests__/
    shipping-address.schema.test.ts
    print-profile.schema.test.ts
    event-metadata.schema.test.ts
    webhook-payload.schema.test.ts

apps/api/src/database/
  json-transformers.ts
  typed-json.helper.ts

Modified Files

libs/domain/src/index.ts                          # Export schemas
apps/api/src/orders/orders.repository.ts          # Use typed JSON
apps/api/src/product-mappings/product-mappings.repository.ts
apps/api/src/event-log/event-log.repository.ts
apps/api/src/shopify/webhook-idempotency.repository.ts
docs/04-development/techdebt/technical-debt-register.md

✅ Validation Checklist

Phase 1: Zod Schemas

  • Zod installed as dependency
  • ShippingAddressSchema created with all Shopify fields
  • PrintProfileSchema created with print settings
  • EventMetadataSchema created with event variants
  • WebhookPayloadSchema created
  • All schemas exported from libs/domain

Phase 2: Transformers

  • json-transformers.ts with safe parsing
  • typed-json.helper.ts with storage validation
  • Logging for validation failures

Phase 3: Repository Updates

  • Orders repository uses typed shipping address
  • ProductMappings repository uses typed print profile
  • EventLog repository uses typed metadata
  • Webhook repository uses typed payload

Phase 4: Tests

  • Schema validation tests pass
  • Edge case tests for all schemas
  • Merge function tests for print profiles

Final Verification

# All tests pass
pnpm nx test domain
pnpm nx test api

# Build succeeds
pnpm nx build api

# Lint passes
pnpm nx lint domain
pnpm nx lint api

🚫 Constraints and Rules

MUST DO

  • Use Zod for all schema definitions
  • Validate at repository boundaries
  • Log validation failures (don't crash)
  • Export types from libs/domain
  • Add tests for all schemas

MUST NOT

  • Throw on read validation failures (use safe parse)
  • Skip validation on write operations
  • Use as unknown as type casts
  • Duplicate schema definitions
  • Store unvalidated data in JSON columns

END OF PROMPT


This prompt resolves TD-003 from the technical debt register by implementing Zod schemas for all JSON columns with runtime validation at read/write boundaries.