359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { mutation, query } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
|
|
|
export const getProjects = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) return [];
|
|
return await ctx.db
|
|
.query("projects")
|
|
.withIndex("by_owner", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
},
|
|
});
|
|
|
|
export const getDefaultProject = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) return null;
|
|
return await ctx.db
|
|
.query("projects")
|
|
.filter((q) => q.and(q.eq(q.field("userId"), userId), q.eq(q.field("isDefault"), true)))
|
|
.first();
|
|
},
|
|
});
|
|
|
|
export const createProject = mutation({
|
|
args: { name: v.string(), isDefault: v.boolean() },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) throw new Error("Unauthorized");
|
|
|
|
if (args.isDefault) {
|
|
const existingDefaults = await ctx.db
|
|
.query("projects")
|
|
.withIndex("by_owner", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
for (const project of existingDefaults) {
|
|
if (project.isDefault) {
|
|
await ctx.db.patch(project._id, { isDefault: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
return await ctx.db.insert("projects", {
|
|
userId,
|
|
name: args.name,
|
|
isDefault: args.isDefault,
|
|
dorkingConfig: { selectedSourceIds: [] },
|
|
});
|
|
},
|
|
});
|
|
|
|
export const updateProject = mutation({
|
|
args: { projectId: v.id("projects"), name: v.string(), isDefault: v.boolean() },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) throw new Error("Unauthorized");
|
|
|
|
const project = await ctx.db.get(args.projectId);
|
|
if (!project || project.userId !== userId) {
|
|
throw new Error("Project not found or unauthorized");
|
|
}
|
|
|
|
if (args.isDefault) {
|
|
const existingDefaults = await ctx.db
|
|
.query("projects")
|
|
.withIndex("by_owner", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
for (const item of existingDefaults) {
|
|
if (item.isDefault && item._id !== args.projectId) {
|
|
await ctx.db.patch(item._id, { isDefault: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
await ctx.db.patch(args.projectId, {
|
|
name: args.name,
|
|
isDefault: args.isDefault,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const deleteProject = mutation({
|
|
args: { projectId: v.id("projects") },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) throw new Error("Unauthorized");
|
|
|
|
const projects = await ctx.db
|
|
.query("projects")
|
|
.withIndex("by_owner", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
|
|
if (projects.length <= 1) {
|
|
throw new Error("You must keep at least one project.");
|
|
}
|
|
|
|
const project = projects.find((item) => item._id === args.projectId);
|
|
if (!project || project.userId !== userId) {
|
|
throw new Error("Project not found or unauthorized");
|
|
}
|
|
|
|
const remainingProjects = projects.filter((item) => item._id !== args.projectId);
|
|
let newDefaultId: typeof args.projectId | null = null;
|
|
|
|
const remainingDefault = remainingProjects.find((item) => item.isDefault);
|
|
if (project.isDefault) {
|
|
newDefaultId = remainingProjects[0]?._id ?? null;
|
|
} else if (!remainingDefault) {
|
|
newDefaultId = remainingProjects[0]?._id ?? null;
|
|
}
|
|
|
|
if (newDefaultId) {
|
|
await ctx.db.patch(newDefaultId, { isDefault: true });
|
|
}
|
|
|
|
const dataSources = await ctx.db
|
|
.query("dataSources")
|
|
.filter((q) => q.eq(q.field("projectId"), args.projectId))
|
|
.collect();
|
|
for (const source of dataSources) {
|
|
await ctx.db.delete(source._id);
|
|
}
|
|
|
|
const analyses = await ctx.db
|
|
.query("analyses")
|
|
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
|
|
.collect();
|
|
for (const analysis of analyses) {
|
|
await ctx.db.delete(analysis._id);
|
|
}
|
|
|
|
const analysisJobs = await ctx.db
|
|
.query("analysisJobs")
|
|
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
|
|
.collect();
|
|
for (const job of analysisJobs) {
|
|
await ctx.db.delete(job._id);
|
|
}
|
|
|
|
const searchJobs = await ctx.db
|
|
.query("searchJobs")
|
|
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
|
|
.collect();
|
|
for (const job of searchJobs) {
|
|
await ctx.db.delete(job._id);
|
|
}
|
|
|
|
const opportunities = await ctx.db
|
|
.query("opportunities")
|
|
.withIndex("by_project_createdAt", (q) => q.eq("projectId", args.projectId))
|
|
.collect();
|
|
for (const opportunity of opportunities) {
|
|
await ctx.db.delete(opportunity._id);
|
|
}
|
|
|
|
const seenUrls = await ctx.db
|
|
.query("seenUrls")
|
|
.withIndex("by_project_lastSeen", (q) => q.eq("projectId", args.projectId))
|
|
.collect();
|
|
for (const seen of seenUrls) {
|
|
await ctx.db.delete(seen._id);
|
|
}
|
|
|
|
await ctx.db.delete(args.projectId);
|
|
|
|
return {
|
|
deletedProjectId: args.projectId,
|
|
newDefaultProjectId: newDefaultId ?? remainingDefault?._id ?? remainingProjects[0]?._id ?? null,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const toggleDataSourceConfig = mutation({
|
|
args: { projectId: v.id("projects"), sourceId: v.id("dataSources"), selected: v.boolean() },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx);
|
|
if (!userId) throw new Error("Unauthorized");
|
|
|
|
const project = await ctx.db.get(args.projectId);
|
|
if (!project || project.userId !== userId) throw new Error("Project not found or unauthorized");
|
|
|
|
let newSelectedIds = project.dorkingConfig.selectedSourceIds;
|
|
if (args.selected) {
|
|
if (!newSelectedIds.includes(args.sourceId)) {
|
|
newSelectedIds.push(args.sourceId);
|
|
}
|
|
} else {
|
|
newSelectedIds = newSelectedIds.filter((id) => id !== args.sourceId);
|
|
}
|
|
|
|
await ctx.db.patch(args.projectId, {
|
|
dorkingConfig: { selectedSourceIds: newSelectedIds },
|
|
});
|
|
},
|
|
});
|
|
|
|
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",
|
|
};
|
|
}
|