Skip to Content
Code ReviewsAPI Router Service Layer Violations

API Router Service Layer Violations

Generated: April 30, 2026
Scope: Direct Prisma database calls in route handlers
Total Violations: 200+

Summary

This document identifies instances where API route handlers directly access the Prisma database client (db.*) instead of delegating to service layer methods. This violates the architectural principle of separation of concerns and creates maintenance and testing challenges.

Impact

  • Maintainability: Business logic scattered across routes instead of centralized in services
  • Testability: Difficult to unit test business logic without HTTP layer
  • Reusability: Logic cannot be easily reused from background jobs, webhooks, or other contexts
  • Transaction Management: Complex multi-step operations harder to manage
  • Code Duplication: Same queries repeated across different endpoints

Good:

// Router const stats = await svc.getStats(actor.tenantId); // Service export async function getStats(tenantId: string) { return db.lead.aggregate({ where: { tenantId }, ... }); }

Bad:

// Router const stats = await db.lead.aggregate({ where: { tenantId: actor.tenantId }, ... });

Violations by Router File

1. leads.ts (1 violation)

Line 109: Direct user lookup for actor name

const actorUser = await db.user.findUnique({ where: { id: actor.sub }, select: { name: true } }); const actorName = actorUser?.name ?? "Unknown";

Suggested Fix:

// Create shared utility or add to auth service // lib/auth-utils.ts export async function getActorName(userId: string): Promise<string> { const user = await db.user.findUnique({ where: { id: userId }, select: { name: true } }); return user?.name ?? "Unknown"; } // In router: const actorName = await getActorName(actor.sub);

2. tenant-backlinks.ts (6 violations)

Lines 70, 84, 100, 150, 162: Multiple direct backlink and directory queries

Example violation (Line 70-73):

const backlink = await db.backlink.findUnique({ where: { id }, select: { id: true, tenantId: true, sourceType: true, outreachStatus: true, directoryId: true }, });

Suggested Fix: Create services/backlinks.service.ts:

export async function getBacklinkById(id: string) { return db.backlink.findUnique({ where: { id }, select: { id: true, tenantId: true, sourceType: true, outreachStatus: true, directoryId: true }, }); } export async function updateBacklinkOpportunityStatus( id: string, status: OpportunityStatus, notes?: string ) { return db.backlink.update({ where: { id }, data: { outreachStatus: status, ...(notes !== undefined ? { notes } : {}), ...(status === "completed" ? { completedAt: new Date() } : {}), ...(status !== "completed" ? { completedAt: null } : {}), }, }); } export async function getBacklinkDirectory(directoryId: string) { return db.backlinkDirectory.findUnique({ where: { directoryId }, select: { isAutoSubmittable: true }, }); }

Router becomes:

const backlink = await svc.getBacklinkById(id); // ... validation ... const updated = await svc.updateBacklinkOpportunityStatus(id, status, notes);

3. tenant/main.ts (30+ violations)

This file has the highest concentration of violations. Major issues:

Lines 58, 64, 89, 92: Agent configuration queries Lines 109, 133, 145: Client context queries Lines 151, 155, 161: Tenant, subscription, and user queries Lines 178, 292, 385, 630, 686: Repeated actor user lookups Lines 202, 230, 441: Strategy queries Lines 250, 451: Subscription lookups Lines 317, 341, 359, 478: Deliverable plan queries Lines 417, 425: Action item queries Lines 544: User queries Lines 580, 622: Notification preferences Lines 655, 676: Tenant updates Lines 716, 749: Notification queries and updates Lines 766: Activity log queries

Example violation (Lines 58-64):

const agents = await db.agentConfig.findMany({ where: { ...activeOnly() }, orderBy: [{ category: "asc" }, { name: "asc" }], }); const overrides = await db.tenantAgentConfig.findMany({ where: { tenantId: payload.tenantId }, });

Suggested Fix: Create services/agent-config.service.ts:

