diff --git a/prisma/seed.ts b/prisma/seed.ts index af7dad0..b4163c1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -136,6 +136,80 @@ async function main() { } console.log(` Created ${settings.length} settings`) + // ========================================================================== + // 1b. Expertise Tags + // ========================================================================== + console.log('\nšŸ·ļø Creating expertise tags...') + + const expertiseTags = [ + // Marine Science + { name: 'Marine Biology', description: 'Study of marine organisms and ecosystems', category: 'Marine Science', color: '#0284c7', sortOrder: 0 }, + { name: 'Oceanography', description: 'Physical, chemical, and biological ocean science', category: 'Marine Science', color: '#0284c7', sortOrder: 1 }, + { name: 'Coral Reef Ecology', description: 'Coral reef ecosystems, health, and restoration', category: 'Marine Science', color: '#0284c7', sortOrder: 2 }, + { name: 'Marine Biodiversity', description: 'Species diversity and conservation in marine environments', category: 'Marine Science', color: '#0284c7', sortOrder: 3 }, + { name: 'Ocean Acidification', description: 'Chemical changes in ocean pH and their impacts', category: 'Marine Science', color: '#0284c7', sortOrder: 4 }, + { name: 'Deep Sea Research', description: 'Exploration and study of deep ocean environments', category: 'Marine Science', color: '#0284c7', sortOrder: 5 }, + + // Technology + { name: 'Ocean Sensors & IoT', description: 'Sensor networks and IoT for ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 10 }, + { name: 'AI & Machine Learning', description: 'AI applications for ocean data analysis and prediction', category: 'Technology', color: '#7c3aed', sortOrder: 11 }, + { name: 'Robotics & AUVs', description: 'Autonomous underwater vehicles and marine robotics', category: 'Technology', color: '#7c3aed', sortOrder: 12 }, + { name: 'Satellite Remote Sensing', description: 'Earth observation and satellite-based ocean monitoring', category: 'Technology', color: '#7c3aed', sortOrder: 13 }, + { name: 'Marine Biotechnology', description: 'Biotechnological solutions from marine organisms', category: 'Technology', color: '#7c3aed', sortOrder: 14 }, + { name: 'Desalination', description: 'Water desalination and purification technologies', category: 'Technology', color: '#7c3aed', sortOrder: 15 }, + + // Policy + { name: 'Maritime Law', description: 'International maritime regulations and legal frameworks', category: 'Policy', color: '#053d57', sortOrder: 20 }, + { name: 'Ocean Governance', description: 'International ocean policy and governance frameworks', category: 'Policy', color: '#053d57', sortOrder: 21 }, + { name: 'Marine Protected Areas', description: 'MPA design, management, and policy', category: 'Policy', color: '#053d57', sortOrder: 22 }, + { name: 'Climate Policy', description: 'Climate change mitigation and adaptation policy', category: 'Policy', color: '#053d57', sortOrder: 23 }, + { name: 'Sustainable Development Goals', description: 'SDG 14 (Life Below Water) and related goals', category: 'Policy', color: '#053d57', sortOrder: 24 }, + + // Conservation + { name: 'Habitat Restoration', description: 'Restoration of mangroves, seagrass, and coastal habitats', category: 'Conservation', color: '#059669', sortOrder: 30 }, + { name: 'Species Protection', description: 'Endangered marine species conservation programs', category: 'Conservation', color: '#059669', sortOrder: 31 }, + { name: 'Pollution Reduction', description: 'Marine pollution prevention and cleanup', category: 'Conservation', color: '#059669', sortOrder: 32 }, + { name: 'Plastic Waste', description: 'Plastic pollution reduction and circular solutions', category: 'Conservation', color: '#059669', sortOrder: 33 }, + { name: 'Blue Carbon', description: 'Carbon sequestration in coastal and marine ecosystems', category: 'Conservation', color: '#059669', sortOrder: 34 }, + { name: 'Coastal Resilience', description: 'Building resilience in coastal communities and ecosystems', category: 'Conservation', color: '#059669', sortOrder: 35 }, + + // Business + { name: 'Blue Economy', description: 'Sustainable economic use of ocean resources', category: 'Business', color: '#557f8c', sortOrder: 40 }, + { name: 'Impact Investing', description: 'Investment strategies for ocean-positive outcomes', category: 'Business', color: '#557f8c', sortOrder: 41 }, + { name: 'Startup Scaling', description: 'Scaling ocean-focused startups and ventures', category: 'Business', color: '#557f8c', sortOrder: 42 }, + { name: 'Sustainable Aquaculture', description: 'Sustainable fish farming and aquaculture practices', category: 'Business', color: '#557f8c', sortOrder: 43 }, + { name: 'Sustainable Shipping', description: 'Green shipping, fuel alternatives, and port operations', category: 'Business', color: '#557f8c', sortOrder: 44 }, + { name: 'Circular Economy', description: 'Circular models for marine industries and products', category: 'Business', color: '#557f8c', sortOrder: 45 }, + + // Education + { name: 'Ocean Literacy', description: 'Public education and awareness about ocean health', category: 'Education', color: '#ea580c', sortOrder: 50 }, + { name: 'Community Engagement', description: 'Engaging coastal communities in conservation', category: 'Education', color: '#ea580c', sortOrder: 51 }, + { name: 'Citizen Science', description: 'Public participation in ocean data collection', category: 'Education', color: '#ea580c', sortOrder: 52 }, + { name: 'Capacity Building', description: 'Training and skill development for ocean stewardship', category: 'Education', color: '#ea580c', sortOrder: 53 }, + + // Engineering + { name: 'Renewable Ocean Energy', description: 'Wave, tidal, and offshore wind energy systems', category: 'Engineering', color: '#be185d', sortOrder: 60 }, + { name: 'Coastal Engineering', description: 'Infrastructure design for coastal protection', category: 'Engineering', color: '#be185d', sortOrder: 61 }, + { name: 'Water Treatment', description: 'Wastewater treatment and water quality engineering', category: 'Engineering', color: '#be185d', sortOrder: 62 }, + { name: 'Marine Materials', description: 'Biodegradable and sustainable materials for marine use', category: 'Engineering', color: '#be185d', sortOrder: 63 }, + ] + + for (const tag of expertiseTags) { + await prisma.expertiseTag.upsert({ + where: { name: tag.name }, + update: {}, + create: { + name: tag.name, + description: tag.description, + category: tag.category, + color: tag.color, + sortOrder: tag.sortOrder, + isActive: true, + }, + }) + } + console.log(` Created ${expertiseTags.length} expertise tags across ${new Set(expertiseTags.map(t => t.category)).size} categories`) + // ========================================================================== // 2. Admin/Staff Users // ========================================================================== diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 1761cd4..b7e4291 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -72,7 +72,7 @@ import { import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' -type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' +type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' interface Assignment { projectId: string @@ -104,6 +104,7 @@ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const ROLE_LABELS: Record = { SUPER_ADMIN: 'Super Admin', PROGRAM_ADMIN: 'Program Admin', + AWARD_MASTER: 'Award Master', JURY_MEMBER: 'Jury Member', MENTOR: 'Mentor', OBSERVER: 'Observer', @@ -276,6 +277,7 @@ export default function MemberInvitePage() { // Fetch current user to check role const { data: currentUser } = trpc.user.me.useQuery() const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' + const isAdmin = isSuperAdmin || currentUser?.role === 'PROGRAM_ADMIN' const bulkCreate = trpc.user.bulkCreate.useMutation({ onSuccess: () => { @@ -406,14 +408,16 @@ export default function MemberInvitePage() { ? 'SUPER_ADMIN' : rawRole === 'PROGRAM_ADMIN' ? 'PROGRAM_ADMIN' - : rawRole === 'MENTOR' - ? 'MENTOR' - : rawRole === 'OBSERVER' - ? 'OBSERVER' - : 'JURY_MEMBER' + : rawRole === 'AWARD_MASTER' + ? 'AWARD_MASTER' + : rawRole === 'MENTOR' + ? 'MENTOR' + : rawRole === 'OBSERVER' + ? 'OBSERVER' + : 'JURY_MEMBER' const isValidFormat = emailRegex.test(email) const isDuplicate = email ? seenEmails.has(email) : false - const isUnauthorizedAdmin = (role === 'PROGRAM_ADMIN' || role === 'SUPER_ADMIN') && !isSuperAdmin + const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin if (isValidFormat && !isDuplicate && email) seenEmails.add(email) return { email, @@ -428,7 +432,7 @@ export default function MemberInvitePage() { : isDuplicate ? 'Duplicate email' : isUnauthorizedAdmin - ? 'Only super admins can invite program admins' + ? 'Only super admins can invite super admins' : undefined, } }) @@ -449,7 +453,7 @@ export default function MemberInvitePage() { const email = r.email.trim().toLowerCase() const isValidFormat = emailRegex.test(email) const isDuplicate = seenEmails.has(email) - const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin + const isUnauthorizedAdmin = r.role === 'SUPER_ADMIN' && !isSuperAdmin if (isValidFormat && !isDuplicate) seenEmails.add(email) return { email, @@ -464,7 +468,7 @@ export default function MemberInvitePage() { : isDuplicate ? 'Duplicate email' : isUnauthorizedAdmin - ? 'Only super admins can invite program admins' + ? 'Only super admins can invite super admins' : undefined, } }) @@ -547,7 +551,7 @@ export default function MemberInvitePage() { Add members individually or upload a CSV file {isSuperAdmin && ( - As a super admin, you can also invite program admins + As a super admin, you can also invite super admins )} @@ -658,11 +662,16 @@ export default function MemberInvitePage() { Super Admin )} - {isSuperAdmin && ( + {isAdmin && ( Program Admin )} + {isAdmin && ( + + Award Master + + )} Jury Member diff --git a/src/components/admin/pipeline/sections/intake-section.tsx b/src/components/admin/pipeline/sections/intake-section.tsx index 77e30ed..3ac4096 100644 --- a/src/components/admin/pipeline/sections/intake-section.tsx +++ b/src/components/admin/pipeline/sections/intake-section.tsx @@ -262,7 +262,7 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
updateFileReq(index, { acceptedMimeTypes: mimeTypes }) } diff --git a/src/components/admin/pipeline/stage-config-editor.tsx b/src/components/admin/pipeline/stage-config-editor.tsx index 97c3fc1..6b36a5c 100644 --- a/src/components/admin/pipeline/stage-config-editor.tsx +++ b/src/components/admin/pipeline/stage-config-editor.tsx @@ -3,7 +3,7 @@ import { useState, useCallback } from 'react' import { EditableCard } from '@/components/ui/editable-card' import { Badge } from '@/components/ui/badge' -import { cn } from '@/lib/utils' + import { Inbox, Filter, @@ -78,8 +78,8 @@ function ConfigSummary({
Late Policy: - {config.lateSubmissionPolicy} - {config.lateGraceHours > 0 && ( + {config.lateSubmissionPolicy ?? 'flag'} + {(config.lateGraceHours ?? 0) > 0 && ( ({config.lateGraceHours}h grace) @@ -94,32 +94,26 @@ function ConfigSummary({ } case 'FILTER': { - const config = configJson as unknown as FilterConfig + const raw = configJson as Record + const seedRules = (raw.deterministic as Record)?.rules as unknown[] | undefined + const ruleCount = (raw.rules as unknown[])?.length ?? seedRules?.length ?? 0 + const aiEnabled = (raw.aiRubricEnabled as boolean) ?? !!(raw.ai) return (
Rules: - {config.rules?.length ?? 0} eligibility rules + {ruleCount} eligibility rules
AI Screening: - {config.aiRubricEnabled ? 'Enabled' : 'Disabled'} + {aiEnabled ? 'Enabled' : 'Disabled'}
- {config.aiRubricEnabled && ( -
- Confidence: - - High {config.aiConfidenceThresholds?.high ?? 0.85} / Med{' '} - {config.aiConfidenceThresholds?.medium ?? 0.6} - -
- )}
Manual Queue: - {config.manualQueueEnabled ? 'Enabled' : 'Disabled'} + {(raw.manualQueueEnabled as boolean) ? 'Enabled' : 'Disabled'}
@@ -127,23 +121,27 @@ function ConfigSummary({ } case 'EVALUATION': { - const config = configJson as unknown as EvaluationConfig + const raw = configJson as Record + const reviews = (raw.requiredReviews as number) ?? 3 + const minLoad = (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? 5 + const maxLoad = (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? 20 + const overflow = (raw.overflowPolicy as string) ?? 'queue' return (
Required Reviews: - {config.requiredReviews ?? 3} + {reviews}
Load per Juror: - {config.minLoadPerJuror ?? 5} - {config.maxLoadPerJuror ?? 20} + {minLoad} - {maxLoad}
Overflow Policy: - {(config.overflowPolicy ?? 'queue').replace('_', ' ')} + {overflow.replace('_', ' ')}
@@ -182,29 +180,32 @@ function ConfigSummary({ } case 'LIVE_FINAL': { - const config = configJson as unknown as LiveFinalConfig + const raw = configJson as Record + const juryEnabled = (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? false + const audienceEnabled = (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false + const audienceWeight = (raw.audienceVoteWeight as number) ?? 0 return (
Jury Voting: - {config.juryVotingEnabled ? 'Enabled' : 'Disabled'} + {juryEnabled ? 'Enabled' : 'Disabled'}
Audience Voting: - {config.audienceVotingEnabled ? 'Enabled' : 'Disabled'} + {audienceEnabled ? 'Enabled' : 'Disabled'} - {config.audienceVotingEnabled && ( + {audienceEnabled && ( - ({config.audienceVoteWeight}% weight) + ({Math.round(audienceWeight * 100)}% weight) )}
Reveal: - {config.revealPolicy ?? 'ceremony'} + {(raw.revealPolicy as string) ?? 'ceremony'}
) @@ -260,10 +261,21 @@ export function StageConfigEditor({ const renderEditor = () => { switch (stageType) { case 'INTAKE': { - const config = { + const rawConfig = { ...defaultIntakeConfig(), ...(localConfig as object), } as IntakeConfig + // Deep-normalize fileRequirements to handle DB shape mismatches + const config: IntakeConfig = { + ...rawConfig, + fileRequirements: (rawConfig.fileRequirements ?? []).map((req) => ({ + name: req.name ?? '', + description: req.description ?? '', + acceptedMimeTypes: req.acceptedMimeTypes ?? ['application/pdf'], + maxSizeMB: req.maxSizeMB ?? 50, + isRequired: req.isRequired ?? (req as Record).required === true, + })), + } return ( + // Normalize seed data shape: deterministic.rules → rules, confidenceBands → aiConfidenceThresholds + const seedRules = (raw.deterministic as Record)?.rules as FilterConfig['rules'] | undefined + const seedBands = raw.confidenceBands as Record> | undefined + const config: FilterConfig = { ...defaultFilterConfig(), - ...(localConfig as object), - } as FilterConfig + ...raw, + rules: (raw.rules as FilterConfig['rules']) ?? seedRules ?? defaultFilterConfig().rules, + aiRubricEnabled: (raw.aiRubricEnabled as boolean | undefined) ?? !!raw.ai, + aiConfidenceThresholds: (raw.aiConfidenceThresholds as FilterConfig['aiConfidenceThresholds']) ?? (seedBands ? { + high: seedBands.high?.threshold ?? 0.85, + medium: seedBands.medium?.threshold ?? 0.6, + low: seedBands.low?.threshold ?? 0.4, + } : defaultFilterConfig().aiConfidenceThresholds), + } return ( + // Normalize seed data shape: minAssignmentsPerJuror → minLoadPerJuror, etc. + const config: EvaluationConfig = { ...defaultEvaluationConfig(), - ...(localConfig as object), - } as EvaluationConfig + ...raw, + requiredReviews: (raw.requiredReviews as number) ?? defaultEvaluationConfig().requiredReviews, + minLoadPerJuror: (raw.minLoadPerJuror as number) ?? (raw.minAssignmentsPerJuror as number) ?? defaultEvaluationConfig().minLoadPerJuror, + maxLoadPerJuror: (raw.maxLoadPerJuror as number) ?? (raw.maxAssignmentsPerJuror as number) ?? defaultEvaluationConfig().maxLoadPerJuror, + } return ( + // Normalize seed data shape: votingEnabled → juryVotingEnabled, audienceVoting → audienceVotingEnabled + const config: LiveFinalConfig = { ...defaultLiveConfig(), - ...(localConfig as object), - } as LiveFinalConfig + ...raw, + juryVotingEnabled: (raw.juryVotingEnabled as boolean) ?? (raw.votingEnabled as boolean) ?? true, + audienceVotingEnabled: (raw.audienceVotingEnabled as boolean) ?? (raw.audienceVoting as boolean) ?? false, + audienceVoteWeight: (raw.audienceVoteWeight as number) ?? 0, + } return ( )} - + Tags - + Analytics @@ -213,6 +212,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Storage )} + {isSuperAdmin && ( + + + Webhooks + + )}
@@ -279,10 +284,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin AI )} - + Tags - + + Analytics @@ -298,6 +304,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Storage + + + Webhooks + +
)} @@ -325,40 +336,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin )} - - - - - - - Expertise Tags - - - Manage tags used for jury expertise, project categorization, and AI-powered matching - - - -

- Expertise tags are used across the platform to: -

-
    -
  • Categorize jury members by their areas of expertise
  • -
  • Tag projects for better organization and filtering
  • -
  • Power AI-based project tagging
  • -
  • Enable smart jury-project matching
  • -
- -
-
-
-
- @@ -528,31 +505,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
{/* end lg:flex */} - {/* Quick Links to sub-pages */} -
- {isSuperAdmin && ( - - - - - Webhooks - - - Configure webhook endpoints for platform events - - - - - - - )} -
) } diff --git a/src/lib/file-type-categories.ts b/src/lib/file-type-categories.ts index 9605bb6..0799d22 100644 --- a/src/lib/file-type-categories.ts +++ b/src/lib/file-type-categories.ts @@ -16,6 +16,7 @@ export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [ /** Get active category IDs from a list of mime types */ export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] { + if (!mimeTypes || !Array.isArray(mimeTypes)) return [] return FILE_TYPE_CATEGORIES.filter((cat) => cat.mimeTypes.some((mime) => mimeTypes.includes(mime)) ).map((cat) => cat.id) diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 59cd3ab..c77d37b 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -183,8 +183,8 @@ export const userRouter = router({ list: adminProcedure .input( z.object({ - role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), - roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), + role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), + roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), @@ -274,7 +274,7 @@ export const userRouter = router({ z.object({ email: z.string().email(), name: z.string().optional(), - role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), + role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional(), }) @@ -339,7 +339,7 @@ export const userRouter = router({ z.object({ id: z.string(), name: z.string().optional().nullable(), - role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), + role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(), @@ -472,7 +472,7 @@ export const userRouter = router({ z.object({ email: z.string().email(), name: z.string().optional(), - role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), + role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), expertiseTags: z.array(z.string()).optional(), // Optional pre-assignments for jury members assignments: z