This commit is contained in:
2026-02-04 01:05:00 +00:00
parent f9222627ef
commit d02d95e680
30 changed files with 2449 additions and 326 deletions

View File

@@ -10,12 +10,14 @@
import type * as analyses from "../analyses.js";
import type * as analysisJobs from "../analysisJobs.js";
import type * as analysisSections from "../analysisSections.js";
import type * as auth from "../auth.js";
import type * as dataSources from "../dataSources.js";
import type * as http from "../http.js";
import type * as opportunities from "../opportunities.js";
import type * as projects from "../projects.js";
import type * as searchJobs from "../searchJobs.js";
import type * as seenUrls from "../seenUrls.js";
import type * as users from "../users.js";
import type {
@@ -27,12 +29,14 @@ import type {
declare const fullApi: ApiFromModules<{
analyses: typeof analyses;
analysisJobs: typeof analysisJobs;
analysisSections: typeof analysisSections;
auth: typeof auth;
dataSources: typeof dataSources;
http: typeof http;
opportunities: typeof opportunities;
projects: typeof projects;
searchJobs: typeof searchJobs;
seenUrls: typeof seenUrls;
users: typeof users;
}>;

View File

@@ -41,6 +41,22 @@ export const getLatestByDataSource = query({
},
});
export const getById = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, { analysisId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const analysis = await ctx.db.get(analysisId);
if (!analysis) return null;
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) return null;
return analysis;
},
});
export const createAnalysis = mutation({
args: {
projectId: v.id("projects"),
@@ -141,7 +157,7 @@ export const createAnalysis = mutation({
throw new Error("Project not found or unauthorized");
}
return await ctx.db.insert("analyses", {
const analysisId = await ctx.db.insert("analyses", {
projectId: args.projectId,
dataSourceId: args.dataSourceId,
createdAt: Date.now(),
@@ -160,5 +176,38 @@ export const createAnalysis = mutation({
dorkQueries: args.analysis.dorkQueries,
scrapedAt: args.analysis.scrapedAt,
});
const now = Date.now();
const sections = [
{
sectionKey: "profile",
items: {
productName: args.analysis.productName,
tagline: args.analysis.tagline,
description: args.analysis.description,
category: args.analysis.category,
positioning: args.analysis.positioning,
},
},
{ sectionKey: "features", items: args.analysis.features },
{ sectionKey: "competitors", items: args.analysis.competitors },
{ sectionKey: "keywords", items: args.analysis.keywords },
{ sectionKey: "problems", items: args.analysis.problemsSolved },
{ sectionKey: "personas", items: args.analysis.personas },
{ sectionKey: "useCases", items: args.analysis.useCases },
{ sectionKey: "dorkQueries", items: args.analysis.dorkQueries },
];
for (const section of sections) {
await ctx.db.insert("analysisSections", {
analysisId,
sectionKey: section.sectionKey,
items: section.items,
source: args.analysis.analysisVersion === "manual" ? "manual" : "ai",
updatedAt: now,
});
}
return analysisId;
},
});

214
convex/analysisSections.ts Normal file
View File

@@ -0,0 +1,214 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
const SECTION_KEYS = [
"profile",
"features",
"competitors",
"keywords",
"problems",
"personas",
"useCases",
"dorkQueries",
] as const;
function assertSectionKey(sectionKey: string) {
if (!SECTION_KEYS.includes(sectionKey as any)) {
throw new Error(`Invalid section key: ${sectionKey}`);
}
}
async function getOwnedAnalysis(ctx: any, analysisId: any) {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthorized");
const analysis = await ctx.db.get(analysisId);
if (!analysis) throw new Error("Analysis not found");
const project = await ctx.db.get(analysis.projectId);
if (!project || project.userId !== userId) {
throw new Error("Project not found or unauthorized");
}
return analysis;
}
async function patchAnalysisFromSection(
ctx: any,
analysisId: any,
sectionKey: string,
items: any
) {
if (sectionKey === "profile" && items && typeof items === "object") {
const patch: Record<string, any> = {};
if (typeof items.productName === "string") patch.productName = items.productName;
if (typeof items.tagline === "string") patch.tagline = items.tagline;
if (typeof items.description === "string") patch.description = items.description;
if (typeof items.category === "string") patch.category = items.category;
if (typeof items.positioning === "string") patch.positioning = items.positioning;
if (Object.keys(patch).length > 0) {
await ctx.db.patch(analysisId, patch);
}
return;
}
if (sectionKey === "features") {
await ctx.db.patch(analysisId, { features: items });
return;
}
if (sectionKey === "competitors") {
await ctx.db.patch(analysisId, { competitors: items });
return;
}
if (sectionKey === "keywords") {
await ctx.db.patch(analysisId, { keywords: items });
return;
}
if (sectionKey === "problems") {
await ctx.db.patch(analysisId, { problemsSolved: items });
return;
}
if (sectionKey === "personas") {
await ctx.db.patch(analysisId, { personas: items });
return;
}
if (sectionKey === "useCases") {
await ctx.db.patch(analysisId, { useCases: items });
return;
}
if (sectionKey === "dorkQueries") {
await ctx.db.patch(analysisId, { dorkQueries: items });
}
}
export const listByAnalysis = query({
args: { analysisId: v.id("analyses") },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", args.analysisId))
.collect();
},
});
export const getSection = query({
args: { analysisId: v.id("analyses"), sectionKey: v.string() },
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
return await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
},
});
export const replaceSection = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const existing = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("analysisSections", {
analysisId: args.analysisId,
sectionKey: args.sectionKey,
items: args.items,
lastPrompt: args.lastPrompt,
source: args.source,
updatedAt: Date.now(),
});
}
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, args.items);
return { success: true };
},
});
export const addItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
item: v.any(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = [...section.items, args.item];
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});
export const removeItem = mutation({
args: {
analysisId: v.id("analyses"),
sectionKey: v.string(),
index: v.number(),
},
handler: async (ctx, args) => {
await getOwnedAnalysis(ctx, args.analysisId);
assertSectionKey(args.sectionKey);
const section = await ctx.db
.query("analysisSections")
.withIndex("by_analysis_section", (q) =>
q.eq("analysisId", args.analysisId).eq("sectionKey", args.sectionKey)
)
.first();
if (!section || !Array.isArray(section.items)) {
throw new Error("Section is not editable as a list.");
}
const updated = section.items.filter((_: any, idx: number) => idx !== args.index);
await ctx.db.patch(section._id, {
items: updated,
source: "mixed",
updatedAt: Date.now(),
});
await patchAnalysisFromSection(ctx, args.analysisId, args.sectionKey, updated);
return { success: true };
},
});

