Terms Setup Implementation Details

Implementation notes, patterns, and development guidelines

Implementation Status

Directory Structure

API Route Handlers

Location: apps/zooly-app/app/api/terms-setup/

apps/zooly-app/app/api/terms-setup/
├── approveIpTerms/
│   └── route.ts                # POST endpoint handler
├── deleteIpTerms/
│   └── route.ts                # DELETE endpoint handler
├── getOrCreateIpTerms/
│   └── route.ts                # POST endpoint handler
└── listIpTerms/
    └── route.ts                # GET endpoint handler

Service Layer

Location: packages/app/srv/src/terms-setup/

packages/app/srv/src/terms-setup/
├── approveIpTerms/
│   └── approveIpTerms.ts       # Core approval logic
├── deleteIpTerms/
│   └── deleteIpTerms.ts        # Core deletion logic
├── getOrCreateIpTerms/
│   ├── getOrCreateIpTerms.ts   # Core retrieval/creation logic
│   └── generateIpTerms.ts      # Default terms generation
├── listIpTerms/
│   └── listIpTerms.ts          # List all terms for account
└── functions/
    ├── formatIpTerms.ts         # Shared formatting utility
    ├── auth.ts                  # Authentication/authorization helpers
    └── responses.ts             # Response helpers

Database Layer

Location: packages/db/src/

packages/db/src/
├── schema/
│   └── ipTerms.ts              # Database schema definition
└── access/
    └── ipTerms.ts              # Database access functions

Terms Package

Location: packages/terms/

packages/terms/
├── src/
│   ├── index.ts                # Main exports
│   └── enums/
│       └── ip-terms/
│           ├── voice-over/
│           │   ├── voice-over-terms.ts      # Blueprint with defaults
│           │   └── formatIpTermsVoiceOver.ts # Formatter function
│           ├── image/
│           │   ├── image-terms.ts
│           │   └── formatIpTermsImage.ts
│           └── likeness/
│               ├── likeness-terms.ts
│               └── formatIpTermsLikeness.ts

Implementation Patterns

Authentication Pattern

All route handlers follow this authentication pattern:

  1. Extract cookie header from request
  2. Call resolveAccountId(cookieHeader, targetAccountId?) which:
    • Authenticates user via getVerifiedUserInfo()
    • Fetches account via getAccountByUserId(user.id)
    • If targetAccountId provided and differs from user's account:
      • Checks user.roles?.includes("admin")
      • Throws 403 if not admin
    • Returns resolved accountId and userId
  3. Use resolved values for subsequent operations

Authorization Pattern

Authorization checks happen at two levels:

  1. Route Level: resolveAccountId() checks admin role when accountId parameter is provided
  2. Service Level: Core functions verify ownership:
    • Fetch term from database
    • Check term.accountId === accountId or user has admin role
    • Throw 403 if ownership mismatch

Error Handling Pattern

All layers follow consistent error handling:

try {
  // Operation logic
  return successResponse(data);
} catch (error) {
  console.error("Operation failed:", error);
  return errorResponse(error.message, statusCode);
}

Error Types:

  • Validation Errors: 400 Bad Request
  • Authentication Errors: 401 Unauthorized
  • Authorization Errors: 403 Forbidden
  • Not Found Errors: 404 Not Found
  • Server Errors: 500 Internal Server Error

Response Pattern

All endpoints return standardized responses:

Success:

{
  status: "success",
  data: { /* endpoint-specific data */ }
}

Error:

{
  status: "error",
  error: "Error message"
}

Formatting Pattern

Terms are formatted using a centralized function:

  1. Route Handler calls service function
  2. Service Function calls formatIpTerms(ipTerms, accountData)
  3. formatIpTerms() routes to type-specific formatter based on ipTermType
  4. Type-Specific Formatter (from @zooly/terms) generates:
    • shortTerms - Brief description with seller name
    • bullets - Array of formatted bullet points
    • longTerms - Full legal text
  5. formatIpTerms() computes priceUSDollar by parsing compensationValue string
  6. Returns FormattedIpTermsResult

Default Generation Pattern

