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
Recommended Pattern
✅ 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 operations15. 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)
- mobile.ts (50+ violations) - Most critical, heavily used
- tenant/main.ts (30+ violations) - Core tenant operations
- social.ts (14+ violations) - High-traffic feature
Phase 2: Medium Impact (Week 2)
- source-maps.ts (10 violations)
- media-library.ts (10 violations)
- tenant/billing.ts (6 violations)
- media.ts (6 violations)
- tenant-backlinks.ts (6 violations)
Phase 3: Low Impact (Week 3)
- newsletters.ts (5 violations)
- pgcallbacks.ts (4 violations) - Special case, low priority
- rag-config.ts (4 violations)
- tenant/important-days.ts (4 violations)
- tenant/credits.ts (3 violations)
- tenant/goals.ts (2 violations)
- tenant/channel-health-summary.ts (2 violations)
- 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
-
Actor lookups - Create
lib/auth-utils.ts:getActorName(userId)getActorDetails(userId)getTenantDetails(tenantId)
-
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 }; } -
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:
-
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(); }); }); -
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); }); }); -
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:
- Review this document with the team
- Prioritize based on business impact
- Create tickets for each phase
- Start with Phase 1 refactoring
- Document patterns as we go