Skip to content

Repositories

Last Updated: 2025-01-22

The Repository layer provides a clean abstraction over Prisma database operations, implementing the Repository Pattern for data access in AccessALI.


Overview

Repositories abstract database operations behind a consistent interface, making the codebase easier to test, maintain, and reason about.

Benefits

  • Abstraction - Hide Prisma implementation details
  • Testability - Easy to mock for unit tests
  • Consistency - Standardized data access patterns
  • Type Safety - Full TypeScript support
  • Error Handling - Centralized error management

Architecture

graph TB
    UseCase[Use Case Layer] --> Repo[Repository Layer]
    Repo --> Prisma[Prisma Client]
    Prisma --> DB[(PostgreSQL)]
    Repo --> Errors[Custom Errors]

Repository Structure

User Repository

// lib/repositories/user-repository.ts
import { prisma } from '@/lib/prisma'
import type { Prisma, User } from '@prisma/client'

export type PublicUser = {
  id: string
  email: string
  firstName: string | null
  lastName: string | null
  profilePhoto: string | null
  role: UserRole
  status: UserStatus
}

export async function getUserByEmail(email: string): Promise<PublicUser | null> {
  const normalizedEmail = email.toLowerCase().trim()

  return prisma.user.findUnique({
    where: { email: normalizedEmail },
    select: {
      id: true,
      email: true,
      firstName: true,
      lastName: true,
      profilePhoto: true,
      role: true,
      status: true,
    },
  })
}

export async function getUserProfile(userId: string): Promise<UserProfileWithCounts | null> {
  try {
    return await prisma.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        profilePhoto: true,
        role: true,
        status: true,
        lastLogin: true,
        createdAt: true,
        updatedAt: true,
        _count: {
          select: { properties: true },
        },
      },
    })
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error in getUserProfile', err)
    throw new Error(`Failed to get user profile: ${err.message}`)
  }
}

export async function createUser(data: Prisma.UserCreateInput): Promise<User> {
  return prisma.user.create({ data })
}

export async function userExists(email: string): Promise<boolean> {
  const user = await prisma.user.findUnique({
    where: { email: email.toLowerCase().trim() },
    select: { id: true },
  })
  return Boolean(user)
}

Property Repository

// lib/repositories/property-repository.ts
import { prisma } from '@/lib/prisma'
import { NotFoundError, UnauthorizedError, DatabaseError } from './errors'

export async function getUserProperties(userId: string): Promise<PropertyListItem[]> {
  try {
    return await prisma.property.findMany({
      where: { userId },
      select: {
        id: true,
        projectName: true,
        unitNumber: true,
        address: true,
        imageUrl: true,
        milestoneStatus: true,
        milestoneProgress: true,
        brand: true,
      },
      orderBy: { createdAt: 'desc' },
    })
  } catch (error) {
    logger.error('Error in getUserProperties', error)
    throw new DatabaseError('get user properties', error)
  }
}

export async function getPropertyById(
  propertyId: string,
  userId?: string
): Promise<PropertyListItem | null> {
  try {
    const property = await prisma.property.findUnique({
      where: { id: propertyId },
      select: {
        id: true,
        userId: true,
        projectName: true,
        unitNumber: true,
        address: true,
        imageUrl: true,
        milestoneStatus: true,
        milestoneProgress: true,
        brand: true,
      },
    })

    if (!property) {
      throw new NotFoundError('Property', propertyId)
    }

    // Validate ownership if userId provided
    if (userId && property.userId !== userId) {
      throw new UnauthorizedError('Property', propertyId, userId)
    }

    return property
  } catch (error) {
    if (error instanceof NotFoundError || error instanceof UnauthorizedError) {
      throw error
    }
    logger.error('Error in getPropertyById', error)
    throw new DatabaseError('get property by id', error)
  }
}

export async function validatePropertyOwnership(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const property = await prisma.property.findUnique({
    where: { id: propertyId },
    select: { userId: true },
  })

  if (!property) {
    throw new NotFoundError('Property', propertyId)
  }

  if (property.userId !== userId) {
    throw new UnauthorizedError('Property', propertyId, userId)
  }

  return true
}

