feat: Implement data source management and analysis flow, allowing users to add and analyze websites for project opportunities.

This commit is contained in:
2026-02-03 20:35:03 +00:00
parent 885bbbf954
commit c47614bc66
9 changed files with 587 additions and 54 deletions

View File

@@ -19,6 +19,28 @@ export const getLatestByProject = query({
},
});
export const getLatestByDataSource = query({
args: { dataSourceId: v.id("dataSources") },
handler: async (ctx, { dataSourceId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const dataSource = await ctx.db.get(dataSourceId);
if (!dataSource) return null;
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) return null;
return await ctx.db
.query("analyses")
.withIndex("by_dataSource_createdAt", (q) =>
q.eq("dataSourceId", dataSourceId)
)
.order("desc")
.first();
},
});
export const createAnalysis = mutation({
args: {
projectId: v.id("projects"),

View File

@@ -52,24 +52,68 @@ export const addDataSource = mutation({
}
}
const sourceId = await ctx.db.insert("dataSources", {
projectId: projectId!, // Assert exists
type: args.type,
url: args.url,
name: args.name,
analysisStatus: "pending",
// analysisResults not set initially
});
const existing = await ctx.db
.query("dataSources")
.withIndex("by_project_url", (q) =>
q.eq("projectId", projectId!).eq("url", args.url)
)
.first();
const sourceId = existing
? existing._id
: await ctx.db.insert("dataSources", {
projectId: projectId!, // Assert exists
type: args.type,
url: args.url,
name: args.name,
analysisStatus: "pending",
lastAnalyzedAt: undefined,
lastError: undefined,
// analysisResults not set initially
});
// Auto-select this source in the project config
const project = await ctx.db.get(projectId!);
if (project) {
const currentSelected = project.dorkingConfig.selectedSourceIds;
await ctx.db.patch(projectId!, {
dorkingConfig: { selectedSourceIds: [...currentSelected, sourceId] }
});
if (!currentSelected.includes(sourceId)) {
await ctx.db.patch(projectId!, {
dorkingConfig: { selectedSourceIds: [...currentSelected, sourceId] }
});
}
}
return { sourceId, projectId: projectId! };
},
});
export const updateDataSourceStatus = mutation({
args: {
dataSourceId: v.id("dataSources"),
analysisStatus: v.union(
v.literal("pending"),
v.literal("completed"),
v.literal("failed")
),
lastError: v.optional(v.string()),
lastAnalyzedAt: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const dataSource = await ctx.db.get(args.dataSourceId);
if (!dataSource) throw new Error("Data source not found");
const project = await ctx.db.get(dataSource.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
await ctx.db.patch(args.dataSourceId, {
analysisStatus: args.analysisStatus,
lastError: args.lastError,
lastAnalyzedAt: args.lastAnalyzedAt,
});
},
});

View File

@@ -67,3 +67,162 @@ export const toggleDataSourceConfig = mutation({
});
},
});
export const getSearchContext = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return { context: null, selectedSourceIds: [], missingSources: [] };
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== userId) {
return { context: null, selectedSourceIds: [], missingSources: [] };
}
const selectedSourceIds = project.dorkingConfig.selectedSourceIds || [];
if (selectedSourceIds.length === 0) {
return { context: null, selectedSourceIds, missingSources: [] };
}
const analyses = [];
const missingSources: { sourceId: string; reason: string }[] = [];
for (const sourceId of selectedSourceIds) {
const dataSource = await ctx.db.get(sourceId);
if (!dataSource || dataSource.projectId !== project._id) {
missingSources.push({ sourceId: sourceId as string, reason: "not_found" });
continue;
}
const latest = await ctx.db
.query("analyses")
.withIndex("by_dataSource_createdAt", (q) => q.eq("dataSourceId", sourceId))
.order("desc")
.first();
if (!latest) {
missingSources.push({ sourceId: sourceId as string, reason: "no_analysis" });
continue;
}
analyses.push(latest);
}
if (analyses.length === 0) {
return { context: null, selectedSourceIds, missingSources };
}
const merged = mergeAnalyses(analyses, project.name);
return { context: merged, selectedSourceIds, missingSources };
},
});
function normalizeKey(value: string) {
return value.trim().toLowerCase();
}
function severityRank(severity: "high" | "medium" | "low") {
if (severity === "high") return 3;
if (severity === "medium") return 2;
return 1;
}
function mergeAnalyses(analyses: any[], fallbackName: string) {
const keywordMap = new Map<string, any>();
const problemMap = new Map<string, any>();
const competitorMap = new Map<string, any>();
const personaMap = new Map<string, any>();
const useCaseMap = new Map<string, any>();
const featureMap = new Map<string, any>();
let latestScrapedAt = analyses[0].scrapedAt;
for (const analysis of analyses) {
if (analysis.scrapedAt > latestScrapedAt) {
latestScrapedAt = analysis.scrapedAt;
}
for (const keyword of analysis.keywords || []) {
const key = normalizeKey(keyword.term);
if (!keywordMap.has(key)) {
keywordMap.set(key, keyword);
} else if (keywordMap.get(key)?.type !== "differentiator" && keyword.type === "differentiator") {
keywordMap.set(key, keyword);
}
}
for (const problem of analysis.problemsSolved || []) {
const key = normalizeKey(problem.problem);
const existing = problemMap.get(key);
if (!existing || severityRank(problem.severity) > severityRank(existing.severity)) {
problemMap.set(key, problem);
}
}
for (const competitor of analysis.competitors || []) {
const key = normalizeKey(competitor.name);
if (!competitorMap.has(key)) {
competitorMap.set(key, competitor);
}
}
for (const persona of analysis.personas || []) {
const key = normalizeKey(`${persona.name}:${persona.role}`);
if (!personaMap.has(key)) {
personaMap.set(key, persona);
}
}
for (const useCase of analysis.useCases || []) {
const key = normalizeKey(useCase.scenario);
if (!useCaseMap.has(key)) {
useCaseMap.set(key, useCase);
}
}
for (const feature of analysis.features || []) {
const key = normalizeKey(feature.name);
if (!featureMap.has(key)) {
featureMap.set(key, feature);
}
}
}
const keywords = Array.from(keywordMap.values())
.sort((a, b) => {
const aDiff = a.type === "differentiator" ? 0 : 1;
const bDiff = b.type === "differentiator" ? 0 : 1;
if (aDiff !== bDiff) return aDiff - bDiff;
return a.term.length - b.term.length;
})
.slice(0, 80);
const problemsSolved = Array.from(problemMap.values())
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity))
.slice(0, 15);
const competitors = Array.from(competitorMap.values()).slice(0, 10);
const personas = Array.from(personaMap.values()).slice(0, 6);
const useCases = Array.from(useCaseMap.values()).slice(0, 10);
const features = Array.from(featureMap.values()).slice(0, 20);
const base = analyses[0];
return {
productName: base.productName || fallbackName,
tagline: base.tagline || "",
description: base.description || "",
category: base.category || "",
positioning: base.positioning || "",
features,
problemsSolved,
personas,
keywords,
useCases,
competitors,
dorkQueries: [],
scrapedAt: latestScrapedAt,
analysisVersion: "aggregated",
};
}

View File

@@ -22,6 +22,8 @@ const schema = defineSchema({
v.literal("completed"),
v.literal("failed")
),
lastAnalyzedAt: v.optional(v.number()),
lastError: v.optional(v.string()),
analysisResults: v.optional(
v.object({
features: v.array(v.string()),
@@ -31,7 +33,7 @@ const schema = defineSchema({
})
),
metadata: v.optional(v.any()),
}),
}).index("by_project_url", ["projectId", "url"]),
analyses: defineTable({
projectId: v.id("projects"),
dataSourceId: v.id("dataSources"),
@@ -121,7 +123,8 @@ const schema = defineSchema({
})),
scrapedAt: v.string(),
})
.index("by_project_createdAt", ["projectId", "createdAt"]),
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
opportunities: defineTable({
projectId: v.id("projects"),
analysisId: v.optional(v.id("analyses")),