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 outreachSendAttemptStatus = v.union( v.literal("success"), 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_contactStatus_and_updatedAt", ["contactStatus", "updatedAt"]) .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_approvalStatus_and_updatedAt", ["approvalStatus", "updatedAt"]) .index("by_approvalStatus_and_sendStatus_and_updatedAt", [ "approvalStatus", "sendStatus", "updatedAt", ]) .index("by_sendStatus", ["sendStatus"]) .index("by_sendStatus_and_updatedAt", ["sendStatus", "updatedAt"]), outreachSendAttempts: defineTable({ outreachId: v.id("outreachRecords"), leadId: v.id("leads"), auditId: v.optional(v.id("audits")), recipient: v.string(), subject: v.string(), body: v.string(), sender: v.string(), auditLink: v.optional(v.union(v.string(), v.null())), status: outreachSendAttemptStatus, sentAt: v.optional(v.number()), smtpMessageId: v.optional(v.string()), smtpResponse: v.optional(v.string()), smtpAccepted: v.optional(v.array(v.string())), smtpRejected: v.optional(v.array(v.string())), errorMessage: v.optional(v.string()), errorCode: v.optional(v.string()), errorResponseCode: v.optional(v.number()), errorResponse: v.optional(v.string()), createdAt: v.number(), updatedAt: v.number(), }) .index("by_outreachId", ["outreachId"]) .index("by_leadId", ["leadId"]) .index("by_status", ["status"]) .index("by_createdAt", ["createdAt"]), 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"]), });