Error Handling

Custom Error Classes

// lib/repositories/errors.ts
export class RepositoryError extends Error {
  constructor(
    message: string,
    public code?: string,
    public statusCode?: number,
    public cause?: unknown
  ) {
    super(message)
    this.name = this.constructor.name
  }
}

export class NotFoundError extends RepositoryError {
  constructor(entityName: string, id: string, cause?: unknown) {
    super(
      `${entityName} with id "${id}" not found`,
      'NOT_FOUND',
      404,
      cause
    )
  }
}

export class UnauthorizedError extends RepositoryError {
  constructor(entityName: string, id: string, userId: string, cause?: unknown) {
    super(
      `User "${userId}" does not have access to ${entityName} "${id}"`,
      'UNAUTHORIZED',
      403,
      cause
    )
  }
}

export class DatabaseError extends RepositoryError {
  constructor(operation: string, cause?: unknown) {
    super(
      `Database operation failed: ${operation}`,
      'DATABASE_ERROR',
      500,
      cause
    )
  }
}

Error Usage

export async function getPropertyById(id: string): Promise<Property> {
  try {
    const property = await prisma.property.findUnique({ where: { id } })

    if (!property) {
      throw new NotFoundError('Property', id)
    }

    return property
  } catch (error) {
    // Re-throw custom errors
    if (error instanceof NotFoundError) {
      throw error
    }

    // Wrap database errors
    logger.error('Database error:', error)
    throw new DatabaseError('get property by id', error)
  }
}

Query Optimization

Select Only Needed Fields

Source: src/lib/repositories/property-repository.ts:23-84

// ❌ Bad - Returns all fields (heavy query)
const property = await prisma.property.findUnique({ where: { id } })

// ✅ Good - Select specific fields (optimized)
const property = await prisma.property.findUnique({
  where: { id },
  select: {
    id: true,
    projectName: true,
    unitNumber: true,
    block: true,
    lot: true,
    status: true,
    imageUrl: true,
  },
})

Dashboard Optimized Query

Real Example: Dashboard property query with nested relations

Source: src/lib/repositories/property-repository.ts:86-181

/**
 * Get user properties optimized for dashboard display
 *
 * Optimizations:
 * - Select only fields needed for dashboard cards
 * - Limit milestones to current only (take: 1)
 * - Limit payments to pending only
 * - Use _count for document counts instead of loading all documents
 *
 * Performance: ~10x faster than fetching full property objects
 */
export async function getUserPropertiesForDashboard(
  userId: string
): Promise<DashboardProperty[]> {
  try {
    const properties = await prisma.property.findMany({
      where: { userId },
      select: {
        // Only select fields needed for dashboard cards
        id: true,
        projectName: true,
        unitNumber: true,
        block: true,
        lot: true,
        phase: true,
        model: true,
        status: true,
        imageUrl: true,
        turnoverDate: true,
        createdAt: true,

        // Fetch current milestone only (not all milestones)
        milestones: {
          select: {
            id: true,
            name: true,
            status: true,
            progressPercentage: true,
            targetDate: true,
            completedDate: true,
          },
          take: DASHBOARD_CURRENT_MILESTONE_LIMIT, // 1
          orderBy: { createdAt: 'desc' },
        },

        // Fetch pending payments only (not all payments)
        payments: {
          select: {
            id: true,
            description: true,
            amount: true,
            status: true,
            dueDate: true,
          },
          where: { status: 'PENDING' },
          take: DASHBOARD_PENDING_PAYMENTS_LIMIT, // 3
          orderBy: { dueDate: 'asc' },
        },

        // Use _count for document counts (very efficient)
        _count: {
          select: { documents: true },
        },
      },
      orderBy: { createdAt: 'desc' },
      take: DASHBOARD_PROPERTIES_LIMIT, // 10
    })

    // Transform Decimal types to numbers for Client Component serialization
    // Prisma returns Decimal for financial fields, must convert for JSON
    return properties.map((property) => ({
      ...property,
      payments: property.payments.map((payment) => ({
        ...payment,
        amount: payment.amount.toNumber(), // Convert Decimal to number
      })),
    }))
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error in getUserPropertiesForDashboard', err)
    throw new DatabaseError('get user properties for dashboard', err)
  }
}

