opentriage-api / src /db /schema.ts
KrishnaCosmic's picture
apply changes
e9b2cbc
import { sqliteTable, text, integer, real, uniqueIndex } 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"), // email: text("email"),
githubAccessToken: text("github_access_token"),
// Sync status tracking (Phase 2) - DISABLED until Turso migration is applied
// Run: turso db shell <db-name> < src/db/migrations/add_sync_status.sql
// syncStatus: text("sync_status").default("IDLE"), // IDLE | PENDING | SYNCING | COMPLETED | FAILED
// lastSyncAt: text("last_sync_at"), // ISO timestamp
// syncError: text("sync_error"), // Error message if failed
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" }),
addedByUser: integer("added_by_user", { mode: "boolean" }).default(false), // true if explicitly added by user (maintainer)
etag: text("etag"), // GitHub ETag for conditional requests
lastSyncedAt: text("last_synced_at"), // When the repo was last synced
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"), // Full body fetched on-demand via /api/issues/:id/details
bodySummary: text("body_summary"), // First 200 chars of body for display optimization
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),
authorAssociation: text("author_association"), // OWNER, MEMBER, COLLABORATOR, etc.
headSha: text("head_sha"), // For PRs: track the commit SHA to detect force-pushes
updatedAt: text("updated_at"), // Track last GitHub update time for efficient syncing
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
bugRiskScore: integer("bug_risk_score"), // 0-10 risk from Quality Assessment
toxicityFlag: integer("toxicity_flag", { mode: "boolean" }).default(false), // From Bilingual Moderation
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 (DEPRECATED - AI chat uses ephemeral history from frontend) ----
// This table stored AI chat history but was never read. Kept for reference.
// To be removed in a future migration after verifying no data loss impact.
// 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(),
editedAt: text("edited_at"),
});
// ---- Global Chat Messages (Ably persistence) ----
export const globalChatMessages = sqliteTable("global_chat_messages", {
id: text("id").primaryKey(),
senderId: text("sender_id").notNull().references(() => users.id, { onDelete: "cascade" }),
senderUsername: text("sender_username").notNull(),
senderAvatarUrl: text("sender_avatar_url"),
content: text("content").notNull(),
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(),
}, (table) => ({
// Unique constraint on userId + repoFullName to prevent duplicate tracking
userRepoUnique: uniqueIndex("user_repo_unique").on(table.userId, table.repoFullName),
}));
// ---- 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 (DEPRECATED - see chatHistory above) ----
// 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(),
});
// ---- Private Resources (Mentor-Contributor Tunnel) ----
// These resources are only visible to the repo owner (mentor) and the specific PR/issue author
export const privateResources = sqliteTable("private_resources", {
id: text("id").primaryKey(),
issueId: text("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
repoOwner: text("repo_owner").notNull(), // The repository owner (mentor) who can see this
prAuthor: text("pr_author").notNull(), // The PR/issue author (contributor) who can see this
resourceType: text("resource_type").notNull(), // link, code_snippet, documentation, tutorial, etc.
title: text("title").notNull(),
content: text("content").notNull(),
description: text("description"),
language: text("language"), // For code snippets
sharedBy: text("shared_by").notNull(), // Username who shared
sharedById: text("shared_by_id").notNull().references(() => users.id),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").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),
}));