When terms don't exist, default terms are generated:

  1. Service Function calls generateIpTerms(ipTermType, accountData, createdBy)
  2. generateIpTerms() selects default function by ipTermType:
    • VoiceOvergetDefaultVoiceOverTerms()
    • ImagegetDefaultImageTerms()
    • LikenessgetDefaultLikenessTerms()
  3. Default Function (from @zooly/terms) returns Partial<NewIpTerms> with defaults
  4. generateIpTerms() creates database record via createIpTerms()
  5. Formats and returns using formatIpTerms()

Key Functions

formatIpTerms()

Location: packages/app/srv/src/terms-setup/functions/formatIpTerms.ts

Purpose: Formats raw database records into structured, human-readable format with legal text.

Signature:

function formatIpTerms(
  ipTerms: IpTerms,
  accountData: Account
): FormattedIpTermsResult

Behavior:

  1. Extracts sellerName from accountData.displayName (defaults to empty string)
  2. Routes to type-specific formatter based on ipTermType
  3. Each formatter generates shortTerms, bullets, and longTerms
  4. Extracts priceUSDollar by parsing compensationValue decimal string to number
  5. Returns FormattedIpTermsResult with seller data and formatted terms

resolveAccountId()

Location: packages/app/srv/src/terms-setup/functions/auth.ts

Purpose: Handles authentication and authorization, resolving target account ID.

Signature:

async function resolveAccountId(
  cookieHeader: string | null,
  targetAccountId?: string
): Promise<ResolvedAccount>

Behavior:

  1. Authenticates user via getVerifiedUserInfo(cookieHeader)
  2. Fetches account via getAccountByUserId(user.id)
  3. If targetAccountId provided and differs from user's account:
    • Checks user.roles?.includes("admin")
    • Throws 403 if not admin
  4. Returns { accountId, userId }

getOrCreateIpTerms()

Location: packages/app/srv/src/terms-setup/getOrCreateIpTerms/getOrCreateIpTerms.ts

Purpose: Retrieves existing IP terms or generates new ones if they don't exist.

Signature:

async function getOrCreateIpTerms(
  ipTermType: IpTermType,
  accountData: Account,
  createdBy: string
): Promise<FormattedIpTermsResult>

Behavior:

  1. Queries for existing term with matching accountId and ipTermType (excluding soft-deleted)
  2. If found: Formats and returns using formatIpTerms()
  3. If not found: Calls generateIpTerms() to create defaults
  4. Returns formatted result

generateIpTerms()

Location: packages/app/srv/src/terms-setup/getOrCreateIpTerms/generateIpTerms.ts

Purpose: Creates a new IpTerms record with default values based on term type.

Signature:

async function generateIpTerms(
  ipTermType: IpTermType,
  accountData: Account,
  createdBy: string
): Promise<FormattedIpTermsResult>

Behavior:

  1. Selects default terms function based on ipTermType
  2. Calls default function from @zooly/terms package
  3. Builds insert payload with all required fields
  4. Creates database record via createIpTerms()
  5. Formats and returns using formatIpTerms()

approveIpTerms()

Location: packages/app/srv/src/terms-setup/approveIpTerms/approveIpTerms.ts

Purpose: Updates IP terms with new data and marks them as approved.

Signature:

async function approveIpTerms(params: {
  ipTermsId: string;
  ipTerms: Partial<UpdateIpTerms>;
  accountId: string;
  userId: string;
}): Promise<FormattedIpTermsResult>

Behavior:

  1. Fetches term by getIpTermsById(ipTermsId) (throws 404 if not found)
  2. Verifies ownership: term.accountId === accountId (admin check done at route level)
  3. Builds update object: merges ipTerms, sets ipApprove: true, updatedBy: userId
  4. Updates via updateIpTerms() (sets updatedAt explicitly)
  5. Logs audit event to console
  6. Fetches account and formats using formatIpTerms()
  7. Returns formatted result

deleteIpTerms()

Location: packages/app/srv/src/terms-setup/deleteIpTerms/deleteIpTerms.ts

Purpose: Soft-deletes an IP terms record.

Signature:

async function deleteIpTerms(
  ipTermsId: string,
  accountId: string,
  userId: string
): Promise<IpTerms>

