Files
webdev-pipeline/convex/schema.ts
2026-06-05 14:14:07 +02:00

553 lines
17 KiB
TypeScript

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { tables as authTables } from "./betterAuth/schema";
import {
AUDIT_GENERATION_STAGES,
AUDIT_GENERATION_STATUSES,
RUN_EVENT_LEVELS,
RUN_STATUSES,
RUN_TYPES,
} from "./domain";
const campaignStatus = v.union(v.literal("active"), v.literal("paused"));
const leadPriority = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadContactStatus = v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
);
const leadDuplicateStatus = v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
);
const leadBlacklistStatus = v.union(v.literal("clear"), v.literal("blocked"));
const auditStatus = v.union(
v.literal("draft"),
v.literal("approved"),
v.literal("published"),
v.literal("deactivated"),
);
const outreachStrategy = v.union(
v.literal("call_first"),
v.literal("email_first"),
v.literal("defer"),
v.literal("do_not_contact"),
);
const outreachApprovalStatus = v.union(
v.literal("draft"),
v.literal("approved"),
v.literal("rejected"),
);
const outreachSendStatus = v.union(
v.literal("not_sent"),
v.literal("queued"),
v.literal("sent"),
v.literal("failed"),
);
const outreachResponseStatus = v.union(
v.literal("none"),
v.literal("manual_reply_recorded"),
v.literal("no_interest"),
v.literal("follow_up_needed"),
);
const outreachSalesStatus = v.union(
v.literal("follow_up_planned"),
v.literal("follow_up_sent"),
v.literal("reply_received"),
v.literal("not_interested"),
v.literal("later"),
v.literal("meeting_scheduled"),
v.literal("proposal_requested"),
v.literal("proposal_sent"),
v.literal("won"),
v.literal("lost"),
v.literal("do_not_pursue"),
);
const blacklistType = v.union(
v.literal("domain"),
v.literal("email"),
v.literal("phone"),
v.literal("company"),
v.literal("google_place_id"),
);
const websiteEnrichmentPageKind = v.union(
v.literal("homepage"),
v.literal("contact"),
v.literal("impressum"),
v.literal("services"),
v.literal("about"),
v.literal("team"),
v.literal("other"),
);
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
const auditGenerationStatus = v.union(
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
);
const auditGenerationStage = v.union(
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
);
const auditGenerationUsage = v.object({
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
totalCostUsd: v.optional(v.number()),
});
const auditGenerationParsedJson = v.union(
v.string(),
v.record(
v.string(),
v.union(
v.string(),
v.number(),
v.boolean(),
v.null(),
v.array(v.string()),
v.array(v.number()),
v.array(v.boolean()),
v.object({}),
),
),
);
const runEventLevel = v.union(
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
);
const screenshotViewport = v.union(v.literal("desktop"), v.literal("mobile"));
const pageSpeedStrategy = v.union(
v.literal("mobile"),
v.literal("desktop"),
);
const pageSpeedResultStatus = v.union(
v.literal("succeeded"),
v.literal("failed"),
);
const pageSpeedErrorType = v.union(
v.literal("quota"),
v.literal("timeout"),
v.literal("unavailable"),
v.literal("invalid_url"),
v.literal("api_error"),
v.literal("unknown"),
);
const settingsValue = v.union(v.string(), v.number(), v.boolean(), v.null());
const auditMetricSummary = v.object({
performanceScore: v.optional(v.number()),
accessibilityScore: v.optional(v.number()),
bestPracticesScore: v.optional(v.number()),
seoScore: v.optional(v.number()),
notes: v.optional(v.array(v.string())),
});
const playwrightSummary = v.object({
pagesVisited: v.number(),
contactLinksFound: v.number(),
formsFound: v.number(),
notes: v.optional(v.array(v.string())),
});
const publicAuditObservation = v.object({
title: v.string(),
observation: v.string(),
impact: v.string(),
suggestion: v.string(),
screenshotIds: v.optional(v.array(v.id("_storage"))),
});
const publicAuditOffer = v.object({
body: v.string(),
ctaLabel: v.optional(v.string()),
ctaHref: v.optional(v.string()),
});
const eventDetail = v.object({
label: v.string(),
value: v.string(),
source: v.optional(v.string()),
});
export default defineSchema({
...authTables,
campaigns: defineTable({
name: v.string(),
categoryMode: v.union(v.literal("preset"), v.literal("custom")),
category: v.string(),
customSearchTerm: v.optional(v.string()),
postalCode: v.string(),
region: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
geocodedAt: v.optional(v.number()),
geocodingPlaceId: v.optional(v.string()),
geocodingFormattedAddress: v.optional(v.string()),
radiusKm: v.number(),
maxNewLeadsPerRun: v.number(),
maxAuditsPerRun: v.number(),
recurrence: v.union(
v.literal("manual"),
v.literal("daily"),
v.literal("weekly"),
v.literal("monthly"),
),
status: campaignStatus,
countryCode: v.optional(v.string()),
country: v.optional(v.string()),
lastRunAt: v.optional(v.number()),
nextRunAt: v.optional(v.union(v.number(), v.null())),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_status", ["status"])
.index("by_nextRunAt", ["nextRunAt"])
.index("by_status_and_nextRunAt", ["status", "nextRunAt"]),
leads: defineTable({
campaignId: v.optional(v.id("campaigns")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
normalizedGooglePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
phone: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
normalizedCompanyName: v.optional(v.string()),
normalizedAddress: v.optional(v.string()),
email: v.optional(v.string()),
emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()),
priorityReason: v.optional(v.string()),
contactStatusReason: v.optional(v.string()),
duplicateReason: v.optional(v.string()),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
priority: leadPriority,
contactStatus: leadContactStatus,
duplicateStatus: leadDuplicateStatus,
blacklistStatus: leadBlacklistStatus,
notes: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_campaignId", ["campaignId"])
.index("by_discoveryRunId", ["discoveryRunId"])
.index("by_contactStatus", ["contactStatus"])
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_normalizedPhone", ["normalizedPhone"])
.index("by_normalizedCompanyName_and_normalizedAddress", [
"normalizedCompanyName",
"normalizedAddress",
])
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
.index("by_googlePlaceId", ["googlePlaceId"])
.index("by_websiteDomain", ["websiteDomain"])
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
.index("by_priority_and_contactStatus", ["priority", "contactStatus"]),
audits: defineTable({
leadId: v.id("leads"),
status: auditStatus,
slug: v.string(),
checkedDomain: v.string(),
checkedPages: v.array(v.string()),
pageSpeedSummary: v.optional(auditMetricSummary),
playwrightSummary: v.optional(playwrightSummary),
textFindings: v.optional(v.array(v.string())),
usedSkills: v.optional(
v.array(
v.object({
name: v.string(),
category: v.string(),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
),
),
skillSummaries: v.optional(
v.array(
v.object({
name: v.string(),
purpose: v.string(),
summary: v.string(),
}),
),
),
multimodalSummary: v.optional(v.string()),
internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicAuditObservation)),
publicOffer: v.optional(publicAuditOffer),
ctaType: v.optional(v.string()),
publishedAt: v.optional(v.number()),
reviewDueAt: v.optional(v.number()),
deactivatedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_slug", ["slug"])
.index("by_status", ["status"])
.index("by_status_and_reviewDueAt", ["status", "reviewDueAt"]),
auditScreenshots: defineTable({
auditId: v.id("audits"),
storageId: v.id("_storage"),
viewport: screenshotViewport,
sourceUrl: v.string(),
capturedAt: v.number(),
width: v.number(),
height: v.number(),
mimeType: v.string(),
createdAt: v.number(),
})
.index("by_auditId", ["auditId"])
.index("by_auditId_and_viewport", ["auditId", "viewport"])
.index("by_storageId", ["storageId"]),
pageSpeedResults: defineTable({
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.optional(v.id("agentRuns")),
strategy: pageSpeedStrategy,
status: pageSpeedResultStatus,
sourceUrl: v.string(),
finalUrl: v.optional(v.string()),
rawStorageId: v.optional(v.id("_storage")),
errorType: v.optional(pageSpeedErrorType),
errorSummary: v.optional(v.string()),
fetchedAt: v.number(),
createdAt: v.number(),
normalized: v.optional(
v.object({
scores: v.optional(
v.object({
performance: v.optional(v.number()),
accessibility: v.optional(v.number()),
bestPractices: v.optional(v.number()),
seo: v.optional(v.number()),
}),
),
metrics: v.optional(
v.object({
firstContentfulPaintMs: v.optional(v.number()),
largestContentfulPaintMs: v.optional(v.number()),
cumulativeLayoutShift: v.optional(v.number()),
totalBlockingTimeMs: v.optional(v.number()),
speedIndexMs: v.optional(v.number()),
}),
),
opportunities: v.optional(v.array(v.string())),
implications: v.optional(v.array(v.string())),
}),
),
})
.index("by_leadId", ["leadId"])
.index("by_runId", ["runId"])
.index("by_auditId", ["auditId"])
.index("by_leadId_and_strategy", ["leadId", "strategy"]),
auditGenerations: defineTable({
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.id("agentRuns"),
stage: auditGenerationStage,
modelProfile: v.string(),
modelId: v.string(),
prompt: v.string(),
systemPrompt: v.optional(v.string()),
rawResponse: v.optional(v.string()),
parsedJson: v.optional(auditGenerationParsedJson),
usage: v.optional(auditGenerationUsage),
finishReason: v.optional(v.string()),
status: auditGenerationStatus,
errorSummary: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_auditId", ["auditId"])
.index("by_runId", ["runId"])
.index("by_stage", ["stage"])
.index("by_leadId_and_stage", ["leadId", "stage"]),
websiteCrawlPages: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),
sourceUrl: v.string(),
finalUrl: v.string(),
pageKind: websiteEnrichmentPageKind,
title: v.optional(v.string()),
metaDescription: v.optional(v.string()),
headings: v.array(v.string()),
visibleTextExcerpt: v.optional(v.string()),
hasContactFormSignal: v.boolean(),
hasContactCtaSignal: v.boolean(),
createdAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_runId", ["runId"])
.index("by_leadId_and_createdAt", ["leadId", "createdAt"]),
websiteCrawlLinks: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),
pageUrl: v.string(),
href: v.string(),
text: v.optional(v.string()),
isInternal: v.boolean(),
isBroken: v.optional(v.boolean()),
createdAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_runId", ["runId"]),
websiteEmailCandidates: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),
email: v.string(),
normalizedEmail: v.string(),
emailSource: v.string(),
sourceUrl: v.string(),
contactPerson: v.optional(v.string()),
isBusinessContactAddress: v.boolean(),
isGeneric: v.boolean(),
accepted: v.boolean(),
createdAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_runId", ["runId"]),
websiteCrawlScreenshots: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),
storageId: v.id("_storage"),
viewport: screenshotViewport,
sourceUrl: v.string(),
capturedAt: v.number(),
width: v.number(),
height: v.number(),
mimeType: v.string(),
createdAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_runId", ["runId"])
.index("by_storageId", ["storageId"]),
websiteTechnicalChecks: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),
sourceUrl: v.string(),
finalUrl: v.optional(v.string()),
usesHttps: v.boolean(),
missingTitle: v.boolean(),
missingMetaDescription: v.boolean(),
hasVisibleContactPath: v.boolean(),
brokenInternalLinkCount: v.number(),
createdAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_runId", ["runId"]),
outreachRecords: defineTable({
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
strategy: outreachStrategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
approvalStatus: outreachApprovalStatus,
sendStatus: outreachSendStatus,
sentAt: v.optional(v.number()),
responseStatus: outreachResponseStatus,
salesStatus: outreachSalesStatus,
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_auditId", ["auditId"])
.index("by_approvalStatus", ["approvalStatus"])
.index("by_sendStatus", ["sendStatus"]),
blacklistEntries: defineTable({
type: blacklistType,
value: v.string(),
normalizedValue: v.string(),
note: v.optional(v.string()),
createdAt: v.number(),
})
.index("by_type_and_normalizedValue", ["type", "normalizedValue"])
.index("by_normalizedValue", ["normalizedValue"]),
agentRuns: defineTable({
type: runType,
campaignId: v.optional(v.id("campaigns")),
leadId: v.optional(v.id("leads")),
auditId: v.optional(v.id("audits")),
status: runStatus,
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
currentStep: v.optional(v.string()),
errorSummary: v.optional(v.string()),
counters: v.optional(
v.object({
leadsFound: v.number(),
leadsCreated: v.number(),
auditsCreated: v.number(),
outreachPrepared: v.number(),
errors: v.number(),
}),
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_status", ["status"])
.index("by_type", ["type"])
.index("by_type_and_status", ["type", "status"])
.index("by_type_and_status_and_leadId", ["type", "status", "leadId"])
.index("by_campaignId_and_updatedAt", ["campaignId", "updatedAt"])
.index("by_campaignId_and_status", ["campaignId", "status"])
.index("by_auditId", ["auditId"]),
agentRunEvents: defineTable({
runId: v.id("agentRuns"),
level: runEventLevel,
message: v.string(),
details: v.optional(v.array(eventDetail)),
createdAt: v.number(),
})
.index("by_runId_and_createdAt", ["runId", "createdAt"])
.index("by_level", ["level"]),
settings: defineTable({
key: v.string(),
value: settingsValue,
description: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_key", ["key"]),
});