Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,30 +1,30 @@
import { redirect } from 'next/navigation'
import type { Route } from 'next'
import { auth } from '@/lib/auth'
import type { UserRole } from '@prisma/client'
const ROLE_DASHBOARDS: Record<string, string> = {
SUPER_ADMIN: '/admin',
PROGRAM_ADMIN: '/admin',
JURY_MEMBER: '/jury',
MENTOR: '/mentor',
OBSERVER: '/observer',
APPLICANT: '/applicant',
}
export async function requireRole(...allowedRoles: UserRole[]) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const userRole = session.user.role
if (!allowedRoles.includes(userRole)) {
const dashboard = ROLE_DASHBOARDS[userRole]
redirect((dashboard || '/login') as Route)
}
return session
}
import { redirect } from 'next/navigation'
import type { Route } from 'next'
import { auth } from '@/lib/auth'
import type { UserRole } from '@prisma/client'
const ROLE_DASHBOARDS: Record<string, string> = {
SUPER_ADMIN: '/admin',
PROGRAM_ADMIN: '/admin',
JURY_MEMBER: '/jury',
MENTOR: '/mentor',
OBSERVER: '/observer',
APPLICANT: '/applicant',
}
export async function requireRole(...allowedRoles: UserRole[]) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const userRole = session.user.role
if (!allowedRoles.includes(userRole)) {
const dashboard = ROLE_DASHBOARDS[userRole]
redirect((dashboard || '/login') as Route)
}
return session
}

View File

@@ -1,91 +1,91 @@
import type { NextAuthConfig } from 'next-auth'
import type { UserRole } from '@prisma/client'
// Extend the built-in session types
declare module 'next-auth' {
interface Session {
user: {
id: string
email: string
name?: string | null
role: UserRole
mustSetPassword?: boolean
}
}
interface User {
role?: UserRole
mustSetPassword?: boolean
}
}
declare module '@auth/core/jwt' {
interface JWT {
id: string
role: UserRole
mustSetPassword?: boolean
}
}
// Edge-compatible auth config (no Node.js-only modules)
// This is used by middleware and can be extended in auth.ts for full functionality
export const authConfig: NextAuthConfig = {
providers: [], // Providers are added in auth.ts
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const { pathname } = nextUrl
// Public paths that don't require authentication
const publicPaths = [
'/login',
'/verify',
'/verify-email',
'/error',
'/accept-invite',
'/apply',
'/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures
]
// Check if it's a public path
if (publicPaths.some((path) => pathname.startsWith(path))) {
return true
}
// If not logged in, redirect to login
if (!isLoggedIn) {
return false // Will redirect to signIn page
}
// Check if user needs to set password
const mustSetPassword = auth?.user?.mustSetPassword
const passwordSetupAllowedPaths = [
'/set-password',
'/api/auth',
'/api/trpc',
]
if (mustSetPassword) {
// Allow access to password setup related paths
if (passwordSetupAllowedPaths.some((path) => pathname.startsWith(path))) {
return true
}
// Redirect to set-password page
return Response.redirect(new URL('/set-password', nextUrl))
}
return true
},
},
pages: {
signIn: '/login',
verifyRequest: '/verify-email',
error: '/error',
newUser: '/set-password',
},
session: {
strategy: 'jwt',
maxAge: parseInt(process.env.SESSION_MAX_AGE || '86400'), // 24 hours
},
}
import type { NextAuthConfig } from 'next-auth'
import type { UserRole } from '@prisma/client'
// Extend the built-in session types
declare module 'next-auth' {
interface Session {
user: {
id: string
email: string
name?: string | null
role: UserRole
mustSetPassword?: boolean
}
}
interface User {
role?: UserRole
mustSetPassword?: boolean
}
}
declare module '@auth/core/jwt' {
interface JWT {
id: string
role: UserRole
mustSetPassword?: boolean
}
}
// Edge-compatible auth config (no Node.js-only modules)
// This is used by middleware and can be extended in auth.ts for full functionality
export const authConfig: NextAuthConfig = {
providers: [], // Providers are added in auth.ts
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const { pathname } = nextUrl
// Public paths that don't require authentication
const publicPaths = [
'/login',
'/verify',
'/verify-email',
'/error',
'/accept-invite',
'/apply',
'/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures
]
// Check if it's a public path
if (publicPaths.some((path) => pathname.startsWith(path))) {
return true
}
// If not logged in, redirect to login
if (!isLoggedIn) {
return false // Will redirect to signIn page
}
// Check if user needs to set password
const mustSetPassword = auth?.user?.mustSetPassword
const passwordSetupAllowedPaths = [
'/set-password',
'/api/auth',
'/api/trpc',
]
if (mustSetPassword) {
// Allow access to password setup related paths
if (passwordSetupAllowedPaths.some((path) => pathname.startsWith(path))) {
return true
}
// Redirect to set-password page
return Response.redirect(new URL('/set-password', nextUrl))
}
return true
},
},
pages: {
signIn: '/login',
verifyRequest: '/verify-email',
error: '/error',
newUser: '/set-password',
},
session: {
strategy: 'jwt',
maxAge: parseInt(process.env.SESSION_MAX_AGE || '86400'), // 24 hours
},
}

View File

@@ -1,316 +1,316 @@
import NextAuth from 'next-auth'
import EmailProvider from 'next-auth/providers/email'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import { sendMagicLinkEmail } from './email'
import { verifyPassword } from './password'
import type { UserRole } from '@prisma/client'
import { authConfig } from './auth.config'
// Failed login attempt tracking (in-memory)
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
providers: [
// Email provider for magic links (used for first login and password reset)
EmailProvider({
// Server config required by NextAuth validation but not used —
// sendVerificationRequest below fully overrides email sending via getTransporter()
server: {
host: process.env.SMTP_HOST || 'localhost',
port: Number(process.env.SMTP_PORT || 587),
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
},
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
sendVerificationRequest: async ({ identifier: email, url }) => {
await sendMagicLinkEmail(email, url)
},
}),
// Credentials provider for email/password login and invite token auth
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
inviteToken: { label: 'Invite Token', type: 'text' },
},
async authorize(credentials) {
// Handle invite token authentication
if (credentials?.inviteToken) {
const token = credentials.inviteToken as string
const user = await prisma.user.findUnique({
where: { inviteToken: token },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
inviteTokenExpiresAt: true,
},
})
if (!user || user.status !== 'INVITED') {
return null
}
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
return null
}
// Clear token, activate user, mark as needing password
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: null,
inviteTokenExpiresAt: null,
status: 'ACTIVE',
mustSetPassword: true,
lastLoginAt: new Date(),
},
})
// Log invitation accepted
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'INVITATION_ACCEPTED',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email, role: user.role },
},
}).catch(() => {})
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: true,
}
}
if (!credentials?.email || !credentials?.password) {
return null
}
const email = (credentials.email as string).toLowerCase()
const password = credentials.password as string
// Check if account is temporarily locked
const attempts = failedAttempts.get(email)
if (attempts && Date.now() < attempts.lockedUntil) {
throw new Error('Account temporarily locked due to too many failed attempts. Try again later.')
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
passwordHash: true,
mustSetPassword: true,
},
})
if (!user || user.status === 'SUSPENDED' || !user.passwordHash) {
// Track failed attempt (don't reveal whether user exists)
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: null,
action: 'LOGIN_FAILED',
entityType: 'User',
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
},
}).catch(() => {})
return null
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) {
// Track failed attempt
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'LOGIN_FAILED',
entityType: 'User',
entityId: user.id,
detailsJson: { email, reason: 'invalid_password' },
},
}).catch(() => {})
return null
}
// Clear failed attempts on successful login
failedAttempts.delete(email)
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: user.mustSetPassword,
}
},
}),
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user, trigger }) {
// Initial sign in
if (user) {
token.id = user.id as string
token.role = user.role as UserRole
token.mustSetPassword = user.mustSetPassword
}
// On session update, refresh from database
if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.mustSetPassword = dbUser.mustSetPassword
}
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.role = token.role as UserRole
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
}
return session
},
async signIn({ user, account }) {
// For email provider (magic link), check user status and get password info
if (account?.provider === 'email') {
const dbUser = await prisma.user.findUnique({
where: { email: user.email! },
select: {
id: true,
status: true,
passwordHash: true,
mustSetPassword: true,
role: true,
},
})
if (dbUser?.status === 'SUSPENDED') {
return false // Block suspended users
}
// Update status to ACTIVE on first login (from NONE or INVITED)
if (dbUser?.status === 'INVITED' || dbUser?.status === 'NONE') {
await prisma.user.update({
where: { email: user.email! },
data: { status: 'ACTIVE' },
})
}
// Add user data for JWT callback
if (dbUser) {
user.id = dbUser.id
user.role = dbUser.role
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
}
}
// Update last login time on actual sign-in
if (user.email) {
await prisma.user.update({
where: { email: user.email },
data: { lastLoginAt: new Date() },
}).catch(() => {
// Ignore errors from updating last login
})
// Log successful login
await prisma.auditLog.create({
data: {
userId: user.id as string,
action: 'LOGIN_SUCCESS',
entityType: 'User',
entityId: user.id as string,
detailsJson: { method: account?.provider || 'unknown', email: user.email },
},
}).catch(() => {})
}
return true
},
async redirect({ url, baseUrl }) {
// Check if user needs to set password and redirect accordingly
// This is called after successful authentication
if (url.startsWith(baseUrl)) {
return url
}
// Allow relative redirects
if (url.startsWith('/')) {
return `${baseUrl}${url}`
}
return baseUrl
},
},
})
// Helper to get session in server components
export async function getServerSession() {
return auth()
}
// Helper to require authentication
export async function requireAuth() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return session
}
// Helper to require specific role(s)
export async function requireRole(...roles: UserRole[]) {
const session = await requireAuth()
if (!roles.includes(session.user.role)) {
throw new Error('Forbidden')
}
return session
}
import NextAuth from 'next-auth'
import EmailProvider from 'next-auth/providers/email'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import { sendMagicLinkEmail } from './email'
import { verifyPassword } from './password'
import type { UserRole } from '@prisma/client'
import { authConfig } from './auth.config'
// Failed login attempt tracking (in-memory)
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
providers: [
// Email provider for magic links (used for first login and password reset)
EmailProvider({
// Server config required by NextAuth validation but not used —
// sendVerificationRequest below fully overrides email sending via getTransporter()
server: {
host: process.env.SMTP_HOST || 'localhost',
port: Number(process.env.SMTP_PORT || 587),
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
},
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
sendVerificationRequest: async ({ identifier: email, url }) => {
await sendMagicLinkEmail(email, url)
},
}),
// Credentials provider for email/password login and invite token auth
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
inviteToken: { label: 'Invite Token', type: 'text' },
},
async authorize(credentials) {
// Handle invite token authentication
if (credentials?.inviteToken) {
const token = credentials.inviteToken as string
const user = await prisma.user.findUnique({
where: { inviteToken: token },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
inviteTokenExpiresAt: true,
},
})
if (!user || user.status !== 'INVITED') {
return null
}
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
return null
}
// Clear token, activate user, mark as needing password
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: null,
inviteTokenExpiresAt: null,
status: 'ACTIVE',
mustSetPassword: true,
lastLoginAt: new Date(),
},
})
// Log invitation accepted
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'INVITATION_ACCEPTED',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email, role: user.role },
},
}).catch(() => {})
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: true,
}
}
if (!credentials?.email || !credentials?.password) {
return null
}
const email = (credentials.email as string).toLowerCase()
const password = credentials.password as string
// Check if account is temporarily locked
const attempts = failedAttempts.get(email)
if (attempts && Date.now() < attempts.lockedUntil) {
throw new Error('Account temporarily locked due to too many failed attempts. Try again later.')
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
passwordHash: true,
mustSetPassword: true,
},
})
if (!user || user.status === 'SUSPENDED' || !user.passwordHash) {
// Track failed attempt (don't reveal whether user exists)
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: null,
action: 'LOGIN_FAILED',
entityType: 'User',
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
},
}).catch(() => {})
return null
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) {
// Track failed attempt
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'LOGIN_FAILED',
entityType: 'User',
entityId: user.id,
detailsJson: { email, reason: 'invalid_password' },
},
}).catch(() => {})
return null
}
// Clear failed attempts on successful login
failedAttempts.delete(email)
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: user.mustSetPassword,
}
},
}),
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user, trigger }) {
// Initial sign in
if (user) {
token.id = user.id as string
token.role = user.role as UserRole
token.mustSetPassword = user.mustSetPassword
}
// On session update, refresh from database
if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.mustSetPassword = dbUser.mustSetPassword
}
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.role = token.role as UserRole
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
}
return session
},
async signIn({ user, account }) {
// For email provider (magic link), check user status and get password info
if (account?.provider === 'email') {
const dbUser = await prisma.user.findUnique({
where: { email: user.email! },
select: {
id: true,
status: true,
passwordHash: true,
mustSetPassword: true,
role: true,
},
})
if (dbUser?.status === 'SUSPENDED') {
return false // Block suspended users
}
// Update status to ACTIVE on first login (from NONE or INVITED)
if (dbUser?.status === 'INVITED' || dbUser?.status === 'NONE') {
await prisma.user.update({
where: { email: user.email! },
data: { status: 'ACTIVE' },
})
}
// Add user data for JWT callback
if (dbUser) {
user.id = dbUser.id
user.role = dbUser.role
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
}
}
// Update last login time on actual sign-in
if (user.email) {
await prisma.user.update({
where: { email: user.email },
data: { lastLoginAt: new Date() },
}).catch(() => {
// Ignore errors from updating last login
})
// Log successful login
await prisma.auditLog.create({
data: {
userId: user.id as string,
action: 'LOGIN_SUCCESS',
entityType: 'User',
entityId: user.id as string,
detailsJson: { method: account?.provider || 'unknown', email: user.email },
},
}).catch(() => {})
}
return true
},
async redirect({ url, baseUrl }) {
// Check if user needs to set password and redirect accordingly
// This is called after successful authentication
if (url.startsWith(baseUrl)) {
return url
}
// Allow relative redirects
if (url.startsWith('/')) {
return `${baseUrl}${url}`
}
return baseUrl
},
},
})
// Helper to get session in server components
export async function getServerSession() {
return auth()
}
// Helper to require authentication
export async function requireAuth() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return session
}
// Helper to require specific role(s)
export async function requireRole(...roles: UserRole[]) {
const session = await requireAuth()
if (!roles.includes(session.user.role)) {
throw new Error('Forbidden')
}
return session
}

View File

@@ -1,34 +1,34 @@
/**
* Detects ChunkLoadError (caused by stale builds or deployment mismatches)
* and auto-reloads the page once to recover.
*/
export function isChunkLoadError(error: Error): boolean {
return (
error.name === 'ChunkLoadError' ||
error.message?.includes('Loading chunk') ||
error.message?.includes('Failed to fetch dynamically imported module') ||
error.message?.includes('error loading dynamically imported module')
)
}
/**
* Attempts auto-reload recovery for ChunkLoadError.
* Uses sessionStorage to prevent infinite reload loops (max once per 30s).
* Returns true if a reload was triggered.
*/
export function attemptChunkErrorRecovery(sectionKey: string): boolean {
if (typeof window === 'undefined') return false
const reloadKey = `chunk-reload-${sectionKey}`
const lastReload = sessionStorage.getItem(reloadKey)
const now = Date.now()
// Only auto-reload if we haven't reloaded in the last 30 seconds
if (!lastReload || now - parseInt(lastReload) > 30000) {
sessionStorage.setItem(reloadKey, String(now))
window.location.reload()
return true
}
return false
}
/**
* Detects ChunkLoadError (caused by stale builds or deployment mismatches)
* and auto-reloads the page once to recover.
*/
export function isChunkLoadError(error: Error): boolean {
return (
error.name === 'ChunkLoadError' ||
error.message?.includes('Loading chunk') ||
error.message?.includes('Failed to fetch dynamically imported module') ||
error.message?.includes('error loading dynamically imported module')
)
}
/**
* Attempts auto-reload recovery for ChunkLoadError.
* Uses sessionStorage to prevent infinite reload loops (max once per 30s).
* Returns true if a reload was triggered.
*/
export function attemptChunkErrorRecovery(sectionKey: string): boolean {
if (typeof window === 'undefined') return false
const reloadKey = `chunk-reload-${sectionKey}`
const lastReload = sessionStorage.getItem(reloadKey)
const now = Date.now()
// Only auto-reload if we haven't reloaded in the last 30 seconds
if (!lastReload || now - parseInt(lastReload) > 30000) {
sessionStorage.setItem(reloadKey, String(now))
window.location.reload()
return true
}
return false
}

