diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index edf8229..7a22fe8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -199,7 +199,8 @@ model User {
country String? // User's home country (for mentor matching)
metadataJson Json? @db.JsonB
- // Profile image
+ // Profile
+ bio String? // User bio for matching with project descriptions
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
profileImageProvider String? // Storage provider used: 's3' or 'local'
diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx
index d25ba25..4678a86 100644
--- a/src/app/(auth)/onboarding/page.tsx
+++ b/src/app/(auth)/onboarding/page.tsx
@@ -25,6 +25,7 @@ import {
import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select'
import { AvatarUpload } from '@/components/shared/avatar-upload'
+import { Textarea } from '@/components/ui/textarea'
import {
User,
Phone,
@@ -36,9 +37,10 @@ import {
ArrowLeft,
Camera,
Globe,
+ FileText,
} from 'lucide-react'
-type Step = 'name' | 'photo' | 'country' | 'phone' | 'tags' | 'preferences' | 'complete'
+type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
export default function OnboardingPage() {
const router = useRouter()
@@ -48,6 +50,7 @@ export default function OnboardingPage() {
// Form state
const [name, setName] = useState('')
const [country, setCountry] = useState('')
+ const [bio, setBio] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const [expertiseTags, setExpertiseTags] = useState([])
const [lockedTags, setLockedTags] = useState([])
@@ -70,6 +73,10 @@ export default function OnboardingPage() {
if (userData.country) {
setCountry(userData.country)
}
+ // Pre-fill bio if available
+ if (userData.bio) {
+ setBio(userData.bio)
+ }
// Pre-fill phone if available
if (userData.phoneNumber) {
setPhoneNumber(userData.phoneNumber)
@@ -96,10 +103,10 @@ export default function OnboardingPage() {
// Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => {
if (whatsappEnabled) {
- return ['name', 'photo', 'country', 'phone', 'tags', 'preferences', 'complete']
+ return ['name', 'photo', 'country', 'bio', 'phone', 'tags', 'preferences', 'complete']
}
// Skip phone step if WhatsApp is disabled
- return ['name', 'photo', 'country', 'tags', 'preferences', 'complete']
+ return ['name', 'photo', 'country', 'bio', 'tags', 'preferences', 'complete']
}, [whatsappEnabled])
const currentIndex = steps.indexOf(step)
@@ -128,6 +135,7 @@ export default function OnboardingPage() {
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
+ bio: bio || undefined,
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
@@ -302,7 +310,49 @@ export default function OnboardingPage() {
>
)}
- {/* Step 4: Phone (only if WhatsApp enabled) */}
+ {/* Step 4: Bio */}
+ {step === 'bio' && (
+ <>
+
+
+
+ About You
+
+
+ Tell us a bit about yourself and your expertise. This helps us match you with relevant projects. (Optional)
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {/* Step 5: Phone (only if WhatsApp enabled) */}
{step === 'phone' && whatsappEnabled && (
<>
@@ -343,7 +393,7 @@ export default function OnboardingPage() {
>
)}
- {/* Step 5: Tags */}
+ {/* Step 6: Tags */}
{step === 'tags' && (
<>
@@ -377,7 +427,7 @@ export default function OnboardingPage() {
>
)}
- {/* Step 6: Preferences */}
+ {/* Step 7: Preferences */}
{step === 'preferences' && (
<>
@@ -434,6 +484,12 @@ export default function OnboardingPage() {
Country: {country}
)}
+ {bio && (
+
+ Bio:{' '}
+ {bio.length > 50 ? `${bio.substring(0, 50)}...` : bio}
+
+ )}
{whatsappEnabled && phoneNumber && (
Phone:{' '}
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index c4c7cd1..fb481ce 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -349,7 +349,7 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
- : { programId: input.programId }
+ : { round: { programId: input.programId } }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index ff5a022..e3f0da4 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -30,6 +30,7 @@ export const userRouter = router({
metadataJson: true,
phoneNumber: true,
country: true,
+ bio: true,
notificationPreference: true,
profileImageKey: true,
createdAt: true,
@@ -789,6 +790,7 @@ export const userRouter = router({
name: z.string().min(1).max(255),
phoneNumber: z.string().optional(),
country: z.string().optional(),
+ bio: z.string().max(500).optional(),
expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
})
@@ -811,6 +813,7 @@ export const userRouter = router({
name: input.name,
phoneNumber: input.phoneNumber,
country: input.country,
+ bio: input.bio,
expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(),
diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts
index a175393..914d901 100644
--- a/src/server/services/smart-assignment.ts
+++ b/src/server/services/smart-assignment.ts
@@ -3,14 +3,16 @@
*
* Calculates scores for jury/mentor-project matching based on:
* - Tag overlap (expertise match)
+ * - Bio/description match (text similarity)
* - Workload balance
* - Country match (mentors only)
*
* Score Breakdown (100 points max):
- * - Tag overlap: 0-50 points (weighted by confidence)
+ * - Tag overlap: 0-40 points (weighted by confidence)
+ * - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points
* - Country match: 0-15 points (mentors only)
- * - Reserved: 0-10 points (future AI boost)
+ * - Reserved: 0-5 points (future AI boost)
*/
import { prisma } from '@/lib/prisma'
@@ -19,6 +21,7 @@ import { prisma } from '@/lib/prisma'
export interface ScoreBreakdown {
tagOverlap: number
+ bioMatch: number
workloadBalance: number
countryMatch: number
aiBoost: number
@@ -44,13 +47,93 @@ export interface ProjectTagData {
// ─── Constants ───────────────────────────────────────────────────────────────
-const MAX_TAG_OVERLAP_SCORE = 50
+const MAX_TAG_OVERLAP_SCORE = 40
+const MAX_BIO_MATCH_SCORE = 15
const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15
-const POINTS_PER_TAG_MATCH = 10
+const POINTS_PER_TAG_MATCH = 8
+
+// Common words to exclude from bio matching
+const STOP_WORDS = new Set([
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
+ 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been', 'be', 'have', 'has', 'had',
+ 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must',
+ 'that', 'which', 'who', 'whom', 'this', 'these', 'those', 'it', 'its', 'i', 'we',
+ 'you', 'he', 'she', 'they', 'them', 'their', 'our', 'my', 'your', 'his', 'her',
+ 'am', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below',
+ 'between', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
+ 'where', 'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some',
+ 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'can',
+ 'just', 'being', 'over', 'both', 'up', 'down', 'out', 'also', 'new', 'any',
+])
// ─── Scoring Functions ───────────────────────────────────────────────────────
+/**
+ * Extract meaningful keywords from text
+ */
+function extractKeywords(text: string | null | undefined): Set {
+ if (!text) return new Set()
+
+ // Tokenize, lowercase, and filter
+ const words = text
+ .toLowerCase()
+ .replace(/[^\w\s]/g, ' ') // Remove punctuation
+ .split(/\s+/)
+ .filter((word) => word.length >= 3 && !STOP_WORDS.has(word))
+
+ return new Set(words)
+}
+
+/**
+ * Calculate bio match score between user bio and project description
+ * Only applies if user has a bio
+ */
+export function calculateBioMatchScore(
+ userBio: string | null | undefined,
+ projectDescription: string | null | undefined
+): { score: number; matchingKeywords: string[] } {
+ // If no bio, return 0 (not penalized, just no bonus)
+ if (!userBio || userBio.trim().length === 0) {
+ return { score: 0, matchingKeywords: [] }
+ }
+
+ // If no project description, can't match
+ if (!projectDescription || projectDescription.trim().length === 0) {
+ return { score: 0, matchingKeywords: [] }
+ }
+
+ const bioKeywords = extractKeywords(userBio)
+ const projectKeywords = extractKeywords(projectDescription)
+
+ if (bioKeywords.size === 0 || projectKeywords.size === 0) {
+ return { score: 0, matchingKeywords: [] }
+ }
+
+ // Find matching keywords
+ const matchingKeywords: string[] = []
+ for (const keyword of bioKeywords) {
+ if (projectKeywords.has(keyword)) {
+ matchingKeywords.push(keyword)
+ }
+ }
+
+ if (matchingKeywords.length === 0) {
+ return { score: 0, matchingKeywords: [] }
+ }
+
+ // Calculate score based on match ratio
+ // Use Jaccard-like similarity: matches / (bio keywords + project keywords - matches)
+ const unionSize = bioKeywords.size + projectKeywords.size - matchingKeywords.length
+ const similarity = matchingKeywords.length / unionSize
+
+ // Scale to max score (15 points)
+ // A good match (20%+ overlap) should get near max
+ const score = Math.min(MAX_BIO_MATCH_SCORE, Math.round(similarity * 100))
+
+ return { score, matchingKeywords }
+}
+
/**
* Calculate tag overlap score between user expertise and project tags
*/
@@ -141,13 +224,19 @@ export async function getSmartSuggestions(options: {
}): Promise {
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
- // Get projects in round with their tags
+ // Get projects in round with their tags and description
const projects = await prisma.project.findMany({
where: {
roundId,
status: { not: 'REJECTED' },
},
- include: {
+ select: {
+ id: true,
+ title: true,
+ teamName: true,
+ description: true,
+ country: true,
+ status: true,
projectTags: {
include: { tag: true },
},
@@ -158,14 +247,21 @@ export async function getSmartSuggestions(options: {
return []
}
- // Get users of the appropriate role
+ // Get users of the appropriate role with bio for matching
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
const users = await prisma.user.findMany({
where: {
role,
status: 'ACTIVE',
},
- include: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ bio: true,
+ expertiseTags: true,
+ maxAssignments: true,
+ country: true,
_count: {
select: {
assignments: {
@@ -222,6 +318,12 @@ export async function getSmartSuggestions(options: {
projectTags
)
+ // Bio match (only if user has a bio)
+ const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
+ user.bio,
+ project.description
+ )
+
const workloadScore = calculateWorkloadScore(
currentCount,
targetPerUser,
@@ -231,19 +333,19 @@ export async function getSmartSuggestions(options: {
// Country match only for mentors
const countryScore =
type === 'mentor'
- ? calculateCountryMatchScore(
- (user as any).country, // User might have country field
- project.country
- )
+ ? calculateCountryMatchScore(user.country, project.country)
: 0
- const totalScore = tagScore + workloadScore + countryScore
+ const totalScore = tagScore + bioScore + workloadScore + countryScore
// Build reasoning
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
}
+ if (bioScore > 0) {
+ reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
+ }
if (workloadScore === MAX_WORKLOAD_SCORE) {
reasoning.push('Available capacity')
} else if (workloadScore > 0) {
@@ -262,6 +364,7 @@ export async function getSmartSuggestions(options: {
score: totalScore,
breakdown: {
tagOverlap: tagScore,
+ bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
@@ -297,13 +400,20 @@ export async function getMentorSuggestionsForProject(
throw new Error(`Project not found: ${projectId}`)
}
- // Get all active mentors
+ // Get all active mentors with bio for matching
const mentors = await prisma.user.findMany({
where: {
role: 'MENTOR',
status: 'ACTIVE',
},
- include: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ bio: true,
+ expertiseTags: true,
+ maxAssignments: true,
+ country: true,
_count: {
select: { mentorAssignments: true },
},
@@ -335,6 +445,12 @@ export async function getMentorSuggestionsForProject(
projectTags
)
+ // Bio match (only if mentor has a bio)
+ const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
+ mentor.bio,
+ project.description
+ )
+
const workloadScore = calculateWorkloadScore(
mentor._count.mentorAssignments,
targetPerMentor,
@@ -342,16 +458,19 @@ export async function getMentorSuggestionsForProject(
)
const countryScore = calculateCountryMatchScore(
- (mentor as any).country,
+ mentor.country,
project.country
)
- const totalScore = tagScore + workloadScore + countryScore
+ const totalScore = tagScore + bioScore + workloadScore + countryScore
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
}
+ if (bioScore > 0) {
+ reasoning.push(`Bio match: ${matchingKeywords.length} keyword(s)`)
+ }
if (countryScore > 0) {
reasoning.push('Same country of origin')
}
@@ -368,6 +487,7 @@ export async function getMentorSuggestionsForProject(
score: totalScore,
breakdown: {
tagOverlap: tagScore,
+ bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,