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.
This commit is contained in:
@@ -1,176 +1,231 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { z } from "zod";
|
||||
import { v } from "convex/values";
|
||||
|
||||
import {
|
||||
preparePublicGitSource,
|
||||
decodePublicGitImportResult,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
type PreparedPublicGitSource,
|
||||
} from "@code/primitives/project";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internal } from "./_generated/api";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { requireCurrentOrganization } from "./authz";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
import { persistPublicGitImportTransaction } from "./projectStore";
|
||||
|
||||
const gitHubRepositorySchema = z.object({
|
||||
default_branch: z.string(),
|
||||
description: z.string().nullable(),
|
||||
html_url: z.url(),
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
owner: z.object({ login: z.string() }),
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parseRepository = (value: string): { owner: string; repo: string } => {
|
||||
const normalized = value.trim().replace(/\.git$/u, "");
|
||||
const match = normalized.match(
|
||||
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
|
||||
);
|
||||
if (!match?.groups?.owner || !match.groups.repo) {
|
||||
throw new ConvexError("Use a GitHub repository in owner/name format");
|
||||
}
|
||||
return { owner: match.groups.owner, repo: match.groups.repo };
|
||||
};
|
||||
|
||||
const readGitHubRepository = (value: unknown) => {
|
||||
const result = gitHubRepositorySchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new ConvexError("GitHub returned incomplete repository metadata");
|
||||
}
|
||||
return {
|
||||
defaultBranch: result.data.default_branch,
|
||||
...(result.data.description === null
|
||||
? {}
|
||||
: { description: result.data.description }),
|
||||
id: result.data.id,
|
||||
name: result.data.name,
|
||||
owner: result.data.owner.login,
|
||||
url: result.data.html_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-code",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ConvexError(
|
||||
"Repository not found. This first flow supports public GitHub repositories."
|
||||
);
|
||||
}
|
||||
throw new ConvexError(`GitHub connection failed with ${response.status}`);
|
||||
}
|
||||
const repository = readGitHubRepository(await response.json());
|
||||
const projectId: Id<"projects"> = await ctx.runMutation(
|
||||
internal.projects.storeGitHubProject,
|
||||
{ ownerId, repository }
|
||||
);
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const storeGitHubProject = internalMutation({
|
||||
export const persistPublicGitImport = internalMutation({
|
||||
args: {
|
||||
ownerId: v.string(),
|
||||
repository: v.object({
|
||||
defaultBranch: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
id: v.number(),
|
||||
name: v.string(),
|
||||
owner: v.string(),
|
||||
userId: v.string(),
|
||||
source: v.object({
|
||||
host: v.string(),
|
||||
projectName: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
remote: v.object({
|
||||
defaultBranch: v.optional(v.string()),
|
||||
documents: v.array(
|
||||
v.object({
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
message: v.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_repository", (q) =>
|
||||
q
|
||||
.eq("ownerId", args.ownerId)
|
||||
.eq("provider", "github")
|
||||
.eq("repoOwner", args.repository.owner)
|
||||
.eq("repoName", args.repository.name)
|
||||
)
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("projects", existing._id, {
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
providerRepoId: args.repository.id,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const result = await persistPublicGitImportTransaction(
|
||||
ctx,
|
||||
args.userId,
|
||||
args.source as PreparedPublicGitSource,
|
||||
args.remote as PublicGitImportResult
|
||||
);
|
||||
|
||||
// Seed operational artifacts on first creation (detected by checking
|
||||
// existing artifacts)
|
||||
const existingArtifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">))
|
||||
.first();
|
||||
if (!existingArtifacts) {
|
||||
const now = Date.now();
|
||||
const artifacts = createInitialArtifacts();
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: now,
|
||||
path,
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: { host: result.source.host, url: result.source.url },
|
||||
kind: "project.connected",
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: timestamp,
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
ownerId: args.ownerId,
|
||||
provider: "github",
|
||||
providerRepoId: args.repository.id,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const artifacts = createInitialArtifacts({
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
});
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: timestamp,
|
||||
path,
|
||||
projectId,
|
||||
revision: 1,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
kind: "project.connected",
|
||||
projectId,
|
||||
});
|
||||
return projectId;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: put a context document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putContextDocument = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
write: v.union(
|
||||
v.object({
|
||||
_tag: v.literal("Repository"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("PublicTextUrl"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("UserText"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: list projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
handler: async (ctx): Promise<ProjectView[]> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.listProjects(userId);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: get a project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.getProject(userId, args.projectId);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public action: import a public Git repository
|
||||
// (daemon wiring added in the Daemon phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const importPublicGit = action({
|
||||
args: { repositoryUrl: v.string() },
|
||||
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
|
||||
// Normalize through the domain layer to validate the URL before the
|
||||
// daemon adapter is wired.
|
||||
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
|
||||
// The daemon PublicGit adapter is wired in the Daemon phase.
|
||||
throw new Error(
|
||||
"PublicGit import is not yet wired to the daemon adapter"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public mutation: put context text (paste/upload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putText = internalMutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
content: v.string(),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(userId, args.projectId, {
|
||||
_tag: "UserText" as const,
|
||||
content: args.content,
|
||||
kind: args.kind,
|
||||
origin: args.origin,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user