Files
zopu-code/packages/backend/convex/projectIssues.ts
-Puter 224e98d0bc feat: add Convex project backend with generic storage and org-scoped authz
Schema:
- Cut projects table to {organizationId, name, description, createdAt, updatedAt}
- Add projectSources table with git source metadata and normalized URL index
- Add projectContextDocuments table with six canonical kinds, origin, revision
- Add by_organizationId index for project enumeration
- Remove all GitHub-specific fields (ownerId, provider, repoOwner, etc.)

Backend:
- Add requireCurrentOrganization and requireProjectMember authz helpers
- Migrate signals.ts authorizeProject to organizationId invariant check
- Update artifactModel: 8 operational artifacts (removed project/business/design.md)
- Add projectStore.ts with query/mutation/action store adapters
- Rewrite projects.ts to thin generic application calls
- Update projectIssues/projectArtifacts to use requireProjectMember
- Update projectEvents.test.ts and signals.test.ts for new project creation

All 33 backend tests pass.
2026-07-23 18:16:41 +05:30

134 lines
3.8 KiB
TypeScript

import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import { requireProjectMember } from "./authz";
export const create = mutation({
args: {
body: v.string(),
projectId: v.id("projects"),
title: v.string(),
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const title = args.title.trim();
const body = args.body.trim();
if (title.length < 3 || title.length > 160) {
throw new ConvexError("Issue title must be between 3 and 160 characters");
}
if (body.length < 10 || body.length > 10_000) {
throw new ConvexError(
"Issue description must be between 10 and 10000 characters"
);
}
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body,
createdAt: timestamp,
number,
projectId: args.projectId,
status: "open",
title,
updatedAt: timestamp,
});
const workArtifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", args.projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, title },
issueId,
kind: "issue.created",
projectId: args.projectId,
});
return issueId;
},
});
export const begin = mutation({
args: { issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") {
return issue.status;
}
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "queued",
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number: issue.number },
issueId: issue._id,
kind: "issue.queued",
projectId: issue.projectId,
});
return "queued" as const;
},
});
export const markDispatchFailed = mutation({
args: { error: v.string(), issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectMember(ctx, issue.projectId);
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "failed",
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
issueId: issue._id,
kind: "agent.failed",
projectId: issue.projectId,
});
},
});
export const list = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
},
});