Auth:
- Add useWebAuth hook as the single web-facing auth interface with four
states (loading/unauthenticated/authenticated/inconsistent)
- Normalize getCurrentUser to return {id: tokenIdentifier, name, email}
- Rename requireOwnerId to requireAuthUserId across all backend callers
- Set expectAuth:true on ConvexReactClient singleton
- Migrate _app, _auth, user-menu, use-personal-organization to useWebAuth
- Remove unused @code/auth/server export (zero callers confirmed)
Primitives:
- Add packages/primitives/src/project.ts with Effect v4 Project domain:
URL normalization, six canonical context kinds, strict remote decoding,
context write decisioning with revision tracking, and no-op detection
- Define PublicGit and ProjectStore replaceable ports
- Define ProjectApplication service orchestrating domain+ports with
deterministic error mapping
- Add comprehensive test suite (28 tests) with in-memory store and fake
PublicGit covering normalization, seeds, idempotency, tenant isolation,
last-write-wins, and typed error mapping
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
import { ConvexError, v } from "convex/values";
|
|
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { mutation, query } from "./_generated/server";
|
|
import type { MutationCtx } from "./_generated/server";
|
|
import { requireAuthUserId } from "./authz"
|
|
|
|
const requireProjectOwner = async (
|
|
ctx: MutationCtx,
|
|
projectId: Id<"projects">,
|
|
ownerId: string
|
|
) => {
|
|
const project = await ctx.db.get("projects", projectId);
|
|
if (!project || project.ownerId !== ownerId) {
|
|
throw new ConvexError("Project not found");
|
|
}
|
|
return project;
|
|
};
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
body: v.string(),
|
|
projectId: v.id("projects"),
|
|
title: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ownerId = await requireAuthUserId(ctx);
|
|
await requireProjectOwner(ctx, args.projectId, ownerId);
|
|
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 ownerId = await requireAuthUserId(ctx);
|
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
|
if (!issue) {
|
|
throw new ConvexError("Issue not found");
|
|
}
|
|
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
|
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 ownerId = await requireAuthUserId(ctx);
|
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
|
if (!issue) {
|
|
throw new ConvexError("Issue not found");
|
|
}
|
|
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
|
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) => {
|
|
const ownerId = await requireAuthUserId(ctx);
|
|
const project = await ctx.db.get("projects", args.projectId);
|
|
if (!project || project.ownerId !== ownerId) {
|
|
return [];
|
|
}
|
|
return await ctx.db
|
|
.query("projectIssues")
|
|
.withIndex("by_project_and_number", (q) =>
|
|
q.eq("projectId", args.projectId)
|
|
)
|
|
.order("desc")
|
|
.take(100);
|
|
},
|
|
});
|