feat: Implement data source management and analysis flow, allowing users to add and analyze websites for project opportunities.
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user