a
This commit is contained in:
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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
214
convex/analysisSections.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
71
convex/seenUrls.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user