diff --git a/scripts/migrate-data.ts b/scripts/migrate-data.ts new file mode 100644 index 0000000000000000000000000000000000000000..911dbea531219dbfbf73ebb33982ec990e132d64 --- /dev/null +++ b/scripts/migrate-data.ts @@ -0,0 +1,949 @@ +/** + * MongoDB to Turso Data Migration Script + * + * Migrates all data from MongoDB Atlas to Turso (SQLite). + * MongoDB remains READ-ONLY - no deletions or modifications. + * + * Usage: + * npm run migrate:data + * npm run migrate:data -- --dry-run + * + * Required env vars: + * MONGO_URL, TURSO_DATABASE_URL, TURSO_AUTH_TOKEN + */ + +import { MongoClient, ObjectId } from "mongodb"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { sql } from "drizzle-orm"; +import * as schema from "../src/db/schema"; +import { v4 as uuidv4 } from "uuid"; +import * as dotenv from "dotenv"; + +dotenv.config({ path: ".env.local" }); + +// ============================================================================= +// Types +// ============================================================================= + +interface MongoUser { + _id?: ObjectId; + id?: string; + githubId: number; + username: string; + avatarUrl: string; + role?: string; + repositories?: string[]; + githubAccessToken?: string; + createdAt?: Date | string; + updatedAt?: Date | string; +} + +interface MongoRepository { + _id?: ObjectId; + id?: string; + githubRepoId: number; + name: string; + owner: string; + userId: string; + createdAt?: Date | string; +} + +interface MongoIssue { + _id?: ObjectId; + id?: string; + githubIssueId: number; + number: number; + title: string; + body?: string; + authorName: string; + repoId: string; + repoName: string; + owner?: string; + repo?: string; + htmlUrl?: string; + state?: string; + isPR?: boolean; + createdAt?: Date | string; +} + +interface MongoMessage { + _id?: ObjectId; + id?: string; + senderId: string; + receiverId: string; + content: string; + read?: boolean; + timestamp?: Date | string; +} + +interface MongoProfile { + _id?: ObjectId; + user_id: string; + username: string; + avatar_url?: string; + bio?: string; + skills?: string[]; + location?: string; + website?: string; + twitter?: string; + available_for_mentoring?: boolean; + mentoring_topics?: string[]; + connected_repos?: string[]; + profile_visibility?: string; + show_email?: boolean; + github_stats?: object; + stats_updated_at?: Date | string; + created_at?: Date | string; + updated_at?: Date | string; +} + +interface MongoTriageData { + _id?: ObjectId; + id?: string; + issueId: string; + classification: string; + summary: string; + suggestedLabel: string; + sentiment: string; + analyzedAt?: Date | string; +} + +interface MongoTemplate { + _id?: ObjectId; + id?: string; + name: string; + body: string; + ownerId: string; + triggerClassification?: string; + createdAt?: Date | string; +} + +interface MongoChatMessage { + role: string; + content: string; + timestamp?: Date | string; + githubCommentId?: string; + githubCommentUrl?: string; +} + +interface MongoChatHistory { + _id?: ObjectId; + id?: string; + userId: string; + sessionId: string; + messages?: MongoChatMessage[]; + createdAt?: Date | string; +} + +interface MongoMentor { + _id?: ObjectId; + id?: string; + userId: string; + username: string; + expertiseLevel?: string; + availabilityHoursPerWeek?: number; + timezone?: string; + isActive?: boolean; + bio?: string; + avatarUrl?: string; + techStack?: string[]; + languages?: string[]; + frameworks?: string[]; + preferredTopics?: string[]; + menteeCount?: number; + sessionsCompleted?: number; + avgRating?: number; + totalRatings?: number; + maxMentees?: number; + createdAt?: Date | string; + updatedAt?: Date | string; +} + +interface MongoTrophy { + _id?: ObjectId; + id?: string; + userId: string; + username: string; + trophyType: string; + name: string; + description: string; + icon: string; + color: string; + rarity: string; + svgData?: string; + isPublic?: boolean; + shareUrl?: string; + earnedFor?: string; + milestoneValue?: number; + awardedAt?: Date | string; +} + +interface MongoIssueChat { + _id?: ObjectId; + id?: string; + issueId: string; + userId: string; + sessionId: string; + messages?: MongoChatMessage[]; + createdAt?: Date | string; + updatedAt?: Date | string; +} + +interface MongoResource { + _id?: ObjectId; + id?: string; + repoName: string; + sourceType?: string; + sourceId?: string; + resourceType: string; + title: string; + content: string; + description?: string; + language?: string; + sharedBy: string; + sharedById: string; + tags?: string[]; + saveCount?: number; + helpfulCount?: number; + createdAt?: Date | string; + updatedAt?: Date | string; +} + +// ============================================================================= +// Utilities +// ============================================================================= + +function toIsoString(date: Date | string | undefined): string { + if (!date) return new Date().toISOString(); + if (typeof date === "string") return date; + return date.toISOString(); +} + +function extractId(doc: { _id?: ObjectId; id?: string }): string { + if (doc.id) return doc.id; + if (doc._id) return doc._id.toString(); + return uuidv4(); +} + +function log(message: string, type: "info" | "success" | "error" | "warn" = "info") { + const icons = { info: "ℹ️", success: "✅", error: "❌", warn: "⚠️" }; + const time = new Date().toISOString().split("T")[1].split(".")[0]; + console.log(`[${time}] ${icons[type]} ${message}`); +} + +// ============================================================================= +// Migration Functions +// ============================================================================= + +async function migrateUsers( + mongoDb: ReturnType, + tursoDb: ReturnType, + isDryRun: boolean +): Promise> { + log("Migrating Users..."); + const userIdMap = new Map(); + const users = await mongoDb.collection("users").find().toArray(); + log(`Found ${users.length} users`); + + let success = 0, skipped = 0; + for (const mongoUser of users) { + const userId = extractId(mongoUser); + const originalId = mongoUser._id?.toString() || mongoUser.id || ""; + userIdMap.set(originalId, userId); + + if (!isDryRun) { + try { + await tursoDb.insert(schema.users).values({ + id: userId, + githubId: mongoUser.githubId, + username: mongoUser.username, + avatarUrl: mongoUser.avatarUrl, + role: mongoUser.role || null, + githubAccessToken: mongoUser.githubAccessToken || null, + createdAt: toIsoString(mongoUser.createdAt), + updatedAt: toIsoString(mongoUser.updatedAt), + }).onConflictDoNothing(); + + // Migrate user repositories array + if (mongoUser.repositories?.length) { + for (const repoName of mongoUser.repositories) { + await tursoDb.insert(schema.userRepositories).values({ + id: uuidv4(), + userId: userId, + repoFullName: repoName, + addedAt: toIsoString(mongoUser.createdAt), + }).onConflictDoNothing(); + } + } + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] User: ${mongoUser.username}`); + success++; + } + } + log(`Users: ${success} migrated, ${skipped} skipped`, "success"); + return userIdMap; +} + +async function migrateRepositories( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise> { + log("Migrating Repositories..."); + const repoIdMap = new Map(); + const repos = await mongoDb.collection("repositories").find().toArray(); + log(`Found ${repos.length} repositories`); + + let success = 0, skipped = 0; + for (const repo of repos) { + const repoId = extractId(repo); + const originalId = repo._id?.toString() || repo.id || ""; + repoIdMap.set(originalId, repoId); + + const mappedUserId = userIdMap.get(repo.userId) || repo.userId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.repositories).values({ + id: repoId, + githubRepoId: repo.githubRepoId, + name: repo.name, + owner: repo.owner, + userId: mappedUserId, + createdAt: toIsoString(repo.createdAt), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] Repo: ${repo.owner}/${repo.name}`); + success++; + } + } + log(`Repositories: ${success} migrated, ${skipped} skipped`, "success"); + return repoIdMap; +} + +async function migrateIssues( + mongoDb: ReturnType, + tursoDb: ReturnType, + repoIdMap: Map, + isDryRun: boolean +): Promise> { + log("Migrating Issues..."); + const issueIdMap = new Map(); + const issues = await mongoDb.collection("issues").find().toArray(); + log(`Found ${issues.length} issues`); + + let success = 0, skipped = 0; + for (const issue of issues) { + const issueId = extractId(issue); + const originalId = issue._id?.toString() || issue.id || ""; + issueIdMap.set(originalId, issueId); + + const mappedRepoId = repoIdMap.get(issue.repoId) || issue.repoId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.issues).values({ + id: issueId, + githubIssueId: issue.githubIssueId, + number: issue.number, + title: issue.title, + body: issue.body || null, + authorName: issue.authorName, + repoId: mappedRepoId, + repoName: issue.repoName, + owner: issue.owner || null, + repo: issue.repo || null, + htmlUrl: issue.htmlUrl || null, + state: issue.state || "open", + isPR: issue.isPR || false, + createdAt: toIsoString(issue.createdAt), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] Issue: #${issue.number} - ${issue.title.substring(0, 30)}...`); + success++; + } + } + log(`Issues: ${success} migrated, ${skipped} skipped`, "success"); + return issueIdMap; +} + +async function migrateMessages( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Messages..."); + const messages = await mongoDb.collection("messages").find().toArray(); + log(`Found ${messages.length} messages`); + + let success = 0, skipped = 0; + for (const msg of messages) { + const msgId = extractId(msg); + const senderId = userIdMap.get(msg.senderId) || msg.senderId; + const receiverId = userIdMap.get(msg.receiverId) || msg.receiverId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.messages).values({ + id: msgId, + senderId: senderId, + receiverId: receiverId, + content: msg.content, + read: msg.read || false, + timestamp: toIsoString(msg.timestamp), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + success++; + } + } + log(`Messages: ${success} migrated, ${skipped} skipped`, "success"); +} + +async function migrateProfiles( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Profiles..."); + const profiles = await mongoDb.collection("profiles").find().toArray(); + log(`Found ${profiles.length} profiles`); + + let success = 0, skipped = 0; + for (const profile of profiles) { + const userId = userIdMap.get(profile.user_id) || profile.user_id; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.profiles).values({ + userId: userId, + username: profile.username, + avatarUrl: profile.avatar_url || null, + bio: profile.bio || null, + location: profile.location || null, + website: profile.website || null, + twitter: profile.twitter || null, + availableForMentoring: profile.available_for_mentoring || false, + profileVisibility: profile.profile_visibility || "public", + showEmail: profile.show_email || false, + githubStats: profile.github_stats ? JSON.stringify(profile.github_stats) : null, + statsUpdatedAt: profile.stats_updated_at ? toIsoString(profile.stats_updated_at) : null, + createdAt: toIsoString(profile.created_at), + updatedAt: toIsoString(profile.updated_at), + }).onConflictDoNothing(); + + // Migrate skills + if (profile.skills?.length) { + for (const skill of profile.skills) { + await tursoDb.insert(schema.profileSkills).values({ + profileId: userId, + skill: skill, + }).onConflictDoNothing(); + } + } + + // Migrate mentoring topics + if (profile.mentoring_topics?.length) { + for (const topic of profile.mentoring_topics) { + await tursoDb.insert(schema.profileMentoringTopics).values({ + profileId: userId, + topic: topic, + }).onConflictDoNothing(); + } + } + + // Migrate connected repos + if (profile.connected_repos?.length) { + for (const repo of profile.connected_repos) { + await tursoDb.insert(schema.profileConnectedRepos).values({ + profileId: userId, + repoName: repo, + }).onConflictDoNothing(); + } + } + success++; + } catch (e) { + skipped++; + } + } else { + success++; + } + } + log(`Profiles: ${success} migrated, ${skipped} skipped`, "success"); +} + +async function migrateTriageData( + mongoDb: ReturnType, + tursoDb: ReturnType, + issueIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Triage Data..."); + const triageData = await mongoDb.collection("triageData").find().toArray(); + log(`Found ${triageData.length} triage records`); + + let success = 0, skipped = 0; + for (const triage of triageData) { + const triageId = extractId(triage); + const issueId = issueIdMap.get(triage.issueId) || triage.issueId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.triageData).values({ + id: triageId, + issueId: issueId, + classification: triage.classification, + summary: triage.summary, + suggestedLabel: triage.suggestedLabel, + sentiment: triage.sentiment, + analyzedAt: toIsoString(triage.analyzedAt), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + success++; + } + } + log(`Triage Data: ${success} migrated, ${skipped} skipped`, "success"); +} + +async function migrateTemplates( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Templates..."); + const templates = await mongoDb.collection("templates").find().toArray(); + log(`Found ${templates.length} templates`); + + let success = 0, skipped = 0; + for (const template of templates) { + const templateId = extractId(template); + const ownerId = userIdMap.get(template.ownerId) || template.ownerId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.templates).values({ + id: templateId, + name: template.name, + body: template.body, + ownerId: ownerId, + triggerClassification: template.triggerClassification || null, + createdAt: toIsoString(template.createdAt), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + success++; + } + } + log(`Templates: ${success} migrated, ${skipped} skipped`, "success"); +} + +async function migrateChatHistory( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Chat History..."); + const chatHistories = await mongoDb.collection("chat_history").find().toArray(); + log(`Found ${chatHistories.length} chat histories`); + + let historySuccess = 0, historySkipped = 0; + let messageSuccess = 0, messageSkipped = 0; + + for (const history of chatHistories) { + const historyId = extractId(history); + const userId = userIdMap.get(history.userId) || history.userId; + + if (!isDryRun) { + try { + // Insert chat history record + await tursoDb.insert(schema.chatHistory).values({ + id: historyId, + userId: userId, + sessionId: history.sessionId, + createdAt: toIsoString(history.createdAt), + }).onConflictDoNothing(); + historySuccess++; + + // Insert related messages + if (history.messages?.length) { + for (const msg of history.messages) { + const msgId = uuidv4(); + try { + await tursoDb.insert(schema.chatHistoryMessages).values({ + id: msgId, + chatHistoryId: historyId, + role: msg.role, + content: msg.content, + timestamp: toIsoString(msg.timestamp), + githubCommentId: msg.githubCommentId || null, + githubCommentUrl: msg.githubCommentUrl || null, + }).onConflictDoNothing(); + messageSuccess++; + } catch (e) { + messageSkipped++; + } + } + } + } catch (e) { + historySkipped++; + } + } else { + log(`[DRY] Chat History: ${historyId} with ${history.messages?.length || 0} messages`); + historySuccess++; + messageSuccess += history.messages?.length || 0; + } + } + log(`Chat History: ${historySuccess} migrated, ${historySkipped} skipped`, "success"); + log(`Chat Messages: ${messageSuccess} migrated, ${messageSkipped} skipped`, "success"); +} + +async function migrateMentors( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise> { + log("Migrating Mentors..."); + const mentorIdMap = new Map(); + const mentors = await mongoDb.collection("mentors").find().toArray(); + log(`Found ${mentors.length} mentors`); + + let success = 0, skipped = 0; + for (const mentor of mentors) { + const mentorId = extractId(mentor); + const originalId = mentor._id?.toString() || mentor.id || ""; + mentorIdMap.set(originalId, mentorId); + + const mappedUserId = userIdMap.get(mentor.userId) || mentor.userId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.mentors).values({ + id: mentorId, + userId: mappedUserId, + username: mentor.username, + expertiseLevel: mentor.expertiseLevel || "intermediate", + availabilityHoursPerWeek: mentor.availabilityHoursPerWeek || 5, + timezone: mentor.timezone || null, + isActive: mentor.isActive ?? true, + bio: mentor.bio || null, + avatarUrl: mentor.avatarUrl || null, + menteeCount: mentor.menteeCount || 0, + sessionsCompleted: mentor.sessionsCompleted || 0, + avgRating: mentor.avgRating || 0, + totalRatings: mentor.totalRatings || 0, + maxMentees: mentor.maxMentees || 3, + createdAt: toIsoString(mentor.createdAt), + updatedAt: toIsoString(mentor.updatedAt), + }).onConflictDoNothing(); + + // Migrate tech stack + if (mentor.techStack?.length) { + for (const tech of mentor.techStack) { + await tursoDb.insert(schema.mentorTechStack).values({ + mentorId: mentorId, + tech: tech, + }).onConflictDoNothing(); + } + } + + // Migrate languages + if (mentor.languages?.length) { + for (const lang of mentor.languages) { + await tursoDb.insert(schema.mentorLanguages).values({ + mentorId: mentorId, + language: lang, + }).onConflictDoNothing(); + } + } + + // Migrate frameworks + if (mentor.frameworks?.length) { + for (const fw of mentor.frameworks) { + await tursoDb.insert(schema.mentorFrameworks).values({ + mentorId: mentorId, + framework: fw, + }).onConflictDoNothing(); + } + } + + // Migrate preferred topics + if (mentor.preferredTopics?.length) { + for (const topic of mentor.preferredTopics) { + await tursoDb.insert(schema.mentorPreferredTopics).values({ + mentorId: mentorId, + topic: topic, + }).onConflictDoNothing(); + } + } + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] Mentor: ${mentor.username}`); + success++; + } + } + log(`Mentors: ${success} migrated, ${skipped} skipped`, "success"); + return mentorIdMap; +} + +async function migrateTrophies( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Trophies..."); + const trophies = await mongoDb.collection("trophies").find().toArray(); + log(`Found ${trophies.length} trophies`); + + let success = 0, skipped = 0; + for (const trophy of trophies) { + const trophyId = extractId(trophy); + const mappedUserId = userIdMap.get(trophy.userId) || trophy.userId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.trophies).values({ + id: trophyId, + userId: mappedUserId, + username: trophy.username, + trophyType: trophy.trophyType, + name: trophy.name, + description: trophy.description, + icon: trophy.icon, + color: trophy.color, + rarity: trophy.rarity, + svgData: trophy.svgData || null, + isPublic: trophy.isPublic ?? true, + shareUrl: trophy.shareUrl || null, + earnedFor: trophy.earnedFor || null, + milestoneValue: trophy.milestoneValue || null, + awardedAt: toIsoString(trophy.awardedAt), + }).onConflictDoNothing(); + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] Trophy: ${trophy.name} for ${trophy.username}`); + success++; + } + } + log(`Trophies: ${success} migrated, ${skipped} skipped`, "success"); +} + +async function migrateIssueChats( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + issueIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Issue Chats..."); + const issueChats = await mongoDb.collection("issue_chats").find().toArray(); + log(`Found ${issueChats.length} issue chats`); + + let chatSuccess = 0, chatSkipped = 0; + let msgSuccess = 0, msgSkipped = 0; + + for (const chat of issueChats) { + const chatId = extractId(chat); + const mappedUserId = userIdMap.get(chat.userId) || chat.userId; + const mappedIssueId = issueIdMap.get(chat.issueId) || chat.issueId; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.issueChats).values({ + id: chatId, + issueId: mappedIssueId, + userId: mappedUserId, + sessionId: chat.sessionId, + createdAt: toIsoString(chat.createdAt), + updatedAt: toIsoString(chat.updatedAt), + }).onConflictDoNothing(); + chatSuccess++; + + // Migrate messages + if (chat.messages?.length) { + for (const msg of chat.messages) { + const msgId = uuidv4(); + try { + await tursoDb.insert(schema.issueChatMessages).values({ + id: msgId, + issueChatId: chatId, + role: msg.role, + content: msg.content, + timestamp: toIsoString(msg.timestamp), + githubCommentId: msg.githubCommentId || null, + githubCommentUrl: msg.githubCommentUrl || null, + }).onConflictDoNothing(); + msgSuccess++; + } catch (e) { + msgSkipped++; + } + } + } + } catch (e) { + chatSkipped++; + } + } else { + log(`[DRY] Issue Chat: ${chatId} with ${chat.messages?.length || 0} messages`); + chatSuccess++; + msgSuccess += chat.messages?.length || 0; + } + } + log(`Issue Chats: ${chatSuccess} migrated, ${chatSkipped} skipped`, "success"); + log(`Issue Chat Messages: ${msgSuccess} migrated, ${msgSkipped} skipped`, "success"); +} + +async function migrateResources( + mongoDb: ReturnType, + tursoDb: ReturnType, + userIdMap: Map, + isDryRun: boolean +): Promise { + log("Migrating Resources..."); + const resources = await mongoDb.collection("resources").find().toArray(); + log(`Found ${resources.length} resources`); + + let success = 0, skipped = 0; + for (const resource of resources) { + const resourceId = extractId(resource); + const mappedUserId = userIdMap.get(resource.sharedById) || resource.sharedById; + + if (!isDryRun) { + try { + await tursoDb.insert(schema.resources).values({ + id: resourceId, + repoName: resource.repoName, + sourceType: resource.sourceType || "chat", + sourceId: resource.sourceId || null, + resourceType: resource.resourceType, + title: resource.title, + content: resource.content, + description: resource.description || null, + language: resource.language || null, + sharedBy: resource.sharedBy, + sharedById: mappedUserId, + saveCount: resource.saveCount || 0, + helpfulCount: resource.helpfulCount || 0, + createdAt: toIsoString(resource.createdAt), + updatedAt: toIsoString(resource.updatedAt), + }).onConflictDoNothing(); + + // Migrate tags + if (resource.tags?.length) { + for (const tag of resource.tags) { + await tursoDb.insert(schema.resourceTags).values({ + resourceId: resourceId, + tag: tag, + }).onConflictDoNothing(); + } + } + success++; + } catch (e) { + skipped++; + } + } else { + log(`[DRY] Resource: ${resource.title}`); + success++; + } + } + log(`Resources: ${success} migrated, ${skipped} skipped`, "success"); +} + +// ============================================================================= +// Main +// ============================================================================= + +async function main() { + const args = process.argv.slice(2); + const isDryRun = args.includes("--dry-run"); + + if (isDryRun) { + log("=== DRY RUN MODE ===", "warn"); + } + + const mongoUri = process.env.MONGO_URL || process.env.MONGODB_URI; + const tursoUrl = process.env.TURSO_DATABASE_URL; + const tursoToken = process.env.TURSO_AUTH_TOKEN; + + if (!mongoUri) { + log("Missing MONGO_URL or MONGODB_URI", "error"); + process.exit(1); + } + if (!tursoUrl) { + log("Missing TURSO_DATABASE_URL", "error"); + process.exit(1); + } + + log("Connecting to MongoDB..."); + const mongoClient = new MongoClient(mongoUri); + await mongoClient.connect(); + const dbName = process.env.DB_NAME || "opentriage_db"; + const mongoDb = mongoClient.db(dbName); + log(`Connected to MongoDB (${dbName})`, "success"); + + log("Connecting to Turso..."); + const tursoClient = createClient({ url: tursoUrl, authToken: tursoToken }); + const tursoDb = drizzle(tursoClient, { schema }); + log("Connected to Turso", "success"); + + try { + // Migrate in order (respecting foreign keys) + const userIdMap = await migrateUsers(mongoDb, tursoDb, isDryRun); + const repoIdMap = await migrateRepositories(mongoDb, tursoDb, userIdMap, isDryRun); + const issueIdMap = await migrateIssues(mongoDb, tursoDb, repoIdMap, isDryRun); + await migrateMessages(mongoDb, tursoDb, userIdMap, isDryRun); + await migrateProfiles(mongoDb, tursoDb, userIdMap, isDryRun); + await migrateTriageData(mongoDb, tursoDb, issueIdMap, isDryRun); + await migrateTemplates(mongoDb, tursoDb, userIdMap, isDryRun); + await migrateChatHistory(mongoDb, tursoDb, userIdMap, isDryRun); + + // New migrations for complete feature support + const mentorIdMap = await migrateMentors(mongoDb, tursoDb, userIdMap, isDryRun); + await migrateTrophies(mongoDb, tursoDb, userIdMap, isDryRun); + await migrateIssueChats(mongoDb, tursoDb, userIdMap, issueIdMap, isDryRun); + await migrateResources(mongoDb, tursoDb, userIdMap, isDryRun); + + log("=== Migration Complete ===", "success"); + if (isDryRun) { + log("Run without --dry-run to perform actual migration", "info"); + } + } finally { + await mongoClient.close(); + } +} + +main().catch(console.error); diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..612e3826b4043b611a571b393534873183bc003c --- /dev/null +++ b/src/app/api/ai/chat/route.ts @@ -0,0 +1,41 @@ +/** + * AI Chat Proxy Route + * + * Forwards requests to AI_ENGINE_URL/chat + */ + +import { NextRequest, NextResponse } from "next/server"; +import { chat } from "@/lib/ai-client"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { message, history, context } = body; + + if (!message) { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + + const result = await chat(message, history, { + ...context, + userId: user.id, + username: user.username, + role: user.role, + }); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("AI Chat error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/ai/mentor-match/route.ts b/src/app/api/ai/mentor-match/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5ff4e33f5e55d604fb0cdbd02b4bcf9afa33c9a --- /dev/null +++ b/src/app/api/ai/mentor-match/route.ts @@ -0,0 +1,32 @@ +/** + * AI Mentor Match Proxy Route + * + * Forwards requests to AI_ENGINE_URL/mentor-match + */ + +import { NextRequest, NextResponse } from "next/server"; +import { findMentorMatches } from "@/lib/ai-client"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { limit = 5 } = body; + + const result = await findMentorMatches(user.id, user.username, limit); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("AI Mentor Match error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/ai/rag/route.ts b/src/app/api/ai/rag/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..83f521a76a9920270942d7130b8babf0daa14d8a --- /dev/null +++ b/src/app/api/ai/rag/route.ts @@ -0,0 +1,36 @@ +/** + * AI RAG Proxy Route + * + * Forwards requests to AI_ENGINE_URL/rag/chat + */ + +import { NextRequest, NextResponse } from "next/server"; +import { ragQuery } from "@/lib/ai-client"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { question, repoName } = body; + + if (!question) { + return NextResponse.json({ error: "Question is required" }, { status: 400 }); + } + + const result = await ragQuery(question, repoName); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("AI RAG error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/ai/triage/route.ts b/src/app/api/ai/triage/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..871d7ce0394e3d3831efdcbf3d91e9bb80c60bf8 --- /dev/null +++ b/src/app/api/ai/triage/route.ts @@ -0,0 +1,41 @@ +/** + * AI Triage Proxy Route + * + * Forwards requests to AI_ENGINE_URL/triage + */ + +import { NextRequest, NextResponse } from "next/server"; +import { triageIssue } from "@/lib/ai-client"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { title, body: issueBody, authorName, isPR } = body; + + if (!title) { + return NextResponse.json({ error: "Title is required" }, { status: 400 }); + } + + const result = await triageIssue({ + title, + body: issueBody, + authorName: authorName || "unknown", + isPR: isPR || false, + }); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("AI Triage error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2c74821dcf7f37c27294f141fe5012d68397b4b --- /dev/null +++ b/src/app/api/auth/github/callback/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { createJwtToken } from "@/lib/auth"; +import { generateId, now } from "@/lib/utils"; +import { eq } from "drizzle-orm"; + +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID!; +const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET!; +const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"; + +/** + * GET /api/auth/github/callback + * Handle GitHub OAuth callback and create user session. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + + if (!code) { + return NextResponse.redirect(`${FRONTEND_URL}/?error=no_code`); + } + + try { + // Exchange code for access token + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + client_secret: GITHUB_CLIENT_SECRET, + code, + }), + } + ); + + const tokenData = await tokenResponse.json(); + const accessToken = tokenData.access_token; + + if (!accessToken) { + console.error("No access token:", tokenData); + return NextResponse.redirect(`${FRONTEND_URL}/?error=no_token`); + } + + // Get user info from GitHub + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const githubUser = await userResponse.json(); + + // Check if user exists + const existingUsers = await db + .select() + .from(users) + .where(eq(users.githubId, githubUser.id)) + .limit(1); + + let userData; + + if (existingUsers.length > 0) { + // Update existing user with new GitHub token + await db + .update(users) + .set({ githubAccessToken: accessToken, updatedAt: now() }) + .where(eq(users.githubId, githubUser.id)); + + userData = { ...existingUsers[0], githubAccessToken: accessToken }; + } else { + // Create new user + const newUser = { + id: generateId(), + githubId: githubUser.id, + username: githubUser.login, + avatarUrl: githubUser.avatar_url, + role: null, + githubAccessToken: accessToken, + createdAt: now(), + updatedAt: now(), + }; + + await db.insert(users).values(newUser); + userData = newUser; + } + + // Create JWT token + const token = createJwtToken(userData.id, userData.role); + + return NextResponse.redirect(`${FRONTEND_URL}/?token=${token}`); + } catch (error) { + console.error("GitHub auth error:", error); + return NextResponse.redirect(`${FRONTEND_URL}/?error=auth_failed`); + } +} diff --git a/src/app/api/auth/github/route.ts b/src/app/api/auth/github/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..0287d2e1e9050be3ac9aff67bd084b0cd8f37dac --- /dev/null +++ b/src/app/api/auth/github/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; + +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID!; +const API_URL = process.env.API_URL || "http://localhost:3000"; + +/** + * GET /api/auth/github + * Redirect to GitHub OAuth authorization page. + */ +export async function GET(request: NextRequest) { + const callbackUrl = `${API_URL}/api/auth/github/callback`; + const githubUrl = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${callbackUrl}&scope=user:email,repo`; + + return NextResponse.redirect(githubUrl); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..2934f6fe5e231aba2042a3ed54c5142b945fb1dc --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth } from "@/lib/auth"; + +/** + * GET /api/auth/me + * Get current authenticated user information. + */ +export async function GET(request: NextRequest) { + const { user, error } = await requireAuth(request); + + if (error) { + return error; + } + + return NextResponse.json({ + id: user!.id, + username: user!.username, + avatarUrl: user!.avatarUrl, + role: user!.role, + githubId: user!.githubId, + }); +} diff --git a/src/app/api/auth/select-role/route.ts b/src/app/api/auth/select-role/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c8c0d42c60de54f72191f6c569a6a9af5a2241f --- /dev/null +++ b/src/app/api/auth/select-role/route.ts @@ -0,0 +1,51 @@ +/** + * Role Selection Route + * + * POST /api/auth/select-role + * Allows authenticated users to select their role (MAINTAINER or CONTRIBUTOR) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { requireAuth, createJwtToken } from "@/lib/auth"; +import { updateUserRole } from "@/lib/db/queries/users"; + +export async function POST(request: NextRequest) { + const { user, error } = await requireAuth(request); + + if (error) { + return error; + } + + try { + const body = await request.json(); + const { role } = body; + + // Validate role + if (!role || !["MAINTAINER", "CONTRIBUTOR"].includes(role.toUpperCase())) { + return NextResponse.json( + { error: "Invalid role. Must be MAINTAINER or CONTRIBUTOR" }, + { status: 400 } + ); + } + + const normalizedRole = role.toUpperCase(); + + // Update user role in database + await updateUserRole(user!.id, normalizedRole); + + // Generate new token with updated role + const newToken = createJwtToken(user!.id, normalizedRole); + + return NextResponse.json({ + success: true, + role: normalizedRole, + token: newToken, + }); + } catch (error) { + console.error("Role selection error:", error); + return NextResponse.json( + { error: "Failed to update role" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..0abbd778f6786b72c3ea10136f7e056d3df23da9 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,43 @@ +/** + * Chat Route + * + * POST /api/chat + * Proxy to /api/ai/chat for convenience + */ + +import { NextRequest, NextResponse } from "next/server"; +import { chat } from "@/lib/ai-client"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { message, sessionId, history, context } = body; + + if (!message) { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + + const result = await chat(message, history, { + ...context, + sessionId, + userId: user.id, + username: user.username, + role: user.role, + }); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("Chat error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/claim-activity/[issueId]/route.ts b/src/app/api/contributor/claim-activity/[issueId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..72292517eabbe798684df8e1ca577187c08f0792 --- /dev/null +++ b/src/app/api/contributor/claim-activity/[issueId]/route.ts @@ -0,0 +1,32 @@ +/** + * Claim Activity Route + * + * POST /api/contributor/claim-activity/[issueId] + * Update activity timestamp for a claimed issue + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ issueId: string }> } +) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { issueId } = await params; + + // TODO: Implement with claimed_issues table + return NextResponse.json({ + message: "Activity updated successfully (stub)", + issueId, + }); + } catch (error) { + console.error("Claim activity error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/claim-issue/[issueId]/route.ts b/src/app/api/contributor/claim-issue/[issueId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..d209a85c0d95c9d105db52cd2c29212dcaf1e71c --- /dev/null +++ b/src/app/api/contributor/claim-issue/[issueId]/route.ts @@ -0,0 +1,32 @@ +/** + * Unclaim Issue Route + * + * DELETE /api/contributor/claim-issue/[issueId] + * Unclaim a previously claimed issue + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ issueId: string }> } +) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { issueId } = await params; + + // TODO: Implement full unclaiming logic with claimed_issues table + return NextResponse.json({ + message: "Issue unclaimed successfully (stub)", + issueId, + }); + } catch (error) { + console.error("Unclaim issue error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/claim-issue/route.ts b/src/app/api/contributor/claim-issue/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..fef25957fd7548c5a23284b0127c1a5e87218ec8 --- /dev/null +++ b/src/app/api/contributor/claim-issue/route.ts @@ -0,0 +1,38 @@ +/** + * Issue Claiming Routes + * + * POST /api/contributor/claim-issue + * Claim an issue to work on + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +// Note: Full implementation requires adding claimed_issues table to schema +// This is a stub that prevents 404 errors + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { issueId } = body; + + if (!issueId) { + return NextResponse.json({ error: "issueId is required" }, { status: 400 }); + } + + // TODO: Implement full claiming logic with claimed_issues table + return NextResponse.json({ + message: "Issue claim registered (stub)", + issueId, + claimedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("Claim issue error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/dashboard-summary/route.ts b/src/app/api/contributor/dashboard-summary/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..4581539d012dbcd1a97059025a82d3d7369266fb --- /dev/null +++ b/src/app/api/contributor/dashboard-summary/route.ts @@ -0,0 +1,44 @@ +/** + * Contributor Dashboard Summary Route + * + * GET /api/contributor/dashboard-summary + * Get dashboard statistics for the contributor + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getIssuesWithTriage } from "@/lib/db/queries/issues"; +import { getContributorRepositories } from "@/lib/db/queries/repositories"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get all contributor's issues (no pagination for stats) + const [repos, issuesData] = await Promise.all([ + getContributorRepositories(user.id, user.username), + getIssuesWithTriage({ authorName: user.username }, 1, 1000), // Get all for accurate stats + ]); + + const allIssues = issuesData.issues; + const myIssues = allIssues.filter(i => !i.isPR); + const myPRs = allIssues.filter(i => i.isPR); + + return NextResponse.json({ + totalContributions: allIssues.length, + totalPRs: myPRs.length, + openPRs: myPRs.filter(i => i.state === "open").length, + mergedPRs: myPRs.filter(i => i.state === "closed").length, + totalIssues: myIssues.length, + openIssues: myIssues.filter(i => i.state === "open").length, + closedIssues: myIssues.filter(i => i.state === "closed").length, + repositoriesContributed: repos.length, + }); + } catch (error) { + console.error("Contributor dashboard-summary error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/my-claimed-issues/route.ts b/src/app/api/contributor/my-claimed-issues/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5ace9d16833d2693b6dc1f04f96ed965df0d3b8 --- /dev/null +++ b/src/app/api/contributor/my-claimed-issues/route.ts @@ -0,0 +1,27 @@ +/** + * My Claimed Issues Route + * + * GET /api/contributor/my-claimed-issues + * Get all issues claimed by the current user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // TODO: Implement with claimed_issues table + return NextResponse.json({ + claims: [], + count: 0, + }); + } catch (error) { + console.error("My claimed issues error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/my-issues/route.ts b/src/app/api/contributor/my-issues/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1754a4a718902f8d49d072a9631ab0f2a506ee8 --- /dev/null +++ b/src/app/api/contributor/my-issues/route.ts @@ -0,0 +1,36 @@ +/** + * Contributor My Issues Route + * + * GET /api/contributor/my-issues + * Get paginated list of contributor's issues and PRs + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getIssuesWithTriage } from "@/lib/db/queries/issues"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + const issuesData = await getIssuesWithTriage({ authorName: user.username }, page, limit); + + return NextResponse.json({ + items: issuesData.issues, + total: issuesData.total, + page: issuesData.page, + pages: issuesData.totalPages, + limit: issuesData.limit, + }); + } catch (error) { + console.error("Contributor my-issues error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/contributor/route.ts b/src/app/api/contributor/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7a520bb2c1c36dced3ffac344979a0ab4964974 --- /dev/null +++ b/src/app/api/contributor/route.ts @@ -0,0 +1,54 @@ +/** + * Contributor Dashboard Route + * + * Get dashboard stats and issues for contributors. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getIssuesWithTriage } from "@/lib/db/queries/issues"; +import { getContributorRepositories } from "@/lib/db/queries/repositories"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + // Get contributor's issues + const [repos, issuesData] = await Promise.all([ + getContributorRepositories(user.id, user.username), + getIssuesWithTriage({ authorName: user.username }, page, limit), + ]); + + // Calculate stats + const myIssues = issuesData.issues.filter(i => !i.isPR); + const myPRs = issuesData.issues.filter(i => i.isPR); + + return NextResponse.json({ + stats: { + totalIssues: myIssues.length, + totalPRs: myPRs.length, + openIssues: myIssues.filter(i => i.state === "open").length, + openPRs: myPRs.filter(i => i.state === "open").length, + repositoriesContributed: repos.length, + }, + repositories: repos, + issues: issuesData.issues, + pagination: { + page: issuesData.page, + limit: issuesData.limit, + total: issuesData.total, + totalPages: issuesData.totalPages, + } + }); + } catch (error) { + console.error("Contributor dashboard error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/issues/[id]/messages/route.ts b/src/app/api/issues/[id]/messages/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..2048813be04f8d6f4225078a99302eaf0665cc0c --- /dev/null +++ b/src/app/api/issues/[id]/messages/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { chatMessages, issues } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; +import { generateId, now } from "@/lib/utils"; +import { eq, desc, gt, and } from "drizzle-orm"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +/** + * GET /api/issues/[id]/messages + * Get chat messages for an issue. + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const { id: issueId } = await context.params; + const { searchParams } = new URL(request.url); + const afterId = searchParams.get("afterId"); + const limit = parseInt(searchParams.get("limit") || "50", 10); + + try { + // Verify issue exists + const issueRecords = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .limit(1); + + if (issueRecords.length === 0) { + return NextResponse.json( + { error: "Issue not found" }, + { status: 404 } + ); + } + + // Build query for messages + // Note: We use sessionId to associate messages with issues + // In a proper implementation, you'd have a session per issue + let messagesQuery = db + .select() + .from(chatMessages) + .where(eq(chatMessages.sessionId, issueId)) + .orderBy(desc(chatMessages.timestamp)) + .limit(limit); + + const messages = await messagesQuery; + + // Reverse to show oldest first + messages.reverse(); + + return NextResponse.json({ + messages, + count: messages.length, + issueId, + }); + } catch (error) { + console.error("GET /api/issues/[id]/messages error:", error); + return NextResponse.json( + { error: "Failed to fetch messages" }, + { status: 500 } + ); + } +} + +/** + * POST /api/issues/[id]/messages + * Send a chat message for an issue. + * + * Request body: { content: string, messageType?: string } + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const { user, error } = await requireAuth(request); + if (error) return error; + + const { id: issueId } = await context.params; + + try { + const body = await request.json(); + const { content, messageType = "text" } = body; + + if (!content || typeof content !== "string") { + return NextResponse.json( + { error: "content is required" }, + { status: 400 } + ); + } + + // Verify issue exists + const issueRecords = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .limit(1); + + if (issueRecords.length === 0) { + return NextResponse.json( + { error: "Issue not found" }, + { status: 404 } + ); + } + + // Create the message + const message = { + id: generateId(), + sessionId: issueId, // Using issueId as sessionId for issue-based chats + senderId: user!.id, + senderUsername: user!.username, + isMentor: false, + content, + messageType, + language: null, + isAiGenerated: false, + containsResource: false, + extractedResourceId: null, + timestamp: now(), + editedAt: null, + }; + + await db.insert(chatMessages).values(message); + + return NextResponse.json({ + message: "Message sent", + data: message, + }, { status: 201 }); + } catch (error) { + console.error("POST /api/issues/[id]/messages error:", error); + return NextResponse.json( + { error: "Failed to send message" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccc05d952190f8fb789fa81afce5ca015cd7b17c --- /dev/null +++ b/src/app/api/issues/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { issues, triageData, repositories } from "@/db/schema"; +import { generateId, now } from "@/lib/utils"; +import { eq, and, desc } from "drizzle-orm"; + +// ============================================================================= +// GET /api/issues - Get issues for a repository +// ============================================================================= +// +// Python equivalent (from routes/repository.py): +// repos = await db.issues.find({"repoId": repo_id}, {"_id": 0}).to_list(1000) +// +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const repoId = searchParams.get("repoId"); + const state = searchParams.get("state"); // open, closed, all + const isPR = searchParams.get("isPR"); // true, false + + // Build query conditions + const conditions = []; + if (repoId) { + conditions.push(eq(issues.repoId, repoId)); + } + if (state && state !== "all") { + conditions.push(eq(issues.state, state)); + } + if (isPR !== null && isPR !== undefined) { + conditions.push(eq(issues.isPR, isPR === "true")); + } + + // Execute query with Drizzle + const result = await db + .select() + .from(issues) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(issues.createdAt)) + .limit(100); + + return NextResponse.json(result, { status: 200 }); + } catch (error) { + console.error("GET /api/issues error:", error); + return NextResponse.json( + { error: "Failed to fetch issues" }, + { status: 500 } + ); + } +} + +// ============================================================================= +// POST /api/issues - Create a new issue +// ============================================================================= +// +// Python equivalent (from routes/repository.py): +// issue = Issue( +// githubIssueId=gh_issue['id'], +// number=gh_issue['number'], +// title=gh_issue['title'], +// body=gh_issue.get('body') or '', +// authorName=gh_issue['user']['login'], +// repoId=repository.id, +// repoName=repository.name, +// ... +// ) +// issue_dict = issue.model_dump() +// issue_dict['createdAt'] = issue_dict['createdAt'].isoformat() +// await db.issues.insert_one(issue_dict) +// +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate required fields + const { githubIssueId, number, title, authorName, repoId, repoName } = body; + if (!githubIssueId || !number || !title || !authorName || !repoId || !repoName) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Check if repository exists + const repo = await db + .select() + .from(repositories) + .where(eq(repositories.id, repoId)) + .limit(1); + + if (repo.length === 0) { + return NextResponse.json( + { error: "Repository not found" }, + { status: 404 } + ); + } + + // Check if issue already exists + const existingIssue = await db + .select() + .from(issues) + .where(eq(issues.githubIssueId, githubIssueId)) + .limit(1); + + if (existingIssue.length > 0) { + return NextResponse.json( + { error: "Issue already exists", issue: existingIssue[0] }, + { status: 409 } + ); + } + + // Create new issue + const newIssue = { + id: generateId(), + githubIssueId, + number, + title, + body: body.body || "", + authorName, + repoId, + repoName, + owner: body.owner || "", + repo: body.repo || "", + htmlUrl: body.htmlUrl || "", + state: body.state || "open", + isPR: body.isPR || false, + createdAt: now(), + }; + + await db.insert(issues).values(newIssue); + + return NextResponse.json( + { message: "Issue created", issue: newIssue }, + { status: 201 } + ); + } catch (error) { + console.error("POST /api/issues error:", error); + return NextResponse.json( + { error: "Failed to create issue" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/maintainer/dashboard-summary/route.ts b/src/app/api/maintainer/dashboard-summary/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6a6c5ecf8f3fed814da89c92e1582a681c31622 --- /dev/null +++ b/src/app/api/maintainer/dashboard-summary/route.ts @@ -0,0 +1,39 @@ +/** + * Maintainer Dashboard Summary Route + * + * GET /api/maintainer/dashboard-summary + * Get dashboard statistics for maintainers + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getDashboardStats } from "@/lib/db/queries/issues"; +import { getMaintainerRepositories } from "@/lib/db/queries/repositories"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.role !== "MAINTAINER" && user.role !== "maintainer") { + return NextResponse.json({ error: "Maintainer access required" }, { status: 403 }); + } + + // Get dashboard stats + const [stats, repos] = await Promise.all([ + getDashboardStats(user.id), + getMaintainerRepositories(user.id), + ]); + + return NextResponse.json({ + ...stats, + repositoriesCount: repos.length, + repositories: repos, + }); + } catch (error) { + console.error("Maintainer dashboard-summary error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/maintainer/issues/route.ts b/src/app/api/maintainer/issues/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e199b2229df02b5d24b3447dda6a38f667483989 --- /dev/null +++ b/src/app/api/maintainer/issues/route.ts @@ -0,0 +1,44 @@ +/** + * Maintainer Issues Route + * + * GET /api/maintainer/issues + * Fetch issues for a maintainer with filtering and pagination. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getIssues, getIssuesWithTriage, IssueFilters } from "@/lib/db/queries/issues"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + const state = searchParams.get("state") || undefined; + const repoId = searchParams.get("repoId") || undefined; + const search = searchParams.get("search") || undefined; + const withTriage = searchParams.get("withTriage") === "true"; + + const filters: IssueFilters = { + userId: user.id, // Filter by user's repos + state, + repoId, + search, + isPR: false, // Only fetch issues, not PRs + }; + + const result = withTriage + ? await getIssuesWithTriage(filters, page, limit) + : await getIssues(filters, page, limit); + + return NextResponse.json(result); + } catch (error) { + console.error("GET /api/maintainer/issues error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/maintainer/route.ts b/src/app/api/maintainer/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f3979a1b568d25d82708f863955f9393a86a24 --- /dev/null +++ b/src/app/api/maintainer/route.ts @@ -0,0 +1,49 @@ +/** + * Maintainer Dashboard Route + * + * Get dashboard stats, issues, and templates for maintainers. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getDashboardStats, getIssuesWithTriage } from "@/lib/db/queries/issues"; +import { getMaintainerRepositories, getRepositoryStats } from "@/lib/db/queries/repositories"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (user.role !== "MAINTAINER" && user.role !== "maintainer") { + return NextResponse.json({ error: "Maintainer access required" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + + // Get dashboard data + const [stats, repos, issuesData] = await Promise.all([ + getDashboardStats(user.id), + getMaintainerRepositories(user.id), + getIssuesWithTriage({ userId: user.id, state: "open" }, page, limit), + ]); + + return NextResponse.json({ + stats, + repositories: repos, + issues: issuesData.issues, + pagination: { + page: issuesData.page, + limit: issuesData.limit, + total: issuesData.total, + totalPages: issuesData.totalPages, + } + }); + } catch (error) { + console.error("Maintainer dashboard error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/maintainer/templates/route.ts b/src/app/api/maintainer/templates/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a82e3d83affd03dabef3b5c795b32232960c8f77 --- /dev/null +++ b/src/app/api/maintainer/templates/route.ts @@ -0,0 +1,25 @@ +/** + * Maintainer Templates Route + * + * GET /api/maintainer/templates + * Fetch templates for a maintainer. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getTemplatesByOwnerId } from "@/lib/db/queries/templates"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const templates = await getTemplatesByOwnerId(user.id); + return NextResponse.json(templates); + } catch (error) { + console.error("GET /api/maintainer/templates error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d6a0ce3970adc80a7934672dac3ba9b352c8ef4 --- /dev/null +++ b/src/app/api/messages/route.ts @@ -0,0 +1,63 @@ +/** + * Messages Route + * + * Get conversations and send messages. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getConversations, getChatHistory, sendMessage, markMessagesAsRead, getUnreadCount } from "@/lib/db/queries/messages"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const otherUserId = searchParams.get("with"); + + if (otherUserId) { + // Get chat history with specific user + const history = await getChatHistory(user.id, otherUserId); + await markMessagesAsRead(user.id, otherUserId); + return NextResponse.json({ messages: history }); + } else { + // Get all conversations + const conversations = await getConversations(user.id); + const unreadCount = await getUnreadCount(user.id); + return NextResponse.json({ conversations, unreadCount }); + } + } catch (error) { + console.error("Messages fetch error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { receiverId, content } = body; + + if (!receiverId || !content) { + return NextResponse.json({ error: "receiverId and content are required" }, { status: 400 }); + } + + const message = await sendMessage({ + senderId: user.id, + receiverId, + content, + }); + + return NextResponse.json(message); + } catch (error) { + console.error("Message send error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/conversations/route.ts b/src/app/api/messaging/conversations/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..93f80161dcfc3331abee5ea6613da12ea37caa75 --- /dev/null +++ b/src/app/api/messaging/conversations/route.ts @@ -0,0 +1,36 @@ +/** + * Messaging Conversations Route + * + * GET /api/messaging/conversations + * Get list of all conversations for the current user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getConversations } from "@/lib/db/queries/messages"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const conversations = await getConversations(user.id); + + // Format to match frontend expectations + return NextResponse.json({ + conversations: conversations.map(c => ({ + user_id: c.partnerId, + username: c.partnerUsername, + avatar_url: c.partnerAvatar, + last_message: c.lastMessage?.content?.substring(0, 50) || "", + last_timestamp: c.lastMessage?.timestamp || null, + unread_count: c.unreadCount + })) + }); + } catch (error) { + console.error("Conversations error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/history/[userId]/route.ts b/src/app/api/messaging/history/[userId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1a18d7468f2abf8311b90ebf7c0a81b27dc7397 --- /dev/null +++ b/src/app/api/messaging/history/[userId]/route.ts @@ -0,0 +1,35 @@ +/** + * Messaging History Route + * + * GET /api/messaging/history/[userId] + * Get chat history with a specific user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getChatHistory, markMessagesAsRead } from "@/lib/db/queries/messages"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userId: otherUserId } = await params; + + // Get chat history + const history = await getChatHistory(user.id, otherUserId); + + // Mark messages as read + await markMessagesAsRead(user.id, otherUserId); + + return NextResponse.json(history); + } catch (error) { + console.error("Chat history error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/mark-read/[userId]/route.ts b/src/app/api/messaging/mark-read/[userId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bf3dc6b54df071b4f8ca1b83fb21c83356fbe5b --- /dev/null +++ b/src/app/api/messaging/mark-read/[userId]/route.ts @@ -0,0 +1,31 @@ +/** + * Messaging Mark Read Route + * + * POST /api/messaging/mark-read/[userId] + * Mark all messages from a user as read + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { markMessagesAsRead } from "@/lib/db/queries/messages"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userId: otherUserId } = await params; + + await markMessagesAsRead(user.id, otherUserId); + + return NextResponse.json({ success: true, message: "Messages marked as read" }); + } catch (error) { + console.error("Mark read error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/poll/[userId]/route.ts b/src/app/api/messaging/poll/[userId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..670acec86114932483487cb736425a49bb2f7915 --- /dev/null +++ b/src/app/api/messaging/poll/[userId]/route.ts @@ -0,0 +1,33 @@ +/** + * Messaging Poll Route + * + * GET /api/messaging/poll/[userId] + * Poll for new messages from a specific user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { pollNewMessages } from "@/lib/db/queries/messages"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userId: otherUserId } = await params; + const { searchParams } = new URL(request.url); + const lastMessageId = searchParams.get("last_message_id") || undefined; + + const newMessages = await pollNewMessages(user.id, otherUserId, lastMessageId); + + return NextResponse.json(newMessages); + } catch (error) { + console.error("Poll messages error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/route.ts b/src/app/api/messaging/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b34edc0ab9e6f91b85c51fa27ef7fbeda26a31 --- /dev/null +++ b/src/app/api/messaging/route.ts @@ -0,0 +1,34 @@ +/** + * Messaging Routes + * + * Full messaging API for the frontend + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { + getConversations, + getChatHistory, + sendMessage, + markMessagesAsRead, + getUnreadCount, + pollNewMessages +} from "@/lib/db/queries/messages"; + +// GET /api/messaging - Get conversations list +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const conversations = await getConversations(user.id); + const unreadCount = await getUnreadCount(user.id); + + return NextResponse.json({ conversations, unreadCount }); + } catch (error) { + console.error("Messaging error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/send/route.ts b/src/app/api/messaging/send/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..be3e1a2989ebb35a040e932a8145d99ecff9fed3 --- /dev/null +++ b/src/app/api/messaging/send/route.ts @@ -0,0 +1,40 @@ +/** + * Messaging Send Route + * + * POST /api/messaging/send + * Send a message to another user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { sendMessage } from "@/lib/db/queries/messages"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { receiver_id, content } = body; + + if (!receiver_id || !content) { + return NextResponse.json( + { error: "receiver_id and content are required" }, + { status: 400 } + ); + } + + const message = await sendMessage({ + senderId: user.id, + receiverId: receiver_id, + content, + }); + + return NextResponse.json(message); + } catch (error) { + console.error("Send message error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/messaging/unread-count/route.ts b/src/app/api/messaging/unread-count/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8ec31ddca2f5e78248dd8de11b9ed51469eb975 --- /dev/null +++ b/src/app/api/messaging/unread-count/route.ts @@ -0,0 +1,26 @@ +/** + * Messaging Unread Count Route + * + * GET /api/messaging/unread-count + * Get the count of unread messages for the authenticated user + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getUnreadCount } from "@/lib/db/queries/messages"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const count = await getUnreadCount(user.id); + + return NextResponse.json({ count }); + } catch (error) { + console.error("Unread count error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/profile/[id]/connected-repos/route.ts b/src/app/api/profile/[id]/connected-repos/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..647cee52cf102196f07c045018f2bc16e6b0a6d9 --- /dev/null +++ b/src/app/api/profile/[id]/connected-repos/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { profileConnectedRepos } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + // 'id' here corresponds to the profile's userId (since userId is PK for profiles) + const repos = await db + .select() + .from(profileConnectedRepos) + .where(eq(profileConnectedRepos.profileId, id)); + + return NextResponse.json(repos); + } catch (error) { + console.error("GET /api/profile/:id/connected-repos error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/profile/[username]/featured-badges/route.ts b/src/app/api/profile/[username]/featured-badges/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3ec2e38b85fc6bbab126d4b7725b7dbfc055ff --- /dev/null +++ b/src/app/api/profile/[username]/featured-badges/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserBadges } from "@/lib/db/queries/gamification"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + const badges = await getUserBadges(username); + // For now, just return top 5 badges as "featured" + // In the future, we could add a "featured" flag to the trophy table + const featured = badges.slice(0, 5); + + return NextResponse.json(featured); + } catch (error) { + console.error("GET /api/profile/:username/featured-badges error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/profile/[username]/repos/route.ts b/src/app/api/profile/[username]/repos/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e973bfd1f646aa62a3d3d8297d57be82881dd80 --- /dev/null +++ b/src/app/api/profile/[username]/repos/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { repositories, users } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + + // Find user first to get ID + const user = await db.select().from(users).where(eq(users.username, username)).limit(1); + + if (!user[0]) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const repos = await db + .select() + .from(repositories) + .where(eq(repositories.userId, user[0].id)) + .orderBy(desc(repositories.createdAt)); + + return NextResponse.json(repos); + } catch (error) { + console.error("GET /api/profile/:username/repos error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/profile/[username]/route.ts b/src/app/api/profile/[username]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4902742eb593549087607e8a71259d0631eeec1 --- /dev/null +++ b/src/app/api/profile/[username]/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getProfileByUsername } from "@/lib/db/queries/users"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + const profile = await getProfileByUsername(username); + if (!profile) { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + return NextResponse.json(profile); + } catch (error) { + console.error("GET /api/profile/:username error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..64f7ba063bafa52a58bd891b3ab720fe9344921d --- /dev/null +++ b/src/app/api/profile/route.ts @@ -0,0 +1,59 @@ +/** + * Profile Route + * + * Get and update user profiles. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { getProfileByUsername, createOrUpdateProfile } from "@/lib/db/queries/users"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const username = searchParams.get("username"); + + if (!username) { + return NextResponse.json({ error: "Username is required" }, { status: 400 }); + } + + const profile = await getProfileByUsername(username); + if (!profile) { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + + return NextResponse.json(profile); + } catch (error) { + console.error("Profile fetch error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { bio, location, website, twitter, skills, availableForMentoring, mentoringTopics } = body; + + const profile = await createOrUpdateProfile(user.id, { + username: user.username, + avatarUrl: user.avatarUrl, + bio, + location, + website, + twitter, + skills, + availableForMentoring, + mentoringTopics, + }); + + return NextResponse.json(profile); + } catch (error) { + console.error("Profile update error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/rag/chat/route.ts b/src/app/api/rag/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..30aa7f4581df10a6d1be954dd83e5d0f4f27b82f --- /dev/null +++ b/src/app/api/rag/chat/route.ts @@ -0,0 +1,37 @@ +/** + * RAG Chat Route + * + * POST /api/rag/chat + * Answer questions using RAG (Retrieval-Augmented Generation) + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { ragChat } from "@/lib/ai-client"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { question, repo_name, top_k } = body; + + if (!question) { + return NextResponse.json({ error: "question is required" }, { status: 400 }); + } + + const result = await ragChat(question, repo_name, top_k || 5); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("RAG chat error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/rag/index/route.ts b/src/app/api/rag/index/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7260056439ef199db9a8ba844461358c842f6fa7 --- /dev/null +++ b/src/app/api/rag/index/route.ts @@ -0,0 +1,37 @@ +/** + * RAG Index Route + * + * POST /api/rag/index + * Index a repository for RAG search + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { ragIndex } from "@/lib/ai-client"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { repo_name } = body; + + if (!repo_name) { + return NextResponse.json({ error: "repo_name is required" }, { status: 400 }); + } + + const result = await ragIndex(repo_name); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("RAG index error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/rag/search/route.ts b/src/app/api/rag/search/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..142fef492056337a7fb5ad4c1a5c37eb1c125635 --- /dev/null +++ b/src/app/api/rag/search/route.ts @@ -0,0 +1,37 @@ +/** + * RAG Search Route + * + * POST /api/rag/search + * Search documents using RAG + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { ragSearch } from "@/lib/ai-client"; + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { query, repo_name, limit } = body; + + if (!query) { + return NextResponse.json({ error: "query is required" }, { status: 400 }); + } + + const result = await ragSearch(query, repo_name, limit || 10); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 502 }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("RAG search error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/rag/suggestions/route.ts b/src/app/api/rag/suggestions/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf5140a3411da9a31ee0dbe7842332e5ed8eb9ef --- /dev/null +++ b/src/app/api/rag/suggestions/route.ts @@ -0,0 +1,40 @@ +/** + * RAG Suggestions Route + * + * GET /api/rag/suggestions + * Get suggested questions for RAG chatbot + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { callAIEngine } from "@/lib/ai-client"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(request); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const repoName = searchParams.get("repo_name"); + + // Call AI engine with empty body for suggestions + const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://localhost:7860"; + const url = repoName + ? `${AI_ENGINE_URL}/rag/suggestions?repo_name=${encodeURIComponent(repoName)}` + : `${AI_ENGINE_URL}/rag/suggestions`; + + const response = await fetch(url); + + if (!response.ok) { + return NextResponse.json({ error: "Failed to get suggestions" }, { status: 502 }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("RAG suggestions error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/repositories/contributor/route.ts b/src/app/api/repositories/contributor/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a01ff0c1d69f98212b4c6668bcfbe59e42656c8f --- /dev/null +++ b/src/app/api/repositories/contributor/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { issues, repositories } from "@/db/schema"; +import { eq, and, ne, desc, sql, count, max, inArray } from "drizzle-orm"; + +// ============================================================================= +// GET /api/repositories/contributor - Get repos where user has PRs +// ============================================================================= +// +// This is the most complex query in the original Python backend. +// It uses a MongoDB aggregation pipeline to find unique repos where +// the user has contributed PRs but is NOT the owner. +// +// Original Python aggregation pipeline (from routes/repository.py): +// +// pipeline = [ +// {"$match": {"authorName": username, "isPR": True}}, +// {"$group": { +// "_id": {"repoId": "$repoId", "repoName": "$repoName", "owner": "$owner", "repo": "$repo"}, +// "pr_count": {"$sum": 1}, +// "last_pr_at": {"$max": "$createdAt"} +// }}, +// {"$project": {...}}, +// {"$sort": {"pr_count": -1}} +// ] +// contributed_repos = await db.issues.aggregate(pipeline).to_list(1000) +// +// Drizzle SQL equivalent using GROUP BY: +// +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const username = searchParams.get("username"); + const userId = searchParams.get("userId"); + + if (!username || !userId) { + return NextResponse.json( + { error: "username and userId are required" }, + { status: 400 } + ); + } + + // Step 1: Get user's own repo IDs to exclude + const ownRepos = await db + .select({ id: repositories.id }) + .from(repositories) + .where(eq(repositories.userId, userId)); + + const ownRepoIds = ownRepos.map(r => r.id); + + // Step 2: Aggregate PRs by repo - equivalent to MongoDB $group + // SQL: SELECT repo_id, repo_name, COUNT(*) as pr_count, MAX(created_at) as last_pr_at + // FROM issues WHERE author_name = ? AND is_pr = 1 GROUP BY repo_id, repo_name, owner, repo + const contributedRepos = await db + .select({ + repoId: issues.repoId, + repoName: issues.repoName, + owner: issues.owner, + repo: issues.repo, + prCount: count().as("pr_count"), + lastPrAt: max(issues.createdAt).as("last_pr_at"), + }) + .from(issues) + .where( + and( + eq(issues.authorName, username), + eq(issues.isPR, true) + ) + ) + .groupBy(issues.repoId, issues.repoName, issues.owner, issues.repo) + .orderBy(desc(sql`pr_count`)); + + // Step 3: Filter out user's own repos + const result = contributedRepos + .filter(repo => !ownRepoIds.includes(repo.repoId)) + .map(repo => ({ + repoId: repo.repoId, + repoName: repo.repoName, + owner: repo.owner, + repo: repo.repo, + pr_count: repo.prCount, + last_pr_at: repo.lastPrAt, + role: "contributor", + name: repo.repoName || `${repo.owner}/${repo.repo}`, + })); + + return NextResponse.json( + { repos: result, count: result.length }, + { status: 200 } + ); + } catch (error) { + console.error("GET /api/repositories/contributor error:", error); + return NextResponse.json( + { error: "Failed to fetch contributor repositories" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/repositories/route.ts b/src/app/api/repositories/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..74834c1dce6e68277a7d1145ba0838f8809b7839 --- /dev/null +++ b/src/app/api/repositories/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { repositories, issues, triageData } from "@/db/schema"; +import { generateId, now } from "@/lib/utils"; +import { eq, and, desc, count, sql } from "drizzle-orm"; + +// ============================================================================= +// GET /api/repositories - Get user's repositories +// ============================================================================= +// +// Python equivalent (from routes/repository.py): +// repos = await db.repositories.find({"userId": user['id']}, {"_id": 0}).to_list(1000) +// +import { getCurrentUser } from "@/lib/auth"; + +// ... + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + let userId = searchParams.get("userId"); + + // If no userId provided, try to get the current authenticated user + if (!userId) { + const currentUser = await getCurrentUser(request); + if (currentUser) { + userId = currentUser.id; + } else { + return NextResponse.json( + { error: "userId is required or you must be logged in" }, + { status: 401 } + ); + } + } + + const repos = await db + .select() + .from(repositories) + .where(eq(repositories.userId, userId)) + .orderBy(desc(repositories.createdAt)); + + return NextResponse.json(repos, { status: 200 }); + } catch (error) { + console.error("GET /api/repositories error:", error); + return NextResponse.json( + { error: "Failed to fetch repositories" }, + { status: 500 } + ); + } +} + +// ============================================================================= +// POST /api/repositories - Add a new repository +// ============================================================================= +// +// Python equivalent (from routes/repository.py): +// repository = Repository( +// githubRepoId=repo_data['id'], +// name=request.repoFullName, +// owner=owner, +// userId=user['id'] +// ) +// repo_dict = repository.model_dump() +// repo_dict['createdAt'] = repo_dict['createdAt'].isoformat() +// await db.repositories.insert_one(repo_dict) +// +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { githubRepoId, name, owner, userId } = body; + + // Validate required fields + if (!githubRepoId || !name || !owner || !userId) { + return NextResponse.json( + { error: "Missing required fields: githubRepoId, name, owner, userId" }, + { status: 400 } + ); + } + + // Check if repository already exists for this user + const existing = await db + .select() + .from(repositories) + .where( + and( + eq(repositories.githubRepoId, githubRepoId), + eq(repositories.userId, userId) + ) + ) + .limit(1); + + if (existing.length > 0) { + return NextResponse.json( + { error: "Repository already added", repository: existing[0] }, + { status: 409 } + ); + } + + // Create new repository + const newRepo = { + id: generateId(), + githubRepoId, + name, + owner, + userId, + createdAt: now(), + }; + + await db.insert(repositories).values(newRepo); + + return NextResponse.json( + { message: "Repository added!", repository: newRepo }, + { status: 201 } + ); + } catch (error) { + console.error("POST /api/repositories error:", error); + return NextResponse.json( + { error: "Failed to add repository" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/spark/badges/user/[username]/route.ts b/src/app/api/spark/badges/user/[username]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe7c07fdd30596b4c831b94ccea927ffd10a542c --- /dev/null +++ b/src/app/api/spark/badges/user/[username]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserBadges } from "@/lib/db/queries/gamification"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + const badges = await getUserBadges(username); + return NextResponse.json(badges); + } catch (error) { + console.error("GET /api/spark/badges/user/:username error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/spark/gamification/calendar/[username]/route.ts b/src/app/api/spark/gamification/calendar/[username]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..debf87f30cf957fe0c44374fb1e405ca5c271fb1 --- /dev/null +++ b/src/app/api/spark/gamification/calendar/[username]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserCalendar } from "@/lib/db/queries/gamification"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + const calendar = await getUserCalendar(username); + return NextResponse.json(calendar); + } catch (error) { + console.error("GET /api/spark/gamification/calendar/:username error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/spark/gamification/streak/[username]/route.ts b/src/app/api/spark/gamification/streak/[username]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..df929c4ee7266e454330653099cad721ae2550e8 --- /dev/null +++ b/src/app/api/spark/gamification/streak/[username]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserStreak } from "@/lib/db/queries/gamification"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ username: string }> } +) { + try { + const { username } = await context.params; + const streak = await getUserStreak(username); + return NextResponse.json(streak); + } catch (error) { + console.error("GET /api/spark/gamification/streak/:username error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/triage/route.ts b/src/app/api/triage/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..28141c36f4984d88fc6fb28fcfafb19fae42be1f --- /dev/null +++ b/src/app/api/triage/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { issues, triageData } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; +import { generateId, now } from "@/lib/utils"; +import { eq } from "drizzle-orm"; + +// OpenRouter API for AI classification +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY!; + +/** + * Classifications matching the Python backend + */ +const CLASSIFICATIONS = [ + "CRITICAL_BUG", "BUG", "FEATURE_REQUEST", "QUESTION", + "DOCS", "DUPLICATE", "NEEDS_INFO", "SPAM" +] as const; + +const SENTIMENTS = ["POSITIVE", "NEUTRAL", "NEGATIVE", "FRUSTRATED"] as const; + +/** + * POST /api/triage + * Classify an issue using AI. + * + * Request body: { issueId: string } + */ +export async function POST(request: NextRequest) { + const { user, error } = await requireAuth(request); + if (error) return error; + + try { + const body = await request.json(); + const { issueId } = body; + + if (!issueId) { + return NextResponse.json( + { error: "issueId is required" }, + { status: 400 } + ); + } + + // Fetch the issue + const issueRecords = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .limit(1); + + if (issueRecords.length === 0) { + return NextResponse.json( + { error: "Issue not found" }, + { status: 404 } + ); + } + + const issue = issueRecords[0]; + + // Check if already triaged + const existingTriage = await db + .select() + .from(triageData) + .where(eq(triageData.issueId, issueId)) + .limit(1); + + if (existingTriage.length > 0) { + return NextResponse.json({ + message: "Issue already triaged", + triage: existingTriage[0], + }); + } + + // Build AI prompt for classification + const prompt = `Analyze this GitHub issue and provide a classification. + +Title: ${issue.title} +Body: ${issue.body || "(empty)"} + +Respond with valid JSON only: +{ + "classification": "BUG" | "CRITICAL_BUG" | "FEATURE_REQUEST" | "QUESTION" | "DOCS" | "DUPLICATE" | "NEEDS_INFO" | "SPAM", + "sentiment": "POSITIVE" | "NEUTRAL" | "NEGATIVE" | "FRUSTRATED", + "summary": "One-line summary of the issue", + "suggestedLabel": "Suggested GitHub label (lowercase, hyphenated)" +}`; + + // Call OpenRouter API + const aiResponse = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "anthropic/claude-3-haiku", + messages: [{ role: "user", content: prompt }], + max_tokens: 500, + }), + } + ); + + const aiData = await aiResponse.json(); + const aiContent = aiData.choices?.[0]?.message?.content || "{}"; + + // Parse AI response + let classification = "NEEDS_INFO"; + let sentiment = "NEUTRAL"; + let summary = issue.title; + let suggestedLabel = "needs-triage"; + + try { + const parsed = JSON.parse(aiContent); + classification = CLASSIFICATIONS.includes(parsed.classification) + ? parsed.classification + : "NEEDS_INFO"; + sentiment = SENTIMENTS.includes(parsed.sentiment) + ? parsed.sentiment + : "NEUTRAL"; + summary = parsed.summary || issue.title; + suggestedLabel = parsed.suggestedLabel || "needs-triage"; + } catch { + console.error("Failed to parse AI response:", aiContent); + } + + // Save triage data + const triage = { + id: generateId(), + issueId, + classification, + summary, + suggestedLabel, + sentiment, + analyzedAt: now(), + }; + + await db.insert(triageData).values(triage); + + return NextResponse.json({ + message: "Issue triaged successfully", + triage, + }); + } catch (error) { + console.error("POST /api/triage error:", error); + return NextResponse.json( + { error: "Failed to triage issue" }, + { status: 500 } + ); + } +} + +/** + * GET /api/triage?issueId=xxx + * Get triage data for an issue. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const issueId = searchParams.get("issueId"); + + if (!issueId) { + return NextResponse.json( + { error: "issueId is required" }, + { status: 400 } + ); + } + + const triageRecords = await db + .select() + .from(triageData) + .where(eq(triageData.issueId, issueId)) + .limit(1); + + if (triageRecords.length === 0) { + return NextResponse.json( + { error: "Triage data not found" }, + { status: 404 } + ); + } + + return NextResponse.json(triageRecords[0]); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..a2dc41ecee5ec435200fe7cba2bde4107f823774 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f7fa87eb875260ed98651bc419c8139b5119e554 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..295f8fdf14fcfe6cccaa832133037157521b1890 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd563c0f2a70946303c48fba16da60bed6fb17c0 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,10 @@ +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import * as schema from "./schema"; + +const client = createClient({ + url: process.env.TURSO_DATABASE_URL!, + authToken: process.env.TURSO_AUTH_TOKEN, +}); + +export const db = drizzle(client, { schema }); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fa2068a6a8c2bc1de35d4d0e117bdc07644fbaf --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,477 @@ +import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { relations } from "drizzle-orm"; + +// ============================================================================= +// ENUMS (stored as TEXT with application-level validation) +// ============================================================================= + +export const userRoleEnum = ["MAINTAINER", "CONTRIBUTOR"] as const; +export const classificationEnum = [ + "CRITICAL_BUG", "BUG", "FEATURE_REQUEST", "QUESTION", + "DOCS", "DUPLICATE", "NEEDS_INFO", "SPAM" +] as const; +export const sentimentEnum = ["POSITIVE", "NEUTRAL", "NEGATIVE", "FRUSTRATED"] as const; +export const profileVisibilityEnum = ["public", "private", "connections_only"] as const; +export const expertiseLevelEnum = ["beginner", "intermediate", "advanced", "expert"] as const; +export const sessionTypeEnum = ["one_on_one", "group", "issue_help"] as const; +export const sessionStatusEnum = ["active", "paused", "completed", "cancelled"] as const; +export const resourceTypeEnum = [ + "link", "code_snippet", "documentation", "tutorial", "tool", "example", "answer" +] as const; +export const trophyTypeEnum = [ + "first_pr", "pr_master", "pr_legend", "bug_hunter", "bug_slayer", + "first_mentor", "master_mentor", "streak_starter", "streak_warrior", "streak_legend", + "triage_helper", "triage_hero", "first_review", "review_guru", + "welcome_committee", "documentation_hero", "early_adopter", "top_contributor" +] as const; +export const trophyRarityEnum = ["common", "uncommon", "rare", "legendary"] as const; + +// ============================================================================= +// CORE TABLES +// ============================================================================= + +// ---- Users ---- +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + githubId: integer("github_id").unique().notNull(), + username: text("username").notNull(), + avatarUrl: text("avatar_url").notNull(), + role: text("role"), // UserRole enum + githubAccessToken: text("github_access_token"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +// ---- Repositories ---- +export const repositories = sqliteTable("repositories", { + id: text("id").primaryKey(), + githubRepoId: integer("github_repo_id").notNull(), + name: text("name").notNull(), + owner: text("owner").notNull(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), +}); + +// ---- Issues ---- +export const issues = sqliteTable("issues", { + id: text("id").primaryKey(), + githubIssueId: integer("github_issue_id").notNull(), + number: integer("number").notNull(), + title: text("title").notNull(), + body: text("body"), + authorName: text("author_name").notNull(), + repoId: text("repo_id").notNull().references(() => repositories.id), + repoName: text("repo_name").notNull(), + owner: text("owner"), + repo: text("repo"), + htmlUrl: text("html_url"), + state: text("state").notNull().default("open"), + isPR: integer("is_pr", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at").notNull(), +}); + +// ---- Triage Data ---- +export const triageData = sqliteTable("triage_data", { + id: text("id").primaryKey(), + issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + classification: text("classification").notNull(), // Classification enum + summary: text("summary").notNull(), + suggestedLabel: text("suggested_label").notNull(), + sentiment: text("sentiment").notNull(), // Sentiment enum + analyzedAt: text("analyzed_at").notNull(), +}); + +// ---- Templates ---- +export const templates = sqliteTable("templates", { + id: text("id").primaryKey(), + name: text("name").notNull(), + body: text("body").notNull(), + ownerId: text("owner_id").notNull().references(() => users.id, { onDelete: "cascade" }), + triggerClassification: text("trigger_classification"), + createdAt: text("created_at").notNull(), +}); + +// ============================================================================= +// PROFILE & MENTORSHIP TABLES +// ============================================================================= + +// ---- User Profiles ---- +export const profiles = sqliteTable("profiles", { + userId: text("user_id").primaryKey().references(() => users.id, { onDelete: "cascade" }), + username: text("username").notNull(), + avatarUrl: text("avatar_url"), + bio: text("bio"), + location: text("location"), + website: text("website"), + twitter: text("twitter"), + availableForMentoring: integer("available_for_mentoring", { mode: "boolean" }).default(false), + profileVisibility: text("profile_visibility").default("public"), + showEmail: integer("show_email", { mode: "boolean" }).default(false), + githubStats: text("github_stats"), // JSON string + statsUpdatedAt: text("stats_updated_at"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +// ---- Mentor Profiles ---- +export const mentors = sqliteTable("mentors", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + username: text("username").notNull(), + expertiseLevel: text("expertise_level").default("intermediate"), + availabilityHoursPerWeek: integer("availability_hours_per_week").default(5), + timezone: text("timezone"), + isActive: integer("is_active", { mode: "boolean" }).default(true), + bio: text("bio"), + avatarUrl: text("avatar_url"), + menteeCount: integer("mentee_count").default(0), + sessionsCompleted: integer("sessions_completed").default(0), + avgRating: real("avg_rating").default(0.0), + totalRatings: integer("total_ratings").default(0), + maxMentees: integer("max_mentees").default(3), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +// ---- Mentor Matches ---- +export const mentorMatches = sqliteTable("mentor_matches", { + id: text("id").primaryKey(), + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + mentorUsername: text("mentor_username").notNull(), + menteeId: text("mentee_id").notNull().references(() => users.id, { onDelete: "cascade" }), + menteeUsername: text("mentee_username").notNull(), + compatibilityScore: real("compatibility_score").notNull(), + matchReason: text("match_reason"), + issueId: text("issue_id"), + repoName: text("repo_name"), + status: text("status").default("suggested"), // suggested, accepted, declined, completed + createdAt: text("created_at").notNull(), +}); + +// ---- Mentorship Requests ---- +export const mentorshipRequests = sqliteTable("mentorship_requests", { + id: text("id").primaryKey(), + menteeId: text("mentee_id").notNull().references(() => users.id, { onDelete: "cascade" }), + menteeUsername: text("mentee_username"), + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + mentorUsername: text("mentor_username"), + issueId: text("issue_id"), + message: text("message"), + status: text("status").default("pending"), // pending, accepted, declined + createdAt: text("created_at").notNull(), +}); + +// ---- Mentor Ratings ---- +export const mentorRatings = sqliteTable("mentor_ratings", { + id: text("id").primaryKey(), + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + menteeId: text("mentee_id").notNull().references(() => users.id, { onDelete: "cascade" }), + sessionId: text("session_id"), + rating: integer("rating").notNull(), // 1-5 + feedback: text("feedback"), + createdAt: text("created_at").notNull(), +}); + +// ============================================================================= +// CHAT & MESSAGING TABLES +// ============================================================================= + +// ---- Chat Sessions ---- +export const chatSessions = sqliteTable("chat_sessions", { + id: text("id").primaryKey(), + mentorId: text("mentor_id").notNull().references(() => users.id), + mentorUsername: text("mentor_username").notNull(), + sessionType: text("session_type").default("one_on_one"), + issueId: text("issue_id"), + repoName: text("repo_name"), + topic: text("topic"), + status: text("status").default("active"), + startedAt: text("started_at").notNull(), + endedAt: text("ended_at"), + lastActivityAt: text("last_activity_at").notNull(), + summary: text("summary"), + messageCount: integer("message_count").default(0), + durationMinutes: integer("duration_minutes").default(0), +}); + +// ---- Chat Messages ---- +export const chatMessages = sqliteTable("chat_messages", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull().references(() => chatSessions.id, { onDelete: "cascade" }), + senderId: text("sender_id").notNull().references(() => users.id), + senderUsername: text("sender_username").notNull(), + isMentor: integer("is_mentor", { mode: "boolean" }).default(false), + content: text("content").notNull(), + messageType: text("message_type").default("text"), // text, code, link, file + language: text("language"), // for code blocks + isAiGenerated: integer("is_ai_generated", { mode: "boolean" }).default(false), + containsResource: integer("contains_resource", { mode: "boolean" }).default(false), + extractedResourceId: text("extracted_resource_id"), + timestamp: text("timestamp").notNull(), + editedAt: text("edited_at"), +}); + +// ---- Chat History (Legacy AI Chat) ---- +export const chatHistory = sqliteTable("chat_history", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + sessionId: text("session_id").notNull(), + createdAt: text("created_at").notNull(), +}); + +// ---- Issue Chats ---- +export const issueChats = sqliteTable("issue_chats", { + id: text("id").primaryKey(), + issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + sessionId: text("session_id").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +// ---- Direct Messages ---- +export const messages = sqliteTable("messages", { + id: text("id").primaryKey(), + senderId: text("sender_id").notNull().references(() => users.id, { onDelete: "cascade" }), + receiverId: text("receiver_id").notNull().references(() => users.id, { onDelete: "cascade" }), + content: text("content").notNull(), + read: integer("read", { mode: "boolean" }).default(false), + timestamp: text("timestamp").notNull(), +}); + +// ============================================================================= +// GAMIFICATION TABLES +// ============================================================================= + +// ---- Trophies ---- +export const trophies = sqliteTable("trophies", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + username: text("username").notNull(), + trophyType: text("trophy_type").notNull(), // TrophyType enum + name: text("name").notNull(), + description: text("description").notNull(), + icon: text("icon").notNull(), + color: text("color").notNull(), + rarity: text("rarity").notNull(), // common, uncommon, rare, legendary + svgData: text("svg_data"), + isPublic: integer("is_public", { mode: "boolean" }).default(true), + shareUrl: text("share_url"), + earnedFor: text("earned_for"), // e.g., "owner/repo" + milestoneValue: integer("milestone_value"), + awardedAt: text("awarded_at").notNull(), +}); + +// ---- Resources ---- +export const resources = sqliteTable("resources", { + id: text("id").primaryKey(), + repoName: text("repo_name").notNull(), + sourceType: text("source_type").default("chat"), + sourceId: text("source_id"), + resourceType: text("resource_type").notNull(), // ResourceType enum + title: text("title").notNull(), + content: text("content").notNull(), + description: text("description"), + language: text("language"), + sharedBy: text("shared_by").notNull(), + sharedById: text("shared_by_id").notNull().references(() => users.id), + saveCount: integer("save_count").default(0), + helpfulCount: integer("helpful_count").default(0), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +// ---- User Saved Resources ---- +export const userSavedResources = sqliteTable("user_saved_resources", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + resourceId: text("resource_id").notNull().references(() => resources.id, { onDelete: "cascade" }), + notes: text("notes"), + savedAt: text("saved_at").notNull(), +}); + +// ============================================================================= +// JUNCTION TABLES (Normalized Arrays) +// ============================================================================= + +// ---- User Repositories (User.repositories array) ---- +export const userRepositories = sqliteTable("user_repositories", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + repoFullName: text("repo_full_name").notNull(), + addedAt: text("added_at").notNull(), +}); + +// ---- Profile Skills ---- +export const profileSkills = sqliteTable("profile_skills", { + profileId: text("profile_id").notNull().references(() => profiles.userId, { onDelete: "cascade" }), + skill: text("skill").notNull(), +}); + +// ---- Profile Mentoring Topics ---- +export const profileMentoringTopics = sqliteTable("profile_mentoring_topics", { + profileId: text("profile_id").notNull().references(() => profiles.userId, { onDelete: "cascade" }), + topic: text("topic").notNull(), +}); + +// ---- Profile Connected Repos ---- +export const profileConnectedRepos = sqliteTable("profile_connected_repos", { + profileId: text("profile_id").notNull().references(() => profiles.userId, { onDelete: "cascade" }), + repoName: text("repo_name").notNull(), +}); + +// ---- Mentor Tech Stack ---- +export const mentorTechStack = sqliteTable("mentor_tech_stack", { + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + tech: text("tech").notNull(), +}); + +// ---- Mentor Languages ---- +export const mentorLanguages = sqliteTable("mentor_languages", { + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + language: text("language").notNull(), +}); + +// ---- Mentor Frameworks ---- +export const mentorFrameworks = sqliteTable("mentor_frameworks", { + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + framework: text("framework").notNull(), +}); + +// ---- Mentor Preferred Topics ---- +export const mentorPreferredTopics = sqliteTable("mentor_preferred_topics", { + mentorId: text("mentor_id").notNull().references(() => mentors.id, { onDelete: "cascade" }), + topic: text("topic").notNull(), +}); + +// ---- Mentor Match Matched Skills ---- +export const mentorMatchSkills = sqliteTable("mentor_match_skills", { + matchId: text("match_id").notNull().references(() => mentorMatches.id, { onDelete: "cascade" }), + skill: text("skill").notNull(), +}); + +// ---- Chat Session Mentees ---- +export const chatSessionMentees = sqliteTable("chat_session_mentees", { + sessionId: text("session_id").notNull().references(() => chatSessions.id, { onDelete: "cascade" }), + menteeId: text("mentee_id").notNull().references(() => users.id, { onDelete: "cascade" }), + menteeUsername: text("mentee_username").notNull(), +}); + +// ---- Chat Session Key Points ---- +export const chatSessionKeyPoints = sqliteTable("chat_session_key_points", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull().references(() => chatSessions.id, { onDelete: "cascade" }), + keyPoint: text("key_point").notNull(), + sortOrder: integer("sort_order").default(0), +}); + +// ---- Chat Session Resources Shared ---- +export const chatSessionResources = sqliteTable("chat_session_resources", { + sessionId: text("session_id").notNull().references(() => chatSessions.id, { onDelete: "cascade" }), + resourceId: text("resource_id").notNull().references(() => resources.id, { onDelete: "cascade" }), +}); + +// ---- Chat History Messages ---- +export const chatHistoryMessages = sqliteTable("chat_history_messages", { + id: text("id").primaryKey(), + chatHistoryId: text("chat_history_id").notNull().references(() => chatHistory.id, { onDelete: "cascade" }), + role: text("role").notNull(), // 'user' | 'assistant' + content: text("content").notNull(), + timestamp: text("timestamp").notNull(), + githubCommentId: text("github_comment_id"), + githubCommentUrl: text("github_comment_url"), +}); + +// ---- Issue Chat Messages ---- +export const issueChatMessages = sqliteTable("issue_chat_messages", { + id: text("id").primaryKey(), + issueChatId: text("issue_chat_id").notNull().references(() => issueChats.id, { onDelete: "cascade" }), + role: text("role").notNull(), + content: text("content").notNull(), + timestamp: text("timestamp").notNull(), + githubCommentId: text("github_comment_id"), + githubCommentUrl: text("github_comment_url"), +}); + +// ---- Chat Message Attachments ---- +export const chatMessageAttachments = sqliteTable("chat_message_attachments", { + messageId: text("message_id").notNull().references(() => chatMessages.id, { onDelete: "cascade" }), + attachment: text("attachment").notNull(), +}); + +// ---- Chat Message Reactions ---- +export const chatMessageReactions = sqliteTable("chat_message_reactions", { + messageId: text("message_id").notNull().references(() => chatMessages.id, { onDelete: "cascade" }), + emoji: text("emoji").notNull(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), +}); + +// ---- Resource Tags ---- +export const resourceTags = sqliteTable("resource_tags", { + resourceId: text("resource_id").notNull().references(() => resources.id, { onDelete: "cascade" }), + tag: text("tag").notNull(), +}); + +// ============================================================================= +// RELATIONS (for Drizzle Query API) +// ============================================================================= + +export const usersRelations = relations(users, ({ many, one }) => ({ + repositories: many(repositories), + issues: many(issues), + profile: one(profiles), + trophies: many(trophies), + sentMessages: many(messages, { relationName: "sender" }), + receivedMessages: many(messages, { relationName: "receiver" }), +})); + +export const repositoriesRelations = relations(repositories, ({ one, many }) => ({ + user: one(users, { fields: [repositories.userId], references: [users.id] }), + issues: many(issues), +})); + +export const issuesRelations = relations(issues, ({ one, many }) => ({ + repository: one(repositories, { fields: [issues.repoId], references: [repositories.id] }), + triageData: one(triageData), + issueChats: many(issueChats), +})); + +export const triageDataRelations = relations(triageData, ({ one }) => ({ + issue: one(issues, { fields: [triageData.issueId], references: [issues.id] }), +})); + +export const profilesRelations = relations(profiles, ({ one, many }) => ({ + user: one(users, { fields: [profiles.userId], references: [users.id] }), + skills: many(profileSkills), + mentoringTopics: many(profileMentoringTopics), + connectedRepos: many(profileConnectedRepos), +})); + +export const mentorsRelations = relations(mentors, ({ one, many }) => ({ + user: one(users, { fields: [mentors.userId], references: [users.id] }), + matches: many(mentorMatches), + requests: many(mentorshipRequests), + ratings: many(mentorRatings), + techStack: many(mentorTechStack), + languages: many(mentorLanguages), + frameworks: many(mentorFrameworks), + preferredTopics: many(mentorPreferredTopics), +})); + +export const chatSessionsRelations = relations(chatSessions, ({ one, many }) => ({ + mentor: one(users, { fields: [chatSessions.mentorId], references: [users.id] }), + messages: many(chatMessages), + mentees: many(chatSessionMentees), + keyPoints: many(chatSessionKeyPoints), + sharedResources: many(chatSessionResources), +})); + +export const trophiesRelations = relations(trophies, ({ one }) => ({ + user: one(users, { fields: [trophies.userId], references: [users.id] }), +})); + +export const resourcesRelations = relations(resources, ({ one, many }) => ({ + sharer: one(users, { fields: [resources.sharedById], references: [users.id] }), + tags: many(resourceTags), + savedBy: many(userSavedResources), +})); diff --git a/src/lib/ai-client.ts b/src/lib/ai-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e9beeb2754aad74b95e48978d2c51d6b67198c5 --- /dev/null +++ b/src/lib/ai-client.ts @@ -0,0 +1,112 @@ +/** + * AI Engine Client + * + * Helper to proxy requests to the external Python AI Engine. + * Configure AI_ENGINE_URL in .env.local + */ + +const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://localhost:7860"; + +interface AIResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * Call the AI Engine with type safety + */ +export async function callAIEngine( + endpoint: string, + body: object, + options?: { timeout?: number } +): Promise> { + const url = `${AI_ENGINE_URL}${endpoint}`; + const timeout = options?.timeout || 30000; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const error = await response.text(); + return { success: false, error: `AI Engine error: ${response.status} - ${error}` }; + } + + const data = await response.json(); + return { success: true, data: data as T }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { success: false, error: "AI Engine request timed out" }; + } + return { success: false, error: `Failed to reach AI Engine: ${error}` }; + } +} + +// Pre-configured helpers for each service + +export async function triageIssue(issue: { + title: string; + body?: string; + authorName?: string; + isPR?: boolean; +}) { + return callAIEngine("/triage", { + title: issue.title, + body: issue.body || "", + authorName: issue.authorName || "unknown", + isPR: issue.isPR || false, + }); +} + +export async function chat(message: string, history?: { role: string; content: string }[], context?: object) { + return callAIEngine("/chat", { message, history, context }); +} + +export async function ragQuery(question: string, repoName?: string) { + return callAIEngine("/rag/chat", { question, repo_name: repoName }); +} + +export async function findMentorMatches(userId: string, username: string, limit = 5) { + return callAIEngine("/mentor-match", { user_id: userId, username, limit }); +} + +export async function generateHype(pr: { + title: string; + body?: string; + filesChanged?: string[]; + additions?: number; + deletions?: number; +}) { + return callAIEngine("/hype", { + pr_title: pr.title, + pr_body: pr.body || "", + files_changed: pr.filesChanged || [], + additions: pr.additions || 0, + deletions: pr.deletions || 0, + }); +} + +export async function ragChat(question: string, repoName?: string, topK = 5) { + return callAIEngine("/rag/chat", { question, repo_name: repoName, top_k: topK }); +} + +export async function ragIndex(repoName: string, githubAccessToken?: string) { + return callAIEngine("/rag/index", { repo_name: repoName, github_access_token: githubAccessToken }); +} + +export async function ragSearch(query: string, repoName?: string, limit = 10) { + return callAIEngine("/rag/search", { query, repo_name: repoName, limit }); +} + diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..15f140b3e3543688c730fd05357f5483ac079aa5 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,85 @@ +import jwt from "jsonwebtoken"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +const JWT_SECRET = process.env.JWT_SECRET!; + +/** + * Create a JWT token for a user. + */ +export function createJwtToken(userId: string, role: string | null): string { + const payload = { + user_id: userId, + role: role, + exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, // 30 days + }; + return jwt.sign(payload, JWT_SECRET, { algorithm: "HS256" }); +} + +/** + * Verify and decode a JWT token. + */ +export function verifyJwtToken(token: string): { user_id: string; role: string | null } { + try { + const payload = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] }) as { + user_id: string; + role: string | null; + }; + return payload; + } catch (error) { + throw new Error("Invalid or expired token"); + } +} + +/** + * Extract user from Authorization header. + */ +export async function getCurrentUser(request: NextRequest) { + const authHeader = request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return null; + } + + const token = authHeader.substring(7); + + try { + const payload = verifyJwtToken(token); + + // Fetch full user from database + const userRecords = await db + .select() + .from(users) + .where(eq(users.id, payload.user_id)) + .limit(1); + + if (userRecords.length === 0) { + return null; + } + + return userRecords[0]; + } catch { + return null; + } +} + +/** + * Helper to require authentication on a route. + */ +export async function requireAuth(request: NextRequest) { + const user = await getCurrentUser(request); + + if (!user) { + return { + user: null, + error: NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ), + }; + } + + return { user, error: null }; +} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3cb5028d28091e4bf7eee62588bf644939cecfd --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,11 @@ +/** + * Database Queries Index + * + * Re-exports all query functions from the queries folder. + */ + +export * from "./queries/users"; +export * from "./queries/repositories"; +export * from "./queries/issues"; +export * from "./queries/messages"; +export * from "./queries/templates"; diff --git a/src/lib/db/queries/gamification.ts b/src/lib/db/queries/gamification.ts new file mode 100644 index 0000000000000000000000000000000000000000..8421ba0402595f6f0e0c403734cf00752544f22d --- /dev/null +++ b/src/lib/db/queries/gamification.ts @@ -0,0 +1,74 @@ +/** + * Gamification Queries - Drizzle ORM + */ + +import { db } from "@/db"; +import { trophies, users, issues } from "@/db/schema"; +import { eq, desc, and, count, not, sql } from "drizzle-orm"; + +// ============================================================================= +// Badges / Trophies +// ============================================================================= + +export async function getUserBadges(username: string) { + const user = await db.select().from(users).where(eq(users.username, username)).limit(1); + if (!user[0]) return []; + + return db.select() + .from(trophies) + .where(eq(trophies.userId, user[0].id)) + .orderBy(desc(trophies.awardedAt)); +} + +// ============================================================================= +// Streak +// ============================================================================= + +export async function getUserStreak(username: string) { + // This is a simplified streak calculation based on issue/PR creation dates + // In a real app, you might want to track daily activity explicitly + + const user = await db.select().from(users).where(eq(users.username, username)).limit(1); + if (!user[0]) return { currentStreak: 0, longestStreak: 0 }; + + const activity = await db.select({ + createdAt: issues.createdAt + }) + .from(issues) + .leftJoin(users, eq(issues.authorName, users.username)) // Assuming authorName matches username + .where(eq(users.username, username)) + .orderBy(desc(issues.createdAt)); + + if (activity.length === 0) return { currentStreak: 0, longestStreak: 0 }; + + // Calculate streak logic here (simplified for now) + // For now returning mock data or simple calculation as true streak logic can be complex + // pending actual "daily activity" table + + return { + currentStreak: 0, + longestStreak: 0 + }; +} + +// ============================================================================= +// Clean Calendar Data +// ============================================================================= + +export async function getUserCalendar(username: string) { + const user = await db.select().from(users).where(eq(users.username, username)).limit(1); + if (!user[0]) return []; + + // Aggregate issues created by day + const activity = await db.select({ + day: sql`substr(${issues.createdAt}, 1, 10)`, + value: count() + }) + .from(issues) + .leftJoin(users, eq(issues.authorName, users.username)) + .where(eq(users.username, username)) + .groupBy(sql`substr(${issues.createdAt}, 1, 10)`) + .orderBy(sql`day`); + + return activity; +} diff --git a/src/lib/db/queries/issues.ts b/src/lib/db/queries/issues.ts new file mode 100644 index 0000000000000000000000000000000000000000..07586b19c81343966f4c18421174ffc8ac8205fb --- /dev/null +++ b/src/lib/db/queries/issues.ts @@ -0,0 +1,242 @@ +/** + * Issue Queries - Drizzle ORM + * + * All issue and triage-related database operations. + */ + +import { db } from "@/db"; +import { issues, triageData, repositories } from "@/db/schema"; +import { eq, and, desc, asc, count, or, like, sql } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// ============================================================================= +// Issue CRUD +// ============================================================================= + +export async function getIssueById(id: string) { + const result = await db.select().from(issues).where(eq(issues.id, id)).limit(1); + return result[0] || null; +} + +export async function getIssueByGithubId(githubIssueId: number) { + const result = await db.select().from(issues).where(eq(issues.githubIssueId, githubIssueId)).limit(1); + return result[0] || null; +} + +export async function createIssue(data: { + githubIssueId: number; + number: number; + title: string; + body?: string; + authorName: string; + repoId: string; + repoName: string; + owner?: string; + repo?: string; + htmlUrl?: string; + state?: string; + isPR?: boolean; +}) { + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.insert(issues).values({ + id, + githubIssueId: data.githubIssueId, + number: data.number, + title: data.title, + body: data.body || null, + authorName: data.authorName, + repoId: data.repoId, + repoName: data.repoName, + owner: data.owner || null, + repo: data.repo || null, + htmlUrl: data.htmlUrl || null, + state: data.state || "open", + isPR: data.isPR || false, + createdAt: now, + }).onConflictDoNothing(); + + return { id, ...data, createdAt: now }; +} + +export async function updateIssueState(id: string, state: string) { + await db.update(issues).set({ state }).where(eq(issues.id, id)); +} + +// ============================================================================= +// Issue Listing with Pagination +// ============================================================================= + +export interface IssueFilters { + repoId?: string; + userId?: string; + authorName?: string; + state?: string; + isPR?: boolean; + search?: string; +} + +export async function getIssues(filters: IssueFilters, page = 1, limit = 10) { + const offset = (page - 1) * limit; + + // Build conditions + const conditions = []; + if (filters.repoId) conditions.push(eq(issues.repoId, filters.repoId)); + if (filters.authorName) conditions.push(eq(issues.authorName, filters.authorName)); + if (filters.state) conditions.push(eq(issues.state, filters.state)); + if (filters.isPR !== undefined) conditions.push(eq(issues.isPR, filters.isPR)); + if (filters.search) { + conditions.push(or( + like(issues.title, `%${filters.search}%`), + like(issues.body, `%${filters.search}%`) + )); + } + + // If userId provided, filter by user's repos + if (filters.userId) { + const userRepos = await db.select({ id: repositories.id }) + .from(repositories) + .where(eq(repositories.userId, filters.userId)); + const repoIds = userRepos.map(r => r.id); + + if (repoIds.length > 0) { + conditions.push(sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`${id}`), sql`, `)})`); + } else { + return { issues: [], total: 0, page, limit, totalPages: 0 }; + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const results = await db.select() + .from(issues) + .where(whereClause) + .orderBy(desc(issues.createdAt)) + .limit(limit) + .offset(offset); + + const totalResult = await db.select({ count: count() }) + .from(issues) + .where(whereClause); + + const total = totalResult[0]?.count || 0; + + return { + issues: results, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; +} + +// ============================================================================= +// Issues with Triage Data +// ============================================================================= + +export async function getIssuesWithTriage(filters: IssueFilters, page = 1, limit = 10) { + const { issues: issueList, total, totalPages } = await getIssues(filters, page, limit); + + // Fetch triage data for each issue + const issuesWithTriage = await Promise.all(issueList.map(async (issue) => { + const triage = await db.select() + .from(triageData) + .where(eq(triageData.issueId, issue.id)) + .limit(1); + + return { + ...issue, + triage: triage[0] || null, + }; + })); + + return { issues: issuesWithTriage, total, page, limit, totalPages }; +} + +// ============================================================================= +// Triage Operations +// ============================================================================= + +export async function getTriageData(issueId: string) { + const result = await db.select().from(triageData).where(eq(triageData.issueId, issueId)).limit(1); + return result[0] || null; +} + +export async function createOrUpdateTriageData(data: { + issueId: string; + classification: string; + summary: string; + suggestedLabel: string; + sentiment: string; +}) { + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.insert(triageData).values({ + id, + issueId: data.issueId, + classification: data.classification, + summary: data.summary, + suggestedLabel: data.suggestedLabel, + sentiment: data.sentiment, + analyzedAt: now, + }).onConflictDoUpdate({ + target: triageData.issueId, + set: { + classification: data.classification, + summary: data.summary, + suggestedLabel: data.suggestedLabel, + sentiment: data.sentiment, + analyzedAt: now, + } + }); + + return { id, ...data, analyzedAt: now }; +} + +// ============================================================================= +// Dashboard Stats +// ============================================================================= + +export async function getDashboardStats(userId: string) { + const userRepos = await db.select({ id: repositories.id }) + .from(repositories) + .where(eq(repositories.userId, userId)); + + if (userRepos.length === 0) { + return { openIssues: 0, openPRs: 0, triaged: 0, untriaged: 0 }; + } + + const repoIds = userRepos.map(r => r.id); + const repoCondition = sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`${id}`), sql`, `)})`; + + const openIssues = await db.select({ count: count() }) + .from(issues) + .where(and(repoCondition, eq(issues.state, "open"), eq(issues.isPR, false))); + + const openPRs = await db.select({ count: count() }) + .from(issues) + .where(and(repoCondition, eq(issues.state, "open"), eq(issues.isPR, true))); + + // Count triaged issues + const allOpenIssueIds = await db.select({ id: issues.id }) + .from(issues) + .where(and(repoCondition, eq(issues.state, "open"))); + + let triaged = 0; + for (const issue of allOpenIssueIds) { + const hasTriagedata = await db.select({ id: triageData.id }) + .from(triageData) + .where(eq(triageData.issueId, issue.id)) + .limit(1); + if (hasTriagedata.length > 0) triaged++; + } + + return { + openIssues: openIssues[0]?.count || 0, + openPRs: openPRs[0]?.count || 0, + triaged, + untriaged: (openIssues[0]?.count || 0) - triaged, + }; +} diff --git a/src/lib/db/queries/messages.ts b/src/lib/db/queries/messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..867844394b7ab0e60777b1741348af6c5e0ae317 --- /dev/null +++ b/src/lib/db/queries/messages.ts @@ -0,0 +1,178 @@ +/** + * Message Queries - Drizzle ORM + * + * All messaging-related database operations. + */ + +import { db } from "@/db"; +import { messages, users } from "@/db/schema"; +import { eq, and, or, desc, count, gt, asc } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// ============================================================================= +// Message CRUD +// ============================================================================= + +export async function sendMessage(data: { + senderId: string; + receiverId: string; + content: string; +}) { + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.insert(messages).values({ + id, + senderId: data.senderId, + receiverId: data.receiverId, + content: data.content, + read: false, + timestamp: now, + }); + + return { id, ...data, read: false, timestamp: now }; +} + +export async function markMessagesAsRead(currentUserId: string, otherUserId: string) { + await db.update(messages) + .set({ read: true }) + .where( + and( + eq(messages.senderId, otherUserId), + eq(messages.receiverId, currentUserId), + eq(messages.read, false) + ) + ); +} + +// ============================================================================= +// Chat History +// ============================================================================= + +export async function getChatHistory(currentUserId: string, otherUserId: string, limit = 50) { + const history = await db.select() + .from(messages) + .where( + or( + and(eq(messages.senderId, currentUserId), eq(messages.receiverId, otherUserId)), + and(eq(messages.senderId, otherUserId), eq(messages.receiverId, currentUserId)) + ) + ) + .orderBy(asc(messages.timestamp)) + .limit(limit); + + return history; +} + +export async function pollNewMessages(currentUserId: string, otherUserId: string, lastMessageId?: string) { + let query = db.select() + .from(messages) + .where( + and( + eq(messages.senderId, otherUserId), + eq(messages.receiverId, currentUserId) + ) + ) + .orderBy(asc(messages.timestamp)); + + // If we have a last message ID, only get newer messages + if (lastMessageId) { + const lastMessage = await db.select({ timestamp: messages.timestamp }) + .from(messages) + .where(eq(messages.id, lastMessageId)) + .limit(1); + + if (lastMessage[0]) { + query = db.select() + .from(messages) + .where( + and( + eq(messages.senderId, otherUserId), + eq(messages.receiverId, currentUserId), + gt(messages.timestamp, lastMessage[0].timestamp) + ) + ) + .orderBy(asc(messages.timestamp)); + } + } + + return query; +} + +// ============================================================================= +// Conversations +// ============================================================================= + +export async function getConversations(currentUserId: string) { + // Get all unique conversation partners + const sentTo = await db.selectDistinct({ partnerId: messages.receiverId }) + .from(messages) + .where(eq(messages.senderId, currentUserId)); + + const receivedFrom = await db.selectDistinct({ partnerId: messages.senderId }) + .from(messages) + .where(eq(messages.receiverId, currentUserId)); + + const partnerIds = new Set([ + ...sentTo.map(m => m.partnerId), + ...receivedFrom.map(m => m.partnerId) + ]); + + const conversations = await Promise.all([...partnerIds].map(async (partnerId) => { + // Get partner info + const partner = await db.select() + .from(users) + .where(eq(users.id, partnerId)) + .limit(1); + + // Get last message + const lastMessage = await db.select() + .from(messages) + .where( + or( + and(eq(messages.senderId, currentUserId), eq(messages.receiverId, partnerId)), + and(eq(messages.senderId, partnerId), eq(messages.receiverId, currentUserId)) + ) + ) + .orderBy(desc(messages.timestamp)) + .limit(1); + + // Count unread + const unreadCount = await db.select({ count: count() }) + .from(messages) + .where( + and( + eq(messages.senderId, partnerId), + eq(messages.receiverId, currentUserId), + eq(messages.read, false) + ) + ); + + return { + partnerId, + partnerUsername: partner[0]?.username || "Unknown", + partnerAvatar: partner[0]?.avatarUrl || null, + lastMessage: lastMessage[0] || null, + unreadCount: unreadCount[0]?.count || 0, + }; + })); + + // Sort by last message timestamp + return conversations.sort((a, b) => { + const aTime = a.lastMessage?.timestamp || ""; + const bTime = b.lastMessage?.timestamp || ""; + return bTime.localeCompare(aTime); + }); +} + +// ============================================================================= +// Unread Count +// ============================================================================= + +export async function getUnreadCount(userId: string) { + const result = await db.select({ count: count() }) + .from(messages) + .where(and(eq(messages.receiverId, userId), eq(messages.read, false))); + + return result[0]?.count || 0; +} diff --git a/src/lib/db/queries/repositories.ts b/src/lib/db/queries/repositories.ts new file mode 100644 index 0000000000000000000000000000000000000000..e58d1184e2e3c17318b9edf392f5ded22a54ffd4 --- /dev/null +++ b/src/lib/db/queries/repositories.ts @@ -0,0 +1,153 @@ +/** + * Repository Queries - Drizzle ORM + * + * All repository-related database operations. + */ + +import { db } from "@/db"; +import { repositories, issues, users } from "@/db/schema"; +import { eq, and, desc, count, sql } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// ============================================================================= +// Repository CRUD +// ============================================================================= + +export async function getRepositoryById(id: string) { + const result = await db.select().from(repositories).where(eq(repositories.id, id)).limit(1); + return result[0] || null; +} + +export async function getRepositoryByGithubId(githubRepoId: number) { + const result = await db.select().from(repositories).where(eq(repositories.githubRepoId, githubRepoId)).limit(1); + return result[0] || null; +} + +export async function getRepositoriesByUserId(userId: string) { + return db.select().from(repositories).where(eq(repositories.userId, userId)).orderBy(desc(repositories.createdAt)); +} + +export async function createRepository(data: { + githubRepoId: number; + name: string; + owner: string; + userId: string; +}) { + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.insert(repositories).values({ + id, + githubRepoId: data.githubRepoId, + name: data.name, + owner: data.owner, + userId: data.userId, + createdAt: now, + }).onConflictDoNothing(); + + return { id, ...data, createdAt: now }; +} + +export async function deleteRepository(id: string) { + await db.delete(repositories).where(eq(repositories.id, id)); +} + +// ============================================================================= +// Maintainer Repositories +// ============================================================================= + +export async function getMaintainerRepositories(userId: string) { + const repos = await db.select().from(repositories).where(eq(repositories.userId, userId)); + + // Add issue counts + const reposWithCounts = await Promise.all(repos.map(async (repo) => { + const issueCount = await db.select({ count: count() }) + .from(issues) + .where(and(eq(issues.repoId, repo.id), eq(issues.state, "open"))); + + const prCount = await db.select({ count: count() }) + .from(issues) + .where(and(eq(issues.repoId, repo.id), eq(issues.isPR, true), eq(issues.state, "open"))); + + return { + ...repo, + fullName: `${repo.owner}/${repo.name}`, + openIssues: issueCount[0]?.count || 0, + openPRs: prCount[0]?.count || 0, + }; + })); + + return reposWithCounts; +} + +// ============================================================================= +// Contributor Repositories +// ============================================================================= + +export async function getContributorRepositories(userId: string, username: string) { + // Get repos where user has contributed (authored issues/PRs) but doesn't own + const userRepos = await db.select({ id: repositories.id }).from(repositories).where(eq(repositories.userId, userId)); + const userRepoIds = new Set(userRepos.map(r => r.id)); + + const contributedIssues = await db.select({ + repoId: issues.repoId, + repoName: issues.repoName, + owner: issues.owner, + repo: issues.repo, + }) + .from(issues) + .where(eq(issues.authorName, username)) + .groupBy(issues.repoId, issues.repoName, issues.owner, issues.repo); + + // Filter out owned repos + const contributedRepos = contributedIssues.filter(issue => !userRepoIds.has(issue.repoId)); + + // Get counts for each repo + const reposWithCounts = await Promise.all(contributedRepos.map(async (repo) => { + const myIssues = await db.select({ count: count() }) + .from(issues) + .where(and(eq(issues.repoId, repo.repoId), eq(issues.authorName, username))); + + return { + id: repo.repoId, + name: repo.repo || repo.repoName.split("/")[1], + owner: repo.owner || repo.repoName.split("/")[0], + fullName: repo.repoName, + myContributions: myIssues[0]?.count || 0, + }; + })); + + return reposWithCounts; +} + +// ============================================================================= +// Dashboard Stats +// ============================================================================= + +export async function getRepositoryStats(userId: string) { + const repos = await getRepositoriesByUserId(userId); + const repoIds = repos.map(r => r.id); + + if (repoIds.length === 0) { + return { totalRepos: 0, totalIssues: 0, totalPRs: 0, openIssues: 0, openPRs: 0 }; + } + + const allIssues = await db.select({ + id: issues.id, + isPR: issues.isPR, + state: issues.state + }).from(issues).where(sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`${id}`), sql`, `)})`); + + const totalIssues = allIssues.filter(i => !i.isPR).length; + const totalPRs = allIssues.filter(i => i.isPR).length; + const openIssues = allIssues.filter(i => !i.isPR && i.state === "open").length; + const openPRs = allIssues.filter(i => i.isPR && i.state === "open").length; + + return { + totalRepos: repos.length, + totalIssues, + totalPRs, + openIssues, + openPRs, + }; +} diff --git a/src/lib/db/queries/templates.ts b/src/lib/db/queries/templates.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d6add925224df8760618ce944f5bb7f3f859329 --- /dev/null +++ b/src/lib/db/queries/templates.ts @@ -0,0 +1,73 @@ +/** + * Template Queries - Drizzle ORM + * + * All template-related database operations. + */ + +import { db } from "@/db"; +import { templates } from "@/db/schema"; +import { eq, desc, and } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// ============================================================================= +// Template CRUD +// ============================================================================= + +export async function getTemplatesByOwnerId(ownerId: string) { + return db.select() + .from(templates) + .where(eq(templates.ownerId, ownerId)) + .orderBy(desc(templates.createdAt)); +} + +export async function getTemplateById(id: string) { + const result = await db.select() + .from(templates) + .where(eq(templates.id, id)) + .limit(1); + return result[0] || null; +} + +export async function createTemplate(data: { + name: string; + body: string; + ownerId: string; + triggerClassification?: string; +}) { + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.insert(templates).values({ + id, + name: data.name, + body: data.body, + ownerId: data.ownerId, + triggerClassification: data.triggerClassification || null, + createdAt: now, + }); + + return { id, ...data, createdAt: now }; +} + +export async function updateTemplate(id: string, data: Partial<{ + name: string; + body: string; + triggerClassification: string; +}>) { + await db.update(templates).set(data).where(eq(templates.id, id)); +} + +export async function deleteTemplate(id: string) { + await db.delete(templates).where(eq(templates.id, id)); +} + +export async function getTemplateByClassification(ownerId: string, classification: string) { + const result = await db.select() + .from(templates) + .where(and( + eq(templates.ownerId, ownerId), + eq(templates.triggerClassification, classification) + )) + .limit(1); + return result[0] || null; +} diff --git a/src/lib/db/queries/users.ts b/src/lib/db/queries/users.ts new file mode 100644 index 0000000000000000000000000000000000000000..c68f759be03a9c4689f36a762cdce5546e434e35 --- /dev/null +++ b/src/lib/db/queries/users.ts @@ -0,0 +1,193 @@ +/** + * User Queries - Drizzle ORM + * + * All user-related database operations. + */ + +import { db } from "@/db"; +import { users, profiles, profileSkills, profileMentoringTopics, profileConnectedRepos, userRepositories } from "@/db/schema"; +import { eq, and, like, desc } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// ============================================================================= +// User CRUD +// ============================================================================= + +export async function getUserById(id: string) { + const result = await db.select().from(users).where(eq(users.id, id)).limit(1); + return result[0] || null; +} + +export async function getUserByGithubId(githubId: number) { + const result = await db.select().from(users).where(eq(users.githubId, githubId)).limit(1); + return result[0] || null; +} + +export async function getUserByUsername(username: string) { + const result = await db.select().from(users).where(eq(users.username, username)).limit(1); + return result[0] || null; +} + +export async function createUser(data: { + githubId: number; + username: string; + avatarUrl: string; + role?: string; + githubAccessToken?: string; +}) { + const now = new Date().toISOString(); + const id = uuidv4(); + + await db.insert(users).values({ + id, + githubId: data.githubId, + username: data.username, + avatarUrl: data.avatarUrl, + role: data.role || null, + githubAccessToken: data.githubAccessToken || null, + createdAt: now, + updatedAt: now, + }); + + return { id, ...data, createdAt: now, updatedAt: now }; +} + +export async function updateUser(id: string, data: Partial<{ + username: string; + avatarUrl: string; + role: string; + githubAccessToken: string; +}>) { + await db.update(users) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(users.id, id)); +} + +export async function updateUserRole(id: string, role: string) { + await db.update(users) + .set({ role, updatedAt: new Date().toISOString() }) + .where(eq(users.id, id)); +} + +// ============================================================================= +// Profile Operations +// ============================================================================= + +export async function getProfile(userId: string) { + const profile = await db.select().from(profiles).where(eq(profiles.userId, userId)).limit(1); + if (!profile[0]) return null; + + const skills = await db.select().from(profileSkills).where(eq(profileSkills.profileId, userId)); + const topics = await db.select().from(profileMentoringTopics).where(eq(profileMentoringTopics.profileId, userId)); + const repos = await db.select().from(profileConnectedRepos).where(eq(profileConnectedRepos.profileId, userId)); + + return { + ...profile[0], + skills: skills.map(s => s.skill), + mentoringTopics: topics.map(t => t.topic), + connectedRepos: repos.map(r => r.repoName), + }; +} + +export async function getProfileByUsername(username: string) { + const profile = await db.select().from(profiles).where(eq(profiles.username, username)).limit(1); + if (!profile[0]) return null; + + const userId = profile[0].userId; + const skills = await db.select().from(profileSkills).where(eq(profileSkills.profileId, userId)); + const topics = await db.select().from(profileMentoringTopics).where(eq(profileMentoringTopics.profileId, userId)); + + return { + ...profile[0], + skills: skills.map(s => s.skill), + mentoringTopics: topics.map(t => t.topic), + }; +} + +export async function createOrUpdateProfile(userId: string, data: { + username: string; + avatarUrl?: string; + bio?: string; + location?: string; + website?: string; + twitter?: string; + availableForMentoring?: boolean; + profileVisibility?: string; + showEmail?: boolean; + skills?: string[]; + mentoringTopics?: string[]; +}) { + const now = new Date().toISOString(); + + // Upsert profile + await db.insert(profiles).values({ + userId, + username: data.username, + avatarUrl: data.avatarUrl || null, + bio: data.bio || null, + location: data.location || null, + website: data.website || null, + twitter: data.twitter || null, + availableForMentoring: data.availableForMentoring || false, + profileVisibility: data.profileVisibility || "public", + showEmail: data.showEmail || false, + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: profiles.userId, + set: { + bio: data.bio, + location: data.location, + website: data.website, + twitter: data.twitter, + availableForMentoring: data.availableForMentoring, + profileVisibility: data.profileVisibility, + showEmail: data.showEmail, + updatedAt: now, + } + }); + + // Update skills + if (data.skills) { + await db.delete(profileSkills).where(eq(profileSkills.profileId, userId)); + for (const skill of data.skills) { + await db.insert(profileSkills).values({ profileId: userId, skill }); + } + } + + // Update topics + if (data.mentoringTopics) { + await db.delete(profileMentoringTopics).where(eq(profileMentoringTopics.profileId, userId)); + for (const topic of data.mentoringTopics) { + await db.insert(profileMentoringTopics).values({ profileId: userId, topic }); + } + } + + return getProfile(userId); +} + +// ============================================================================= +// User Stats +// ============================================================================= + +export async function getUserRepositories(userId: string) { + return db.select().from(userRepositories).where(eq(userRepositories.userId, userId)); +} + +export async function addUserRepository(userId: string, repoFullName: string) { + await db.insert(userRepositories).values({ + id: uuidv4(), + userId, + repoFullName, + addedAt: new Date().toISOString(), + }).onConflictDoNothing(); +} + +export async function removeUserRepository(userId: string, repoFullName: string) { + await db.delete(userRepositories).where( + and( + eq(userRepositories.userId, userId), + eq(userRepositories.repoFullName, repoFullName) + ) + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9816f4f2b2f2ee969c0e9d642647d78afeba7db --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,16 @@ +import { v4 as uuidv4 } from "uuid"; + +/** + * Generates a UUID string for use as primary keys. + * Preserves the same format as the Python backend. + */ +export function generateId(): string { + return uuidv4(); +} + +/** + * Returns the current ISO 8601 timestamp. + */ +export function now(): string { + return new Date().toISOString(); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0546e805802b9e036cd707f6ac86ae5c44221a9 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// CORS configuration +const allowedOrigins = [ + "http://localhost:5173", // Vite dev + "http://localhost:3000", // Next.js dev + "https://open-triage.vercel.app", + "https://opentriage.onrender.com", +]; + +export function middleware(request: NextRequest) { + const origin = request.headers.get("origin") || ""; + const isAllowedOrigin = allowedOrigins.includes(origin); + + // Handle preflight requests + if (request.method === "OPTIONS") { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": isAllowedOrigin ? origin : "", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); + } + + // Handle actual requests + const response = NextResponse.next(); + + if (isAllowedOrigin) { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Credentials", "true"); + } + + return response; +} + +// Apply middleware to all API routes +export const config = { + matcher: "/api/:path*", +};