View File

@@ -1,433 +1,433 @@
/**
* Country utilities for geographic visualization.
* Maps ISO 3166-1 alpha-2 codes to display names and centroid coordinates.
*/
type CountryInfo = {
name: string
lat: number
lng: number
}
/**
* Country data: ISO alpha-2 code → { name, lat, lng }
* Centroid coordinates for pin placement on world maps.
*/
export const COUNTRIES: Record<string, CountryInfo> = {
AF: { name: 'Afghanistan', lat: 33.94, lng: 67.71 },
AL: { name: 'Albania', lat: 41.15, lng: 20.17 },
DZ: { name: 'Algeria', lat: 28.03, lng: 1.66 },
AD: { name: 'Andorra', lat: 42.55, lng: 1.6 },
AO: { name: 'Angola', lat: -11.2, lng: 17.87 },
AG: { name: 'Antigua and Barbuda', lat: 17.06, lng: -61.8 },
AR: { name: 'Argentina', lat: -38.42, lng: -63.62 },
AM: { name: 'Armenia', lat: 40.07, lng: 45.04 },
AU: { name: 'Australia', lat: -25.27, lng: 133.78 },
AT: { name: 'Austria', lat: 47.52, lng: 14.55 },
AZ: { name: 'Azerbaijan', lat: 40.14, lng: 47.58 },
BS: { name: 'Bahamas', lat: 25.03, lng: -77.4 },
BH: { name: 'Bahrain', lat: 26.07, lng: 50.56 },
BD: { name: 'Bangladesh', lat: 23.68, lng: 90.36 },
BB: { name: 'Barbados', lat: 13.19, lng: -59.54 },
BY: { name: 'Belarus', lat: 53.71, lng: 27.95 },
BE: { name: 'Belgium', lat: 50.5, lng: 4.47 },
BZ: { name: 'Belize', lat: 17.19, lng: -88.5 },
BJ: { name: 'Benin', lat: 9.31, lng: 2.32 },
BT: { name: 'Bhutan', lat: 27.51, lng: 90.43 },
BO: { name: 'Bolivia', lat: -16.29, lng: -63.59 },
BA: { name: 'Bosnia and Herzegovina', lat: 43.92, lng: 17.68 },
BW: { name: 'Botswana', lat: -22.33, lng: 24.68 },
BR: { name: 'Brazil', lat: -14.24, lng: -51.93 },
BN: { name: 'Brunei', lat: 4.54, lng: 114.73 },
BG: { name: 'Bulgaria', lat: 42.73, lng: 25.49 },
BF: { name: 'Burkina Faso', lat: 12.24, lng: -1.56 },
BI: { name: 'Burundi', lat: -3.37, lng: 29.92 },
CV: { name: 'Cabo Verde', lat: 16.0, lng: -24.01 },
KH: { name: 'Cambodia', lat: 12.57, lng: 104.99 },
CM: { name: 'Cameroon', lat: 7.37, lng: 12.35 },
CA: { name: 'Canada', lat: 56.13, lng: -106.35 },
CF: { name: 'Central African Republic', lat: 6.61, lng: 20.94 },
TD: { name: 'Chad', lat: 15.45, lng: 18.73 },
CL: { name: 'Chile', lat: -35.68, lng: -71.54 },
CN: { name: 'China', lat: 35.86, lng: 104.2 },
CO: { name: 'Colombia', lat: 4.57, lng: -74.3 },
KM: { name: 'Comoros', lat: -11.88, lng: 43.87 },
CG: { name: 'Congo', lat: -0.23, lng: 15.83 },
CD: { name: 'Congo (DRC)', lat: -4.04, lng: 21.76 },
CR: { name: 'Costa Rica', lat: 9.75, lng: -83.75 },
CI: { name: "Cote d'Ivoire", lat: 7.54, lng: -5.55 },
HR: { name: 'Croatia', lat: 45.1, lng: 15.2 },
CU: { name: 'Cuba', lat: 21.52, lng: -77.78 },
CY: { name: 'Cyprus', lat: 35.13, lng: 33.43 },
CZ: { name: 'Czechia', lat: 49.82, lng: 15.47 },
DK: { name: 'Denmark', lat: 56.26, lng: 9.5 },
DJ: { name: 'Djibouti', lat: 11.83, lng: 42.59 },
DM: { name: 'Dominica', lat: 15.41, lng: -61.37 },
DO: { name: 'Dominican Republic', lat: 18.74, lng: -70.16 },
EC: { name: 'Ecuador', lat: -1.83, lng: -78.18 },
EG: { name: 'Egypt', lat: 26.82, lng: 30.8 },
SV: { name: 'El Salvador', lat: 13.79, lng: -88.9 },
GQ: { name: 'Equatorial Guinea', lat: 1.65, lng: 10.27 },
ER: { name: 'Eritrea', lat: 15.18, lng: 39.78 },
EE: { name: 'Estonia', lat: 58.6, lng: 25.01 },
SZ: { name: 'Eswatini', lat: -26.52, lng: 31.47 },
ET: { name: 'Ethiopia', lat: 9.15, lng: 40.49 },
FJ: { name: 'Fiji', lat: -17.71, lng: 178.07 },
FI: { name: 'Finland', lat: 61.92, lng: 25.75 },
FR: { name: 'France', lat: 46.23, lng: 2.21 },
GA: { name: 'Gabon', lat: -0.8, lng: 11.61 },
GM: { name: 'Gambia', lat: 13.44, lng: -15.31 },
GE: { name: 'Georgia', lat: 42.32, lng: 43.36 },
DE: { name: 'Germany', lat: 51.17, lng: 10.45 },
GH: { name: 'Ghana', lat: 7.95, lng: -1.02 },
GR: { name: 'Greece', lat: 39.07, lng: 21.82 },
GD: { name: 'Grenada', lat: 12.12, lng: -61.68 },
GT: { name: 'Guatemala', lat: 15.78, lng: -90.23 },
GN: { name: 'Guinea', lat: 9.95, lng: -11.08 },
GW: { name: 'Guinea-Bissau', lat: 11.8, lng: -15.18 },
GY: { name: 'Guyana', lat: 4.86, lng: -58.93 },
HT: { name: 'Haiti', lat: 18.97, lng: -72.29 },
HN: { name: 'Honduras', lat: 15.2, lng: -86.24 },
HU: { name: 'Hungary', lat: 47.16, lng: 19.5 },
IS: { name: 'Iceland', lat: 64.96, lng: -19.02 },
IN: { name: 'India', lat: 20.59, lng: 78.96 },
ID: { name: 'Indonesia', lat: -0.79, lng: 113.92 },
IR: { name: 'Iran', lat: 32.43, lng: 53.69 },
IQ: { name: 'Iraq', lat: 33.22, lng: 43.68 },
IE: { name: 'Ireland', lat: 53.14, lng: -7.69 },
IL: { name: 'Israel', lat: 31.05, lng: 34.85 },
IT: { name: 'Italy', lat: 41.87, lng: 12.57 },
JM: { name: 'Jamaica', lat: 18.11, lng: -77.3 },
JP: { name: 'Japan', lat: 36.2, lng: 138.25 },
JO: { name: 'Jordan', lat: 30.59, lng: 36.24 },
KZ: { name: 'Kazakhstan', lat: 48.02, lng: 66.92 },
KE: { name: 'Kenya', lat: -0.02, lng: 37.91 },
KI: { name: 'Kiribati', lat: -3.37, lng: -168.73 },
KP: { name: 'North Korea', lat: 40.34, lng: 127.51 },
KR: { name: 'South Korea', lat: 35.91, lng: 127.77 },
KW: { name: 'Kuwait', lat: 29.31, lng: 47.48 },
KG: { name: 'Kyrgyzstan', lat: 41.2, lng: 74.77 },
LA: { name: 'Laos', lat: 19.86, lng: 102.5 },
LV: { name: 'Latvia', lat: 56.88, lng: 24.6 },
LB: { name: 'Lebanon', lat: 33.85, lng: 35.86 },
LS: { name: 'Lesotho', lat: -29.61, lng: 28.23 },
LR: { name: 'Liberia', lat: 6.43, lng: -9.43 },
LY: { name: 'Libya', lat: 26.34, lng: 17.23 },
LI: { name: 'Liechtenstein', lat: 47.17, lng: 9.56 },
LT: { name: 'Lithuania', lat: 55.17, lng: 23.88 },
LU: { name: 'Luxembourg', lat: 49.82, lng: 6.13 },
MG: { name: 'Madagascar', lat: -18.77, lng: 46.87 },
MW: { name: 'Malawi', lat: -13.25, lng: 34.3 },
MY: { name: 'Malaysia', lat: 4.21, lng: 101.98 },
MV: { name: 'Maldives', lat: 3.2, lng: 73.22 },
ML: { name: 'Mali', lat: 17.57, lng: -4.0 },
MT: { name: 'Malta', lat: 35.94, lng: 14.38 },
MH: { name: 'Marshall Islands', lat: 7.13, lng: 171.18 },
MR: { name: 'Mauritania', lat: 21.01, lng: -10.94 },
MU: { name: 'Mauritius', lat: -20.35, lng: 57.55 },
MX: { name: 'Mexico', lat: 23.63, lng: -102.55 },
FM: { name: 'Micronesia', lat: 7.43, lng: 150.55 },
MD: { name: 'Moldova', lat: 47.41, lng: 28.37 },
MC: { name: 'Monaco', lat: 43.75, lng: 7.42 },
MN: { name: 'Mongolia', lat: 46.86, lng: 103.85 },
ME: { name: 'Montenegro', lat: 42.71, lng: 19.37 },
MA: { name: 'Morocco', lat: 31.79, lng: -7.09 },
MZ: { name: 'Mozambique', lat: -18.67, lng: 35.53 },
MM: { name: 'Myanmar', lat: 21.91, lng: 95.96 },
NA: { name: 'Namibia', lat: -22.96, lng: 18.49 },
NR: { name: 'Nauru', lat: -0.52, lng: 166.93 },
NP: { name: 'Nepal', lat: 28.39, lng: 84.12 },
NL: { name: 'Netherlands', lat: 52.13, lng: 5.29 },
NZ: { name: 'New Zealand', lat: -40.9, lng: 174.89 },
NI: { name: 'Nicaragua', lat: 12.87, lng: -85.21 },
NE: { name: 'Niger', lat: 17.61, lng: 8.08 },
NG: { name: 'Nigeria', lat: 9.08, lng: 8.68 },
MK: { name: 'North Macedonia', lat: 41.51, lng: 21.75 },
NO: { name: 'Norway', lat: 60.47, lng: 8.47 },
OM: { name: 'Oman', lat: 21.47, lng: 55.98 },
PK: { name: 'Pakistan', lat: 30.38, lng: 69.35 },
PW: { name: 'Palau', lat: 7.51, lng: 134.58 },
PS: { name: 'Palestine', lat: 31.95, lng: 35.23 },
PA: { name: 'Panama', lat: 8.54, lng: -80.78 },
PG: { name: 'Papua New Guinea', lat: -6.31, lng: 143.96 },
PY: { name: 'Paraguay', lat: -23.44, lng: -58.44 },
PE: { name: 'Peru', lat: -9.19, lng: -75.02 },
PH: { name: 'Philippines', lat: 12.88, lng: 121.77 },
PL: { name: 'Poland', lat: 51.92, lng: 19.15 },
PT: { name: 'Portugal', lat: 39.4, lng: -8.22 },
QA: { name: 'Qatar', lat: 25.35, lng: 51.18 },
RO: { name: 'Romania', lat: 45.94, lng: 24.97 },
RU: { name: 'Russia', lat: 61.52, lng: 105.32 },
RW: { name: 'Rwanda', lat: -1.94, lng: 29.87 },
KN: { name: 'Saint Kitts and Nevis', lat: 17.36, lng: -62.78 },
LC: { name: 'Saint Lucia', lat: 13.91, lng: -60.98 },
VC: { name: 'Saint Vincent and the Grenadines', lat: 12.98, lng: -61.29 },
WS: { name: 'Samoa', lat: -13.76, lng: -172.1 },
SM: { name: 'San Marino', lat: 43.94, lng: 12.46 },
ST: { name: 'Sao Tome and Principe', lat: 0.19, lng: 6.61 },
SA: { name: 'Saudi Arabia', lat: 23.89, lng: 45.08 },
SN: { name: 'Senegal', lat: 14.5, lng: -14.45 },
RS: { name: 'Serbia', lat: 44.02, lng: 21.01 },
SC: { name: 'Seychelles', lat: -4.68, lng: 55.49 },
SL: { name: 'Sierra Leone', lat: 8.46, lng: -11.78 },
SG: { name: 'Singapore', lat: 1.35, lng: 103.82 },
SK: { name: 'Slovakia', lat: 48.67, lng: 19.7 },
SI: { name: 'Slovenia', lat: 46.15, lng: 14.99 },
SB: { name: 'Solomon Islands', lat: -9.65, lng: 160.16 },
SO: { name: 'Somalia', lat: 5.15, lng: 46.2 },
ZA: { name: 'South Africa', lat: -30.56, lng: 22.94 },
SS: { name: 'South Sudan', lat: 6.88, lng: 31.31 },
ES: { name: 'Spain', lat: 40.46, lng: -3.75 },
LK: { name: 'Sri Lanka', lat: 7.87, lng: 80.77 },
SD: { name: 'Sudan', lat: 12.86, lng: 30.22 },
SR: { name: 'Suriname', lat: 3.92, lng: -56.03 },
SE: { name: 'Sweden', lat: 60.13, lng: 18.64 },
CH: { name: 'Switzerland', lat: 46.82, lng: 8.23 },
SY: { name: 'Syria', lat: 34.8, lng: 39.0 },
TW: { name: 'Taiwan', lat: 23.7, lng: 120.96 },
TJ: { name: 'Tajikistan', lat: 38.86, lng: 71.28 },
TZ: { name: 'Tanzania', lat: -6.37, lng: 34.89 },
TH: { name: 'Thailand', lat: 15.87, lng: 100.99 },
TL: { name: 'Timor-Leste', lat: -8.87, lng: 125.73 },
TG: { name: 'Togo', lat: 8.62, lng: 0.82 },
TO: { name: 'Tonga', lat: -21.18, lng: -175.2 },
TT: { name: 'Trinidad and Tobago', lat: 10.69, lng: -61.22 },
TN: { name: 'Tunisia', lat: 33.89, lng: 9.54 },
TR: { name: 'Turkey', lat: 38.96, lng: 35.24 },
TM: { name: 'Turkmenistan', lat: 38.97, lng: 59.56 },
TV: { name: 'Tuvalu', lat: -7.11, lng: 177.65 },
UG: { name: 'Uganda', lat: 1.37, lng: 32.29 },
UA: { name: 'Ukraine', lat: 48.38, lng: 31.17 },
AE: { name: 'United Arab Emirates', lat: 23.42, lng: 53.85 },
GB: { name: 'United Kingdom', lat: 55.38, lng: -3.44 },
US: { name: 'United States', lat: 37.09, lng: -95.71 },
UY: { name: 'Uruguay', lat: -32.52, lng: -55.77 },
UZ: { name: 'Uzbekistan', lat: 41.38, lng: 64.59 },
VU: { name: 'Vanuatu', lat: -15.38, lng: 166.96 },
VA: { name: 'Vatican City', lat: 41.9, lng: 12.45 },
VE: { name: 'Venezuela', lat: 6.42, lng: -66.59 },
VN: { name: 'Vietnam', lat: 14.06, lng: 108.28 },
YE: { name: 'Yemen', lat: 15.55, lng: 48.52 },
ZM: { name: 'Zambia', lat: -13.13, lng: 27.85 },
ZW: { name: 'Zimbabwe', lat: -19.02, lng: 29.15 },
}
export function getCountryName(code: string): string {
return COUNTRIES[code]?.name || code
}
/**
* Convert ISO 3166-1 alpha-2 code to flag emoji.
* Uses regional indicator symbols (Unicode).
*/
export function getCountryFlag(code: string): string {
if (!code || code.length !== 2) return ''
const upper = code.toUpperCase()
return String.fromCodePoint(
...Array.from(upper).map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)
)
}
export function getCountryCoordinates(code: string): [number, number] | null {
const country = COUNTRIES[code]
if (!country) return null
return [country.lat, country.lng]
}
/**
* Country name to ISO-2 code mappings.
* Includes English, French, and common alternate spellings.
*/
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
// Build reverse lookup from COUNTRIES
...Object.fromEntries(
Object.entries(COUNTRIES).flatMap(([code, info]) => [
[info.name.toLowerCase(), code],
])
),
// French names and alternate spellings
'tunisie': 'TN',
'royaume-uni': 'GB',
'uk': 'GB',
'angleterre': 'GB',
'england': 'GB',
'espagne': 'ES',
'inde': 'IN',
'états-unis': 'US',
'etats-unis': 'US',
'usa': 'US',
'allemagne': 'DE',
'italie': 'IT',
'suisse': 'CH',
'belgique': 'BE',
'pays-bas': 'NL',
'australie': 'AU',
'japon': 'JP',
'chine': 'CN',
'brésil': 'BR',
'bresil': 'BR',
'mexique': 'MX',
'maroc': 'MA',
'egypte': 'EG',
'afrique du sud': 'ZA',
'sénégal': 'SN',
'senegal': 'SN',
"côte d'ivoire": 'CI',
'cote d\'ivoire': 'CI',
'indonésie': 'ID',
'indonesie': 'ID',
'thaïlande': 'TH',
'thailande': 'TH',
'malaisie': 'MY',
'singapour': 'SG',
'grèce': 'GR',
'grece': 'GR',
'turquie': 'TR',
'pologne': 'PL',
'norvège': 'NO',
'norvege': 'NO',
'suède': 'SE',
'suede': 'SE',
'danemark': 'DK',
'finlande': 'FI',
'irlande': 'IE',
'autriche': 'AT',
'nigéria': 'NG',
'nigeria': 'NG',
'tanzanie': 'TZ',
'ouganda': 'UG',
'zambie': 'ZM',
'somalie': 'SO',
'jordanie': 'JO',
'algérie': 'DZ',
'algerie': 'DZ',
'cameroun': 'CM',
'maurice': 'MU',
'malte': 'MT',
'croatie': 'HR',
'roumanie': 'RO',
'hongrie': 'HU',
'tchéquie': 'CZ',
'tcheque': 'CZ',
'slovaquie': 'SK',
'slovénie': 'SI',
'estonie': 'EE',
'lettonie': 'LV',
'lituanie': 'LT',
'chypre': 'CY',
'malawi': 'MW',
'mozambique': 'MZ',
'namibie': 'NA',
'botswana': 'BW',
'zimbabwe': 'ZW',
'éthiopie': 'ET',
'ethiopie': 'ET',
'soudan': 'SD',
'libye': 'LY',
'arabie saoudite': 'SA',
'émirats arabes unis': 'AE',
'emirats arabes unis': 'AE',
'uae': 'AE',
'qatar': 'QA',
'koweït': 'KW',
'koweit': 'KW',
'bahreïn': 'BH',
'bahrein': 'BH',
'oman': 'OM',
'yémen': 'YE',
'yemen': 'YE',
'irak': 'IQ',
'iran': 'IR',
'afghanistan': 'AF',
'pakistan': 'PK',
'bangladesh': 'BD',
'sri lanka': 'LK',
'népal': 'NP',
'nepal': 'NP',
'birmanie': 'MM',
'myanmar': 'MM',
'cambodge': 'KH',
'laos': 'LA',
'corée du sud': 'KR',
'coree du sud': 'KR',
'south korea': 'KR',
'corée du nord': 'KP',
'coree du nord': 'KP',
'north korea': 'KP',
'nouvelle-zélande': 'NZ',
'nouvelle zelande': 'NZ',
'fidji': 'FJ',
'fiji': 'FJ',
'papouasie-nouvelle-guinée': 'PG',
'argentine': 'AR',
'chili': 'CL',
'colombie': 'CO',
'pérou': 'PE',
'perou': 'PE',
'venezuela': 'VE',
'équateur': 'EC',
'equateur': 'EC',
'bolivie': 'BO',
'paraguay': 'PY',
'uruguay': 'UY',
'costa rica': 'CR',
'panama': 'PA',
'guatemala': 'GT',
'honduras': 'HN',
'salvador': 'SV',
'nicaragua': 'NI',
'cuba': 'CU',
'haïti': 'HT',
'haiti': 'HT',
'jamaïque': 'JM',
'jamaique': 'JM',
'trinidad': 'TT',
'trinité-et-tobago': 'TT',
'république dominicaine': 'DO',
'republique dominicaine': 'DO',
'dominican republic': 'DO',
'puerto rico': 'PR',
'porto rico': 'PR',
}
/**
* Convert a country name or code to ISO-2 code.
* Handles:
* - Already valid ISO-2 codes (returns as-is)
* - Full country names (English or French)
* - Common alternate spellings
*
* @param input Country name or code
* @returns ISO-2 code or null if not recognized
*/
export function normalizeCountryToCode(input: string | null | undefined): string | null {
if (!input) return null
const trimmed = input.trim()
if (!trimmed) return null
// If already a valid 2-letter ISO code
if (/^[A-Z]{2}$/.test(trimmed) && COUNTRIES[trimmed]) {
return trimmed
}
// Check uppercase version
const upper = trimmed.toUpperCase()
if (/^[A-Z]{2}$/.test(upper) && COUNTRIES[upper]) {
return upper
}
// Try to find in name mappings
const lower = trimmed.toLowerCase()
const code = COUNTRY_NAME_TO_CODE[lower]
if (code) return code
// Try partial matching for country names
for (const [name, countryCode] of Object.entries(COUNTRY_NAME_TO_CODE)) {
if (lower.includes(name) || name.includes(lower)) {
return countryCode
}
}
return null
}
/**
* Country utilities for geographic visualization.
* Maps ISO 3166-1 alpha-2 codes to display names and centroid coordinates.
*/
type CountryInfo = {
name: string
lat: number
lng: number
}
/**
* Country data: ISO alpha-2 code → { name, lat, lng }
* Centroid coordinates for pin placement on world maps.
*/
export const COUNTRIES: Record<string, CountryInfo> = {
AF: { name: 'Afghanistan', lat: 33.94, lng: 67.71 },
AL: { name: 'Albania', lat: 41.15, lng: 20.17 },
DZ: { name: 'Algeria', lat: 28.03, lng: 1.66 },
AD: { name: 'Andorra', lat: 42.55, lng: 1.6 },
AO: { name: 'Angola', lat: -11.2, lng: 17.87 },
AG: { name: 'Antigua and Barbuda', lat: 17.06, lng: -61.8 },
AR: { name: 'Argentina', lat: -38.42, lng: -63.62 },
AM: { name: 'Armenia', lat: 40.07, lng: 45.04 },
AU: { name: 'Australia', lat: -25.27, lng: 133.78 },
AT: { name: 'Austria', lat: 47.52, lng: 14.55 },
AZ: { name: 'Azerbaijan', lat: 40.14, lng: 47.58 },
BS: { name: 'Bahamas', lat: 25.03, lng: -77.4 },
BH: { name: 'Bahrain', lat: 26.07, lng: 50.56 },
BD: { name: 'Bangladesh', lat: 23.68, lng: 90.36 },
BB: { name: 'Barbados', lat: 13.19, lng: -59.54 },
BY: { name: 'Belarus', lat: 53.71, lng: 27.95 },
BE: { name: 'Belgium', lat: 50.5, lng: 4.47 },
BZ: { name: 'Belize', lat: 17.19, lng: -88.5 },
BJ: { name: 'Benin', lat: 9.31, lng: 2.32 },
BT: { name: 'Bhutan', lat: 27.51, lng: 90.43 },
BO: { name: 'Bolivia', lat: -16.29, lng: -63.59 },
BA: { name: 'Bosnia and Herzegovina', lat: 43.92, lng: 17.68 },
BW: { name: 'Botswana', lat: -22.33, lng: 24.68 },
BR: { name: 'Brazil', lat: -14.24, lng: -51.93 },
BN: { name: 'Brunei', lat: 4.54, lng: 114.73 },
BG: { name: 'Bulgaria', lat: 42.73, lng: 25.49 },
BF: { name: 'Burkina Faso', lat: 12.24, lng: -1.56 },
BI: { name: 'Burundi', lat: -3.37, lng: 29.92 },
CV: { name: 'Cabo Verde', lat: 16.0, lng: -24.01 },
KH: { name: 'Cambodia', lat: 12.57, lng: 104.99 },
CM: { name: 'Cameroon', lat: 7.37, lng: 12.35 },
CA: { name: 'Canada', lat: 56.13, lng: -106.35 },
CF: { name: 'Central African Republic', lat: 6.61, lng: 20.94 },
TD: { name: 'Chad', lat: 15.45, lng: 18.73 },
CL: { name: 'Chile', lat: -35.68, lng: -71.54 },
CN: { name: 'China', lat: 35.86, lng: 104.2 },
CO: { name: 'Colombia', lat: 4.57, lng: -74.3 },
KM: { name: 'Comoros', lat: -11.88, lng: 43.87 },
CG: { name: 'Congo', lat: -0.23, lng: 15.83 },
CD: { name: 'Congo (DRC)', lat: -4.04, lng: 21.76 },
CR: { name: 'Costa Rica', lat: 9.75, lng: -83.75 },
CI: { name: "Cote d'Ivoire", lat: 7.54, lng: -5.55 },
HR: { name: 'Croatia', lat: 45.1, lng: 15.2 },
CU: { name: 'Cuba', lat: 21.52, lng: -77.78 },
CY: { name: 'Cyprus', lat: 35.13, lng: 33.43 },
CZ: { name: 'Czechia', lat: 49.82, lng: 15.47 },
DK: { name: 'Denmark', lat: 56.26, lng: 9.5 },
DJ: { name: 'Djibouti', lat: 11.83, lng: 42.59 },
DM: { name: 'Dominica', lat: 15.41, lng: -61.37 },
DO: { name: 'Dominican Republic', lat: 18.74, lng: -70.16 },
EC: { name: 'Ecuador', lat: -1.83, lng: -78.18 },
EG: { name: 'Egypt', lat: 26.82, lng: 30.8 },
SV: { name: 'El Salvador', lat: 13.79, lng: -88.9 },
GQ: { name: 'Equatorial Guinea', lat: 1.65, lng: 10.27 },
ER: { name: 'Eritrea', lat: 15.18, lng: 39.78 },
EE: { name: 'Estonia', lat: 58.6, lng: 25.01 },
SZ: { name: 'Eswatini', lat: -26.52, lng: 31.47 },
ET: { name: 'Ethiopia', lat: 9.15, lng: 40.49 },
FJ: { name: 'Fiji', lat: -17.71, lng: 178.07 },
FI: { name: 'Finland', lat: 61.92, lng: 25.75 },
FR: { name: 'France', lat: 46.23, lng: 2.21 },
GA: { name: 'Gabon', lat: -0.8, lng: 11.61 },
GM: { name: 'Gambia', lat: 13.44, lng: -15.31 },
GE: { name: 'Georgia', lat: 42.32, lng: 43.36 },
DE: { name: 'Germany', lat: 51.17, lng: 10.45 },
GH: { name: 'Ghana', lat: 7.95, lng: -1.02 },
GR: { name: 'Greece', lat: 39.07, lng: 21.82 },
GD: { name: 'Grenada', lat: 12.12, lng: -61.68 },
GT: { name: 'Guatemala', lat: 15.78, lng: -90.23 },
GN: { name: 'Guinea', lat: 9.95, lng: -11.08 },
GW: { name: 'Guinea-Bissau', lat: 11.8, lng: -15.18 },
GY: { name: 'Guyana', lat: 4.86, lng: -58.93 },
HT: { name: 'Haiti', lat: 18.97, lng: -72.29 },
HN: { name: 'Honduras', lat: 15.2, lng: -86.24 },
HU: { name: 'Hungary', lat: 47.16, lng: 19.5 },
IS: { name: 'Iceland', lat: 64.96, lng: -19.02 },
IN: { name: 'India', lat: 20.59, lng: 78.96 },
ID: { name: 'Indonesia', lat: -0.79, lng: 113.92 },
IR: { name: 'Iran', lat: 32.43, lng: 53.69 },
IQ: { name: 'Iraq', lat: 33.22, lng: 43.68 },
IE: { name: 'Ireland', lat: 53.14, lng: -7.69 },
IL: { name: 'Israel', lat: 31.05, lng: 34.85 },
IT: { name: 'Italy', lat: 41.87, lng: 12.57 },
JM: { name: 'Jamaica', lat: 18.11, lng: -77.3 },
JP: { name: 'Japan', lat: 36.2, lng: 138.25 },
JO: { name: 'Jordan', lat: 30.59, lng: 36.24 },
KZ: { name: 'Kazakhstan', lat: 48.02, lng: 66.92 },
KE: { name: 'Kenya', lat: -0.02, lng: 37.91 },
KI: { name: 'Kiribati', lat: -3.37, lng: -168.73 },
KP: { name: 'North Korea', lat: 40.34, lng: 127.51 },
KR: { name: 'South Korea', lat: 35.91, lng: 127.77 },
KW: { name: 'Kuwait', lat: 29.31, lng: 47.48 },
KG: { name: 'Kyrgyzstan', lat: 41.2, lng: 74.77 },
LA: { name: 'Laos', lat: 19.86, lng: 102.5 },
LV: { name: 'Latvia', lat: 56.88, lng: 24.6 },
LB: { name: 'Lebanon', lat: 33.85, lng: 35.86 },
LS: { name: 'Lesotho', lat: -29.61, lng: 28.23 },
LR: { name: 'Liberia', lat: 6.43, lng: -9.43 },
LY: { name: 'Libya', lat: 26.34, lng: 17.23 },
LI: { name: 'Liechtenstein', lat: 47.17, lng: 9.56 },
LT: { name: 'Lithuania', lat: 55.17, lng: 23.88 },
LU: { name: 'Luxembourg', lat: 49.82, lng: 6.13 },
MG: { name: 'Madagascar', lat: -18.77, lng: 46.87 },
MW: { name: 'Malawi', lat: -13.25, lng: 34.3 },
MY: { name: 'Malaysia', lat: 4.21, lng: 101.98 },
MV: { name: 'Maldives', lat: 3.2, lng: 73.22 },
ML: { name: 'Mali', lat: 17.57, lng: -4.0 },
MT: { name: 'Malta', lat: 35.94, lng: 14.38 },
MH: { name: 'Marshall Islands', lat: 7.13, lng: 171.18 },
MR: { name: 'Mauritania', lat: 21.01, lng: -10.94 },
MU: { name: 'Mauritius', lat: -20.35, lng: 57.55 },
MX: { name: 'Mexico', lat: 23.63, lng: -102.55 },
FM: { name: 'Micronesia', lat: 7.43, lng: 150.55 },
MD: { name: 'Moldova', lat: 47.41, lng: 28.37 },
MC: { name: 'Monaco', lat: 43.75, lng: 7.42 },
MN: { name: 'Mongolia', lat: 46.86, lng: 103.85 },
ME: { name: 'Montenegro', lat: 42.71, lng: 19.37 },
MA: { name: 'Morocco', lat: 31.79, lng: -7.09 },
MZ: { name: 'Mozambique', lat: -18.67, lng: 35.53 },
MM: { name: 'Myanmar', lat: 21.91, lng: 95.96 },
NA: { name: 'Namibia', lat: -22.96, lng: 18.49 },
NR: { name: 'Nauru', lat: -0.52, lng: 166.93 },
NP: { name: 'Nepal', lat: 28.39, lng: 84.12 },
NL: { name: 'Netherlands', lat: 52.13, lng: 5.29 },
NZ: { name: 'New Zealand', lat: -40.9, lng: 174.89 },
NI: { name: 'Nicaragua', lat: 12.87, lng: -85.21 },
NE: { name: 'Niger', lat: 17.61, lng: 8.08 },
NG: { name: 'Nigeria', lat: 9.08, lng: 8.68 },
MK: { name: 'North Macedonia', lat: 41.51, lng: 21.75 },
NO: { name: 'Norway', lat: 60.47, lng: 8.47 },
OM: { name: 'Oman', lat: 21.47, lng: 55.98 },
PK: { name: 'Pakistan', lat: 30.38, lng: 69.35 },
PW: { name: 'Palau', lat: 7.51, lng: 134.58 },
PS: { name: 'Palestine', lat: 31.95, lng: 35.23 },
PA: { name: 'Panama', lat: 8.54, lng: -80.78 },
PG: { name: 'Papua New Guinea', lat: -6.31, lng: 143.96 },
PY: { name: 'Paraguay', lat: -23.44, lng: -58.44 },
PE: { name: 'Peru', lat: -9.19, lng: -75.02 },
PH: { name: 'Philippines', lat: 12.88, lng: 121.77 },
PL: { name: 'Poland', lat: 51.92, lng: 19.15 },
PT: { name: 'Portugal', lat: 39.4, lng: -8.22 },
QA: { name: 'Qatar', lat: 25.35, lng: 51.18 },
RO: { name: 'Romania', lat: 45.94, lng: 24.97 },
RU: { name: 'Russia', lat: 61.52, lng: 105.32 },
RW: { name: 'Rwanda', lat: -1.94, lng: 29.87 },
KN: { name: 'Saint Kitts and Nevis', lat: 17.36, lng: -62.78 },
LC: { name: 'Saint Lucia', lat: 13.91, lng: -60.98 },
VC: { name: 'Saint Vincent and the Grenadines', lat: 12.98, lng: -61.29 },
WS: { name: 'Samoa', lat: -13.76, lng: -172.1 },
SM: { name: 'San Marino', lat: 43.94, lng: 12.46 },
ST: { name: 'Sao Tome and Principe', lat: 0.19, lng: 6.61 },
SA: { name: 'Saudi Arabia', lat: 23.89, lng: 45.08 },
SN: { name: 'Senegal', lat: 14.5, lng: -14.45 },
RS: { name: 'Serbia', lat: 44.02, lng: 21.01 },
SC: { name: 'Seychelles', lat: -4.68, lng: 55.49 },
SL: { name: 'Sierra Leone', lat: 8.46, lng: -11.78 },
SG: { name: 'Singapore', lat: 1.35, lng: 103.82 },
SK: { name: 'Slovakia', lat: 48.67, lng: 19.7 },
SI: { name: 'Slovenia', lat: 46.15, lng: 14.99 },
SB: { name: 'Solomon Islands', lat: -9.65, lng: 160.16 },
SO: { name: 'Somalia', lat: 5.15, lng: 46.2 },
ZA: { name: 'South Africa', lat: -30.56, lng: 22.94 },
SS: { name: 'South Sudan', lat: 6.88, lng: 31.31 },
ES: { name: 'Spain', lat: 40.46, lng: -3.75 },
LK: { name: 'Sri Lanka', lat: 7.87, lng: 80.77 },
SD: { name: 'Sudan', lat: 12.86, lng: 30.22 },
SR: { name: 'Suriname', lat: 3.92, lng: -56.03 },
SE: { name: 'Sweden', lat: 60.13, lng: 18.64 },
CH: { name: 'Switzerland', lat: 46.82, lng: 8.23 },
SY: { name: 'Syria', lat: 34.8, lng: 39.0 },
TW: { name: 'Taiwan', lat: 23.7, lng: 120.96 },
TJ: { name: 'Tajikistan', lat: 38.86, lng: 71.28 },
TZ: { name: 'Tanzania', lat: -6.37, lng: 34.89 },
TH: { name: 'Thailand', lat: 15.87, lng: 100.99 },
TL: { name: 'Timor-Leste', lat: -8.87, lng: 125.73 },
TG: { name: 'Togo', lat: 8.62, lng: 0.82 },
TO: { name: 'Tonga', lat: -21.18, lng: -175.2 },
TT: { name: 'Trinidad and Tobago', lat: 10.69, lng: -61.22 },
TN: { name: 'Tunisia', lat: 33.89, lng: 9.54 },
TR: { name: 'Turkey', lat: 38.96, lng: 35.24 },
TM: { name: 'Turkmenistan', lat: 38.97, lng: 59.56 },
TV: { name: 'Tuvalu', lat: -7.11, lng: 177.65 },
UG: { name: 'Uganda', lat: 1.37, lng: 32.29 },
UA: { name: 'Ukraine', lat: 48.38, lng: 31.17 },
AE: { name: 'United Arab Emirates', lat: 23.42, lng: 53.85 },
GB: { name: 'United Kingdom', lat: 55.38, lng: -3.44 },
US: { name: 'United States', lat: 37.09, lng: -95.71 },
UY: { name: 'Uruguay', lat: -32.52, lng: -55.77 },
UZ: { name: 'Uzbekistan', lat: 41.38, lng: 64.59 },
VU: { name: 'Vanuatu', lat: -15.38, lng: 166.96 },
VA: { name: 'Vatican City', lat: 41.9, lng: 12.45 },
VE: { name: 'Venezuela', lat: 6.42, lng: -66.59 },
VN: { name: 'Vietnam', lat: 14.06, lng: 108.28 },
YE: { name: 'Yemen', lat: 15.55, lng: 48.52 },
ZM: { name: 'Zambia', lat: -13.13, lng: 27.85 },
ZW: { name: 'Zimbabwe', lat: -19.02, lng: 29.15 },
}
export function getCountryName(code: string): string {
return COUNTRIES[code]?.name || code
}
/**
* Convert ISO 3166-1 alpha-2 code to flag emoji.
* Uses regional indicator symbols (Unicode).
*/
export function getCountryFlag(code: string): string {
if (!code || code.length !== 2) return ''
const upper = code.toUpperCase()
return String.fromCodePoint(
...Array.from(upper).map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)
)
}
export function getCountryCoordinates(code: string): [number, number] | null {
const country = COUNTRIES[code]
if (!country) return null
return [country.lat, country.lng]
}
/**
* Country name to ISO-2 code mappings.
* Includes English, French, and common alternate spellings.
*/
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
// Build reverse lookup from COUNTRIES
...Object.fromEntries(
Object.entries(COUNTRIES).flatMap(([code, info]) => [
[info.name.toLowerCase(), code],
])
),
// French names and alternate spellings
'tunisie': 'TN',
'royaume-uni': 'GB',
'uk': 'GB',
'angleterre': 'GB',
'england': 'GB',
'espagne': 'ES',
'inde': 'IN',
'états-unis': 'US',
'etats-unis': 'US',
'usa': 'US',
'allemagne': 'DE',
'italie': 'IT',
'suisse': 'CH',
'belgique': 'BE',
'pays-bas': 'NL',
'australie': 'AU',
'japon': 'JP',
'chine': 'CN',
'brésil': 'BR',
'bresil': 'BR',
'mexique': 'MX',
'maroc': 'MA',
'egypte': 'EG',
'afrique du sud': 'ZA',
'sénégal': 'SN',
'senegal': 'SN',
"côte d'ivoire": 'CI',
'cote d\'ivoire': 'CI',
'indonésie': 'ID',
'indonesie': 'ID',
'thaïlande': 'TH',
'thailande': 'TH',
'malaisie': 'MY',
'singapour': 'SG',
'grèce': 'GR',
'grece': 'GR',
'turquie': 'TR',
'pologne': 'PL',
'norvège': 'NO',
'norvege': 'NO',
'suède': 'SE',
'suede': 'SE',
'danemark': 'DK',
'finlande': 'FI',
'irlande': 'IE',
'autriche': 'AT',
'nigéria': 'NG',
'nigeria': 'NG',
'tanzanie': 'TZ',
'ouganda': 'UG',
'zambie': 'ZM',
'somalie': 'SO',
'jordanie': 'JO',
'algérie': 'DZ',
'algerie': 'DZ',
'cameroun': 'CM',
'maurice': 'MU',
'malte': 'MT',
'croatie': 'HR',
'roumanie': 'RO',
'hongrie': 'HU',
'tchéquie': 'CZ',
'tcheque': 'CZ',
'slovaquie': 'SK',
'slovénie': 'SI',
'estonie': 'EE',
'lettonie': 'LV',
'lituanie': 'LT',
'chypre': 'CY',
'malawi': 'MW',
'mozambique': 'MZ',
'namibie': 'NA',
'botswana': 'BW',
'zimbabwe': 'ZW',
'éthiopie': 'ET',
'ethiopie': 'ET',
'soudan': 'SD',
'libye': 'LY',
'arabie saoudite': 'SA',
'émirats arabes unis': 'AE',
'emirats arabes unis': 'AE',
'uae': 'AE',
'qatar': 'QA',
'koweït': 'KW',
'koweit': 'KW',
'bahreïn': 'BH',
'bahrein': 'BH',
'oman': 'OM',
'yémen': 'YE',
'yemen': 'YE',
'irak': 'IQ',
'iran': 'IR',
'afghanistan': 'AF',
'pakistan': 'PK',
'bangladesh': 'BD',
'sri lanka': 'LK',
'népal': 'NP',
'nepal': 'NP',
'birmanie': 'MM',
'myanmar': 'MM',
'cambodge': 'KH',
'laos': 'LA',
'corée du sud': 'KR',
'coree du sud': 'KR',
'south korea': 'KR',
'corée du nord': 'KP',
'coree du nord': 'KP',
'north korea': 'KP',
'nouvelle-zélande': 'NZ',
'nouvelle zelande': 'NZ',
'fidji': 'FJ',
'fiji': 'FJ',
'papouasie-nouvelle-guinée': 'PG',
'argentine': 'AR',
'chili': 'CL',
'colombie': 'CO',
'pérou': 'PE',
'perou': 'PE',
'venezuela': 'VE',
'équateur': 'EC',
'equateur': 'EC',
'bolivie': 'BO',
'paraguay': 'PY',
'uruguay': 'UY',
'costa rica': 'CR',
'panama': 'PA',
'guatemala': 'GT',
'honduras': 'HN',
'salvador': 'SV',
'nicaragua': 'NI',
'cuba': 'CU',
'haïti': 'HT',
'haiti': 'HT',
'jamaïque': 'JM',
'jamaique': 'JM',
'trinidad': 'TT',
'trinité-et-tobago': 'TT',
'république dominicaine': 'DO',
'republique dominicaine': 'DO',
'dominican republic': 'DO',
'puerto rico': 'PR',
'porto rico': 'PR',
}
/**
* Convert a country name or code to ISO-2 code.
* Handles:
* - Already valid ISO-2 codes (returns as-is)
* - Full country names (English or French)
* - Common alternate spellings
*
* @param input Country name or code
* @returns ISO-2 code or null if not recognized
*/
export function normalizeCountryToCode(input: string | null | undefined): string | null {
if (!input) return null
const trimmed = input.trim()
if (!trimmed) return null
// If already a valid 2-letter ISO code
if (/^[A-Z]{2}$/.test(trimmed) && COUNTRIES[trimmed]) {
return trimmed
}
// Check uppercase version
const upper = trimmed.toUpperCase()
if (/^[A-Z]{2}$/.test(upper) && COUNTRIES[upper]) {
return upper
}
// Try to find in name mappings
const lower = trimmed.toLowerCase()
const code = COUNTRY_NAME_TO_CODE[lower]
if (code) return code
// Try partial matching for country names
for (const [name, countryCode] of Object.entries(COUNTRY_NAME_TO_CODE)) {
if (lower.includes(name) || name.includes(lower)) {
return countryCode
}
}
return null
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,30 @@
export type FileTypeCategory = {
id: string
label: string
mimeTypes: string[]
extensions: string[]
}
export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
{ id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
{ id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
{ id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
{ id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
{ id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
{ id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
]
/** 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)
}
/** Convert category IDs to flat mime type array */
export function categoriesToMimeTypes(categoryIds: string[]): string[] {
return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
(cat) => cat.mimeTypes
)
}
export type FileTypeCategory = {
id: string
label: string
mimeTypes: string[]
extensions: string[]
}
export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
{ id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
{ id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
{ id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
{ id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
{ id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
{ id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
]
/** 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)
}
/** Convert category IDs to flat mime type array */
export function categoriesToMimeTypes(categoryIds: string[]): string[] {
return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
(cat) => cat.mimeTypes
)
}

View File

@@ -1,132 +1,132 @@
import * as Minio from 'minio'
// MinIO client singleton (lazy-initialized to avoid build-time errors)
const globalForMinio = globalThis as unknown as {
minio: Minio.Client | undefined
}
// Internal endpoint for server-to-server communication
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000'
// Public endpoint for browser-accessible URLs (pre-signed URLs)
// If not set, falls back to internal endpoint
export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT
function createMinioClient(): Minio.Client {
const url = new URL(MINIO_ENDPOINT)
const accessKey = process.env.MINIO_ACCESS_KEY
const secretKey = process.env.MINIO_SECRET_KEY
if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production')
}
return new Minio.Client({
endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:',
accessKey: accessKey || 'minioadmin',
secretKey: secretKey || 'minioadmin',
})
}
/**
* Get the MinIO client instance (lazy-initialized).
* The client is only created on first access, not at module import time.
* This prevents build-time errors when env vars are not available.
*/
export function getMinioClient(): Minio.Client {
if (!globalForMinio.minio) {
globalForMinio.minio = createMinioClient()
}
return globalForMinio.minio
}
// Backward-compatible export — lazy getter via Proxy
export const minio: Minio.Client = new Proxy({} as Minio.Client, {
get(_target, prop, receiver) {
return Reflect.get(getMinioClient(), prop, receiver)
},
})
// Default bucket name
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files'
/**
* Replace internal endpoint with public endpoint in a URL
*/
function replaceEndpoint(url: string): string {
if (MINIO_PUBLIC_ENDPOINT === MINIO_ENDPOINT) {
return url
}
try {
const internalUrl = new URL(MINIO_ENDPOINT)
const publicUrl = new URL(MINIO_PUBLIC_ENDPOINT)
return url.replace(
`${internalUrl.protocol}//${internalUrl.host}`,
`${publicUrl.protocol}//${publicUrl.host}`
)
} catch {
return url
}
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Generate a pre-signed URL for file download or upload
* Uses MINIO_PUBLIC_ENDPOINT for browser-accessible URLs
*/
export async function getPresignedUrl(
bucket: string,
objectKey: string,
method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900 // 15 minutes default
): Promise<string> {
let url: string
if (method === 'GET') {
url = await minio.presignedGetObject(bucket, objectKey, expirySeconds)
} else {
url = await minio.presignedPutObject(bucket, objectKey, expirySeconds)
}
// Replace internal endpoint with public endpoint for browser access
return replaceEndpoint(url)
}
/**
* Check if a bucket exists, create if not
*/
export async function ensureBucket(bucket: string): Promise<void> {
const exists = await minio.bucketExists(bucket)
if (!exists) {
await minio.makeBucket(bucket)
console.log(`Created MinIO bucket: ${bucket}`)
}
}
/**
* Delete an object from MinIO
*/
export async function deleteObject(
bucket: string,
objectKey: string
): Promise<void> {
await minio.removeObject(bucket, objectKey)
}
/**
* Generate a unique object key for a project file
*/
export function generateObjectKey(
projectId: string,
fileName: string
): string {
const timestamp = Date.now()
const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
return `projects/${projectId}/${timestamp}-${sanitizedName}`
}
import * as Minio from 'minio'
// MinIO client singleton (lazy-initialized to avoid build-time errors)
const globalForMinio = globalThis as unknown as {
minio: Minio.Client | undefined
}
// Internal endpoint for server-to-server communication
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000'
// Public endpoint for browser-accessible URLs (pre-signed URLs)
// If not set, falls back to internal endpoint
export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT
function createMinioClient(): Minio.Client {
const url = new URL(MINIO_ENDPOINT)
const accessKey = process.env.MINIO_ACCESS_KEY
const secretKey = process.env.MINIO_SECRET_KEY
if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production')
}
return new Minio.Client({
endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:',
accessKey: accessKey || 'minioadmin',
secretKey: secretKey || 'minioadmin',
})
}
/**
* Get the MinIO client instance (lazy-initialized).
* The client is only created on first access, not at module import time.
* This prevents build-time errors when env vars are not available.
*/
export function getMinioClient(): Minio.Client {
if (!globalForMinio.minio) {
globalForMinio.minio = createMinioClient()
}
return globalForMinio.minio
}
// Backward-compatible export — lazy getter via Proxy
export const minio: Minio.Client = new Proxy({} as Minio.Client, {
get(_target, prop, receiver) {
return Reflect.get(getMinioClient(), prop, receiver)
},
})
// Default bucket name
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files'
/**
* Replace internal endpoint with public endpoint in a URL
*/
function replaceEndpoint(url: string): string {
if (MINIO_PUBLIC_ENDPOINT === MINIO_ENDPOINT) {
return url
}
try {
const internalUrl = new URL(MINIO_ENDPOINT)
const publicUrl = new URL(MINIO_PUBLIC_ENDPOINT)
return url.replace(
`${internalUrl.protocol}//${internalUrl.host}`,
`${publicUrl.protocol}//${publicUrl.host}`
)
} catch {
return url
}
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Generate a pre-signed URL for file download or upload
* Uses MINIO_PUBLIC_ENDPOINT for browser-accessible URLs
*/
export async function getPresignedUrl(
bucket: string,
objectKey: string,
method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900 // 15 minutes default
): Promise<string> {
let url: string
if (method === 'GET') {
url = await minio.presignedGetObject(bucket, objectKey, expirySeconds)
} else {
url = await minio.presignedPutObject(bucket, objectKey, expirySeconds)
}
// Replace internal endpoint with public endpoint for browser access
return replaceEndpoint(url)
}
/**
* Check if a bucket exists, create if not
*/
export async function ensureBucket(bucket: string): Promise<void> {
const exists = await minio.bucketExists(bucket)
if (!exists) {
await minio.makeBucket(bucket)
console.log(`Created MinIO bucket: ${bucket}`)
}
}
/**
* Delete an object from MinIO
*/
export async function deleteObject(
bucket: string,
objectKey: string
): Promise<void> {
await minio.removeObject(bucket, objectKey)
}
/**
* Generate a unique object key for a project file
*/
export function generateObjectKey(
projectId: string,
fileName: string
): string {
const timestamp = Date.now()
const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
return `projects/${projectId}/${timestamp}-${sanitizedName}`
}

View File

@@ -1,93 +1,93 @@
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 12
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}
interface PasswordValidation {
valid: boolean
errors: string[]
}
/**
* Validate password meets requirements:
* - Minimum 8 characters
* - At least one uppercase letter
* - At least one lowercase letter
* - At least one number
*/
export function validatePassword(password: string): PasswordValidation {
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters long')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number')
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Get password strength score (0-4)
* 0 = very weak, 4 = very strong
*/
export function getPasswordStrength(password: string): {
score: number
label: 'Very Weak' | 'Weak' | 'Fair' | 'Strong' | 'Very Strong'
} {
let score = 0
// Length
if (password.length >= 8) score++
if (password.length >= 12) score++
// Character variety
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^a-zA-Z0-9]/.test(password)) score++
// Normalize to 0-4
const normalizedScore = Math.min(4, score)
const labels: Record<number, 'Very Weak' | 'Weak' | 'Fair' | 'Strong' | 'Very Strong'> = {
0: 'Very Weak',
1: 'Weak',
2: 'Fair',
3: 'Strong',
4: 'Very Strong',
}
return {
score: normalizedScore,
label: labels[normalizedScore],
}
}
import bcrypt from 'bcryptjs'
const SALT_ROUNDS = 12
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}
interface PasswordValidation {
valid: boolean
errors: string[]
}
/**
* Validate password meets requirements:
* - Minimum 8 characters
* - At least one uppercase letter
* - At least one lowercase letter
* - At least one number
*/
export function validatePassword(password: string): PasswordValidation {
const errors: string[] = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters long')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number')
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Get password strength score (0-4)
* 0 = very weak, 4 = very strong
*/
export function getPasswordStrength(password: string): {
score: number
label: 'Very Weak' | 'Weak' | 'Fair' | 'Strong' | 'Very Strong'
} {
let score = 0
// Length
if (password.length >= 8) score++
if (password.length >= 12) score++
// Character variety
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^a-zA-Z0-9]/.test(password)) score++
// Normalize to 0-4
const normalizedScore = Math.min(4, score)
const labels: Record<number, 'Very Weak' | 'Weak' | 'Fair' | 'Strong' | 'Very Strong'> = {
0: 'Very Weak',
1: 'Weak',
2: 'Fair',
3: 'Strong',
4: 'Very Strong',
}
return {
score: normalizedScore,
label: labels[normalizedScore],
}
}

View File

@@ -1,422 +1,422 @@
import { jsPDF } from 'jspdf'
import { autoTable } from 'jspdf-autotable'
import html2canvas from 'html2canvas'
// =========================================================================
// Brand constants
// =========================================================================
const COLORS = {
darkBlue: '#053d57',
red: '#de0f1e',
teal: '#557f8c',
lightGray: '#f0f4f8',
white: '#ffffff',
textDark: '#1a1a1a',
textMuted: '#888888',
} as const
const DARK_BLUE_RGB: [number, number, number] = [5, 61, 87]
const TEAL_RGB: [number, number, number] = [85, 127, 140]
const RED_RGB: [number, number, number] = [222, 15, 30]
const LIGHT_GRAY_RGB: [number, number, number] = [240, 244, 248]
const PAGE_WIDTH = 210 // A4 mm
const PAGE_HEIGHT = 297
const MARGIN = 15
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2
// =========================================================================
// Font & logo caching
// =========================================================================
let cachedFonts: { regular: string; bold: string } | null = null
let cachedLogo: string | null = null
let fontLoadAttempted = false
let logoLoadAttempted = false
async function loadFonts(): Promise<{ regular: string; bold: string } | null> {
if (cachedFonts) return cachedFonts
if (fontLoadAttempted) return null
fontLoadAttempted = true
try {
const [regularRes, boldRes] = await Promise.all([
fetch('/fonts/Montserrat-Regular.ttf'),
fetch('/fonts/Montserrat-Bold.ttf'),
])
if (!regularRes.ok || !boldRes.ok) return null
const [regularBuf, boldBuf] = await Promise.all([
regularRes.arrayBuffer(),
boldRes.arrayBuffer(),
])
const toBase64 = (buf: ArrayBuffer) => {
const bytes = new Uint8Array(buf)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
cachedFonts = { regular: toBase64(regularBuf), bold: toBase64(boldBuf) }
return cachedFonts
} catch {
return null
}
}
async function loadLogo(): Promise<string | null> {
if (cachedLogo) return cachedLogo
if (logoLoadAttempted) return null
logoLoadAttempted = true
try {
const res = await fetch('/images/MOPC-blue-long.png')
if (!res.ok) return null
const blob = await res.blob()
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
cachedLogo = reader.result as string
resolve(cachedLogo)
}
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
} catch {
return null
}
}
// =========================================================================
// Document creation
// =========================================================================
export interface ReportDocumentOptions {
orientation?: 'portrait' | 'landscape'
}
export async function createReportDocument(
options?: ReportDocumentOptions
): Promise<jsPDF> {
const doc = new jsPDF({
orientation: options?.orientation || 'portrait',
unit: 'mm',
format: 'a4',
})
// Load and register fonts
const fonts = await loadFonts()
if (fonts) {
doc.addFileToVFS('Montserrat-Regular.ttf', fonts.regular)
doc.addFont('Montserrat-Regular.ttf', 'Montserrat', 'normal')
doc.addFileToVFS('Montserrat-Bold.ttf', fonts.bold)
doc.addFont('Montserrat-Bold.ttf', 'Montserrat', 'bold')
doc.setFont('Montserrat', 'normal')
} else {
doc.setFont('helvetica', 'normal')
}
return doc
}
// =========================================================================
// Cover page
// =========================================================================
export interface CoverPageOptions {
title: string
subtitle?: string
roundName?: string
programName?: string
}
export async function addCoverPage(
doc: jsPDF,
options: CoverPageOptions
): Promise<void> {
const logo = await loadLogo()
// Logo centered
if (logo) {
const logoWidth = 80
const logoHeight = 20
const logoX = (PAGE_WIDTH - logoWidth) / 2
doc.addImage(logo, 'PNG', logoX, 60, logoWidth, logoHeight)
}
// Title
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(24)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(options.title, PAGE_WIDTH / 2, logo ? 110 : 100, { align: 'center' })
// Subtitle
if (options.subtitle) {
doc.setFont(fontName, 'normal')
doc.setFontSize(14)
doc.setTextColor(...TEAL_RGB)
doc.text(options.subtitle, PAGE_WIDTH / 2, logo ? 125 : 115, { align: 'center' })
}
// Round & program
let infoY = logo ? 145 : 135
doc.setFontSize(12)
doc.setTextColor(...DARK_BLUE_RGB)
if (options.programName) {
doc.text(options.programName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
if (options.roundName) {
doc.setFont(fontName, 'bold')
doc.text(options.roundName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
// Date
doc.setFont(fontName, 'normal')
doc.setFontSize(10)
doc.setTextColor(136, 136, 136)
doc.text(
`Generated on ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`,
PAGE_WIDTH / 2,
infoY + 10,
{ align: 'center' }
)
// Decorative line
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN + 30, infoY + 20, PAGE_WIDTH - MARGIN - 30, infoY + 20)
}
// =========================================================================
// Header (on content pages)
// =========================================================================
export async function addHeader(doc: jsPDF, title: string): Promise<void> {
const logo = await loadLogo()
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 8, 30, 8)
}
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(11)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, PAGE_WIDTH / 2, 14, { align: 'center' })
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(136, 136, 136)
doc.text(
new Date().toLocaleDateString('en-GB'),
PAGE_WIDTH - MARGIN,
14,
{ align: 'right' }
)
// Line under header
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.3)
doc.line(MARGIN, 18, PAGE_WIDTH - MARGIN, 18)
}
// =========================================================================
// Footer
// =========================================================================
export function addFooter(
doc: jsPDF,
pageNumber: number,
totalPages: number
): void {
const fontName = getFont(doc)
const y = PAGE_HEIGHT - 10
doc.setFont(fontName, 'normal')
doc.setFontSize(7)
doc.setTextColor(136, 136, 136)
doc.text('Generated by MOPC Platform', MARGIN, y)
doc.text('Confidential', PAGE_WIDTH / 2, y, { align: 'center' })
doc.text(`Page ${pageNumber} of ${totalPages}`, PAGE_WIDTH - MARGIN, y, {
align: 'right',
})
}
export function addAllPageFooters(doc: jsPDF): void {
const totalPages = doc.getNumberOfPages()
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i)
addFooter(doc, i, totalPages)
}
}
// =========================================================================
// Section title
// =========================================================================
export function addSectionTitle(doc: jsPDF, title: string, y: number): number {
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(16)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, MARGIN, y)
// Teal underline
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN, y + 2, MARGIN + doc.getTextWidth(title), y + 2)
return y + 12
}
// =========================================================================
// Stat cards row
// =========================================================================
export function addStatCards(
doc: jsPDF,
stats: Array<{ label: string; value: string | number }>,
y: number
): number {
const fontName = getFont(doc)
const cardCount = Math.min(stats.length, 4)
const gap = 4
const cardWidth = (CONTENT_WIDTH - gap * (cardCount - 1)) / cardCount
const cardHeight = 22
for (let i = 0; i < cardCount; i++) {
const x = MARGIN + i * (cardWidth + gap)
// Card background
doc.setFillColor(...LIGHT_GRAY_RGB)
doc.roundedRect(x, y, cardWidth, cardHeight, 2, 2, 'F')
// Value
doc.setFont(fontName, 'bold')
doc.setFontSize(18)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(String(stats[i].value), x + cardWidth / 2, y + 10, {
align: 'center',
})
// Label
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(...TEAL_RGB)
doc.text(stats[i].label, x + cardWidth / 2, y + 18, { align: 'center' })
}
return y + cardHeight + 8
}
// =========================================================================
// Table via autoTable
// =========================================================================
export function addTable(
doc: jsPDF,
headers: string[],
rows: (string | number)[][],
y: number
): number {
const fontName = getFont(doc)
autoTable(doc, {
startY: y,
head: [headers],
body: rows,
margin: { left: MARGIN, right: MARGIN },
styles: {
font: fontName,
fontSize: 9,
cellPadding: 3,
textColor: [26, 26, 26],
},
headStyles: {
fillColor: DARK_BLUE_RGB,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 9,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
theme: 'grid',
tableLineColor: [220, 220, 220],
tableLineWidth: 0.1,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finalY = (doc as any).lastAutoTable?.finalY ?? y + 20
return finalY + 8
}
// =========================================================================
// Chart image capture
// =========================================================================
export async function addChartImage(
doc: jsPDF,
element: HTMLElement,
y: number,
options?: { maxHeight?: number }
): Promise<number> {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: COLORS.white,
logging: false,
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const imgWidth = CONTENT_WIDTH
const ratio = canvas.height / canvas.width
let imgHeight = imgWidth * ratio
const maxH = options?.maxHeight || 100
if (imgHeight > maxH) {
imgHeight = maxH
}
// Check page break
y = checkPageBreak(doc, y, imgHeight + 5)
doc.addImage(imgData, 'JPEG', MARGIN, y, imgWidth, imgHeight)
return y + imgHeight + 8
}
// =========================================================================
// Page break helper
// =========================================================================
export function checkPageBreak(
doc: jsPDF,
y: number,
neededHeight: number
): number {
const availableHeight = PAGE_HEIGHT - 20 // leave room for footer
if (y + neededHeight > availableHeight) {
doc.addPage()
return 25 // start below header area
}
return y
}
export function addPageBreak(doc: jsPDF): void {
doc.addPage()
}
// =========================================================================
// Save
// =========================================================================
export function savePdf(doc: jsPDF, filename: string): void {
doc.save(filename)
}
// =========================================================================
// Helper
// =========================================================================
function getFont(doc: jsPDF): string {
// Check if Montserrat was loaded
try {
const fonts = doc.getFontList()
if (fonts['Montserrat']) return 'Montserrat'
} catch {
// Fallback
}
return 'helvetica'
}
import { jsPDF } from 'jspdf'
import { autoTable } from 'jspdf-autotable'
import html2canvas from 'html2canvas'
// =========================================================================
// Brand constants
// =========================================================================
const COLORS = {
darkBlue: '#053d57',
red: '#de0f1e',
teal: '#557f8c',
lightGray: '#f0f4f8',
white: '#ffffff',
textDark: '#1a1a1a',
textMuted: '#888888',
} as const
const DARK_BLUE_RGB: [number, number, number] = [5, 61, 87]
const TEAL_RGB: [number, number, number] = [85, 127, 140]
const RED_RGB: [number, number, number] = [222, 15, 30]
const LIGHT_GRAY_RGB: [number, number, number] = [240, 244, 248]
const PAGE_WIDTH = 210 // A4 mm
const PAGE_HEIGHT = 297
const MARGIN = 15
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2
// =========================================================================
// Font & logo caching
// =========================================================================
let cachedFonts: { regular: string; bold: string } | null = null
let cachedLogo: string | null = null
let fontLoadAttempted = false
let logoLoadAttempted = false
async function loadFonts(): Promise<{ regular: string; bold: string } | null> {
if (cachedFonts) return cachedFonts
if (fontLoadAttempted) return null
fontLoadAttempted = true
try {
const [regularRes, boldRes] = await Promise.all([
fetch('/fonts/Montserrat-Regular.ttf'),
fetch('/fonts/Montserrat-Bold.ttf'),
])
if (!regularRes.ok || !boldRes.ok) return null
const [regularBuf, boldBuf] = await Promise.all([
regularRes.arrayBuffer(),
boldRes.arrayBuffer(),
])
const toBase64 = (buf: ArrayBuffer) => {
const bytes = new Uint8Array(buf)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
cachedFonts = { regular: toBase64(regularBuf), bold: toBase64(boldBuf) }
return cachedFonts
} catch {
return null
}
}
async function loadLogo(): Promise<string | null> {
if (cachedLogo) return cachedLogo
if (logoLoadAttempted) return null
logoLoadAttempted = true
try {
const res = await fetch('/images/MOPC-blue-long.png')
if (!res.ok) return null
const blob = await res.blob()
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
cachedLogo = reader.result as string
resolve(cachedLogo)
}
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
} catch {
return null
}
}
// =========================================================================
// Document creation
// =========================================================================
export interface ReportDocumentOptions {
orientation?: 'portrait' | 'landscape'
}
export async function createReportDocument(
options?: ReportDocumentOptions
): Promise<jsPDF> {
const doc = new jsPDF({
orientation: options?.orientation || 'portrait',
unit: 'mm',
format: 'a4',
})
// Load and register fonts
const fonts = await loadFonts()
if (fonts) {
doc.addFileToVFS('Montserrat-Regular.ttf', fonts.regular)
doc.addFont('Montserrat-Regular.ttf', 'Montserrat', 'normal')
doc.addFileToVFS('Montserrat-Bold.ttf', fonts.bold)
doc.addFont('Montserrat-Bold.ttf', 'Montserrat', 'bold')
doc.setFont('Montserrat', 'normal')
} else {
doc.setFont('helvetica', 'normal')
}
return doc
}
// =========================================================================
// Cover page
// =========================================================================
export interface CoverPageOptions {
title: string
subtitle?: string
roundName?: string
programName?: string
}
export async function addCoverPage(
doc: jsPDF,
options: CoverPageOptions
): Promise<void> {
const logo = await loadLogo()
// Logo centered
if (logo) {
const logoWidth = 80
const logoHeight = 20
const logoX = (PAGE_WIDTH - logoWidth) / 2
doc.addImage(logo, 'PNG', logoX, 60, logoWidth, logoHeight)
}
// Title
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(24)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(options.title, PAGE_WIDTH / 2, logo ? 110 : 100, { align: 'center' })
// Subtitle
if (options.subtitle) {
doc.setFont(fontName, 'normal')
doc.setFontSize(14)
doc.setTextColor(...TEAL_RGB)
doc.text(options.subtitle, PAGE_WIDTH / 2, logo ? 125 : 115, { align: 'center' })
}
// Round & program
let infoY = logo ? 145 : 135
doc.setFontSize(12)
doc.setTextColor(...DARK_BLUE_RGB)
if (options.programName) {
doc.text(options.programName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
if (options.roundName) {
doc.setFont(fontName, 'bold')
doc.text(options.roundName, PAGE_WIDTH / 2, infoY, { align: 'center' })
infoY += 8
}
// Date
doc.setFont(fontName, 'normal')
doc.setFontSize(10)
doc.setTextColor(136, 136, 136)
doc.text(
`Generated on ${new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`,
PAGE_WIDTH / 2,
infoY + 10,
{ align: 'center' }
)
// Decorative line
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN + 30, infoY + 20, PAGE_WIDTH - MARGIN - 30, infoY + 20)
}
// =========================================================================
// Header (on content pages)
// =========================================================================
export async function addHeader(doc: jsPDF, title: string): Promise<void> {
const logo = await loadLogo()
if (logo) {
doc.addImage(logo, 'PNG', MARGIN, 8, 30, 8)
}
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(11)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, PAGE_WIDTH / 2, 14, { align: 'center' })
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(136, 136, 136)
doc.text(
new Date().toLocaleDateString('en-GB'),
PAGE_WIDTH - MARGIN,
14,
{ align: 'right' }
)
// Line under header
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.3)
doc.line(MARGIN, 18, PAGE_WIDTH - MARGIN, 18)
}
// =========================================================================
// Footer
// =========================================================================
export function addFooter(
doc: jsPDF,
pageNumber: number,
totalPages: number
): void {
const fontName = getFont(doc)
const y = PAGE_HEIGHT - 10
doc.setFont(fontName, 'normal')
doc.setFontSize(7)
doc.setTextColor(136, 136, 136)
doc.text('Generated by MOPC Platform', MARGIN, y)
doc.text('Confidential', PAGE_WIDTH / 2, y, { align: 'center' })
doc.text(`Page ${pageNumber} of ${totalPages}`, PAGE_WIDTH - MARGIN, y, {
align: 'right',
})
}
export function addAllPageFooters(doc: jsPDF): void {
const totalPages = doc.getNumberOfPages()
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i)
addFooter(doc, i, totalPages)
}
}
// =========================================================================
// Section title
// =========================================================================
export function addSectionTitle(doc: jsPDF, title: string, y: number): number {
const fontName = getFont(doc)
doc.setFont(fontName, 'bold')
doc.setFontSize(16)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(title, MARGIN, y)
// Teal underline
doc.setDrawColor(...TEAL_RGB)
doc.setLineWidth(0.5)
doc.line(MARGIN, y + 2, MARGIN + doc.getTextWidth(title), y + 2)
return y + 12
}
// =========================================================================
// Stat cards row
// =========================================================================
export function addStatCards(
doc: jsPDF,
stats: Array<{ label: string; value: string | number }>,
y: number
): number {
const fontName = getFont(doc)
const cardCount = Math.min(stats.length, 4)
const gap = 4
const cardWidth = (CONTENT_WIDTH - gap * (cardCount - 1)) / cardCount
const cardHeight = 22
for (let i = 0; i < cardCount; i++) {
const x = MARGIN + i * (cardWidth + gap)
// Card background
doc.setFillColor(...LIGHT_GRAY_RGB)
doc.roundedRect(x, y, cardWidth, cardHeight, 2, 2, 'F')
// Value
doc.setFont(fontName, 'bold')
doc.setFontSize(18)
doc.setTextColor(...DARK_BLUE_RGB)
doc.text(String(stats[i].value), x + cardWidth / 2, y + 10, {
align: 'center',
})
// Label
doc.setFont(fontName, 'normal')
doc.setFontSize(8)
doc.setTextColor(...TEAL_RGB)
doc.text(stats[i].label, x + cardWidth / 2, y + 18, { align: 'center' })
}
return y + cardHeight + 8
}
// =========================================================================
// Table via autoTable
// =========================================================================
export function addTable(
doc: jsPDF,
headers: string[],
rows: (string | number)[][],
y: number
): number {
const fontName = getFont(doc)
autoTable(doc, {
startY: y,
head: [headers],
body: rows,
margin: { left: MARGIN, right: MARGIN },
styles: {
font: fontName,
fontSize: 9,
cellPadding: 3,
textColor: [26, 26, 26],
},
headStyles: {
fillColor: DARK_BLUE_RGB,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 9,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
theme: 'grid',
tableLineColor: [220, 220, 220],
tableLineWidth: 0.1,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finalY = (doc as any).lastAutoTable?.finalY ?? y + 20
return finalY + 8
}
// =========================================================================
// Chart image capture
// =========================================================================
export async function addChartImage(
doc: jsPDF,
element: HTMLElement,
y: number,
options?: { maxHeight?: number }
): Promise<number> {
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
backgroundColor: COLORS.white,
logging: false,
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const imgWidth = CONTENT_WIDTH
const ratio = canvas.height / canvas.width
let imgHeight = imgWidth * ratio
const maxH = options?.maxHeight || 100
if (imgHeight > maxH) {
imgHeight = maxH
}
// Check page break
y = checkPageBreak(doc, y, imgHeight + 5)
doc.addImage(imgData, 'JPEG', MARGIN, y, imgWidth, imgHeight)
return y + imgHeight + 8
}
// =========================================================================
// Page break helper
// =========================================================================
export function checkPageBreak(
doc: jsPDF,
y: number,
neededHeight: number
): number {
const availableHeight = PAGE_HEIGHT - 20 // leave room for footer
if (y + neededHeight > availableHeight) {
doc.addPage()
return 25 // start below header area
}
return y
}
export function addPageBreak(doc: jsPDF): void {
doc.addPage()
}
// =========================================================================
// Save
// =========================================================================
export function savePdf(doc: jsPDF, filename: string): void {
doc.save(filename)
}
// =========================================================================
// Helper
// =========================================================================
function getFont(doc: jsPDF): string {
// Check if Montserrat was loaded
try {
const fonts = doc.getFontList()
if (fonts['Montserrat']) return 'Montserrat'
} catch {
// Fallback
}
return 'helvetica'
}

View File

@@ -1,144 +1,144 @@
import type {
IntakeConfig,
FilterConfig,
EvaluationConfig,
SelectionConfig,
LiveFinalConfig,
ResultsConfig,
WizardStageConfig,
WizardTrackConfig,
WizardState,
} from '@/types/pipeline-wizard'
export function defaultIntakeConfig(): IntakeConfig {
return {
submissionWindowEnabled: true,
lateSubmissionPolicy: 'flag',
lateGraceHours: 24,
fileRequirements: [
{
name: 'Executive Summary',
description: 'A PDF executive summary of your project',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: true,
},
],
}
}
export function defaultFilterConfig(): FilterConfig {
return {
rules: [],
aiRubricEnabled: false,
aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true,
}
}
export function defaultEvaluationConfig(): EvaluationConfig {
return {
requiredReviews: 3,
maxLoadPerJuror: 20,
minLoadPerJuror: 5,
availabilityWeighting: true,
overflowPolicy: 'queue',
}
}
export function defaultSelectionConfig(): SelectionConfig {
return {
finalistCount: undefined,
rankingMethod: 'score_average',
tieBreaker: 'admin_decides',
}
}
export function defaultLiveConfig(): LiveFinalConfig {
return {
juryVotingEnabled: true,
audienceVotingEnabled: false,
audienceVoteWeight: 0,
cohortSetupMode: 'manual',
revealPolicy: 'ceremony',
}
}
export function defaultResultsConfig(): ResultsConfig {
return {
publicationMode: 'manual',
showDetailedScores: false,
showRankings: true,
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function defaultMainTrackStages(): WizardStageConfig[] {
return [
{ name: 'Intake', slug: 'intake', stageType: 'INTAKE', sortOrder: 0, configJson: defaultIntakeConfig() as unknown as Record<string, unknown> },
{ name: 'Filtering', slug: 'filtering', stageType: 'FILTER', sortOrder: 1, configJson: defaultFilterConfig() as unknown as Record<string, unknown> },
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 2, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Selection', slug: 'selection', stageType: 'SELECTION', sortOrder: 3, configJson: defaultSelectionConfig() as unknown as Record<string, unknown> },
{ name: 'Live Finals', slug: 'live-finals', stageType: 'LIVE_FINAL', sortOrder: 4, configJson: defaultLiveConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 5, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
]
}
export function defaultMainTrack(): WizardTrackConfig {
return {
name: 'Main Competition',
slug: 'main-competition',
kind: 'MAIN',
sortOrder: 0,
stages: defaultMainTrackStages(),
}
}
export function defaultAwardTrack(index: number): WizardTrackConfig {
const name = `Award ${index + 1}`
return {
name,
slug: slugify(name),
kind: 'AWARD',
sortOrder: index + 1,
routingModeDefault: 'PARALLEL',
decisionMode: 'JURY_VOTE',
stages: [
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 0, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 1, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
],
awardConfig: { name, scoringMode: 'PICK_WINNER' },
}
}
export function defaultNotificationConfig(): Record<string, boolean> {
return {
'stage.transitioned': true,
'filtering.completed': true,
'assignment.generated': true,
'routing.executed': true,
'live.cursor.updated': true,
'cohort.window.changed': true,
'decision.overridden': true,
'award.winner.finalized': true,
}
}
export function defaultWizardState(programId: string): WizardState {
return {
name: '',
slug: '',
programId,
settingsJson: {},
tracks: [defaultMainTrack()],
notificationConfig: defaultNotificationConfig(),
overridePolicy: { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
}
}
import type {
IntakeConfig,
FilterConfig,
EvaluationConfig,
SelectionConfig,
LiveFinalConfig,
ResultsConfig,
WizardStageConfig,
WizardTrackConfig,
WizardState,
} from '@/types/pipeline-wizard'
export function defaultIntakeConfig(): IntakeConfig {
return {
submissionWindowEnabled: true,
lateSubmissionPolicy: 'flag',
lateGraceHours: 24,
fileRequirements: [
{
name: 'Executive Summary',
description: 'A PDF executive summary of your project',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: true,
},
],
}
}
export function defaultFilterConfig(): FilterConfig {
return {
rules: [],
aiRubricEnabled: false,
aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true,
}
}
export function defaultEvaluationConfig(): EvaluationConfig {
return {
requiredReviews: 3,
maxLoadPerJuror: 20,
minLoadPerJuror: 5,
availabilityWeighting: true,
overflowPolicy: 'queue',
}
}
export function defaultSelectionConfig(): SelectionConfig {
return {
finalistCount: undefined,
rankingMethod: 'score_average',
tieBreaker: 'admin_decides',
}
}
export function defaultLiveConfig(): LiveFinalConfig {
return {
juryVotingEnabled: true,
audienceVotingEnabled: false,
audienceVoteWeight: 0,
cohortSetupMode: 'manual',
revealPolicy: 'ceremony',
}
}
export function defaultResultsConfig(): ResultsConfig {
return {
publicationMode: 'manual',
showDetailedScores: false,
showRankings: true,
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function defaultMainTrackStages(): WizardStageConfig[] {
return [
{ name: 'Intake', slug: 'intake', stageType: 'INTAKE', sortOrder: 0, configJson: defaultIntakeConfig() as unknown as Record<string, unknown> },
{ name: 'Filtering', slug: 'filtering', stageType: 'FILTER', sortOrder: 1, configJson: defaultFilterConfig() as unknown as Record<string, unknown> },
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 2, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Selection', slug: 'selection', stageType: 'SELECTION', sortOrder: 3, configJson: defaultSelectionConfig() as unknown as Record<string, unknown> },
{ name: 'Live Finals', slug: 'live-finals', stageType: 'LIVE_FINAL', sortOrder: 4, configJson: defaultLiveConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 5, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
]
}
export function defaultMainTrack(): WizardTrackConfig {
return {
name: 'Main Competition',
slug: 'main-competition',
kind: 'MAIN',
sortOrder: 0,
stages: defaultMainTrackStages(),
}
}
export function defaultAwardTrack(index: number): WizardTrackConfig {
const name = `Award ${index + 1}`
return {
name,
slug: slugify(name),
kind: 'AWARD',
sortOrder: index + 1,
routingModeDefault: 'PARALLEL',
decisionMode: 'JURY_VOTE',
stages: [
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 0, configJson: defaultEvaluationConfig() as unknown as Record<string, unknown> },
{ name: 'Results', slug: 'results', stageType: 'RESULTS', sortOrder: 1, configJson: defaultResultsConfig() as unknown as Record<string, unknown> },
],
awardConfig: { name, scoringMode: 'PICK_WINNER' },
}
}
export function defaultNotificationConfig(): Record<string, boolean> {
return {
'stage.transitioned': true,
'filtering.completed': true,
'assignment.generated': true,
'routing.executed': true,
'live.cursor.updated': true,
'cohort.window.changed': true,
'decision.overridden': true,
'award.winner.finalized': true,
}
}
export function defaultWizardState(programId: string): WizardState {
return {
name: '',
slug: '',
programId,
settingsJson: {},
tracks: [defaultMainTrack()],
notificationConfig: defaultNotificationConfig(),
overridePolicy: { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
}
}

View File

@@ -1,112 +1,149 @@
import type { ValidationResult, WizardState, WizardTrackConfig, WizardStageConfig } from '@/types/pipeline-wizard'
function ok(): ValidationResult {
return { valid: true, errors: [], warnings: [] }
}
function fail(errors: string[], warnings: string[] = []): ValidationResult {
return { valid: false, errors, warnings }
}
export function validateBasics(state: WizardState): ValidationResult {
const errors: string[] = []
if (!state.name.trim()) errors.push('Pipeline name is required')
if (!state.slug.trim()) errors.push('Pipeline slug is required')
else if (!/^[a-z0-9-]+$/.test(state.slug)) errors.push('Slug must be lowercase alphanumeric with hyphens only')
if (!state.programId) errors.push('Program must be selected')
return errors.length ? fail(errors) : ok()
}
import { parseAndValidateStageConfig } from '@/lib/stage-config-schema'
function ok(): ValidationResult {
return { valid: true, errors: [], warnings: [] }
}
function fail(errors: string[], warnings: string[] = []): ValidationResult {
return { valid: false, errors, warnings }
}
export function validateBasics(state: WizardState): ValidationResult {
const errors: string[] = []
if (!state.name.trim()) errors.push('Pipeline name is required')
if (!state.slug.trim()) errors.push('Pipeline slug is required')
else if (!/^[a-z0-9-]+$/.test(state.slug)) errors.push('Slug must be lowercase alphanumeric with hyphens only')
if (!state.programId) errors.push('Program must be selected')
return errors.length ? fail(errors) : ok()
}
export function validateStage(stage: WizardStageConfig): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!stage.name.trim()) errors.push(`Stage name is required`)
if (!stage.slug.trim()) errors.push(`Stage slug is required`)
else if (!/^[a-z0-9-]+$/.test(stage.slug)) errors.push(`Stage slug "${stage.slug}" is invalid`)
return errors.length ? fail(errors) : ok()
}
export function validateTrack(track: WizardTrackConfig): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!track.name.trim()) errors.push('Track name is required')
if (!track.slug.trim()) errors.push('Track slug is required')
if (track.stages.length === 0) errors.push(`Track "${track.name}" must have at least one stage`)
// Check for duplicate slugs within track
const slugs = new Set<string>()
for (const stage of track.stages) {
if (slugs.has(stage.slug)) {
errors.push(`Duplicate stage slug "${stage.slug}" in track "${track.name}"`)
}
slugs.add(stage.slug)
const stageResult = validateStage(stage)
errors.push(...stageResult.errors)
try {
parseAndValidateStageConfig(stage.stageType, stage.configJson, {
strictUnknownKeys: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid stage config'
errors.push(`Stage "${stage.name || stage.slug}" config invalid: ${message}`)
}
if (stage.windowOpenAt && stage.windowCloseAt && stage.windowCloseAt <= stage.windowOpenAt) {
errors.push(`Stage "${stage.name || stage.slug}" close window must be after open window`)
}
if (stage.stageType === 'SELECTION') {
const config = stage.configJson as Record<string, unknown>
if (config.finalistCount == null) {
warnings.push(`Selection stage "${stage.name || stage.slug}" has no finalist target`)
}
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateTrack(track: WizardTrackConfig): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (!track.name.trim()) errors.push('Track name is required')
if (!track.slug.trim()) errors.push('Track slug is required')
if (track.stages.length === 0) errors.push(`Track "${track.name}" must have at least one stage`)
// Check for duplicate slugs within track
const slugs = new Set<string>()
for (const stage of track.stages) {
if (slugs.has(stage.slug)) {
errors.push(`Duplicate stage slug "${stage.slug}" in track "${track.name}"`)
}
slugs.add(stage.slug)
const stageResult = validateStage(stage)
errors.push(...stageResult.errors)
}
// MAIN track should ideally have at least INTAKE and one other stage
if (track.kind === 'MAIN' && track.stages.length < 2) {
warnings.push('Main track should have at least 2 stages')
}
// AWARD tracks need awardConfig
if (track.kind === 'AWARD' && !track.awardConfig?.name) {
errors.push(`Award track "${track.name}" requires an award name`)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateTracks(tracks: WizardTrackConfig[]): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (tracks.length === 0) {
errors.push('At least one track is required')
return fail(errors)
}
const mainTracks = tracks.filter((t) => t.kind === 'MAIN')
if (mainTracks.length === 0) {
errors.push('At least one MAIN track is required')
} else if (mainTracks.length > 1) {
warnings.push('Multiple MAIN tracks detected — typically only one is needed')
}
// Check for duplicate track slugs
const trackSlugs = new Set<string>()
for (const track of tracks) {
if (trackSlugs.has(track.slug)) {
errors.push(`Duplicate track slug "${track.slug}"`)
if (track.kind === 'MAIN') {
const stageTypes = new Set(track.stages.map((s) => s.stageType))
const requiredStageTypes: Array<WizardStageConfig['stageType']> = [
'INTAKE',
'FILTER',
'EVALUATION',
]
for (const stageType of requiredStageTypes) {
if (!stageTypes.has(stageType)) {
warnings.push(`Main track is missing recommended ${stageType} stage`)
}
}
trackSlugs.add(track.slug)
const trackResult = validateTrack(track)
errors.push(...trackResult.errors)
warnings.push(...trackResult.warnings)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateNotifications(config: Record<string, boolean>): ValidationResult {
// Notifications are optional — just validate structure
return ok()
}
export function validateAll(state: WizardState): {
valid: boolean
sections: {
basics: ValidationResult
tracks: ValidationResult
notifications: ValidationResult
}
} {
const basics = validateBasics(state)
const tracks = validateTracks(state.tracks)
const notifications = validateNotifications(state.notificationConfig)
return {
valid: basics.valid && tracks.valid && notifications.valid,
sections: { basics, tracks, notifications },
}
}
// AWARD tracks need awardConfig
if (track.kind === 'AWARD' && !track.awardConfig?.name) {
errors.push(`Award track "${track.name}" requires an award name`)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateTracks(tracks: WizardTrackConfig[]): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
if (tracks.length === 0) {
errors.push('At least one track is required')
return fail(errors)
}
const mainTracks = tracks.filter((t) => t.kind === 'MAIN')
if (mainTracks.length === 0) {
errors.push('At least one MAIN track is required')
} else if (mainTracks.length > 1) {
warnings.push('Multiple MAIN tracks detected — typically only one is needed')
}
// Check for duplicate track slugs
const trackSlugs = new Set<string>()
for (const track of tracks) {
if (trackSlugs.has(track.slug)) {
errors.push(`Duplicate track slug "${track.slug}"`)
}
trackSlugs.add(track.slug)
const trackResult = validateTrack(track)
errors.push(...trackResult.errors)
warnings.push(...trackResult.warnings)
}
return errors.length ? fail(errors, warnings) : { valid: true, errors: [], warnings }
}
export function validateNotifications(config: Record<string, boolean>): ValidationResult {
// Notifications are optional — just validate structure
return ok()
}
export function validateAll(state: WizardState): {
valid: boolean
sections: {
basics: ValidationResult
tracks: ValidationResult
notifications: ValidationResult
}
} {
const basics = validateBasics(state)
const tracks = validateTracks(state.tracks)
const notifications = validateNotifications(state.notificationConfig)
return {
valid: basics.valid && tracks.valid && notifications.valid,
sections: { basics, tracks, notifications },
}
}

View File

@@ -1,26 +1,26 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
function getDatasourceUrl(): string | undefined {
const url = process.env.DATABASE_URL
if (!url) return undefined
// Append connection pool params if not already present
if (url.includes('connection_limit')) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}connection_limit=20&pool_timeout=10`
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
datasourceUrl: getDatasourceUrl(),
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
function getDatasourceUrl(): string | undefined {
const url = process.env.DATABASE_URL
if (!url) return undefined
// Append connection pool params if not already present
if (url.includes('connection_limit')) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}connection_limit=20&pool_timeout=10`
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
datasourceUrl: getDatasourceUrl(),
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

View File

@@ -0,0 +1,457 @@
import { z } from 'zod'
import type { StageType } from '@prisma/client'
const STAGE_TYPES = [
'INTAKE',
'FILTER',
'EVALUATION',
'SELECTION',
'LIVE_FINAL',
'RESULTS',
] as const
type StageTypeKey = (typeof STAGE_TYPES)[number]
type JsonObject = Record<string, unknown>
const fileRequirementSchema = z
.object({
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
maxSizeMB: z.number().int().min(1).max(5000).optional(),
isRequired: z.boolean().default(false),
})
.strict()
const intakeSchema = z
.object({
submissionWindowEnabled: z.boolean().default(true),
lateSubmissionPolicy: z.enum(['reject', 'flag', 'accept']).default('flag'),
lateGraceHours: z.number().int().min(0).max(168).default(24),
fileRequirements: z.array(fileRequirementSchema).default([]),
})
.strict()
const filterRuleSchema = z
.object({
field: z.string().min(1),
operator: z.string().min(1),
value: z.union([z.string(), z.number(), z.boolean()]),
weight: z.number().min(0).max(1).default(1),
})
.strict()
const filterSchema = z
.object({
rules: z.array(filterRuleSchema).default([]),
aiRubricEnabled: z.boolean().default(false),
aiCriteriaText: z.string().default(''),
aiConfidenceThresholds: z
.object({
high: z.number().min(0).max(1).default(0.85),
medium: z.number().min(0).max(1).default(0.6),
low: z.number().min(0).max(1).default(0.4),
})
.strict()
.default({ high: 0.85, medium: 0.6, low: 0.4 }),
manualQueueEnabled: z.boolean().default(true),
})
.strict()
const evaluationSchema = z
.object({
requiredReviews: z.number().int().min(1).max(20).default(3),
maxLoadPerJuror: z.number().int().min(1).max(100).default(20),
minLoadPerJuror: z.number().int().min(0).max(50).default(5),
availabilityWeighting: z.boolean().default(true),
overflowPolicy: z
.enum(['queue', 'expand_pool', 'reduce_reviews'])
.default('queue'),
})
.strict()
.superRefine((value, ctx) => {
if (value.minLoadPerJuror > value.maxLoadPerJuror) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'minLoadPerJuror cannot exceed maxLoadPerJuror',
path: ['minLoadPerJuror'],
})
}
})
const selectionSchema = z
.object({
finalistCount: z.number().int().min(1).max(500).optional(),
rankingMethod: z
.enum(['score_average', 'weighted_criteria', 'binary_pass'])
.default('score_average'),
tieBreaker: z
.enum(['admin_decides', 'highest_individual', 'revote'])
.default('admin_decides'),
})
.strict()
const liveFinalSchema = z
.object({
juryVotingEnabled: z.boolean().default(true),
audienceVotingEnabled: z.boolean().default(false),
audienceVoteWeight: z.number().min(0).max(1).default(0),
cohortSetupMode: z.enum(['auto', 'manual']).default('manual'),
revealPolicy: z
.enum(['immediate', 'delayed', 'ceremony'])
.default('ceremony'),
})
.strict()
const resultsSchema = z
.object({
publicationMode: z.enum(['manual', 'auto_on_close']).default('manual'),
showDetailedScores: z.boolean().default(false),
showRankings: z.boolean().default(true),
})
.strict()
export const stageConfigSchemas: Record<
StageTypeKey,
z.ZodType<Record<string, unknown>>
> = {
INTAKE: intakeSchema,
FILTER: filterSchema,
EVALUATION: evaluationSchema,
SELECTION: selectionSchema,
LIVE_FINAL: liveFinalSchema,
RESULTS: resultsSchema,
}
const CANONICAL_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: [
'submissionWindowEnabled',
'lateSubmissionPolicy',
'lateGraceHours',
'fileRequirements',
],
FILTER: [
'rules',
'aiRubricEnabled',
'aiCriteriaText',
'aiConfidenceThresholds',
'manualQueueEnabled',
],
EVALUATION: [
'requiredReviews',
'maxLoadPerJuror',
'minLoadPerJuror',
'availabilityWeighting',
'overflowPolicy',
],
SELECTION: ['finalistCount', 'rankingMethod', 'tieBreaker'],
LIVE_FINAL: [
'juryVotingEnabled',
'audienceVotingEnabled',
'audienceVoteWeight',
'cohortSetupMode',
'revealPolicy',
],
RESULTS: ['publicationMode', 'showDetailedScores', 'showRankings'],
}
const LEGACY_ALIAS_KEYS: Record<StageTypeKey, string[]> = {
INTAKE: ['lateSubmissionGrace', 'deadline', 'maxSubmissions'],
FILTER: ['deterministic', 'ai', 'confidenceBands'],
EVALUATION: [
'minAssignmentsPerJuror',
'maxAssignmentsPerJuror',
'criteriaVersion',
'assignmentStrategy',
],
SELECTION: ['finalistTarget', 'selectionMethod', 'rankingSource'],
LIVE_FINAL: [
'votingEnabled',
'audienceVoting',
'sessionMode',
'presentationDurationMinutes',
'qaDurationMinutes',
'votingMode',
'maxFavorites',
'requireIdentification',
'votingDurationMinutes',
],
RESULTS: ['publicationPolicy', 'rankingWeights', 'announcementDate'],
}
function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function asRecord(value: unknown): JsonObject {
return isRecord(value) ? value : {}
}
function toStringSafe(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback
}
function toBool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback
}
function toInt(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.trunc(value)
: fallback
}
function toFloat(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function mapLegacyMimeType(type: string | undefined): string[] {
switch ((type ?? '').toUpperCase()) {
case 'PDF':
return ['application/pdf']
case 'VIDEO':
return ['video/*']
case 'IMAGE':
return ['image/*']
case 'DOC':
case 'DOCX':
return [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
case 'PPT':
case 'PPTX':
return [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]
default:
return []
}
}
function normalizeIntakeConfig(raw: JsonObject): JsonObject {
const rawRequirements = Array.isArray(raw.fileRequirements)
? raw.fileRequirements
: []
const fileRequirements = rawRequirements
.map((item) => {
const req = asRecord(item)
const acceptedMimeTypes = Array.isArray(req.acceptedMimeTypes)
? req.acceptedMimeTypes.filter((mime) => typeof mime === 'string')
: mapLegacyMimeType(
typeof req.type === 'string' ? req.type : undefined
)
return {
name: toStringSafe(req.name, '').trim(),
description: toStringSafe(req.description, ''),
acceptedMimeTypes,
maxSizeMB:
typeof req.maxSizeMB === 'number' && Number.isFinite(req.maxSizeMB)
? Math.trunc(req.maxSizeMB)
: undefined,
isRequired: toBool(req.isRequired, toBool(req.required, false)),
}
})
.filter((req) => req.name.length > 0)
return {
submissionWindowEnabled: toBool(raw.submissionWindowEnabled, true),
lateSubmissionPolicy: toStringSafe(raw.lateSubmissionPolicy, 'flag'),
lateGraceHours: toInt(
raw.lateGraceHours ?? raw.lateSubmissionGrace,
24
),
fileRequirements,
}
}
function normalizeFilterConfig(raw: JsonObject): JsonObject {
const deterministic = asRecord(raw.deterministic)
const aiLegacy = asRecord(raw.ai)
const confidenceBands = asRecord(raw.confidenceBands)
const highBand = asRecord(confidenceBands.high)
const mediumBand = asRecord(confidenceBands.medium)
const lowBand = asRecord(confidenceBands.low)
const sourceRules = Array.isArray(raw.rules)
? raw.rules
: Array.isArray(deterministic.rules)
? deterministic.rules
: []
const rules = sourceRules
.map((item) => {
const rule = asRecord(item)
const value =
typeof rule.value === 'string' ||
typeof rule.value === 'number' ||
typeof rule.value === 'boolean'
? rule.value
: ''
return {
field: toStringSafe(rule.field, '').trim(),
operator: toStringSafe(rule.operator, 'equals'),
value,
weight: toFloat(rule.weight, 1),
}
})
.filter((rule) => rule.field.length > 0)
return {
rules,
aiRubricEnabled: toBool(raw.aiRubricEnabled, Object.keys(aiLegacy).length > 0),
aiCriteriaText: toStringSafe(
raw.aiCriteriaText ?? aiLegacy.criteriaText,
''
),
aiConfidenceThresholds: {
high: toFloat(
asRecord(raw.aiConfidenceThresholds).high ?? highBand.threshold,
0.85
),
medium: toFloat(
asRecord(raw.aiConfidenceThresholds).medium ?? mediumBand.threshold,
0.6
),
low: toFloat(
asRecord(raw.aiConfidenceThresholds).low ?? lowBand.threshold,
0.4
),
},
manualQueueEnabled: toBool(raw.manualQueueEnabled, true),
}
}
function normalizeEvaluationConfig(raw: JsonObject): JsonObject {
return {
requiredReviews: toInt(raw.requiredReviews, 3),
maxLoadPerJuror: toInt(
raw.maxLoadPerJuror ?? raw.maxAssignmentsPerJuror,
20
),
minLoadPerJuror: toInt(
raw.minLoadPerJuror ?? raw.minAssignmentsPerJuror,
5
),
availabilityWeighting: toBool(raw.availabilityWeighting, true),
overflowPolicy: toStringSafe(raw.overflowPolicy, 'queue'),
}
}
function normalizeSelectionConfig(raw: JsonObject): JsonObject {
const selectionMethod = toStringSafe(raw.selectionMethod, '')
const inferredRankingMethod =
selectionMethod === 'binary_pass'
? 'binary_pass'
: selectionMethod === 'weighted_criteria'
? 'weighted_criteria'
: 'score_average'
return {
finalistCount:
typeof raw.finalistCount === 'number'
? Math.trunc(raw.finalistCount)
: typeof raw.finalistTarget === 'number'
? Math.trunc(raw.finalistTarget)
: undefined,
rankingMethod: toStringSafe(raw.rankingMethod, inferredRankingMethod),
tieBreaker: toStringSafe(raw.tieBreaker, 'admin_decides'),
}
}
function normalizeLiveFinalConfig(raw: JsonObject): JsonObject {
return {
juryVotingEnabled: toBool(raw.juryVotingEnabled ?? raw.votingEnabled, true),
audienceVotingEnabled: toBool(
raw.audienceVotingEnabled ?? raw.audienceVoting,
false
),
audienceVoteWeight: toFloat(raw.audienceVoteWeight, 0),
cohortSetupMode: toStringSafe(raw.cohortSetupMode, 'manual'),
revealPolicy: toStringSafe(raw.revealPolicy, 'ceremony'),
}
}
function normalizeResultsConfig(raw: JsonObject): JsonObject {
const publicationModeRaw = toStringSafe(
raw.publicationMode ?? raw.publicationPolicy,
'manual'
)
const publicationMode =
publicationModeRaw === 'auto_on_close' ? 'auto_on_close' : 'manual'
return {
publicationMode,
showDetailedScores: toBool(raw.showDetailedScores, false),
showRankings: toBool(raw.showRankings, true),
}
}
export function normalizeStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown
): JsonObject {
const raw = asRecord(rawInput)
switch (stageType) {
case 'INTAKE':
return normalizeIntakeConfig(raw)
case 'FILTER':
return normalizeFilterConfig(raw)
case 'EVALUATION':
return normalizeEvaluationConfig(raw)
case 'SELECTION':
return normalizeSelectionConfig(raw)
case 'LIVE_FINAL':
return normalizeLiveFinalConfig(raw)
case 'RESULTS':
return normalizeResultsConfig(raw)
default:
return raw
}
}
function getUnknownRootKeys(
stageType: StageTypeKey,
rawInput: unknown
): string[] {
const raw = asRecord(rawInput)
const allowed = new Set([
...CANONICAL_KEYS[stageType],
...LEGACY_ALIAS_KEYS[stageType],
])
return Object.keys(raw).filter((key) => !allowed.has(key))
}
export type ParseStageConfigResult = {
config: JsonObject
normalized: JsonObject
}
export function parseAndValidateStageConfig(
stageType: StageType | StageTypeKey,
rawInput: unknown,
options?: { strictUnknownKeys?: boolean }
): ParseStageConfigResult {
const strictUnknownKeys = options?.strictUnknownKeys ?? true
const stageTypeKey = stageType as StageTypeKey
if (!STAGE_TYPES.includes(stageTypeKey)) {
throw new Error(`Unsupported stage type: ${String(stageType)}`)
}
if (strictUnknownKeys) {
const unknownKeys = getUnknownRootKeys(stageTypeKey, rawInput)
if (unknownKeys.length > 0) {
throw new Error(
`Unknown config keys for ${stageTypeKey}: ${unknownKeys.join(', ')}`
)
}
}
const normalized = normalizeStageConfig(stageTypeKey, rawInput)
const config = stageConfigSchemas[stageTypeKey].parse(normalized)
return { config, normalized }
}

View File

@@ -1,131 +1,131 @@
import type { StorageProvider, StorageProviderType } from './types'
import { S3StorageProvider } from './s3-provider'
import { LocalStorageProvider } from './local-provider'
import { prisma } from '@/lib/prisma'
export type { StorageProvider, StorageProviderType } from './types'
export { S3StorageProvider } from './s3-provider'
export { LocalStorageProvider } from './local-provider'
// Cached provider instance
let cachedProvider: StorageProvider | null = null
let cachedProviderType: StorageProviderType | null = null
/**
* Get the configured storage provider type from system settings
*/
async function getProviderTypeFromSettings(): Promise<StorageProviderType> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'storage_provider' },
})
const value = setting?.value as StorageProviderType | undefined
return value === 'local' ? 'local' : 's3' // Default to S3
} catch {
// If settings table doesn't exist or error, default to S3
return 's3'
}
}
/**
* Get the current storage provider type from settings
*/
export async function getCurrentProviderType(): Promise<StorageProviderType> {
return getProviderTypeFromSettings()
}
/**
* Get a storage provider instance based on system settings
* Caches the provider for performance
*/
export async function getStorageProvider(): Promise<StorageProvider> {
const providerType = await getProviderTypeFromSettings()
// Return cached provider if type hasn't changed
if (cachedProvider && cachedProviderType === providerType) {
return cachedProvider
}
// Create new provider
if (providerType === 'local') {
cachedProvider = new LocalStorageProvider()
} else {
cachedProvider = new S3StorageProvider()
}
cachedProviderType = providerType
return cachedProvider
}
/**
* Get a storage provider and its type together
*/
export async function getStorageProviderWithType(): Promise<{
provider: StorageProvider
providerType: StorageProviderType
}> {
const providerType = await getProviderTypeFromSettings()
const provider = await getStorageProvider()
return { provider, providerType }
}
/**
* Create a specific storage provider (bypasses settings)
*/
export function createStorageProvider(type: StorageProviderType): StorageProvider {
if (type === 'local') {
return new LocalStorageProvider()
}
return new S3StorageProvider()
}
/**
* Clear the cached provider (call when settings change)
*/
export function clearStorageProviderCache(): void {
cachedProvider = null
cachedProviderType = null
}
/**
* Generate a unique storage key for avatars
*/
export function generateAvatarKey(userId: string, fileName: string): string {
const timestamp = Date.now()
const ext = fileName.split('.').pop() || 'jpg'
return `avatars/${userId}/${timestamp}.${ext}`
}
/**
* Generate a unique storage key for project logos
*/
export function generateLogoKey(projectId: string, fileName: string): string {
const timestamp = Date.now()
const ext = fileName.split('.').pop() || 'png'
return `logos/${projectId}/${timestamp}.${ext}`
}
/**
* Get content type from file extension
*/
export function getContentType(fileName: string): string {
const ext = fileName.toLowerCase().split('.').pop()
const types: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
}
return types[ext || ''] || 'application/octet-stream'
}
/**
* Validate image file type
*/
export function isValidImageType(contentType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return validTypes.includes(contentType)
}
import type { StorageProvider, StorageProviderType } from './types'
import { S3StorageProvider } from './s3-provider'
import { LocalStorageProvider } from './local-provider'
import { prisma } from '@/lib/prisma'
export type { StorageProvider, StorageProviderType } from './types'
export { S3StorageProvider } from './s3-provider'
export { LocalStorageProvider } from './local-provider'
// Cached provider instance
let cachedProvider: StorageProvider | null = null
let cachedProviderType: StorageProviderType | null = null
/**
* Get the configured storage provider type from system settings
*/
async function getProviderTypeFromSettings(): Promise<StorageProviderType> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'storage_provider' },
})
const value = setting?.value as StorageProviderType | undefined
return value === 'local' ? 'local' : 's3' // Default to S3
} catch {
// If settings table doesn't exist or error, default to S3
return 's3'
}
}
/**
* Get the current storage provider type from settings
*/
export async function getCurrentProviderType(): Promise<StorageProviderType> {
return getProviderTypeFromSettings()
}
/**
* Get a storage provider instance based on system settings
* Caches the provider for performance
*/
export async function getStorageProvider(): Promise<StorageProvider> {
const providerType = await getProviderTypeFromSettings()
// Return cached provider if type hasn't changed
if (cachedProvider && cachedProviderType === providerType) {
return cachedProvider
}
// Create new provider
if (providerType === 'local') {
cachedProvider = new LocalStorageProvider()
} else {
cachedProvider = new S3StorageProvider()
}
cachedProviderType = providerType
return cachedProvider
}
/**
* Get a storage provider and its type together
*/
export async function getStorageProviderWithType(): Promise<{
provider: StorageProvider
providerType: StorageProviderType
}> {
const providerType = await getProviderTypeFromSettings()
const provider = await getStorageProvider()
return { provider, providerType }
}
/**
* Create a specific storage provider (bypasses settings)
*/
export function createStorageProvider(type: StorageProviderType): StorageProvider {
if (type === 'local') {
return new LocalStorageProvider()
}
return new S3StorageProvider()
}
/**
* Clear the cached provider (call when settings change)
*/
export function clearStorageProviderCache(): void {
cachedProvider = null
cachedProviderType = null
}
/**
* Generate a unique storage key for avatars
*/
export function generateAvatarKey(userId: string, fileName: string): string {
const timestamp = Date.now()
const ext = fileName.split('.').pop() || 'jpg'
return `avatars/${userId}/${timestamp}.${ext}`
}
/**
* Generate a unique storage key for project logos
*/
export function generateLogoKey(projectId: string, fileName: string): string {
const timestamp = Date.now()
const ext = fileName.split('.').pop() || 'png'
return `logos/${projectId}/${timestamp}.${ext}`
}
/**
* Get content type from file extension
*/
export function getContentType(fileName: string): string {
const ext = fileName.toLowerCase().split('.').pop()
const types: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
}
return types[ext || ''] || 'application/octet-stream'
}
/**
* Validate image file type
*/
export function isValidImageType(contentType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return validTypes.includes(contentType)
}