export async function getAgentsForTenant(tenantId: string) { const [agents, overrides] = await Promise.all([ db.agentConfig.findMany({ where: { ...activeOnly() }, orderBy: [{ category: "asc" }, { name: "asc" }], }), db.tenantAgentConfig.findMany({ where: { tenantId }, }), ]); const overrideMap = new Map(overrides.map(o => [o.agentConfigId, o])); return agents.map(agent => ({ ...agent, isEnabled: overrideMap.get(agent.id)?.isEnabled ?? true, modelOverride: overrideMap.get(agent.id)?.modelOverride ?? null, })); } export async function getAgentById(agentId: string, tenantId: string) { const agent = await db.agentConfig.findUnique({ where: { id: agentId } }); if (!agent || !agent.isActive) return null; const override = await db.tenantAgentConfig.findUnique({ where: { tenantId_agentConfigId: { tenantId, agentConfigId: agentId } }, }); return { ...agent, isEnabled: override?.isEnabled ?? true, modelOverride: override?.modelOverride ?? null, }; }

Pattern for repeated user lookups:

// Create lib/auth-utils.ts or add to existing auth service export async function getActorDetails(userId: string) { return db.user.findUnique({ where: { id: userId }, select: { name: true, email: true, country: true } }); } export async function getTenantDetails(tenantId: string) { return db.tenant.findUnique({ where: { id: tenantId }, select: { name: true } }); }

4. tenant/important-days.ts (4 violations)

Lines 46, 54, 122, 132, 155: Direct tenant, importantDay, activity, and socialPost queries

Example violation (Lines 132-140):