Performance Impact:

  • Fetches ~15 fields instead of ~30+ fields per property
  • Fetches 1 milestone instead of 10+ milestones
  • Fetches 3 payments instead of 20+ payments
  • Uses _count for documents (1 number) instead of loading all document objects
  • Result: ~10x faster and much smaller payload

Decimal Type Handling

Important: Prisma returns Decimal type for financial fields, which must be converted to number for JSON serialization.

Source: src/lib/repositories/property-repository.ts:86-181

// ❌ Wrong - Sends Decimal objects to client (causes JSON error)
return properties.map(property => ({
  ...property,
  totalContractPrice: property.totalContractPrice // Decimal object
}))

// ✅ Correct - Convert Decimal to number for client
return properties.map(property => ({
  ...property,
  totalContractPrice: property.totalContractPrice
    ? property.totalContractPrice.toNumber()
    : null,
  payments: property.payments.map(payment => ({
    ...payment,
    amount: payment.amount.toNumber(), // Convert each Decimal field
    amountPaid: payment.amountPaid ? payment.amountPaid.toNumber() : null,
  })),
}))

Why Decimal in Database:

  • Prevents floating-point precision errors
  • 123.45 stored exactly, not as 123.44999999
  • Critical for financial calculations

Why Number in Client:

  • JSON doesn't support Decimal objects
  • React components expect numbers
  • Conversion safe after calculations complete

Use Aggregations

Source: src/lib/repositories/property-repository.ts:183-233

/**
 * Get property statistics using single aggregate query
 *
 * Much more efficient than fetching all properties and calculating in code
 */
export async function getUserPropertyStats(userId: string) {
  try {
    const [totalProperties, activeProperties, completedProperties, financials] =
      await Promise.all([
        // Count total properties
        prisma.property.count({ where: { userId } }),

        // Count active properties
        prisma.property.count({
          where: {
            userId,
            status: { in: ['UNDER_CONSTRUCTION', 'PRE_CONSTRUCTION'] },
          },
        }),

        // Count completed properties
        prisma.property.count({
          where: { userId, status: 'COMPLETED' },
        }),

        // Aggregate financial data
        prisma.property.aggregate({
          where: { userId },
          _sum: { totalContractPrice: true },
        }),
      ])

    return {
      totalProperties,
      activeProperties,
      completedProperties,
      totalValue: financials._sum.totalContractPrice
        ? financials._sum.totalContractPrice.toNumber()
        : 0,
      // Additional stats...
    }
  } catch (error: unknown) {
    logger.error('Error in getUserPropertyStats', error)
    throw new DatabaseError('get user property stats', error)
  }
}

Performance: 4 aggregate queries in parallel vs. fetching + iterating all properties

Parallel Queries

// Execute independent queries in parallel for optimal performance
const [user, properties, notifications] = await Promise.all([
  getUserProfile(userId),
  getUserPropertiesForDashboard(userId),
  getUrgentNotifications(userId, 3),
])

Testing Repositories

import { getUserProfile } from '../user-repository'
import { prisma } from '@/lib/prisma'

jest.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findUnique: jest.fn(),
    },
  },
}))

describe('getUserProfile', () => {
  it('should return user profile', async () => {
    const mockUser = {
      id: 'user-123',
      email: 'john@example.com',
      firstName: 'John',
      _count: { properties: 2 },
    }

    ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser)

    const result = await getUserProfile('user-123')

    expect(result).toEqual(mockUser)
  })

  it('should return null when user not found', async () => {
    ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)

    const result = await getUserProfile('invalid')

    expect(result).toBeNull()
  })
})

Best Practices

  • Use select to optimize queries
  • Validate ownership for user-specific resources
  • Throw custom errors for known cases
  • Log errors before throwing DatabaseError
  • Use transactions for multi-step operations
  • Implement proper pagination
  • Cache frequently accessed data
  • Use TypeScript types from Prisma


Next Steps

  1. Review Use Cases layer
  2. Understand Error Handling
  3. Learn Testing strategies
  4. Explore Server Actions