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:
- Runtime Type Errors: Invalid data shapes cause crashes in production
- No IDE Support: Developers guess at JSON structure
- Inconsistent Validation: Each consumer validates differently
- 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 astype 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.