Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions drizzle/0001_sprint_lifecycle_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
-- Add new columns to profiles
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "technologies" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "tools" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "time_commitment" text;--> statement-breakpoint
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "primary_goal" text;--> statement-breakpoint

-- Add new columns to projects for quality gate and trust boundary
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "contribution_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "first_pr_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "approved_at" timestamp with time zone DEFAULT now();--> statement-breakpoint

-- Create issue_claims table
CREATE TABLE IF NOT EXISTS "issue_claims" (
"id" text PRIMARY KEY NOT NULL,
"issue_id" text NOT NULL,
"user_id" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"claimed_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"released_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint

-- Create pull_requests table
CREATE TABLE IF NOT EXISTS "pull_requests" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"project_id" text NOT NULL,
"issue_id" text,
"github_pr_id" integer NOT NULL,
"github_pr_number" integer NOT NULL,
"title" text NOT NULL,
"url" text NOT NULL,
"state" text DEFAULT 'open' NOT NULL,
"draft" boolean DEFAULT false NOT NULL,
"merge_commit_sha" text,
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
"merged_at" timestamp with time zone,
"closed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pull_requests_url_unique" UNIQUE("url")
);--> statement-breakpoint

-- Create pull_request_reviews table
CREATE TABLE IF NOT EXISTS "pull_request_reviews" (
"id" text PRIMARY KEY NOT NULL,
"pull_request_id" text NOT NULL,
"reviewer_github_id" integer NOT NULL,
"reviewer_username" text NOT NULL,
"review_state" text NOT NULL,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL,
"github_review_id" integer,
"html_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint

-- Create github_webhook_deliveries table
CREATE TABLE IF NOT EXISTS "github_webhook_deliveries" (
"id" text PRIMARY KEY NOT NULL,
"delivery_id" text NOT NULL,
"event_type" text NOT NULL,
"repository" text NOT NULL,
"received_at" timestamp with time zone DEFAULT now() NOT NULL,
"processed_at" timestamp with time zone,
"status" text DEFAULT 'received' NOT NULL,
"error" text,
CONSTRAINT "webhook_deliveries_delivery_id_unique" UNIQUE("delivery_id")
);--> statement-breakpoint

-- Create notifications table
CREATE TABLE IF NOT EXISTS "notifications" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"message" text NOT NULL,
"link_url" text,
"is_read" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint

-- Create mentor_requests table
CREATE TABLE IF NOT EXISTS "mentor_requests" (
"id" text PRIMARY KEY NOT NULL,
"student_id" text NOT NULL,
"mentor_id" text,
"issue_id" text NOT NULL,
"message" text NOT NULL,
"reply" text,
"status" text DEFAULT 'open' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);--> statement-breakpoint

-- Add Foreign Keys
ALTER TABLE "issue_claims" ADD CONSTRAINT "issue_claims_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_claims" ADD CONSTRAINT "issue_claims_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pull_requests" ADD CONSTRAINT "pull_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint

ALTER TABLE "pull_request_reviews" ADD CONSTRAINT "pr_reviews_pull_request_id_pull_requests_id_fk" FOREIGN KEY ("pull_request_id") REFERENCES "public"."pull_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_student_id_users_id_fk" FOREIGN KEY ("student_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_mentor_id_users_id_fk" FOREIGN KEY ("mentor_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mentor_requests" ADD CONSTRAINT "mentor_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

-- Indexes
CREATE INDEX IF NOT EXISTS "issue_claims_issue_id_idx" ON "issue_claims" USING btree ("issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_claims_user_id_idx" ON "issue_claims" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_claims_status_idx" ON "issue_claims" USING btree ("status");--> statement-breakpoint

CREATE INDEX IF NOT EXISTS "pull_requests_user_id_idx" ON "pull_requests" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pull_requests_project_id_idx" ON "pull_requests" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pull_requests_issue_id_idx" ON "pull_requests" USING btree ("issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pull_requests_state_idx" ON "pull_requests" USING btree ("state");--> statement-breakpoint

CREATE INDEX IF NOT EXISTS "pr_reviews_pull_request_id_idx" ON "pull_request_reviews" USING btree ("pull_request_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pr_reviews_reviewer_username_idx" ON "pull_request_reviews" USING btree ("reviewer_username");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "pr_reviews_review_state_idx" ON "pull_request_reviews" USING btree ("review_state");--> statement-breakpoint

CREATE INDEX IF NOT EXISTS "webhook_deliveries_status_idx" ON "github_webhook_deliveries" USING btree ("status");--> statement-breakpoint

CREATE INDEX IF NOT EXISTS "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notifications_is_read_idx" ON "notifications" USING btree ("is_read");--> statement-breakpoint

CREATE INDEX IF NOT EXISTS "mentor_requests_student_id_idx" ON "mentor_requests" USING btree ("student_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mentor_requests_mentor_id_idx" ON "mentor_requests" USING btree ("mentor_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mentor_requests_issue_id_idx" ON "mentor_requests" USING btree ("issue_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "mentor_requests_status_idx" ON "mentor_requests" USING btree ("status");
13 changes: 6 additions & 7 deletions src/app/api/admin/credentials/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session";
import { getFreshAuthenticatedUser } from "@/lib/auth/authorization";
import { getDb, schema } from "@/lib/db";
import { eq } from "drizzle-orm";

Expand All @@ -10,13 +10,12 @@ const revokeSchema = z.object({
});

export async function POST(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
const freshUser = await getFreshAuthenticatedUser(request);
if (!freshUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const sessionUser = await verifySessionToken(token);
if (!sessionUser || sessionUser.role !== "admin") {
if (freshUser.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden. Administrative privileges required." },
{ status: 403 }
Expand Down Expand Up @@ -54,12 +53,12 @@ export async function POST(request: NextRequest) {
// Create immutable audit log
await db.insert(schema.auditLogs).values({
id: `audit_${crypto.randomUUID()}`,
actorId: sessionUser.id,
actorId: freshUser.id,
action: "credential.revoked",
targetType: "credential",
targetId: data.credentialId,
metadata: {
revokedBy: sessionUser.githubUsername,
revokedBy: freshUser.githubUsername,
reason: data.reason,
targetUserId: cred.userId,
credentialType: cred.type,
Expand Down
92 changes: 82 additions & 10 deletions src/app/api/onboarding/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { z } from "zod";
import { verifySessionToken, createSessionToken, COOKIE_NAME, SESSION_MAX_AGE_SECONDS } from "@/lib/auth/session";
import { getDb, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import { recommendIssues } from "@/lib/recommendations/engine";

const onboardingSchema = z.object({
skills: z.array(z.string()).min(1, "Select at least one skill"),
experienceLevel: z.enum(["complete_beginner", "beginner", "intermediate", "advanced"]),
preferredLanguages: z.array(z.string()).min(1, "Select at least one programming language"),
technologies: z.array(z.string()).default([]),
tools: z.array(z.string()).default([]),
interests: z.array(z.string()).min(1, "Select at least one area of interest"),
experienceLevel: z.enum(["beginner", "intermediate", "advanced"]),
preferredLanguages: z.array(z.string()).default([]),
contributionPreferences: z.array(z.string()).default([]),
contributionPreferences: z.array(z.string()).min(1, "Select at least one contribution preference"),
timeCommitment: z.string().optional(),
primaryGoal: z.string().optional(),
});

export async function POST(request: NextRequest) {
Expand All @@ -29,6 +33,15 @@ export async function POST(request: NextRequest) {

const db = await getDb();

// Deduplicate and combine into backward-compatible skills array
const combinedSkills = Array.from(
new Set([
...validated.preferredLanguages,
...validated.technologies,
...validated.tools,
])
);

// Upsert profile
const existingProfile = await db
.select()
Expand All @@ -40,23 +53,31 @@ export async function POST(request: NextRequest) {
await db
.update(schema.profiles)
.set({
skills: validated.skills,
interests: validated.interests,
experienceLevel: validated.experienceLevel,
preferredLanguages: validated.preferredLanguages,
technologies: validated.technologies,
tools: validated.tools,
skills: combinedSkills,
interests: validated.interests,
contributionPreferences: validated.contributionPreferences,
timeCommitment: validated.timeCommitment || null,
primaryGoal: validated.primaryGoal || null,
updatedAt: new Date(),
})
.where(eq(schema.profiles.userId, sessionUser.id));
} else {
await db.insert(schema.profiles).values({
id: `prof_${crypto.randomUUID()}`,
userId: sessionUser.id,
skills: validated.skills,
interests: validated.interests,
experienceLevel: validated.experienceLevel,
preferredLanguages: validated.preferredLanguages,
technologies: validated.technologies,
tools: validated.tools,
skills: combinedSkills,
interests: validated.interests,
contributionPreferences: validated.contributionPreferences,
timeCommitment: validated.timeCommitment || null,
primaryGoal: validated.primaryGoal || null,
});
}

Expand All @@ -69,14 +90,62 @@ export async function POST(request: NextRequest) {
})
.where(eq(schema.users.id, sessionUser.id));

// Calculate count of matching issues
const openIssues = await db
.select({
id: schema.issues.id,
projectId: schema.issues.projectId,
projectName: schema.projects.name,
githubRepo: schema.projects.githubRepo,
title: schema.issues.title,
bodySnippet: schema.issues.bodySnippet,
htmlUrl: schema.issues.htmlUrl,
labels: schema.issues.labels,
difficulty: schema.issues.difficulty,
estimatedEffort: schema.issues.estimatedEffort,
skillsRequired: schema.issues.skillsRequired,
isGoodFirstIssue: schema.issues.isGoodFirstIssue,
isHelpWanted: schema.issues.isHelpWanted,
primaryLanguage: schema.projects.primaryLanguage,
})
.from(schema.issues)
.innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id))
.where(eq(schema.issues.state, "open"))
.limit(50);

const mappedExpLevel =
validated.experienceLevel === "complete_beginner"
? "beginner"
: (validated.experienceLevel as "beginner" | "intermediate" | "advanced");

const matched = recommendIssues(
openIssues.map((i: any) => ({
...i,
difficulty: i.difficulty as "beginner" | "intermediate" | "advanced",
})),
{
skills: combinedSkills,
interests: validated.interests,
experienceLevel: mappedExpLevel,
preferredLanguages: validated.preferredLanguages,
contributionPreferences: validated.contributionPreferences,
},
10
);

// Refresh session token with isOnboarded = true
const updatedSessionUser = {
...sessionUser,
isOnboarded: true,
};
const newToken = await createSessionToken(updatedSessionUser);

const response = NextResponse.json({ success: true, redirect: "/dashboard" });
const response = NextResponse.json({
success: true,
matchingIssuesCount: matched.length,
redirect: "/dashboard",
});

response.cookies.set(COOKIE_NAME, newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
Expand All @@ -90,6 +159,9 @@ export async function POST(request: NextRequest) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 });
}
return NextResponse.json({ error: error.message || "Failed to complete onboarding" }, { status: 500 });
return NextResponse.json(
{ error: error.message || "Failed to complete onboarding" },
{ status: 500 }
);
}
}
Loading
Loading