const activity = await db.activity.create({ data: { tenantId, type: "social_post", contentType, status: "scheduled", // ... more fields }, });

Suggested Fix: Move to services/important-days.service.ts or use existing activity service:

export async function getImportantDaysForTenant(tenantId: string, dateRange?: { from: Date; to: Date }) { const tenant = await db.tenant.findUnique({ where: { id: tenantId }, select: { country: true }, }); const allDays = await db.importantDay.findMany({ where: { ...(dateRange ? { date: { gte: dateRange.from, lte: dateRange.to } } : {}), }, orderBy: { date: "asc" }, }); return { tenant, importantDays: allDays }; } export async function createActivityFromImportantDay(data: CreateActivityInput) { return db.activity.create({ data }); }

5. tenant/goals.ts (2 violations)

Lines 24, 32, 65: Direct deliverable plan and goal queries

Suggested Fix: Create services/goals.service.ts:

export async function getGoalsForPlan(tenantId: string) { const plan = await db.deliverablePlan.findFirst({ where: { tenantId }, orderBy: { createdAt: "desc" }, }); if (!plan) return { plan: null, goals: [] }; const goals = await db.goal.findMany({ where: { deliverablePlanId: plan.id }, orderBy: { createdAt: "asc" }, }); return { plan, goals }; } export async function getGoalById(id: string, tenantId: string) { return db.goal.findUnique({ where: { id }, include: { deliverablePlan: { select: { tenantId: true } } }, }); }

6. tenant/credits.ts (3 violations)

Lines 134, 140, 161: Direct credit ledger queries

Suggested Fix: Create services/credits.service.ts:

export async function getCreditLedger( tenantId: string, filters: { type?: string; page: number; limit: number } ) { const where = { tenantId, ...(filters.type ? { type: filters.type } : {}), }; const [entries, total] = await Promise.all([ db.creditLedger.findMany({ where, orderBy: { createdAt: "desc" }, skip: (filters.page - 1) * filters.limit, take: filters.limit, }), db.creditLedger.count({ where }), ]); return { data: entries, total, page: filters.page, limit: filters.limit, pages: Math.ceil(total / filters.limit), }; } export async function getRecentCredits(tenantId: string, limit = 5) { return db.creditLedger.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" }, take: limit, }); }

7. tenant/channel-health-summary.ts (2 violations)

Lines 25, 34: Direct connected channel and channel insight queries

Suggested Fix: Use existing services/channels.service.ts or create:

export async function getChannelHealthSummary(tenantId: string) { const [channels, insights] = await Promise.all([ db.connectedChannel.findMany({ where: { tenantId }, select: { id: true, type: true, displayName: true }, }), db.channelInsight.findMany({ where: { tenantId, status: "done" }, select: { channelType: true, completedAt: true }, }), ]); return { channels, insights }; }

8. tenant/billing.ts (6 violations)

Lines 29, 50, 69, 88, 132, 173, 255, 256: Subscription and invoice queries

Suggested Fix: Use existing services/billing.service.ts - check if these methods exist, if not add:

export async function getActiveSubscription(tenantId: string) { return db.subscription.findFirst({ where: { tenantId }, orderBy: { createdAt: "desc" }, include: { plan: true }, }); } export async function getInvoices(tenantId: string, limit?: number) { return db.invoice.findMany({ where: { tenantId }, orderBy: { createdAt: "desc" }, ...(limit ? { take: limit } : {}), }); } export async function getInvoiceById(invoiceId: string, tenantId: string) { const invoice = await db.invoice.findUnique({ where: { id: invoiceId } }); if (!invoice || invoice.tenantId !== tenantId) return null; return invoice; } export async function updateInvoiceStatus(invoiceId: string, status: string) { return db.invoice.update({ where: { id: invoiceId }, data: { status }, }); }

9. source-maps.ts (10 violations)

Lines 34, 52, 84, 85, 95, 106, 112, 137, 141, 158, 161, 172: Channel source map CRUD operations

Suggested Fix: Create services/source-maps.service.ts:

export async function listSourceMaps(tenantId: string) { return db.channelSourceMap.findMany({ where: { tenantId }, include: { githubChannel: { select: { type: true, displayName: true } }, targetChannel: { select: { type: true, displayName: true } }, }, }); } export async function getSourceMapById(id: string, tenantId: string) { return db.channelSourceMap.findFirst({ where: { id, tenantId } }); } export async function createSourceMap(data: CreateSourceMapInput) { // Validate channels exist const [githubChannel, targetChannel] = await Promise.all([ db.connectedChannel.findFirst({ where: { id: data.githubChannelId, tenantId: data.tenantId } }), db.connectedChannel.findFirst({ where: { id: data.targetChannelId, tenantId: data.tenantId } }), ]); if (!githubChannel || !targetChannel) return null; // Check for existing mapping const existing = await db.channelSourceMap.findUnique({ where: { targetChannelId: data.targetChannelId } }); if (existing) { // Update existing return db.channelSourceMap.update({ where: { id: existing.id }, data: { githubChannelId: data.githubChannelId, repoOwner: data.repoOwner, repoName: data.repoName, branch: data.branch, filePath: data.filePath, }, }); } return db.channelSourceMap.create({ data }); } export async function updateSourceMap(id: string, tenantId: string, updates: UpdateSourceMapInput) { const map = await getSourceMapById(id, tenantId); if (!map) return null; return db.channelSourceMap.update({ where: { id }, data: updates, }); } export async function deleteSourceMap(id: string, tenantId: string) { const map = await getSourceMapById(id, tenantId); if (!map) return null; await db.channelSourceMap.delete({ where: { id } }); return true; }

10. social.ts (14 violations)

Lines 114, 193, 222, 254, 296, 303, 327, 339, 351, 407, 444, 487, 493, 530, 536, 564, 578, 580, 610, 618: Various social post and channel queries

Suggested Fix: Extend existing services/social.service.ts:

export async function getConnectedChannelForPlatform(tenantId: string, platform: string) { return db.connectedChannel.findFirst({ where: { tenantId, type: platform }, }); } export async function getSocialPostById(id: string, tenantId?: string) { const where = tenantId ? { id, tenantId } : { id }; return db.socialPost.findUnique({ where }); } export async function getChannelsForPost(tenantId: string, platform: string) { return db.connectedChannel.findMany({ where: { tenantId, type: platform }, }); } export async function updatePostStatus(id: string, status: string, userId: string, userName: string) { return db.socialPost.update({ where: { id }, data: { status, rejectedBy: status === "rejected" ? userId : null, rejectedByName: status === "rejected" ? userName : null, rejectedAt: status === "rejected" ? new Date() : null, }, }); } export async function deleteSocialPost(id: string) { await db.socialPost.delete({ where: { id } }); return true; }

11. rag-config.ts (4 violations)

Lines 44, 96, 98, 99, 101: RAG provider config CRUD

Suggested Fix: Create services/rag-config.service.ts:

export async function getRagConfig() { return db.ragProviderConfig.findFirst(); } export async function upsertRagConfig(data: RagConfigInput) { const existing = await db.ragProviderConfig.findFirst(); const updateData = { provider: data.provider, ...(data.apiKey ? { apiKey: data.apiKey } : {}), ...(data.endpoint ? { endpoint: data.endpoint } : {}), ...(data.model ? { model: data.model } : {}), }; return existing ? await db.ragProviderConfig.update({ where: { id: existing.id }, data: updateData }) : await db.ragProviderConfig.create({ data: updateData as any }); }

12. pgcallbacks.ts (4 violations)

Lines 154, 187, 205, 225: Payment webhook invoice/subscription updates

Context: This is a special case - webhook handlers. However, business logic should still be in services.

Suggested Fix: Create services/payment-webhooks.service.ts:

export async function handlePaymentCaptured( razorpayPaymentId: string, razorpayOrderId: string ) { await db.invoice.updateMany({ where: { razorpayOrderId }, data: { status: "paid", razorpayPaymentId, paidAt: new Date(), }, }); } export async function handleSubscriptionCharged(subscriptionId: string) { await db.subscription.updateMany({ where: { razorpaySubscriptionId: subscriptionId }, data: { status: "active", lastChargedAt: new Date() }, }); } export async function handleSubscriptionCancelled(subscriptionId: string) { await db.subscription.updateMany({ where: { razorpaySubscriptionId: subscriptionId }, data: { status: "cancelled", cancelledAt: new Date() }, }); } export async function handlePaymentFailed(razorpayOrderId: string, reason: string) { await db.invoice.updateMany({ where: { razorpayOrderId }, data: { status: "failed", notes: reason, }, }); }

13. newsletters.ts (5 violations)

Lines 81, 93, 107, 124, 133, 161, 167: Newsletter and contact queries

Suggested Fix: Create services/newsletters.service.ts:

export async function getNewsletters( tenantId: string, filters: { status?: string; page: number; limit: number } ) { const where = { tenantId, ...(filters.status ? { status: filters.status } : {}), }; const [newsletters, total] = await Promise.all([ db.emailNewsletter.findMany({ where, orderBy: { createdAt: "desc" }, skip: (filters.page - 1) * filters.limit, take: filters.limit, }), db.emailNewsletter.count({ where }), ]); return { newsletters, total }; } export async function getNewsletterById(id: string, tenantId: string) { return db.emailNewsletter.findFirst({ where: { id, tenantId }, }); } export async function updateNewsletterAudience(id: string, audienceSegment: any) { return db.emailNewsletter.update({ where: { id }, data: { audienceSegment }, }); } export async function unsubscribeContact(token: string) { const contact = await db.contact.findUnique({ where: { unsubscribeToken: token } }); if (!contact) return null; await db.contact.update({ where: { id: contact.id }, data: { isUnsubscribed: true, unsubscribedAt: new Date() }, }); return contact; }

14. mobile.ts (50+ violations)

Critical: This file has the most violations - nearly every endpoint directly queries the database.

Key violations:

  • Lines 39-58: Dashboard stats (multiple parallel queries)
  • Lines 107, 144, 164, 202, 240: Activity queries
  • Lines 248, 250, 256, 262, 264, 273: Activity and post updates
  • Lines 304, 321, 326, 369, 373, 418: Client context operations
  • Lines 334-336, 381-383, 564-566: User/tenant/subscription lookups
  • Lines 448, 483, 490, 494, 507, 548, 551, 553, 597, 603, 626, 632: Strategy operations
  • Lines 657, 687, 695, 724, 732, 734, 773, 780: Deliverable plan operations
  • Lines 804, 827, 870, 904, 935, 964, 995, 1025, 1061, 1093: Various entity queries

Suggested Fix: This file needs major refactoring. Create comprehensive mobile-specific services:

// services/mobile-dashboard.service.ts export async function getDashboardStats(tenantId: string) { const [ context, goalsCount, activitiesCount, // ... all other stats ] = await Promise.all([ db.clientContext.findUnique({ where: { tenantId } }), db.goal.count({ where: { tenantId, status: "active" } }), // ... rest of parallel queries ]); return { context, stats: { goals: goalsCount, activities: activitiesCount, // ... }, }; } // services/mobile-activities.service.ts export async function getActivitiesForMobile(tenantId: string, filters: MobileActivityFilters) { // Move all activity query logic here } export async function approveActivity(id: string, tenantId: string) { const activity = await db.activity.findFirst({ where: { id, tenantId }, }); if (!activity) return null; await db.activity.update({ where: { id }, data: { status: "approved" } }); // Update related entities (blog, social post, etc.) if (activity.blogPostId) { await db.blogPost.update({ where: { id: activity.blogPostId }, data: { status: "approved" }, }); } if (activity.socialPostId) { await db.socialPost.update({ where: { id: activity.socialPostId }, data: { status: "approved" }, }); } return activity; } // Similar pattern for context, strategy, deliverable plan operations

15. media.ts (6 violations)

Lines 198, 214, 251, 257, 270, 284, 286: Media asset CRUD operations

Suggested Fix: Create services/media-assets.service.ts:

export async function createMediaAsset(data: CreateMediaAssetInput) { return db.mediaAsset.create({ data }); } export async function getMediaAssets( tenantId: string, filters: { page: number; limit: number } ) { const where = { tenantId }; const [assets, total] = await Promise.all([ db.mediaAsset.findMany({ where, orderBy: { createdAt: "desc" }, skip: (filters.page - 1) * filters.limit, take: filters.limit, }), db.mediaAsset.count({ where }), ]); return { assets, total }; } export async function getMediaAssetById(id: string, tenantId: string) { return db.mediaAsset.findFirst({ where: { id, tenantId }, }); } export async function deleteMediaAsset(id: string, tenantId: string) { const asset = await getMediaAssetById(id, tenantId); if (!asset) return null; await db.mediaAsset.delete({ where: { id } }); return asset; }

16. media-library.ts (10 violations)

Lines 221, 227, 278, 337, 385, 412, 458, 468, 499, 504, 508: Media library CRUD

Context: Note that this file already imports from @leadmetrics/storage - good pattern, but database operations should also be in services.

Suggested Fix: Create services/media-library.service.ts:

export async function getMediaLibrary( tenantId: string, filters: { platform?: string; mediaType?: string; format?: string; page: number; limit: number } ) { const where = { tenantId, ...(filters.platform ? { platform: filters.platform } : {}), ...(filters.mediaType ? { mediaType: filters.mediaType } : {}), ...(filters.format ? { format: filters.format } : {}), }; const [media, total] = await Promise.all([ db.media.findMany({ where, orderBy: { createdAt: "desc" }, skip: (filters.page - 1) * filters.limit, take: filters.limit, }), db.media.count({ where }), ]); return { media, total }; } export async function createMedia(data: CreateMediaInput) { return db.media.create({ data }); } export async function getMediaById(id: string, tenantId?: string) { const where = tenantId ? { id, tenantId } : { id }; return db.media.findUnique({ where }); } export async function deleteMedia(id: string, tenantId: string) { const media = await getMediaById(id, tenantId); if (!media) return null; await db.media.delete({ where: { id } }); return media; } export async function reuseMedia(id: string, socialPostId: string, tenantId: string) { // Verify ownership const [media, socialPost] = await Promise.all([ getMediaById(id, tenantId), db.socialPost.findUnique({ where: { id: socialPostId } }), ]); if (!media || !socialPost || socialPost.tenantId !== tenantId) { return null; } return db.media.update({ where: { id }, data: { socialPostId, usageCount: { increment: 1 }, }, }); }

Refactoring Strategy

Phase 1: High-Impact Files (Week 1)

  1. mobile.ts (50+ violations) - Most critical, heavily used
  2. tenant/main.ts (30+ violations) - Core tenant operations
  3. social.ts (14+ violations) - High-traffic feature

Phase 2: Medium Impact (Week 2)

  1. source-maps.ts (10 violations)
  2. media-library.ts (10 violations)
  3. tenant/billing.ts (6 violations)
  4. media.ts (6 violations)
  5. tenant-backlinks.ts (6 violations)

Phase 3: Low Impact (Week 3)

  1. newsletters.ts (5 violations)
  2. pgcallbacks.ts (4 violations) - Special case, low priority
  3. rag-config.ts (4 violations)
  4. tenant/important-days.ts (4 violations)
  5. tenant/credits.ts (3 violations)
  6. tenant/goals.ts (2 violations)
  7. tenant/channel-health-summary.ts (2 violations)
  8. leads.ts (1 violation)

Implementation Checklist

For each file:

  • Create corresponding service file if not exists
  • Move database queries to service methods
  • Add proper TypeScript types for inputs/outputs
  • Update router to call service methods
  • Add unit tests for service methods
  • Update integration tests if needed
  • Verify no regressions in staging
  • Deploy to production

Common Patterns to Extract

  1. Actor lookups - Create lib/auth-utils.ts:

    • getActorName(userId)
    • getActorDetails(userId)
    • getTenantDetails(tenantId)
  2. Pagination helpers - Already exists in some services, standardize:

    interface PaginationOptions { page: number; limit: number; } async function paginatedQuery<T>( model: any, where: any, options: PaginationOptions ) { const [data, total] = await Promise.all([ model.findMany({ where, skip: (options.page - 1) * options.limit, take: options.limit, }), model.count({ where }), ]); return { data, total, ...options }; }
  3. Tenant ownership validation - Create shared validator:

    async function validateTenantOwnership<T extends { tenantId: string }>( entity: T | null, expectedTenantId: string ): Promise<T | null> { if (!entity || entity.tenantId !== expectedTenantId) return null; return entity; }

Testing Guidelines

When refactoring to services:

  1. Unit test the service (without HTTP layer):

    describe('MediaLibraryService', () => { it('should get media by id', async () => { const media = await svc.getMediaById('test-id', 'tenant-123'); expect(media).toBeDefined(); }); });
  2. Keep integration tests for routes (HTTP layer):

    describe('GET /tenant/v1/media/:id', () => { it('should return 200 with valid media', async () => { const res = await request(app).get('/tenant/v1/media/test-id'); expect(res.status).toBe(200); }); });
  3. Mock the database in service tests:

    jest.mock('../db', () => ({ db: { media: { findUnique: jest.fn(), }, }, }));

Benefits After Refactoring

Maintainability

  • Business logic centralized and easier to understand
  • Consistent patterns across the codebase
  • Easier to make changes without breaking multiple routes

Testability

  • Can test business logic without HTTP layer
  • Faster unit tests (no HTTP overhead)
  • Easier to mock dependencies

Reusability

  • Services can be called from:
    • Multiple routes
    • Background jobs
    • Webhooks
    • CLI scripts
    • Other services

Performance

  • Easier to optimize queries in one place
  • Can add caching at service layer
  • Better transaction management

Type Safety

  • Explicit input/output types
  • Better IDE autocomplete
  • Catch errors at compile time

Questions or Concerns?

If you need help with:

  • Determining which service a method belongs in
  • Naming service methods
  • Breaking down complex transactions
  • Writing tests for refactored code

Please reach out to the architecture team.


Next Steps:

  1. Review this document with the team
  2. Prioritize based on business impact
  3. Create tickets for each phase
  4. Start with Phase 1 refactoring
  5. Document patterns as we go

© 2026 Leadmetrics — Internal use only