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
_countfor 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.45stored exactly, not as123.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
Related Documentation¶
Next Steps¶
- Review Use Cases layer
- Understand Error Handling
- Learn Testing strategies
- Explore Server Actions