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
18 changes: 18 additions & 0 deletions drizzle/0010_partner_proposals.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS "partner_proposals" (
"id" text PRIMARY KEY NOT NULL,
"organization_name" text NOT NULL,
"contact_name" text NOT NULL,
"contact_email" text NOT NULL,
"partner_type" text NOT NULL,
"proposal_type" text NOT NULL,
"summary" text NOT NULL,
"target_technologies" jsonb DEFAULT '[]'::jsonb NOT NULL,
"estimated_student_capacity" integer DEFAULT 10 NOT NULL,
"status" text DEFAULT 'submitted' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "partner_proposals_status_idx" ON "partner_proposals" ("status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "partner_proposals_created_at_idx" ON "partner_proposals" ("created_at");
350 changes: 226 additions & 124 deletions src/app/admin/AdminDashboardClient.tsx

Large diffs are not rendered by default.

41 changes: 35 additions & 6 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { AdminDashboardClient } from "./AdminDashboardClient";

export const metadata = {
title: "Maintainer & Admin Operations — TechNexusOrg",
description: "Repository approvals, issue difficulty classification, and PR review triage.",
description:
"Repository approvals, issue difficulty classification, and PR review triage.",
};

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -81,7 +82,10 @@ export default async function AdminPage() {
openedAt: schema.pullRequests.openedAt,
})
.from(schema.pullRequests)
.innerJoin(schema.projects, eq(schema.pullRequests.projectId, schema.projects.id))
.innerJoin(
schema.projects,
eq(schema.pullRequests.projectId, schema.projects.id),
)
.where(eq(schema.pullRequests.state, "open"))
.orderBy(desc(schema.pullRequests.openedAt));

Expand All @@ -97,33 +101,58 @@ export default async function AdminPage() {
issueNumber: schema.issues.githubIssueNumber,
})
.from(schema.mentorRequests)
.innerJoin(schema.users, eq(schema.mentorRequests.studentId, schema.users.id))
.innerJoin(schema.issues, eq(schema.mentorRequests.issueId, schema.issues.id))
.innerJoin(
schema.users,
eq(schema.mentorRequests.studentId, schema.users.id),
)
.innerJoin(
schema.issues,
eq(schema.mentorRequests.issueId, schema.issues.id),
)
.orderBy(desc(schema.mentorRequests.createdAt))
.limit(50);

// 5. Fetch partner proposals
let partnerProposals: any[] = [];
try {
partnerProposals = await db
.select()
.from(schema.partnerProposals)
.orderBy(desc(schema.partnerProposals.createdAt))
.limit(50);
} catch {
partnerProposals = [];
}

return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 space-y-8">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-white font-mono">
Maintainer Operations Center
</h1>
<p className="mt-1 text-xs text-slate-400">
Review official repository trust boundaries, classify issue difficulty, and monitor PR review queues.
Review official repository trust boundaries, classify issue
difficulty, and monitor PR review queues.
</p>
</div>

<AdminDashboardClient
projects={projects.map((p: any) => ({
...p,
approvedAt: p.approvedAt ? (p.approvedAt as Date).toISOString() : null,
approvedAt: p.approvedAt
? (p.approvedAt as Date).toISOString()
: null,
}))}
issues={issues as any}
prs={prs.map((pr: any) => ({
...pr,
openedAt: (pr.openedAt as Date).toISOString(),
}))}
mentorRequests={mentorRequests as any}
partnerProposals={partnerProposals.map((prop: any) => ({
...prop,
createdAt: (prop.createdAt as Date).toISOString(),
}))}
/>
</div>
);
Expand Down
71 changes: 71 additions & 0 deletions src/app/api/partners/proposals/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from "vitest";
import { POST } from "./route";
import { getDb, schema } from "@/lib/db";
import { runMigrations } from "@/lib/db/migrate";
import { eq } from "drizzle-orm";

describe("POST /api/partners/proposals", () => {
beforeEach(async () => {
await runMigrations();
});

it("should reject invalid proposal payloads", async () => {
const req = new Request("http://localhost:3000/api/partners/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
organizationName: "",
contactName: "",
contactEmail: "invalid-email",
}),
});

const res = await POST(req);
expect(res.status).toBe(400);

const data = await res.json();
expect(data.error).toBe("Validation failed");
expect(data.details).toBeDefined();
});

it("should successfully store a valid institutional proposal", async () => {
const payload = {
organizationName: "Indian Institute of Open Source",
contactName: "Prof. Vikram Sarabhai",
contactEmail: "vikram@iios.edu.in",
partnerType: "university",
proposalType: "project_partnership",
summary:
"Proposing a semester-long capstone track in distributed systems and POSIX kernels.",
targetTechnologies: ["Linux", "Go", "Rust", "PostgreSQL"],
estimatedStudentCapacity: 25,
};

const req = new Request("http://localhost:3000/api/partners/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

const res = await POST(req);
expect(res.status).toBe(201);

const data = await res.json();
expect(data.success).toBe(true);
expect(data.proposalId).toBeDefined();

// Verify record in database
const db = await getDb();
const rows = await db
.select()
.from(schema.partnerProposals)
.where(eq(schema.partnerProposals.id, data.proposalId));

expect(rows).toHaveLength(1);
expect(rows[0].organizationName).toBe(payload.organizationName);
expect(rows[0].contactEmail).toBe(payload.contactEmail);
expect(rows[0].partnerType).toBe(payload.partnerType);
expect(rows[0].status).toBe("submitted");
expect(rows[0].estimatedStudentCapacity).toBe(25);
});
});
91 changes: 91 additions & 0 deletions src/app/api/partners/proposals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import crypto from "crypto";
import { getDb, schema } from "@/lib/db";
import { runMigrations } from "@/lib/db/migrate";

const proposalSchema = z.object({
organizationName: z
.string()
.min(2, "Organization name must be at least 2 characters"),
contactName: z.string().min(2, "Contact name must be at least 2 characters"),
contactEmail: z.string().email("Please provide a valid email address"),
partnerType: z.enum([
"university",
"company",
"open_source_collective",
"other",
]),
proposalType: z.enum([
"project_partnership",
"cohort_sponsorship",
"mentorship_program",
"hiring_pipeline",
]),
summary: z
.string()
.min(10, "Summary must be at least 10 characters")
.max(2000),
targetTechnologies: z.array(z.string()).default([]),
estimatedStudentCapacity: z.number().int().min(1).max(1000).default(10),
});

export async function POST(req: Request) {
try {
const body = await req.json();
const result = proposalSchema.safeParse(body);

if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: result.error.errors },
{ status: 400 },
);
}

const {
organizationName,
contactName,
contactEmail,
partnerType,
proposalType,
summary,
targetTechnologies,
estimatedStudentCapacity,
} = result.data;

// Ensure database migration has executed
await runMigrations();
const db = await getDb();

const proposalId = `prop_${crypto.randomUUID()}`;

await db.insert(schema.partnerProposals).values({
id: proposalId,
organizationName,
contactName,
contactEmail,
partnerType,
proposalType,
summary,
targetTechnologies,
estimatedStudentCapacity,
status: "submitted",
});

return NextResponse.json(
{
success: true,
message:
"Partner proposal submitted successfully. Our team will review and contact you.",
proposalId,
},
{ status: 201 },
);
} catch (err: any) {
console.error("Failed to submit partner proposal:", err);
return NextResponse.json(
{ error: "Internal server error submitting proposal" },
{ status: 500 },
);
}
}
Loading
Loading