Use Cases¶
Last Updated: 2025-01-22
Use Cases orchestrate business logic by coordinating repository calls, applying business rules, and managing complex workflows in AccessALI.
Overview¶
The Use Case layer sits between Server Actions and Repositories, containing business logic that's reusable across different entry points (Server Actions, API Routes, etc.).
Responsibilities¶
- Orchestrate multiple repository calls
- Apply business logic and validation
- Handle complex workflows
- Transform data between layers
- Coordinate transactions
Pattern¶
// lib/use-cases/feature-name.ts
import { getUserProfile } from '@/lib/repositories/user-repository'
import { getUserProperties } from '@/lib/repositories/property-repository'
export async function featureNameUseCase(userId: string, params: any) {
// 1. Validate business rules
if (!isValidBusinessRule(params)) {
throw new ValidationError('Invalid params')
}
// 2. Fetch required data (parallel when possible)
const [user, properties] = await Promise.all([
getUserProfile(userId),
getUserProperties(userId),
])
// 3. Apply business logic
const result = applyBusinessLogic(user, properties, params)
// 4. Update data if needed
if (result.needsUpdate) {
await updateData(result.updateData)
}
// 5. Return transformed result
return transformResult(result)
}
Dashboard Use Case¶
Source: src/lib/use-cases/dashboard.ts
getDashboardDataUseCase¶
Orchestrates parallel data fetching from multiple repositories and transforms data for the dashboard view.
// lib/use-cases/dashboard.ts
import { getUserProfile } from '@/lib/repositories/user-repository'
import {
getUserPropertiesForDashboard,
getUserPropertyStats,
} from '@/lib/repositories/property-repository'
import { getUrgentNotifications } from '@/lib/repositories/notification-repository'
import { getRecentMessages } from '@/lib/repositories/message-repository'
import type { DashboardData } from '@/lib/types'
import { logger } from '@/lib/logger'
// Dashboard display limits
const DASHBOARD_URGENT_NOTIFICATIONS_LIMIT = 3
const DASHBOARD_RECENT_MESSAGES_LIMIT = 3
/**
* Get complete dashboard data for authenticated user
*
* Orchestrates parallel fetching from 5 repositories:
* - User profile with property count
* - User properties (optimized query)
* - Property statistics (single aggregate query)
* - Urgent notifications (limit 3)
* - Recent messages (limit 3)
*
* Performance: All queries run in parallel using Promise.all
*
* @param {string} userId - Authenticated user ID
* @returns {Promise<DashboardData>} Complete dashboard data
* @throws {Error} If user not found or database error
*/
export async function getDashboardDataUseCase(
userId: string
): Promise<DashboardData> {
try {
// Use optimized queries - fetches all data in parallel for optimal performance
const [userProfile, properties, propertyStats, notifications, messages] =
await Promise.all([
getUserProfile(userId),
getUserPropertiesForDashboard(userId), // Optimized query with selective fields
getUserPropertyStats(userId), // Single aggregate query for stats
getUrgentNotifications(userId, DASHBOARD_URGENT_NOTIFICATIONS_LIMIT),
getRecentMessages(userId, DASHBOARD_RECENT_MESSAGES_LIMIT),
])
// Validate that user exists
if (!userProfile) {
throw new Error(`User not found: ${userId}`)
}
// Calculate quick action badges from notifications and messages
const quickActionBadges = calculateQuickActionBadges(
notifications,
messages
)
// Transform data to match DashboardData interface
return {
user: {
id: userProfile.id,
firstName: userProfile.firstName ?? '',
lastName: userProfile.lastName ?? '',
email: userProfile.email,
role: userProfile.role,
profilePhoto: userProfile.profilePhoto,
propertyCount: userProfile._count.properties,
},
properties: properties.map((property) => ({
id: property.id,
projectName: property.projectName,
unitNumber: property.unitNumber,
block: property.block,
lot: property.lot,
phase: property.phase,
model: property.model,
status: property.status,
// Use deterministic random image if no image URL
imageUrl: property.imageUrl ?? getRandomPropertyImage(property.id),
turnoverDate: property.turnoverDate,
currentMilestone: property.milestones[0] ?? null,
pendingPayments: property.payments,
documentCount: property._count.documents,
createdAt: property.createdAt,
})),
propertyStats: {
totalProperties: propertyStats.totalProperties,
activeProperties: propertyStats.activeProperties,
completedProperties: propertyStats.completedProperties,
totalValue: propertyStats.totalValue,
pendingPayments: propertyStats.pendingPayments,
upcomingMilestones: propertyStats.upcomingMilestones,
},
notifications: notifications.map((notification) => ({
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
priority: notification.priority,
isRead: notification.isRead,
createdAt: notification.createdAt,
})),
messages: messages.map((message) => ({
id: message.id,
subject: message.subject,
preview: message.body.substring(0, 100),
sender: message.sender,
isUnread: !message.isRead,
createdAt: message.createdAt,
})),
quickActionBadges,
}
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error))
logger.error('Error in getDashboardDataUseCase', err)
// Re-throw error to be handled by Server Action
throw err
}
}
/**
* Calculate badge counts for quick action buttons
*
* Badges indicate:
* - pay: Has unread payment notifications
* - schedule: Has unread appointment notifications
* - view: Has unread document notifications
* - contact: Count of unread messages
*
* @param {Notification[]} notifications - Urgent notifications
* @param {Message[]} messages - Recent messages
* @returns {QuickActionBadges} Badge counts for each action
*/
function calculateQuickActionBadges(
notifications: Notification[],
messages: Message[]
): QuickActionBadges {
return {
pay: notifications.some((n) => n.type === 'PAYMENT' && !n.isRead) ? 1 : 0,
schedule: notifications.some((n) => n.type === 'APPOINTMENT' && !n.isRead)
? 1
: 0,
view: notifications.some((n) => n.type === 'DOCUMENT' && !n.isRead) ? 1 : 0,
contact: messages.filter((m) => !m.isRead).length,
}
}
/**
* Get deterministic random property image based on property ID
*
* Uses property ID as hash seed to ensure same image is always
* returned for the same property across sessions.
*
* @param {string} propertyId - Property UUID
* @returns {string} Property image URL
*/
export function getRandomPropertyImage(propertyId: string): string {
// Use property ID to generate a deterministic index
let hash = 0
for (let i = 0; i < propertyId.length; i++) {
const char = propertyId.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32-bit integer
}
const index = Math.abs(hash) % MODERN_PROPERTY_IMAGES.length
return MODERN_PROPERTY_IMAGES[index]
}
Performance Optimizations:
- Parallel Fetching: All 5 repository calls execute simultaneously
- Selective Fields: Repositories use Prisma
selectto fetch only needed fields - Aggregate Query: Property stats use single aggregate query instead of multiple queries
- Limited Results: Notifications and messages limited to most recent/urgent
Data Flow:
getDashboardDataUseCase(userId)
↓ (parallel)
├─→ getUserProfile() → User + property count
├─→ getUserPropertiesForDashboard() → Properties with current milestone
├─→ getUserPropertyStats() → Aggregate stats (1 query)
├─→ getUrgentNotifications() → Top 3 urgent notifications
└─→ getRecentMessages() → Last 3 messages
↓ (transform)
DashboardData → Sent to client
Testing Use Cases¶
import { getDashboardDataUseCase } from '../dashboard'
import * as userRepo from '@/lib/repositories/user-repository'
import * as propertyRepo from '@/lib/repositories/property-repository'
jest.mock('@/lib/repositories/user-repository')
jest.mock('@/lib/repositories/property-repository')
describe('getDashboardDataUseCase', () => {
it('should return complete dashboard data', async () => {
jest.spyOn(userRepo, 'getUserProfile').mockResolvedValue(mockUser)
jest.spyOn(propertyRepo, 'getUserProperties').mockResolvedValue(mockProperties)
const result = await getDashboardDataUseCase('user-123')
expect(result.user).toBeDefined()
expect(result.properties).toHaveLength(2)
})
it('should throw when user not found', async () => {
jest.spyOn(userRepo, 'getUserProfile').mockResolvedValue(null)
await expect(getDashboardDataUseCase('invalid')).rejects.toThrow('User not found')
})
})
Best Practices¶
- Keep use cases focused on single business operations
- Validate business rules before data access
- Use parallel queries with Promise.all when possible
- Re-throw repository errors unchanged
- Transform data to match UI requirements
- Keep use cases testable and independent