Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
632
src/lib/auth.ts
632
src/lib/auth.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
3672
src/lib/email.ts
3672
src/lib/email.ts
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
264
src/lib/minio.ts
264
src/lib/minio.ts
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
457
src/lib/stage-config-schema.ts
Normal file
457
src/lib/stage-config-schema.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user