Behavior:

  1. Fetches term by getIpTermsById(ipTermsId) (throws 404 if not found)
  2. Verifies ownership: term.accountId === accountId
  3. Calls softDeleteIpTerms(ipTermsId, userId) which:
    • Sets deletedAt to current timestamp
    • Sets updatedBy to userId
    • Sets updatedAt to current timestamp
  4. Logs audit event to console
  5. Returns soft-deleted record

listIpTerms()

Location: packages/app/srv/src/terms-setup/listIpTerms/listIpTerms.ts

Purpose: Retrieves all IP terms for an account.

Signature:

async function listIpTerms(
  accountId: string
): Promise<FormattedIpTermsResult[]>

Behavior:

  1. Calls listIpTermsByAccountId(accountId) (excludes soft-deleted, ordered by ipTermType)
  2. Fetches account once via getAccountById(accountId)
  3. Maps each term through formatIpTerms(term, accountData)
  4. Returns array of formatted terms

Database Access Functions

All database operations go through access functions in packages/db/src/access/ipTerms.ts:

getIpTermsById()

Queries by ID, excluding soft-deleted records. Returns null if not found or soft-deleted.

getIpTermsByAccountAndType()

Queries by accountId and ipTermType, excluding soft-deleted records. Returns null if not found.

listIpTermsByAccountId()

Queries all terms for accountId, excluding soft-deleted records. Ordered by ipTermType ascending.

createIpTerms()

Inserts new term with explicit defaults for ipApprove and termsVersion. Uses Drizzle .returning() to get created record.

updateIpTerms()

Updates term by ID, excluding soft-deleted records. Always sets updatedAt: new Date() explicitly. Returns updated row or null.

softDeleteIpTerms()

Sets deletedAt, updatedBy, and updatedAt to current timestamp. Filters to deletedAt IS NULL to prevent double-deletion. Returns soft-deleted record or null.

Type Safety

The entire implementation uses strong TypeScript typing:

  • Database Schema: Drizzle ORM generates types from schema
  • Shared Types: Types defined in @zooly/types package
  • Function Signatures: All functions have explicit parameter and return types
  • Enum Validation: Enum values validated at runtime and compile time

Type Conversions

Important: compensationValue is stored as decimal string in database. Always use priceUSDollar from formatted response for numeric operations:

// ❌ Don't use directly
const price = parseFloat(ipTerms.compensationValue); // May be null, string conversion

// ✅ Use formatted response
const price = formattedResult.priceUSDollar; // Already converted, null-safe

Testing Considerations

Unit Tests

Test individual functions in isolation:

  • Formatting Functions: Test with various input combinations
  • Default Generators: Verify default values match expected structure
  • Validation: Test enum validation and input validation
  • Type-Specific Logic: Test each term type separately

Integration Tests

Test database operations with test database:

  • Access Functions: Test CRUD operations
  • Soft Delete: Verify soft-deleted records are excluded
  • Unique Constraint: Test partial unique index behavior
  • Foreign Key: Test referential integrity

E2E Tests

Test API endpoints with authenticated requests:

  • Authentication: Test with valid/invalid sessions
  • Authorization: Test admin vs non-admin access
  • Error Cases: Test all error scenarios

Common Pitfalls

1. Accessing Table Directly

Don't: Query ipTermsTable directly Do: Use access functions from packages/db/src/access/ipTerms.ts

2. Forgetting Soft Delete Filter

Don't: Query without checking deletedAt IS NULL Do: Use access functions (they handle this automatically)

3. Using compensationValue Directly

Don't: Parse compensationValue string directly Do: Use priceUSDollar from formatted response

4. Not Setting updatedBy

Don't: Update without setting updatedBy Do: Always provide updatedBy when updating

5. Hard Deleting Records

Don't: Physically delete records Do: Use softDeleteIpTerms() to preserve audit trail

Future Enhancements

Client Components

React components for managing terms in the UI:

  • Form components for editing term fields
  • Display components for showing formatted legal text
  • Approval workflows
  • List views for all terms

Audit Table

Create ip_terms_audit table for:

  • Detailed change history
  • Previous and new values
  • Full audit trail for compliance

Terms Expiration

Add expiresAt field for:

  • Time-limited terms
  • Automatic expiration handling
  • Renewal workflows

Rate Limiting

Add rate limiting to API endpoints to prevent abuse.