diff --git a/prisma/migrations/20260204000000_add_round_entry_notification/migration.sql b/prisma/migrations/20260204000000_add_round_entry_notification/migration.sql new file mode 100644 index 0000000..8040e18 --- /dev/null +++ b/prisma/migrations/20260204000000_add_round_entry_notification/migration.sql @@ -0,0 +1,4 @@ +-- AddRoundEntryNotification +-- Adds the entryNotificationType column to Round table for configurable notifications + +ALTER TABLE "Round" ADD COLUMN "entryNotificationType" TEXT; diff --git a/prisma/seed-expertise-tags.ts b/prisma/seed-expertise-tags.ts new file mode 100644 index 0000000..30d523d --- /dev/null +++ b/prisma/seed-expertise-tags.ts @@ -0,0 +1,189 @@ +/** + * Seed script for expertise tags + * + * Run with: npx tsx prisma/seed-expertise-tags.ts + * Or in Docker: docker exec mopc-app npx tsx prisma/seed-expertise-tags.ts + */ + +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +interface TagDefinition { + name: string + description: string + category: string + color: string +} + +const EXPERTISE_TAGS: TagDefinition[] = [ + // Marine Science & Biology + { name: 'Marine Biology', description: 'Study of marine organisms and ecosystems', category: 'Marine Science', color: '#0ea5e9' }, + { name: 'Oceanography', description: 'Physical and chemical properties of the ocean', category: 'Marine Science', color: '#0284c7' }, + { name: 'Marine Ecology', description: 'Relationships between marine organisms and environment', category: 'Marine Science', color: '#0891b2' }, + { name: 'Fisheries Science', description: 'Management and conservation of fish populations', category: 'Marine Science', color: '#06b6d4' }, + { name: 'Marine Microbiology', description: 'Study of microorganisms in marine environments', category: 'Marine Science', color: '#22d3ee' }, + { name: 'Marine Genetics', description: 'Genetic research on marine species', category: 'Marine Science', color: '#67e8f9' }, + { name: 'Deep Sea Research', description: 'Exploration and study of deep ocean environments', category: 'Marine Science', color: '#164e63' }, + + // Conservation & Environment + { name: 'Ocean Conservation', description: 'Protection and preservation of marine environments', category: 'Conservation', color: '#059669' }, + { name: 'Biodiversity', description: 'Marine species diversity and protection', category: 'Conservation', color: '#10b981' }, + { name: 'Coral Reef Restoration', description: 'Rehabilitation and protection of coral ecosystems', category: 'Conservation', color: '#f97316' }, + { name: 'Marine Protected Areas', description: 'Design and management of ocean reserves', category: 'Conservation', color: '#34d399' }, + { name: 'Species Conservation', description: 'Protection of endangered marine species', category: 'Conservation', color: '#6ee7b7' }, + { name: 'Habitat Restoration', description: 'Rehabilitation of damaged marine habitats', category: 'Conservation', color: '#a7f3d0' }, + { name: 'Wildlife Management', description: 'Management of marine wildlife populations', category: 'Conservation', color: '#047857' }, + + // Climate & Environment + { name: 'Climate Science', description: 'Ocean-climate interactions and research', category: 'Climate', color: '#14b8a6' }, + { name: 'Climate Adaptation', description: 'Strategies for adapting to climate change impacts', category: 'Climate', color: '#2dd4bf' }, + { name: 'Climate Mitigation', description: 'Reducing greenhouse gas emissions', category: 'Climate', color: '#5eead4' }, + { name: 'Blue Carbon', description: 'Carbon sequestration in marine ecosystems', category: 'Climate', color: '#99f6e4' }, + { name: 'Ocean Acidification', description: 'Impact of CO2 on ocean chemistry', category: 'Climate', color: '#0d9488' }, + { name: 'Sea Level Rise', description: 'Causes and impacts of rising sea levels', category: 'Climate', color: '#115e59' }, + + // Pollution & Waste + { name: 'Plastic Pollution', description: 'Marine plastic waste and solutions', category: 'Pollution', color: '#84cc16' }, + { name: 'Waste Management', description: 'Marine and coastal waste solutions', category: 'Pollution', color: '#65a30d' }, + { name: 'Microplastics Research', description: 'Study of microplastic impacts', category: 'Pollution', color: '#a3e635' }, + { name: 'Chemical Pollution', description: 'Industrial and agricultural runoff', category: 'Pollution', color: '#bef264' }, + { name: 'Oil Spill Response', description: 'Prevention and cleanup of oil spills', category: 'Pollution', color: '#4d7c0f' }, + { name: 'Water Quality', description: 'Monitoring and improving water quality', category: 'Pollution', color: '#3f6212' }, + { name: 'Circular Economy', description: 'Reducing waste through circular systems', category: 'Pollution', color: '#d9f99d' }, + + // Sustainable Industries + { name: 'Sustainable Fishing', description: 'Environmentally responsible fishing practices', category: 'Sustainable Industries', color: '#22c55e' }, + { name: 'Aquaculture', description: 'Sustainable fish and seafood farming', category: 'Sustainable Industries', color: '#16a34a' }, + { name: 'Sustainable Shipping', description: 'Green maritime transportation', category: 'Sustainable Industries', color: '#6366f1' }, + { name: 'Sustainable Tourism', description: 'Eco-friendly coastal and marine tourism', category: 'Sustainable Industries', color: '#4f46e5' }, + { name: 'Blue Economy', description: 'Sustainable use of ocean resources', category: 'Sustainable Industries', color: '#3b82f6' }, + { name: 'Seaweed Industry', description: 'Sustainable seaweed cultivation and products', category: 'Sustainable Industries', color: '#15803d' }, + { name: 'Seafood Traceability', description: 'Supply chain transparency in seafood', category: 'Sustainable Industries', color: '#166534' }, + + // Technology & Innovation + { name: 'Marine Technology', description: 'Technological solutions for ocean challenges', category: 'Technology', color: '#8b5cf6' }, + { name: 'Ocean Robotics', description: 'Autonomous underwater vehicles and drones', category: 'Technology', color: '#7c3aed' }, + { name: 'Remote Sensing', description: 'Satellite and sensor-based ocean monitoring', category: 'Technology', color: '#a78bfa' }, + { name: 'Data Science', description: 'Ocean data analysis and modeling', category: 'Technology', color: '#c4b5fd' }, + { name: 'AI & Machine Learning', description: 'Artificial intelligence for marine research', category: 'Technology', color: '#6d28d9' }, + { name: 'Biotechnology', description: 'Marine-derived biotechnology applications', category: 'Technology', color: '#5b21b6' }, + { name: 'Sensors & IoT', description: 'Internet of Things for ocean monitoring', category: 'Technology', color: '#4c1d95' }, + { name: 'Blockchain', description: 'Distributed ledger for transparency and traceability', category: 'Technology', color: '#ddd6fe' }, + + // Energy + { name: 'Renewable Energy', description: 'Marine renewable energy sources', category: 'Energy', color: '#eab308' }, + { name: 'Offshore Wind', description: 'Wind energy from offshore installations', category: 'Energy', color: '#ca8a04' }, + { name: 'Wave Energy', description: 'Power generation from ocean waves', category: 'Energy', color: '#facc15' }, + { name: 'Tidal Energy', description: 'Power generation from tidal movements', category: 'Energy', color: '#fde047' }, + { name: 'Ocean Thermal Energy', description: 'OTEC and thermal gradient technologies', category: 'Energy', color: '#a16207' }, + + // Policy & Governance + { name: 'Environmental Policy', description: 'Ocean and environmental policy development', category: 'Policy', color: '#a855f7' }, + { name: 'International Law', description: 'Law of the sea and maritime regulations', category: 'Policy', color: '#9333ea' }, + { name: 'Ocean Governance', description: 'Management and governance of ocean spaces', category: 'Policy', color: '#c084fc' }, + { name: 'Regulatory Compliance', description: 'Environmental regulations and compliance', category: 'Policy', color: '#e879f9' }, + { name: 'Stakeholder Engagement', description: 'Engaging communities and stakeholders', category: 'Policy', color: '#d946ef' }, + { name: 'Advocacy', description: 'Promoting ocean conservation causes', category: 'Policy', color: '#a21caf' }, + + // Business & Finance + { name: 'Entrepreneurship', description: 'Ocean-focused startup development', category: 'Business', color: '#f43f5e' }, + { name: 'Investment & Finance', description: 'Sustainable ocean investment', category: 'Business', color: '#e11d48' }, + { name: 'Impact Investing', description: 'Investment for environmental impact', category: 'Business', color: '#fb7185' }, + { name: 'Business Development', description: 'Growing ocean-focused businesses', category: 'Business', color: '#fda4af' }, + { name: 'Social Enterprise', description: 'Businesses with social and environmental missions', category: 'Business', color: '#be123c' }, + { name: 'Carbon Markets', description: 'Carbon credit trading and blue carbon markets', category: 'Business', color: '#9f1239' }, + + // Education & Outreach + { name: 'Education & Outreach', description: 'Ocean literacy and public education', category: 'Education', color: '#ec4899' }, + { name: 'Research & Academia', description: 'Academic research and publication', category: 'Education', color: '#db2777' }, + { name: 'Citizen Science', description: 'Public participation in scientific research', category: 'Education', color: '#f472b6' }, + { name: 'Youth Engagement', description: 'Engaging young people in ocean conservation', category: 'Education', color: '#f9a8d4' }, + { name: 'Media & Communications', description: 'Science communication and journalism', category: 'Education', color: '#be185d' }, + { name: 'Documentary Filmmaking', description: 'Visual storytelling for ocean conservation', category: 'Education', color: '#9d174d' }, + + // Coastal & Community + { name: 'Coastal Management', description: 'Integrated coastal zone management', category: 'Coastal', color: '#f59e0b' }, + { name: 'Community Development', description: 'Supporting coastal communities', category: 'Coastal', color: '#d97706' }, + { name: 'Small-scale Fisheries', description: 'Supporting artisanal and local fishers', category: 'Coastal', color: '#fbbf24' }, + { name: 'Indigenous Knowledge', description: 'Traditional ecological knowledge', category: 'Coastal', color: '#fcd34d' }, + { name: 'Disaster Resilience', description: 'Coastal hazard preparedness and response', category: 'Coastal', color: '#b45309' }, + { name: 'Mangrove Conservation', description: 'Protection and restoration of mangroves', category: 'Coastal', color: '#92400e' }, + + // Specific Focus Areas + { name: 'Arctic & Antarctic', description: 'Polar ocean research and conservation', category: 'Regions', color: '#0369a1' }, + { name: 'Mediterranean', description: 'Mediterranean Sea conservation', category: 'Regions', color: '#0c4a6e' }, + { name: 'Pacific Islands', description: 'Pacific island marine conservation', category: 'Regions', color: '#075985' }, + { name: 'Coral Triangle', description: 'Indo-Pacific coral reef biodiversity', category: 'Regions', color: '#0284c7' }, + + // Cross-cutting + { name: 'Project Management', description: 'Managing conservation and research projects', category: 'Skills', color: '#64748b' }, + { name: 'Grant Writing', description: 'Securing funding through proposals', category: 'Skills', color: '#475569' }, + { name: 'Monitoring & Evaluation', description: 'Measuring project impact and outcomes', category: 'Skills', color: '#334155' }, + { name: 'GIS & Mapping', description: 'Geographic information systems for ocean data', category: 'Skills', color: '#1e293b' }, + { name: 'Scientific Diving', description: 'Underwater research and data collection', category: 'Skills', color: '#94a3b8' }, +] + +async function main() { + console.log('šŸ·ļø Seeding expertise tags...\n') + + // Get existing tags + const existingTags = await prisma.expertiseTag.findMany({ + select: { name: true }, + }) + const existingNames = new Set(existingTags.map((t) => t.name)) + + // Filter out tags that already exist + const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name)) + + if (newTags.length === 0) { + console.log('āœ… All tags already exist in database') + return + } + + // Get max sort order + const maxOrder = await prisma.expertiseTag.aggregate({ + _max: { sortOrder: true }, + }) + const startOrder = (maxOrder._max.sortOrder || 0) + 1 + + // Create tags + const result = await prisma.expertiseTag.createMany({ + data: newTags.map((tag, index) => ({ + name: tag.name, + description: tag.description, + category: tag.category, + color: tag.color, + sortOrder: startOrder + index, + isActive: true, + })), + }) + + console.log(`āœ… Created ${result.count} new expertise tags`) + console.log(` Skipped ${existingNames.size} existing tags`) + + // Print summary by category + const byCategory = newTags.reduce( + (acc, tag) => { + acc[tag.category] = (acc[tag.category] || 0) + 1 + return acc + }, + {} as Record + ) + + console.log('\nšŸ“Š Tags created by category:') + Object.entries(byCategory) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([category, count]) => { + console.log(` ${category}: ${count}`) + }) +} + +main() + .catch((e) => { + console.error('āŒ Error seeding expertise tags:', e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx index f3178c9..a189c4a 100644 --- a/src/app/(auth)/onboarding/page.tsx +++ b/src/app/(auth)/onboarding/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -39,15 +39,44 @@ type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete' export default function OnboardingPage() { const router = useRouter() const [step, setStep] = useState('name') + const [initialized, setInitialized] = useState(false) // Form state const [name, setName] = useState('') const [phoneNumber, setPhoneNumber] = useState('') const [expertiseTags, setExpertiseTags] = useState([]) + const [lockedTags, setLockedTags] = useState([]) const [notificationPreference, setNotificationPreference] = useState< 'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE' >('EMAIL') + // Fetch current user data to get admin-preset tags + const { data: userData, isLoading: userLoading } = trpc.user.me.useQuery() + + // Initialize form with user data + useEffect(() => { + if (userData && !initialized) { + // Pre-fill name if available + if (userData.name) { + setName(userData.name) + } + // Pre-fill phone if available + if (userData.phoneNumber) { + setPhoneNumber(userData.phoneNumber) + } + // Set admin-preset tags as both locked and selected + if (userData.expertiseTags && userData.expertiseTags.length > 0) { + setLockedTags(userData.expertiseTags) + setExpertiseTags(userData.expertiseTags) + } + // Pre-fill notification preference if available + if (userData.notificationPreference) { + setNotificationPreference(userData.notificationPreference) + } + setInitialized(true) + } + }, [userData, initialized]) + // Fetch feature flags const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery() const whatsappEnabled = featureFlags?.whatsappEnabled ?? false @@ -95,15 +124,36 @@ export default function OnboardingPage() { setStep('complete') toast.success('Welcome to MOPC!') - // Redirect after a short delay + // Redirect after a short delay based on user role setTimeout(() => { - router.push('/jury') + const role = userData?.role + if (role === 'MENTOR') { + router.push('/mentor') + } else if (role === 'OBSERVER') { + router.push('/observer') + } else { + router.push('/jury') + } }, 2000) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding') } } + // Show loading while fetching user data + if (userLoading || !initialized) { + return ( +
+ + + +

Loading your profile...

+
+
+
+ ) + } + return (
@@ -219,6 +269,7 @@ export default function OnboardingPage() { value={expertiseTags} onChange={setExpertiseTags} maxTags={5} + lockedTags={lockedTags} />
diff --git a/src/components/shared/expertise-select.tsx b/src/components/shared/expertise-select.tsx index 79ab216..cc5a4f9 100644 --- a/src/components/shared/expertise-select.tsx +++ b/src/components/shared/expertise-select.tsx @@ -1,32 +1,12 @@ 'use client' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import { Check, X } from 'lucide-react' - -// Predefined expertise areas for ocean conservation -const EXPERTISE_OPTIONS = [ - { id: 'marine-biology', name: 'Marine Biology', color: '#0ea5e9' }, - { id: 'ocean-conservation', name: 'Ocean Conservation', color: '#06b6d4' }, - { id: 'climate-science', name: 'Climate Science', color: '#14b8a6' }, - { id: 'sustainable-fishing', name: 'Sustainable Fishing', color: '#22c55e' }, - { id: 'plastic-pollution', name: 'Plastic Pollution', color: '#84cc16' }, - { id: 'coral-reef', name: 'Coral Reef Restoration', color: '#f97316' }, - { id: 'blue-economy', name: 'Blue Economy', color: '#3b82f6' }, - { id: 'marine-technology', name: 'Marine Technology', color: '#8b5cf6' }, - { id: 'environmental-policy', name: 'Environmental Policy', color: '#a855f7' }, - { id: 'oceanography', name: 'Oceanography', color: '#0284c7' }, - { id: 'renewable-energy', name: 'Renewable Energy', color: '#16a34a' }, - { id: 'waste-management', name: 'Waste Management', color: '#65a30d' }, - { id: 'biodiversity', name: 'Biodiversity', color: '#059669' }, - { id: 'shipping-maritime', name: 'Shipping & Maritime', color: '#6366f1' }, - { id: 'education-outreach', name: 'Education & Outreach', color: '#ec4899' }, - { id: 'entrepreneurship', name: 'Entrepreneurship', color: '#f43f5e' }, - { id: 'investment-finance', name: 'Investment & Finance', color: '#eab308' }, - { id: 'research-academia', name: 'Research & Academia', color: '#7c3aed' }, -] +import { trpc } from '@/lib/trpc/client' +import { Check, X, Lock, Search, Loader2, ChevronDown, ChevronRight } from 'lucide-react' interface ExpertiseSelectProps { value: string[] @@ -34,6 +14,7 @@ interface ExpertiseSelectProps { maxTags?: number disabled?: boolean className?: string + lockedTags?: string[] // Tags set by admin that cannot be removed } export function ExpertiseSelect({ @@ -42,46 +23,141 @@ export function ExpertiseSelect({ maxTags = 10, disabled = false, className, + lockedTags = [], }: ExpertiseSelectProps) { + const [searchQuery, setSearchQuery] = useState('') + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + + // Fetch tags from database + const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true }) + const tags = data?.tags || [] + + // Group tags by category + const tagsByCategory = useMemo(() => { + const grouped: Record = {} + for (const tag of tags) { + const category = tag.category || 'Other' + if (!grouped[category]) { + grouped[category] = [] + } + grouped[category].push(tag) + } + return grouped + }, [tags]) + + // Filter tags by search query + const filteredTagsByCategory = useMemo(() => { + if (!searchQuery.trim()) return tagsByCategory + + const query = searchQuery.toLowerCase() + const filtered: Record = {} + + for (const [category, categoryTags] of Object.entries(tagsByCategory)) { + const matchingTags = categoryTags.filter( + (tag) => + tag.name.toLowerCase().includes(query) || + tag.description?.toLowerCase().includes(query) || + category.toLowerCase().includes(query) + ) + if (matchingTags.length > 0) { + filtered[category] = matchingTags + } + } + + return filtered + }, [tagsByCategory, searchQuery]) + + // Check if a tag is locked + const isTagLocked = (tagName: string) => lockedTags.includes(tagName) + const handleToggle = (name: string) => { - if (disabled) return + if (disabled || isTagLocked(name)) return if (value.includes(name)) { onChange(value.filter((t) => t !== name)) } else { - if (maxTags && value.length >= maxTags) return + // Don't count locked tags against the max + const selectableTags = value.filter((t) => !isTagLocked(t)) + if (maxTags && selectableTags.length >= maxTags) return onChange([...value, name]) } } const handleRemove = (name: string) => { - if (disabled) return + if (disabled || isTagLocked(name)) return onChange(value.filter((t) => t !== name)) } - const getOption = (name: string) => - EXPERTISE_OPTIONS.find((o) => o.name === name) + const toggleCategory = (category: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev) + if (next.has(category)) { + next.delete(category) + } else { + next.add(category) + } + return next + }) + } + + const getTag = (name: string) => tags.find((t) => t.name === name) + + // Count user-selected tags (not including locked) + const userSelectedCount = value.filter((t) => !isTagLocked(t)).length + + if (isLoading) { + return ( +
+ + Loading expertise areas... +
+ ) + } + + if (tags.length === 0) { + return ( +
+ No expertise areas available. Please contact an administrator. +
+ ) + } return (
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ {/* Selected tags at the top */} {value.length > 0 && (
{value.map((name) => { - const option = getOption(name) + const tag = getTag(name) + const isLocked = isTagLocked(name) return ( + {isLocked && } {name} - {!disabled && ( + {!disabled && !isLocked && (
)} - {/* Grid of options */} -
- {EXPERTISE_OPTIONS.map((option) => { - const isSelected = value.includes(option.name) - const isDisabled = disabled || (!isSelected && value.length >= maxTags) + {/* Locked tags notice */} + {lockedTags.length > 0 && ( +

+ + Tags with a lock icon were pre-selected by your administrator +

+ )} - return ( - + + {isExpanded && ( +
+ {categoryTags.map((tag) => { + const isSelected = value.includes(tag.name) + const isLocked = isTagLocked(tag.name) + const isDisabledOption = + disabled || + (isLocked && isSelected) || + (!isSelected && !isLocked && userSelectedCount >= maxTags) + + return ( + + ) + })} +
)} - style={{ - borderColor: isSelected ? option.color : undefined, - backgroundColor: isSelected ? option.color : undefined, - }} - > - {isSelected && }
- {option.name} - - ) - })} + ) + })}
+ {Object.keys(filteredTagsByCategory).length === 0 && searchQuery && ( +

+ No expertise areas match your search. +

+ )} + {/* Counter */}

- {value.length} of {maxTags} selected + {userSelectedCount} of {maxTags} selected + {lockedTags.length > 0 && ` (+ ${lockedTags.length} pre-selected)`}

) diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 5b24ee9..fe4c1c5 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -734,12 +734,23 @@ export const userRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + // Get existing user to preserve admin-set tags + const existingUser = await ctx.prisma.user.findUniqueOrThrow({ + where: { id: ctx.user.id }, + select: { expertiseTags: true }, + }) + + // Merge admin-set tags with user-selected tags (preserving order: admin first, then user) + const adminTags = existingUser.expertiseTags || [] + const userTags = input.expertiseTags || [] + const mergedTags = [...new Set([...adminTags, ...userTags])] + const user = await ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { name: input.name, phoneNumber: input.phoneNumber, - expertiseTags: input.expertiseTags || [], + expertiseTags: mergedTags, notificationPreference: input.notificationPreference || 'EMAIL', onboardingCompletedAt: new Date(), status: 'ACTIVE', // Activate user after onboarding @@ -771,8 +782,9 @@ export const userRouter = router({ select: { onboardingCompletedAt: true, role: true }, }) - // Only jury members need onboarding - if (user.role !== 'JURY_MEMBER') { + // Jury members and mentors need onboarding + const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR'] + if (!rolesRequiringOnboarding.includes(user.role)) { return false }