View File

@@ -170,6 +170,13 @@ export const remove = mutation({
)
.collect();
for (const analysis of analyses) {
const sections = await ctx.db
.query("analysisSections")
.withIndex("by_analysis", (q) => q.eq("analysisId", analysis._id))
.collect();
for (const section of sections) {
await ctx.db.delete(section._id);
}
await ctx.db.delete(analysis._id);
}

View File

@@ -122,6 +122,28 @@ export const upsertBatch = mutation({
});
created += 1;
}
const seenExisting = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", opp.url)
)
.first();
if (seenExisting) {
await ctx.db.patch(seenExisting._id, {
lastSeenAt: now,
source: "opportunities",
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url: opp.url,
firstSeenAt: now,
lastSeenAt: now,
source: "opportunities",
});
}
}
return { created, updated };

View File

@@ -83,6 +83,97 @@ export const updateProject = mutation({
},
});
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) => {

View File

@@ -125,6 +125,16 @@ const schema = defineSchema({
})
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_dataSource_createdAt", ["dataSourceId", "createdAt"]),
analysisSections: defineTable({
analysisId: v.id("analyses"),
sectionKey: v.string(),
items: v.any(),
lastPrompt: v.optional(v.string()),
source: v.union(v.literal("ai"), v.literal("manual"), v.literal("mixed")),
updatedAt: v.number(),
})
.index("by_analysis", ["analysisId"])
.index("by_analysis_section", ["analysisId", "sectionKey"]),
opportunities: defineTable({
projectId: v.id("projects"),
analysisId: v.optional(v.id("analyses")),
@@ -147,6 +157,15 @@ const schema = defineSchema({
.index("by_project_status", ["projectId", "status"])
.index("by_project_createdAt", ["projectId", "createdAt"])
.index("by_project_url", ["projectId", "url"]),
seenUrls: defineTable({
projectId: v.id("projects"),
url: v.string(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
source: v.optional(v.string()),
})
.index("by_project_url", ["projectId", "url"])
.index("by_project_lastSeen", ["projectId", "lastSeenAt"]),
analysisJobs: defineTable({
projectId: v.id("projects"),
dataSourceId: v.optional(v.id("dataSources")),

71
convex/seenUrls.ts Normal file
View File

@@ -0,0 +1,71 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
export const listExisting = query({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) return [];
const project = await ctx.db.get(args.projectId);
if (!project || project.userId !== userId) return [];
const existing: string[] = [];
for (const url of args.urls) {
const match = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (match) existing.push(url);
}
return existing;
},
});
export const markSeenBatch = mutation({
args: {
projectId: v.id("projects"),
urls: v.array(v.string()),
source: v.optional(v.string()),
},
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");
}
const now = Date.now();
for (const url of args.urls) {
const existing = await ctx.db
.query("seenUrls")
.withIndex("by_project_url", (q) =>
q.eq("projectId", args.projectId).eq("url", url)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
lastSeenAt: now,
source: args.source ?? existing.source,
});
} else {
await ctx.db.insert("seenUrls", {
projectId: args.projectId,
url,
firstSeenAt: now,
lastSeenAt: now,
source: args.source,
});
}
}
},
});

View File

@@ -9,3 +9,18 @@ export const getCurrent = query({
return await ctx.db.get(userId);
},
});
export const getCurrentProfile = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
const user = await ctx.db.get(userId);
if (!user) return null;
const accounts = await ctx.db
.query("authAccounts")
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId))
.collect();
return { user, accounts };
},
});