Code Duplication & Inefficiency Audit v2 — Leadmetrics v3
Date: 2026-04-25 Reviewer: Claude Code Scope: Full codebase — all apps, packages/agents workers, API routes, server actions
Overview
This audit covers the entire monorepo. 14 duplication patterns were identified across:
- 18 BullMQ worker files — each copy-pastes
getClaudeAdapter()and the credits reserve/consume/release lifecycle - 21 API route files — all repeat the same pagination parsing boilerplate
- 12+ DM API route files — all repeat the same tenant membership check
- 30+ server action files — each wraps work in identical try/catch +
{ ok: boolean }returns - 2 content workers — slug generation and markdown extraction helpers defined twice each
Prior review: code-duplication-v1.md — resolved 15 of 16 items (D-1 through D-16).
Status
| ID | Category | Severity | Files | Status |
|---|---|---|---|---|
| D-17 | Workers — Claude Adapter | High | 18 files | Open |
| D-18 | Workers — Insight start/stop boilerplate | High | 8 files | Open |
| D-19 | Workers — Credit lifecycle (reserve/consume/release) | High | 16 files | Open |
| D-20 | Workers — Worker factory start/stop pattern | Medium | 12 files | Open |
| D-21 | Workers — JSON brace-depth extractor | Medium | 4 files | Open |
| D-22 | Workers — Markdown helpers (title/meta/slug/wordCount) | Medium | 2 files | Open |
| D-23 | API Routes — Pagination boilerplate | Medium | 21 files | Open |
| D-24 | API Routes — Tenant membership check | Medium | 12 files | Open |
| D-25 | Actions — Inline fetch helper (dmFetch/apiFetch) | High | 30+ files | Open |
| D-26 | Actions — { ok: boolean } try/catch pattern | Low | 25+ files | Open |
| D-27 | Actions — Audit log + requireSession boilerplate | Low | 21 files | Open |
| D-28 | Workers — mdToHtml conversion | Low | 1 file | Open |
| D-29 | Workers — publishAgentEvent progress callback | Low | 15 files | Open |
| D-30 | Profile normalization (carried from v1 D-15) | Low | 2 files | Open |
D-17 · Claude Adapter Definition — 18 Workers
Category: Workers
Severity: High — every new worker copies this block verbatim
Files:
packages/agents/src/workers/activity.worker.ts:36-47
packages/agents/src/workers/backlink-outreach-writer.worker.ts:28-39
packages/agents/src/workers/blog-faq-writer.worker.ts:29-40
packages/agents/src/workers/blog-writer.worker.ts:46-57
packages/agents/src/workers/content-repurposer.worker.ts:40-51
packages/agents/src/workers/content.worker.ts:36-47
packages/agents/src/workers/custom-report-writer.worker.ts:39-50
packages/agents/src/workers/gbp-post-writer.worker.ts:42-53
packages/agents/src/workers/landing-page-writer.worker.ts:45-56
packages/agents/src/workers/linkedin-ads-optimizer.worker.ts:18-29
packages/agents/src/workers/meta-ads-optimizer.worker.ts:18-29
packages/agents/src/workers/search-term-classifier.worker.ts:23-34
packages/agents/src/workers/social-post-writer.worker.ts:45-57
packages/agents/src/workers/strategy-writer.worker.ts (similar)
packages/agents/src/workers/strategy.worker.ts (similar)
packages/agents/src/workers/website-crawler.worker.ts (similar)
packages/agents/src/workers/seo-optimizer.worker.ts (similar)
packages/agents/src/workers/insights/insight-worker-base.ts:33-47 ← already centralised for insight workersThe duplicate block (repeated 17 times outside the base):
type ProgressEvent = { type: "tool_use"; toolName: string } | { type: "text"; preview: string };
async function getClaudeAdapter() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require("@leadmetrics/adapter-claude-local/server") as {
execute: (ctx: unknown, onProgress?: (e: ProgressEvent) => void) => Promise<{
success: boolean;
output?: string;
error?: string;
durationMs?: number;
costUsd?: number;
inputTokens?: number;
outputTokens?: number;
}>;
};
return mod.execute;
}Proposed Fix: insight-worker-base.ts already has the canonical definition. Move it to the shared lib and re-export.
// packages/agents/src/lib/claude-adapter.ts (NEW FILE)
export type ClaudeProgressEvent =
| { type: "tool_use"; toolName: string }
| { type: "text"; preview: string };
export type ClaudeExecuteFn = (
ctx: unknown,
onProgress?: (e: ClaudeProgressEvent) => void
) => Promise<{
success: boolean;
output?: string;
error?: string;
durationMs?: number;
costUsd?: number;
inputTokens?: number;
outputTokens?: number;
}>;
export async function getClaudeAdapter(): Promise<ClaudeExecuteFn> {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require("@leadmetrics/adapter-claude-local/server") as { execute: ClaudeExecuteFn };
return mod.execute;
}Then in every worker: import { getClaudeAdapter } from "../lib/claude-adapter";
And in insight-worker-base.ts: export { getClaudeAdapter } from "../lib/claude-adapter";
D-18 · Insight Worker Start/Stop Boilerplate — 8 Files
Category: Workers
Severity: High — the same 20-line pattern repeated verbatim in 8 files
Files:
packages/agents/src/workers/insights/facebook-insights.worker.ts:140-165
packages/agents/src/workers/insights/ga-insights.worker.ts:155-180
packages/agents/src/workers/insights/gbp-insights.worker.ts (similar)
packages/agents/src/workers/insights/google-ads-insights.worker.ts (similar)
packages/agents/src/workers/insights/gsc-insights.worker.ts (similar)
packages/agents/src/workers/insights/instagram-insights.worker.ts (similar)
packages/agents/src/workers/insights/linkedin-insights.worker.ts (similar)
packages/agents/src/workers/insights/meta-ads-insights.worker.ts (similar)The duplicate block:
let _worker: Worker<InsightJobData> | null = null;
export function startFacebookInsightsWorkers(): void {
_worker = new Worker<InsightJobData>(QUEUE_NAME, processJob, {
connection: getRedisConnection(),
concurrency: CONCURRENCY,
});
_worker.on("failed", (job, err) => {
log.error({ jobId: job?.id, insightId: job?.data.insightId, err }, "Facebook insights job failed");
});
log.info({ queue: QUEUE_NAME, concurrency: CONCURRENCY }, "Facebook insights worker started");
}
export async function stopFacebookInsightsWorkers(): Promise<void> {
await _worker?.close();
_worker = null;
}Proposed Fix: Add factory to insight-worker-base.ts:
// packages/agents/src/workers/insights/insight-worker-base.ts (add at bottom)
export function createInsightWorker(config: {
queueName: string;
label: string;
concurrency: number;
processJob: (job: Job<InsightJobData>) => Promise<void>;
}): { start: () => void; stop: () => Promise<void> } {
let _worker: Worker<InsightJobData> | null = null;
return {
start() {
_worker = new Worker<InsightJobData>(config.queueName, config.processJob, {
connection: getRedisConnection(),
concurrency: config.concurrency,
});
_worker.on("failed", (job, err) => {
log.error({ jobId: job?.id, insightId: job?.data.insightId, err }, `${config.label} job failed`);
});
log.info({ queue: config.queueName, concurrency: config.concurrency }, `${config.label} worker started`);
},
async stop() {
await _worker?.close();
_worker = null;
},
};
}Then in each insight worker:
// packages/agents/src/workers/insights/facebook-insights.worker.ts
const { start, stop } = createInsightWorker({
queueName: QUEUE_NAME,
label: "Facebook insights",
concurrency: CONCURRENCY,
processJob,
});
export const startFacebookInsightsWorkers = start;
export const stopFacebookInsightsWorkers = stop;D-19 · Credit Lifecycle (reserve/consume/release) — 16 Workers
Category: Workers
Severity: High — reserve + try + consume / catch + release repeated in 16 files with subtle variation
Files:
packages/agents/src/workers/activity.worker.ts
packages/agents/src/workers/ai-visibility-monitor.worker.ts
packages/agents/src/workers/backlink-outreach-writer.worker.ts
packages/agents/src/workers/blog-writer.worker.ts
packages/agents/src/workers/content-repurposer.worker.ts
packages/agents/src/workers/content.worker.ts
packages/agents/src/workers/custom-report-writer.worker.ts
packages/agents/src/workers/gbp-post-writer.worker.ts
packages/agents/src/workers/insights/brand-narrative-analyst.worker.ts
packages/agents/src/workers/landing-page-writer.worker.ts
packages/agents/src/workers/seo-optimizer.worker.ts
packages/agents/src/workers/setup.worker.ts
packages/agents/src/workers/social-post-designer.worker.ts
packages/agents/src/workers/social-post-writer.worker.ts
packages/agents/src/workers/strategy-writer.worker.ts
packages/agents/src/workers/strategy.worker.tsThe duplicate pattern (from blog-writer.worker.ts:309-352):
await getOrCreateCreditBalance(db, tenantId);
await reserveCredits(db, tenantId, getCreditCost("blog_post"), runId, "blog_post");
// ... do work ...
// on failure:
await releaseCredits(db, tenantId, getCreditCost("blog_post"), runId).catch(() => {});
throw new Error(result.error ?? "...");
// on success:
await consumeCredits(db, tenantId, getCreditCost("blog_post"), runId, "blog_post").catch((err) =>
log.warn({ tenantId, err }, "Failed to consume credits")
);Proposed Fix: Extract to packages/agents/src/lib/with-credits.ts:
import { db } from "@leadmetrics/db";
import { getOrCreateCreditBalance, reserveCredits, consumeCredits, releaseCredits, getCreditCost } from "@leadmetrics/billing";
import type { CreditType } from "@leadmetrics/billing";
import { createLogger } from "@leadmetrics/logger";
const log = createLogger({ service: "credits", logFile: false });
export async function withCredits<T>(
tenantId: string,
runId: string,
creditType: CreditType,
fn: () => Promise<T>
): Promise<T> {
await getOrCreateCreditBalance(db, tenantId);
await reserveCredits(db, tenantId, getCreditCost(creditType), runId, creditType);
try {
const result = await fn();
await consumeCredits(db, tenantId, getCreditCost(creditType), runId, creditType).catch((err: unknown) =>
log.warn({ tenantId, runId, err }, "Failed to consume credits after successful run")
);
return result;
} catch (err) {
await releaseCredits(db, tenantId, getCreditCost(creditType), runId).catch(() => {});
throw err;
}
}Then in each worker:
const result = await withCredits(tenantId, runId, "blog_post", async () => {
return await execute({ ... });
});D-20 · Worker Factory Start/Stop Pattern — 12 Top-Level Workers
Category: Workers
Severity: Medium — identical Map<string, Worker> management in every top-level worker
Files (representative sample):
packages/agents/src/workers/blog-writer.worker.ts:516-549
packages/agents/src/workers/social-post-writer.worker.ts (similar)
packages/agents/src/workers/landing-page-writer.worker.ts (similar)
packages/agents/src/workers/content.worker.ts (similar)
packages/agents/src/workers/strategy.worker.ts (similar)
packages/agents/src/workers/activity.worker.ts (similar)
... 6 moreThe duplicate block:
const _workers = new Map<string, Worker>();
export function startBlogWriterWorkers(): void {
const queueName = `agent__blog-writer`;
if (_workers.has(queueName)) return;
const worker = new Worker<ActivityJobData>(queueName, processJob, {
connection: getRedisConnection(),
concurrency: 1,
lockDuration: 360_000,
maxStalledCount: 2,
});
worker.on("failed", (job, err) => {
log.error({ queueName, jobId: job?.id, err }, "Blog writer job failed");
});
worker.on("completed", (job) => {
log.info({ queueName, jobId: job.id }, "Blog writer job completed");
});
_workers.set(queueName, worker);
}
export async function stopBlogWriterWorkers(): Promise<void> {
await Promise.all(Array.from(_workers.values()).map(w => w.close()));
_workers.clear();
}Proposed Fix: Add factory to packages/agents/src/lib/worker-factory.ts:
import { Worker, type Job } from "bullmq";
import type { ActivityJobData } from "@leadmetrics/queue";
import { getRedisConnection } from "@leadmetrics/queue";
import { createLogger } from "@leadmetrics/logger";
const log = createLogger({ service: "worker-factory", logFile: false });
export function createActivityWorker(config: {
queueName: string;
label: string;
concurrency?: number;
lockDuration?: number;
processJob: (job: Job<ActivityJobData>) => Promise<void>;
}): { start: () => void; stop: () => Promise<void> } {
const _workers = new Map<string, Worker>();
return {
start() {
if (_workers.has(config.queueName)) return;
const worker = new Worker<ActivityJobData>(config.queueName, config.processJob, {
connection: getRedisConnection(),
concurrency: config.concurrency ?? 1,
lockDuration: config.lockDuration ?? 360_000,
maxStalledCount: 2,
});
worker.on("failed", (job, err) => log.error({ queueName: config.queueName, jobId: job?.id, err }, `${config.label} job failed`));
worker.on("completed", (job) => log.info({ queueName: config.queueName, jobId: job.id }, `${config.label} job completed`));
_workers.set(config.queueName, worker);
},
async stop() {
await Promise.all(Array.from(_workers.values()).map(w => w.close()));
_workers.clear();
},
};
}Then in each worker:
const { start, stop } = createActivityWorker({
queueName: "agent__blog-writer",
label: "Blog writer",
processJob: processBlogWriterJob,
});
export const startBlogWriterWorkers = start;
export const stopBlogWriterWorkers = stop;D-21 · JSON Brace-Depth Extractor — 4 Workers
Category: Workers
Severity: Medium — subtle bugs in one version don’t propagate fixes to others
Files:
packages/agents/src/workers/activity.worker.ts:54-86 ← most complete version
packages/agents/src/workers/content-repurposer.worker.ts
packages/agents/src/workers/strategy.worker.ts
packages/agents/src/workers/gbp-post-writer.worker.ts ← simpler indexOf/lastIndexOf variant (fragile)Problem: The activity.worker.ts version uses brace-depth matching (correct), while gbp-post-writer.worker.ts uses lastIndexOf("}") which breaks when the output contains trailing JSON objects or prose after the target object.
Proposed Fix: Move the canonical version to packages/agents/src/lib/json-extractor.ts:
export function extractJson<T>(output: string): T {
const fenced = output.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = (fenced ? fenced[1] : output).trim();
const start = raw.indexOf("{");
if (start === -1) throw new Error(`No JSON object in output. Preview: ${raw.slice(0, 200)}`);
let depth = 0;
let inString = false;
let escape = false;
let end = -1;
for (let i = start; i < raw.length; i++) {
const ch = raw[i];
if (escape) { escape = false; continue; }
if (ch === "\\" && inString) { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") { depth--; if (depth === 0) { end = i; break; } }
}
if (end === -1) throw new Error(`Unclosed JSON in output. Preview: ${raw.slice(0, 200)}`);
return JSON.parse(raw.slice(start, end + 1)) as T;
}Then: import { extractJson } from "../lib/json-extractor";
D-22 · Markdown Helpers (extractTitle / extractMetaTitle / slugify / countWords) — 2 Workers
Category: Workers
Severity: Medium — 4 identical utility functions defined in 2 files
Files:
packages/agents/src/workers/blog-writer.worker.ts:111-150
packages/agents/src/workers/landing-page-writer.worker.ts:122-160The duplicated functions:
export function extractTitle(markdown: string): string { ... }
export function extractMetaTitle(markdown: string): string | undefined { ... }
export function extractMetaDescription(markdown: string): string | undefined { ... }
export function slugify(title: string): string { ... }
export function countWords(text: string): number { ... }Proposed Fix: Move all five to packages/agents/src/lib/markdown-helpers.ts and export from there. slugify and countWords are generic enough to also live in packages/common/src/format.ts and be exported from its public index.
// packages/agents/src/lib/markdown-helpers.ts
export function extractTitle(markdown: string, fallback = "Untitled"): string {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? fallback;
}
export function extractMetaTitle(markdown: string): string | undefined {
return markdown.match(/\*\*Meta Title:\*\*\s*(.+)/i)?.[1]?.trim();
}
export function extractMetaDescription(markdown: string): string | undefined {
return (
markdown.match(/\*\*Meta Description:\*\*\s*(.+)/i)?.[1]?.trim() ??
markdown.match(/^Meta Description:\s*(.+)/im)?.[1]?.trim()
);
}
export function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 80);
}
export function countWords(text: string): number {
return text.split(/\s+/).filter(w => w.length > 0).length;
}D-23 · Pagination Boilerplate — 21 API Route Files
Category: API Routes
Severity: Medium — one off-by-one error in one file won’t be noticed; inconsistent defaults across routes
Files (confirmed):
apps/api/src/routers/admin/audit.ts
apps/api/src/routers/admin/billing.ts
apps/api/src/routers/admin/content.ts
apps/api/src/routers/blog.ts
apps/api/src/routers/campaigns.ts
apps/api/src/routers/dm/blog.ts
apps/api/src/routers/dm/calendar.ts
apps/api/src/routers/dm/content-briefs.ts
apps/api/src/routers/dm/credits.ts
apps/api/src/routers/dm/landing-pages.ts
apps/api/src/routers/dm/media.ts
apps/api/src/routers/dm/newsletters.ts
apps/api/src/routers/dm/social.ts
apps/api/src/routers/insights.ts
apps/api/src/routers/landing-pages.ts
apps/api/src/routers/leads.ts
apps/api/src/routers/media-library.ts
apps/api/src/routers/media.ts
apps/api/src/routers/newsletters.ts
apps/api/src/routers/social.ts
apps/api/src/routers/tenant/credits.tsThe duplicate block:
const page = Math.max(1, parseInt(rawPage ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(rawLimit ?? "20", 10) || 20));
const skip = (page - 1) * limit;Proposed Fix: apps/api/src/lib/pagination.ts
export interface PaginationResult { page: number; limit: number; skip: number; }
export function parsePagination(params: { page?: string; limit?: string }): PaginationResult {
const page = Math.max(1, parseInt(params.page ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(params.limit ?? "20", 10) || 20));
return { page, limit, skip: (page - 1) * limit };
}Usage:
const { page, limit, skip } = parsePagination({ page: request.query.page, limit: request.query.limit });D-24 · Tenant Membership Check — 12 DM API Route Files
Category: API Routes
Severity: Medium — one change to access rules must be made in 12 places
Files (confirmed subset):
apps/api/src/routers/dm/activities.ts
apps/api/src/routers/dm/agents.ts
apps/api/src/routers/dm/blog.ts
apps/api/src/routers/dm/calendar.ts
apps/api/src/routers/dm/content-briefs.ts
apps/api/src/routers/dm/context.ts
apps/api/src/routers/dm/landing-pages.ts
apps/api/src/routers/dm/media.ts
apps/api/src/routers/dm/newsletters.ts
apps/api/src/routers/dm/reports.ts
apps/api/src/routers/dm/social.ts
apps/api/src/routers/dm/strategy.tsThe duplicate block:
if (actor.role !== "super_admin") {
const membership = await db.tenantMember.findFirst({ where: { userId: actor.sub, tenantId } });
if (!membership) return apiError(reply, 403, "FORBIDDEN", "No access to this tenant.");
}Proposed Fix: The existing apps/api/src/lib/auth.ts already has requireTenantUser. Verify that function includes the super_admin bypass and use it consistently. If it doesn’t have the bypass, add it:
// apps/api/src/lib/auth.ts — add to existing file
export async function assertTenantAccess(
actor: { sub: string; role: string },
tenantId: string,
reply: FastifyReply
): Promise<boolean> {
if (actor.role === "super_admin") return true;
const member = await db.tenantMember.findFirst({ where: { userId: actor.sub, tenantId } });
if (!member) {
apiError(reply, 403, "FORBIDDEN", "No access to this tenant.");
return false;
}
return true;
}Usage:
if (!await assertTenantAccess(actor, tenantId, reply)) return;D-25 · Inline Fetch Helper (dmFetch / apiFetch) — 30+ Files
Category: Actions / API proxy routes
Severity: High — inconsistent error handling, auth header construction, and base URL resolution across all portals
Files (confirmed sample):
apps/dm/src/app/(dm)/profile/actions.ts
apps/dm/src/app/(dm)/strategy/deliverable-plan/actions.ts
apps/dm/src/app/api/activities/route.ts
apps/dm/src/app/api/activities/grouped/route.ts
apps/dm/src/app/api/activities/kanban/route.ts
apps/dm/src/app/api/activities/calendar/route.ts
apps/dm/src/app/api/agents/running/route.ts
apps/dm/src/app/api/approvals/route.ts
apps/dm/src/app/api/calendar/route.ts
apps/dm/src/app/api/campaigns/[campaignId]/emails/[emailId]/route.ts
... 20+ more in dm
apps/dashboard/src/app/(dashboard)/client-info/actions.ts
apps/dashboard/src/app/(dashboard)/settings/brand-voice/actions.tsThe duplicate pattern:
// Defined inline in each file:
async function dmFetch(token: string, path: string, method: string, body?: object) {
const res = await fetch(`${API_URL}${path}`, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
});
return res;
}
// Then each caller does its own res.ok check with its own error messageProposed Fix: One shared client per portal (since portals have different base URLs and cookie requirements):
// apps/dm/src/lib/api-client.ts (NEW FILE)
const API_URL = process.env.API_URL ?? "http://localhost:3003";
export class DmApiClient {
constructor(private token: string) {}
async fetch<T = unknown>(path: string, method: string, body?: object): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}` },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: { message?: string } };
throw new Error(err.error?.message ?? `API error ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
get<T>(path: string) { return this.fetch<T>(path, "GET"); }
post<T>(path: string, body?: object){ return this.fetch<T>(path, "POST", body); }
patch<T>(path: string, body?: object){ return this.fetch<T>(path, "PATCH", body); }
delete<T>(path: string) { return this.fetch<T>(path, "DELETE"); }
}Same pattern for apps/dashboard/src/lib/api-client.ts. Then in actions:
const { token } = await requireAuth();
const api = new DmApiClient(token);
const data = await api.post("/dm/v1/goals", { planId, title });D-26 · { ok: boolean } Try/Catch Pattern — 25+ Action Files
Category: Actions
Severity: Low — structural duplication; low risk but adds noise
Files: 12 in apps/dashboard, 9 in apps/dm, 4 in apps/manage
The duplicate pattern:
export async function doSomething(...): Promise<{ ok: boolean; error?: string }> {
try {
// ... do work ...
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Unknown error" };
}
}Proposed Fix: Shared wrapper in each portal’s lib folder:
// apps/dashboard/src/lib/action.ts (NEW FILE — same pattern for dm, manage)
export async function safeAction<T = void>(
fn: () => Promise<T>
): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
try {
return { ok: true, data: await fn() };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "An error occurred" };
}
}Usage:
export async function updateProfile(data: ProfileData) {
return safeAction(async () => {
const { token } = await requireAuth();
await api.patch("/dm/v1/profile", data);
revalidatePath("/profile");
});
}D-27 · Audit Log + requireSession Boilerplate — 21 Dashboard Files
Category: Actions
Severity: Low — verbose but low coupling; writing the wrong action string is the main risk
Files: 21 files in apps/dashboard/src/app/(dashboard)/*/actions.ts
The duplicate pattern:
const { tenantId, userId, userName } = await requireSession();
// ... do work ...
writeAuditLog({
action: "activity.status_changed",
actorId: userId, actorName: userName, actorRole: "client",
tenantId, resourceType: "activity", resourceId: activityId,
resourceName: activity.label, metadata: { status },
});Proposed Fix: A thin wrapper in apps/dashboard/src/lib/audit.ts:
export async function auditAction(
action: string,
resource: { type: string; id: string; name?: string },
metadata?: Record<string, unknown>
) {
const { tenantId, userId, userName } = await requireSession();
writeAuditLog({
action,
actorId: userId, actorName: userName, actorRole: "client",
tenantId, resourceType: resource.type, resourceId: resource.id,
resourceName: resource.name, metadata,
});
}D-28 · mdToHtml Conversion — email-writer
Category: Workers
Severity: Low — single file, but useful to share if landing pages / blog also need HTML output
File:
packages/agents/src/workers/email-writer.worker.ts:4-17Proposed Fix: Move to packages/common/src/markdown.ts and export from packages/common/src/index.ts. This avoids re-implementation if a future worker needs HTML output from markdown.
D-29 · publishAgentEvent Progress Callback — 15 Workers
Category: Workers
Severity: Low — boilerplate, not a correctness risk; but the toolsUsed++ counter pattern is repeated identically
Files: 15 workers that call Claude (same set as D-17)
The duplicate block:
let toolsUsed = 0;
const result = await execute(ctx, async (event) => {
if (event.type === "tool_use") {
toolsUsed++;
await publishAgentEvent({
type: "agent:progress",
tenantId, agentRole: "blog-writer", agentName: "Blog Writer",
runId, timestamp: new Date().toISOString(),
toolName: event.toolName, toolsUsed,
message: `Using tool: ${event.toolName}`,
});
}
});Proposed Fix: Extract to a factory in packages/agents/src/lib/claude-adapter.ts (same file as D-17 fix):
export function makeProgressHandler(opts: {
tenantId: string;
runId: string;
agentRole: string;
agentName: string;
}): { handler: (event: ClaudeProgressEvent) => Promise<void>; getToolsUsed: () => number } {
let toolsUsed = 0;
return {
async handler(event) {
if (event.type === "tool_use") {
toolsUsed++;
await publishAgentEvent({
type: "agent:progress",
...opts,
timestamp: new Date().toISOString(),
toolName: event.toolName, toolsUsed,
message: `Using tool: ${event.toolName}`,
});
}
},
getToolsUsed: () => toolsUsed,
};
}D-30 · Profile Data Normalization (carried from v1 D-15)
Category: Actions
Severity: Low — skipped in v1; still open
Files:
apps/dashboard/src/app/(dashboard)/settings/profile/actions.ts
apps/dm/src/app/(dm)/profile/actions.tsBoth files trim strings and coerce empty strings to null for the same profile shape (name, phone, country, about, whatsapp). The difference is the target backend (BetterAuth DB vs. Fastify API), but the normalization itself is identical.
Proposed Fix: (unchanged from v1)
// packages/common/src/profile.ts
export function normalizeProfileInput(data: {
name: string; phone: string; country: string; about: string; whatsapp: string;
}) {
return {
name: data.name.trim(),
phone: data.phone.trim() || null,
country: data.country.trim() || null,
about: data.about.trim() || null,
whatsapp: data.whatsapp.trim() || null,
};
}Recommended Fix Order
Do first (High — widest impact, unblocks others):
- D-17 —
getClaudeAdapter: one-line import change in 17 files; zero runtime risk - D-25 —
DmApiClient: centralises error handling for all DM proxy routes; reduces silent 200-with-error bugs - D-19 —
withCredits: prevents credits leaking on partial failures in 16 workers
Do next (Medium — correctness risk in specific paths):
4. D-21 — extractJson: gbp-post-writer fragile lastIndexOf will silently corrupt on complex outputs
5. D-23 — parsePagination: ensures consistent defaults across 21 routes
6. D-24 — assertTenantAccess: single place to enforce tenant isolation rules
7. D-18 — Insight worker factory: 8-file boilerplate collapse
8. D-20 — Activity worker factory: 12-file boilerplate collapse
9. D-22 — Markdown helpers: consolidates 5 functions from 2 workers
Do when convenient (Low):
10. D-26 — safeAction wrapper
11. D-27 — auditAction wrapper
12. D-28 — mdToHtml to common
13. D-29 — makeProgressHandler factory
14. D-30 — normalizeProfileInput to common
Total estimated LOC to remove: ~1,200–1,500 lines across workers, routes, and actions.