View File

@@ -1,137 +1,137 @@
import {
type WizardConfig,
type WizardStep,
type WizardFieldConfig,
type WizardStepId,
DEFAULT_WIZARD_CONFIG,
wizardConfigSchema,
} from '@/types/wizard-config'
/**
* Parse wizard config from Program.settingsJson with fallback to defaults.
* Used by both backend (application router) and frontend (apply pages).
*/
export function parseWizardConfig(settingsJson: unknown): WizardConfig {
if (!settingsJson || typeof settingsJson !== 'object') {
return DEFAULT_WIZARD_CONFIG
}
const settings = settingsJson as Record<string, unknown>
if (!settings.wizardConfig) {
return DEFAULT_WIZARD_CONFIG
}
try {
const parsed = wizardConfigSchema.parse(settings.wizardConfig)
return mergeWizardConfig(parsed)
} catch {
console.error('[WizardConfig] Invalid config, using defaults')
return DEFAULT_WIZARD_CONFIG
}
}
/**
* Get enabled steps sorted by order.
*/
export function getActiveSteps(config: WizardConfig): WizardStep[] {
return config.steps.filter((step) => step.enabled).sort((a, b) => a.order - b.order)
}
/**
* Evaluate conditional step visibility based on current form values.
* Returns only steps whose conditions are met (or have no condition).
*/
export function getVisibleSteps(
config: WizardConfig,
formValues: Record<string, unknown>
): WizardStep[] {
return getActiveSteps(config).filter((step) => {
if (!step.conditionalOn) return true
const { field, operator, value } = step.conditionalOn
const fieldValue = formValues[field]
switch (operator) {
case 'equals':
return fieldValue === value
case 'notEquals':
return fieldValue !== value
case 'in':
return Array.isArray(value) && value.includes(String(fieldValue))
case 'notIn':
return Array.isArray(value) && !value.includes(String(fieldValue))
default:
return true
}
})
}
/**
* Get field configuration with sensible defaults.
*/
export function getFieldConfig(config: WizardConfig, fieldName: string): WizardFieldConfig {
return config.fields[fieldName] ?? { required: true, visible: true }
}
/**
* Check if a specific field should be visible based on config.
*/
export function isFieldVisible(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.visible !== false
}
/**
* Check if a specific field is required based on config.
*/
export function isFieldRequired(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.required !== false
}
/**
* Get custom fields assigned to a specific step, sorted by order.
*/
export function getCustomFieldsForStep(
config: WizardConfig,
stepId: WizardStepId
): NonNullable<WizardConfig['customFields']> {
return (config.customFields ?? [])
.filter((f) => f.stepId === stepId)
.sort((a, b) => a.order - b.order)
}
/**
* Merge partial config with defaults. Ensures all arrays/objects exist.
*/
export function mergeWizardConfig(partial: Partial<WizardConfig>): WizardConfig {
return {
steps: partial.steps?.length ? partial.steps : DEFAULT_WIZARD_CONFIG.steps,
fields: partial.fields ?? DEFAULT_WIZARD_CONFIG.fields,
competitionCategories:
partial.competitionCategories ?? DEFAULT_WIZARD_CONFIG.competitionCategories,
oceanIssues: partial.oceanIssues ?? DEFAULT_WIZARD_CONFIG.oceanIssues,
features: { ...DEFAULT_WIZARD_CONFIG.features, ...partial.features },
welcomeMessage: partial.welcomeMessage,
customFields: partial.customFields ?? [],
}
}
/**
* Build the STEPS array for the wizard from config (format used by apply pages).
* Maps step IDs to their validation fields for per-step validation.
*/
export function buildStepsArray(
config: WizardConfig
): Array<{ id: string; title: string; fields: string[] }> {
const STEP_FIELDS_MAP: Record<string, string[]> = {
welcome: ['competitionCategory'],
contact: ['contactName', 'contactEmail', 'contactPhone', 'country'],
project: ['projectName', 'description', 'oceanIssue'],
team: [],
additional: [],
review: ['gdprConsent'],
}
return getActiveSteps(config).map((step) => ({
id: step.id,
title: step.title ?? step.id.charAt(0).toUpperCase() + step.id.slice(1),
fields: (STEP_FIELDS_MAP[step.id] ?? []).filter((f) => isFieldVisible(config, f)),
}))
}
import {
type WizardConfig,
type WizardStep,
type WizardFieldConfig,
type WizardStepId,
DEFAULT_WIZARD_CONFIG,
wizardConfigSchema,
} from '@/types/wizard-config'
/**
* Parse wizard config from Program.settingsJson with fallback to defaults.
* Used by both backend (application router) and frontend (apply pages).
*/
export function parseWizardConfig(settingsJson: unknown): WizardConfig {
if (!settingsJson || typeof settingsJson !== 'object') {
return DEFAULT_WIZARD_CONFIG
}
const settings = settingsJson as Record<string, unknown>
if (!settings.wizardConfig) {
return DEFAULT_WIZARD_CONFIG
}
try {
const parsed = wizardConfigSchema.parse(settings.wizardConfig)
return mergeWizardConfig(parsed)
} catch {
console.error('[WizardConfig] Invalid config, using defaults')
return DEFAULT_WIZARD_CONFIG
}
}
/**
* Get enabled steps sorted by order.
*/
export function getActiveSteps(config: WizardConfig): WizardStep[] {
return config.steps.filter((step) => step.enabled).sort((a, b) => a.order - b.order)
}
/**
* Evaluate conditional step visibility based on current form values.
* Returns only steps whose conditions are met (or have no condition).
*/
export function getVisibleSteps(
config: WizardConfig,
formValues: Record<string, unknown>
): WizardStep[] {
return getActiveSteps(config).filter((step) => {
if (!step.conditionalOn) return true
const { field, operator, value } = step.conditionalOn
const fieldValue = formValues[field]
switch (operator) {
case 'equals':
return fieldValue === value
case 'notEquals':
return fieldValue !== value
case 'in':
return Array.isArray(value) && value.includes(String(fieldValue))
case 'notIn':
return Array.isArray(value) && !value.includes(String(fieldValue))
default:
return true
}
})
}
/**
* Get field configuration with sensible defaults.
*/
export function getFieldConfig(config: WizardConfig, fieldName: string): WizardFieldConfig {
return config.fields[fieldName] ?? { required: true, visible: true }
}
/**
* Check if a specific field should be visible based on config.
*/
export function isFieldVisible(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.visible !== false
}
/**
* Check if a specific field is required based on config.
*/
export function isFieldRequired(config: WizardConfig, fieldName: string): boolean {
const fieldConfig = config.fields[fieldName]
return fieldConfig?.required !== false
}
/**
* Get custom fields assigned to a specific step, sorted by order.
*/
export function getCustomFieldsForStep(
config: WizardConfig,
stepId: WizardStepId
): NonNullable<WizardConfig['customFields']> {
return (config.customFields ?? [])
.filter((f) => f.stepId === stepId)
.sort((a, b) => a.order - b.order)
}
/**
* Merge partial config with defaults. Ensures all arrays/objects exist.
*/
export function mergeWizardConfig(partial: Partial<WizardConfig>): WizardConfig {
return {
steps: partial.steps?.length ? partial.steps : DEFAULT_WIZARD_CONFIG.steps,
fields: partial.fields ?? DEFAULT_WIZARD_CONFIG.fields,
competitionCategories:
partial.competitionCategories ?? DEFAULT_WIZARD_CONFIG.competitionCategories,
oceanIssues: partial.oceanIssues ?? DEFAULT_WIZARD_CONFIG.oceanIssues,
features: { ...DEFAULT_WIZARD_CONFIG.features, ...partial.features },
welcomeMessage: partial.welcomeMessage,
customFields: partial.customFields ?? [],
}
}
/**
* Build the STEPS array for the wizard from config (format used by apply pages).
* Maps step IDs to their validation fields for per-step validation.
*/
export function buildStepsArray(
config: WizardConfig
): Array<{ id: string; title: string; fields: string[] }> {
const STEP_FIELDS_MAP: Record<string, string[]> = {
welcome: ['competitionCategory'],
contact: ['contactName', 'contactEmail', 'contactPhone', 'country'],
project: ['projectName', 'description', 'oceanIssue'],
team: [],
additional: [],
review: ['gdprConsent'],
}
return getActiveSteps(config).map((step) => ({
id: step.id,
title: step.title ?? step.id.charAt(0).toUpperCase() + step.id.slice(1),
fields: (STEP_FIELDS_MAP[step.id] ?? []).filter((f) => isFieldVisible(config, f)),
}))
}