Service Layer Refactoring Plan
Status: COMPLETE — All 10 original steps + extended scope done (2026-04-30)
Goal
Three targeted improvements to the API data layer, in priority order:
- Service function extraction — pull business logic out of fat route handlers into
apps/api/src/services/ - Soft-delete query helpers — eliminate manual
deletedAt: null/isActive: truefiltering scattered across routes - Transaction wrapping — ensure multi-step writes succeed or fail atomically
Architecture Decision: Where Services Live
apps/api/src/services/ already exists with 4 service files:
lead.service.tsconnected-channel.service.tszoho-books-sync.tsbaseline-sync.service.ts
We extend this directory. No new shared package is needed because all three refactoring targets (blog approval, social approval, campaign import) are HTTP-triggered. Workers that create content (blog-writer, social-post-writer) use agent prompt logic — they do not share approval business logic with routes.
Workers continue to export pure functions with injected db as they already do.
Phase 1 — Soft-Delete Query Helpers
Context
Three models use deletedAt DateTime? for soft deletion; nine use isActive Boolean for soft filtering. In both cases, developers must manually append the filter to every query — which is error-prone and already showing inconsistency.
Models with deletedAt (schema lines 1741, 1785, 1843):
RagDataset,RagFile,TenantDoc
Models with isActive (9 models):
AgentConfig,DeliverableTemplate,RepurposingTemplate,Offering,Region,Plan,ChannelMaster,Competitor,AIVisibilityPrompt,BacklinkDirectory
Files to Create
packages/db/src/helpers/soft-delete.tsFiles to Modify
| File | Change |
|---|---|
packages/db/src/index.ts | Export helpers from the db package |
apps/api/src/routers/blog.ts | Replace manual deletedAt: null with helper |
apps/api/src/routers/social.ts | Same |
apps/api/src/routers/docs.ts | Same |
apps/api/src/routers/knowledge.ts | Same |
Helper Pattern
// packages/db/src/helpers/soft-delete.ts
/** Use in where clauses for RagDataset, RagFile, TenantDoc */
export function notDeleted() {
return { deletedAt: null } as const;
}
/** Use in where clauses for AgentConfig, Plan, Offering, Region, etc. */
export function activeOnly() {
return { isActive: true } as const;
}
// Usage in route handlers:
const dataset = await db.ragDataset.findFirst({
where: { tenantId, name: "published_content", ...notDeleted() },
});
const plan = await db.plan.findFirst({
where: { id: planId, ...activeOnly() },
});Status: [ ] TODO
Phase 2 — Service Function Extraction
Context
Three route files contain mixed HTTP transport + business logic. Identified by fat handler size and logic that would need to be called from multiple places:
| Router | Lines | Primary Issue |
|---|---|---|
apps/api/src/routers/blog.ts | 440 | Approval state machine (lines 241–319) mixed into route handler |
apps/api/src/routers/social.ts | 646 | Approval state machine (lines 319–432) same pattern |
apps/api/src/routers/campaigns.ts | 1,447 | Import logic (lines 166–196) and create (lines 301–335) |
Files to Create
apps/api/src/services/blog.service.ts
apps/api/src/services/social.service.ts
apps/api/src/services/campaign.service.tsblog.service.ts
approveBlogPost(db, blogPostId, tenantId, approvedByUserId)
Extracted from: blog.ts:241–319
DB writes (will be wrapped in transaction in Phase 3):
db.blogPost.update— status →client_approved, setclientApprovedBy,clientApprovedAt, clear rejectionNotedb.activity.update— status →done(conditional onactivityIdpresent)db.deliverable.updateMany— status →approveddb.ragDataset.findFirst— findpublished_contentdataset (usesnotDeleted()helper)db.ragFile.create— create KB entry if dataset found
Returns: { post, ragFileCreated: boolean }
Side effects (caller’s responsibility, outside transaction):
enqueueRagContent(...)if ragFile createdwriteAuditLog(...)enqueueSearchSync(...)pushToWordPress(...)(existing helper in blog.ts, keep in place)
rejectBlogPost(db, blogPostId, tenantId, rejectedByUserId, rejectionNote)
Extracted from: blog.ts rejection flow
DB writes:
db.blogPost.update— status →dm_review, setrejectionNote, clear approval fieldsdb.deliverable.updateMany— status →in_review
updateBlogPost(db, blogPostId, tenantId, data)
Extracted from: blog.ts:207–237
Logic: validate ownership, update title/content/wordCount/faqs
social.service.ts
approveSocialPost(db, socialPostId, tenantId, approvedByUserId)
Extracted from: social.ts:319–432
DB writes (transaction in Phase 3):
db.socialPost.update— status →client_approveddb.activity.update— status →done(conditional)db.deliverable.updateMany— status →approveddb.ragDataset.findFirst— findpublished_contentdatasetdb.ragFile.create— if dataset found andbodyTextpresent
Returns: { post, ragFileCreated: boolean }
Side effects (caller’s responsibility):
enqueueSocialPublisher(...)enqueueRagContent(...)if ragFile createdwriteAuditLog(...)enqueueSearchSync(...)
rejectSocialPost(db, socialPostId, tenantId, rejectionNote)
Extracted from: social.ts:436–481
DB writes:
db.socialPost.update— status →dm_review, setrejectionNotedb.deliverable.updateMany— status →in_review
campaign.service.ts
importCampaigns(db, tenantId, campaigns)
Extracted from: campaigns.ts:166–196
Current problem: campaign create + externalMapping create run in parallel promises with no rollback — orphaned campaigns if mapping fails.
New logic: wrap each pair in db.$transaction():
const created = await Promise.all(
campaigns.map((c) =>
db.$transaction(async (tx) => {
const campaign = await tx.campaign.create({ data: { tenantId, ...c } });
await tx.campaignExternalMapping.create({ data: { campaignId: campaign.id, ...c.mapping } });
return campaign;
})
)
);createCampaign(db, tenantId, data)
Extracted from: campaigns.ts:301–335
Logic: create campaign, return it. Side effects (search sync, audit log) stay in the route handler.
Route Handler Pattern After Extraction
// Before — 80 lines of business logic in the route:
fastify.patch('/blog/:id/approve', async (req, reply) => {
const post = await db.blogPost.findUnique(...);
// ... validation, multiple db calls, conditional logic ...
});
// After — thin handler, service owns the logic:
fastify.patch('/blog/:id/approve', async (req, reply) => {
const { tenantId, userId } = req.user;
const { post, ragFileCreated } = await approveBlogPost(db, req.params.id, tenantId, userId);
if (ragFileCreated) enqueueRagContent({ ... });
writeAuditLog({ ... });
enqueueSearchSync({ ... });
reply.send(post);
});Status: [ ] TODO
Phase 3 — Transaction Wrapping
Context
Multi-step writes that must succeed or fail atomically are not wrapped in db.$transaction(). Failure mid-sequence leaves records in inconsistent state (e.g., post marked client_approved but deliverable still in in_review).
Locations to Fix
3.1 Blog Post Approval — blog.service.ts:approveBlogPost
Wrap in a single transaction:
const result = await db.$transaction(async (tx) => {
const post = await tx.blogPost.update({ ... }); // status change
if (post.activityId) await tx.activity.update({ ... }); // mark done
await tx.deliverable.updateMany({ ... }); // sync status
const dataset = await tx.ragDataset.findFirst({ where: { ...notDeleted() } });
const ragFile = dataset ? await tx.ragFile.create({ ... }) : null;
return { post, ragFile };
});
// Side effects OUTSIDE the transaction:
if (result.ragFile) enqueueRagContent({ ... });
writeAuditLog({ ... });
enqueueSearchSync({ ... });Why keep side effects outside: queue operations and audit logs must not roll back if a downstream side effect fails — the approval itself succeeded.
3.2 Social Post Approval — social.service.ts:approveSocialPost
Same pattern as 3.1.
3.3 Campaign Import — campaign.service.ts:importCampaigns
Each campaign + externalMapping pair wrapped atomically (see Phase 2 pattern above).
3.4 Tenant Delete N+1 — tenant-delete.worker.ts:335–346
Not a transaction issue — a query efficiency fix. Replace the per-user count loop:
// Before (N queries for N users):
for (const user of tenantUsers) {
const count = await db.tenantMember.count({
where: { userId: user.id, tenantId: { not: tenantId } },
});
if (count === 0) orphanIds.push(user.id);
}
// After (single query):
const membersElsewhere = await db.tenantMember.groupBy({
by: ["userId"],
where: {
userId: { in: tenantUsers.map((u) => u.id) },
tenantId: { not: tenantId },
},
_count: { userId: true },
});
const hasOtherTenant = new Set(membersElsewhere.map((m) => m.userId));
const orphanIds = tenantUsers.map((u) => u.id).filter((id) => !hasOtherTenant.has(id));Status: [ ] TODO
Migration Steps
| # | Action | File(s) | Status |
|---|---|---|---|
| 1 | Create soft-delete helpers | packages/db/src/helpers/soft-delete.ts | [x] |
| 2 | Export helpers from db package | packages/db/src/index.ts | [x] |
| 3 | Update callers to use helpers | blog.ts, social.ts, docs.ts, knowledge.ts, landing-pages.ts, admin/tenants.ts | [x] |
| 4 | Create blog.service.ts with approval + rejection functions | apps/api/src/services/blog.service.ts | [x] |
| 5 | Refactor blog.ts to call blog service; add transaction in service | apps/api/src/routers/blog.ts | [x] |
| 6 | Create social.service.ts with approval + rejection functions | apps/api/src/services/social.service.ts | [x] |
| 7 | Refactor social.ts to call social service; add transaction in service | apps/api/src/routers/social.ts | [x] |
| 8 | Create campaign.service.ts with import + create functions | apps/api/src/services/campaign.service.ts | [x] |
| 9 | Refactor campaigns.ts to call campaign service | apps/api/src/routers/campaigns.ts | [x] |
| 10 | Fix N+1 in tenant-delete worker | packages/agents/src/workers/tenant-delete.worker.ts | [x] |
Testing Checklist (run after each step)
-
pnpm typecheckpasses across all affected packages - Existing unit tests pass (
pnpm test --filter @leadmetrics/db,pnpm test --filter @leadmetrics/agents) - Manual smoke test: blog post approval flow in dashboard
- Manual smoke test: social post approval flow
- Manual smoke test: campaign import via API
- Playwright E2E suite passes for affected flows (blog, social, campaigns)
Extended Scope (2026-04-30)
After the original 10 steps, the refactoring was extended to all remaining API routers.
Additional Service Files Created
| File | Key Helpers |
|---|---|
auth.service.ts | regSessionExpiry, buildStepHistory, computeInvoiceAmounts, verifyRazorpaySignature, mapCountryToRegion, generateInvoiceNumber(db, ref) |
billing.service.ts | computeInvoiceAmounts, buildLineItems, generateInvoiceNumber(db, now), computeInvoiceDueDate, computeNextBillingDate, buildSubscriptionTitle, formatAmountDisplay, aggregateCosts |
tenants.service.ts | computeInvoiceAmounts, formatCurrencyAmount, buildSocialPostRagContent |
campaigns.service.ts | extractAccessToken, buildGoogleAdsService, CAMPAIGN_TRANSITIONS, assertValidTransition |
channel-connect.service.ts | escHtml, buildProviderCfg, getBaseUrl, callbackUrl, parsePageSelectBody, buildLinkedInAdsTokenInfo, buildChannelAuditPayload |
channels.service.ts | fmtDate, parseDateRange, previousPeriod, deltaPercent, buildAdsHeaders, LinkedIn/GSC/Bing/FB/IG metric aggregators, buildGbpReviewReplyPrompt, isTokenExpiringSoon |
landing-page.service.ts | Landing page helpers |
strategy.service.ts | Strategy helpers |
Additional Routers Covered
auth.ts, admin/tenants.ts, admin/billing.ts, brand-voice.ts, channels.ts, campaigns.ts, channel-connect.ts, dm/calendar.ts
Dead Code Cleaned Up
- Removed duplicate
generateInvoiceNumberfromadmin/tenants.ts(advisory lock inlined into registration transaction) - Removed local
generateInvoiceNumberfromauth.ts; both now callgenerateInvoiceNumber(db, now)from their respective service files
Notes
- Async side effects stay outside transactions — queue operations (
enqueueRagContent,enqueueSearchSync,writeAuditLog) must not be rolled back if a downstream failure occurs after a successful DB write. The pattern is: transaction commits → then enqueue. isActiveis not a soft delete — it signals whether a feature/config is enabled, not whether a record is deleted. KeepnotDeleted()andactiveOnly()as separate helpers with distinct semantics.- Do not touch worker approval logic — workers create content; they do not approve it. Approval is always user-triggered via HTTP. Workers and services do not need to share code for these flows.
- Advisory lock pattern —
pg_advisory_xact_lock(hashtext(prefix))must be the first statement inside adb.$transaction(). Never nest transactions. - Service functions that need db — take
db: PrismaClientas the first parameter (e.g.generateInvoiceNumber(db, now)). Pure helpers (no DB) take only business params.