Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

29
src/lib/auth-redirect.ts Normal file
View File

@@ -0,0 +1,29 @@
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',
}
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
}

87
src/lib/auth.config.ts Normal file
View File

@@ -0,0 +1,87 @@
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-email',
'/auth-error',
'/api/auth',
]
// 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: '/auth-error',
newUser: '/set-password',
},
session: {
strategy: 'jwt',
maxAge: parseInt(process.env.SESSION_MAX_AGE || '86400'), // 24 hours
},
}

224
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,224 @@
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: {
host: process.env.SMTP_HOST,
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
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
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)
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)
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 from INVITED to ACTIVE on first login
if (dbUser?.status === 'INVITED') {
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
})
}
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
}

223
src/lib/countries.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* 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
}
export function getCountryCoordinates(code: string): [number, number] | null {
const country = COUNTRIES[code]
if (!country) return null
return [country.lat, country.lng]
}

590
src/lib/email.ts Normal file
View File

@@ -0,0 +1,590 @@
import nodemailer from 'nodemailer'
// Create reusable transporter
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
// Default sender
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>'
// =============================================================================
// Brand Colors & Logo URLs
// =============================================================================
const BRAND = {
red: '#de0f1e',
redHover: '#b91c1c',
darkBlue: '#053d57',
teal: '#557f8c',
white: '#fefefe',
lightGray: '#f5f5f5',
textDark: '#1f2937',
textMuted: '#6b7280',
}
const getSmallLogoUrl = () => `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/images/MOPC-blue-small.png`
const getBigLogoUrl = () => `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/images/MOPC-blue-long.png`
// =============================================================================
// Email Template Wrapper & Helpers
// =============================================================================
/**
* Wrap email content with consistent branding:
* - Small logo at top of white content box
* - Big logo in dark blue footer
*/
function getEmailWrapper(content: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>MOPC</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: ${BRAND.lightGray}; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: ${BRAND.lightGray};">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Main Container -->
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
<!-- Content Box -->
<tr>
<td style="background-color: ${BRAND.white}; border-radius: 12px 12px 0 0; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- Small Logo Header -->
<tr>
<td align="center" style="padding: 32px 40px 24px 40px;">
<img src="${getSmallLogoUrl()}" alt="MOPC" width="100" height="auto" style="display: block; border: 0; max-width: 100px;">
</td>
</tr>
<!-- Email Content -->
<tr>
<td style="padding: 0 40px 40px 40px;">
${content}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer with Big Logo -->
<tr>
<td style="background-color: ${BRAND.darkBlue}; border-radius: 0 0 12px 12px; padding: 32px 40px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
<img src="${getBigLogoUrl()}" alt="Monaco Ocean Protection Challenge" width="200" height="auto" style="display: block; border: 0; max-width: 200px; margin-bottom: 16px;">
</td>
</tr>
<tr>
<td align="center">
<p style="color: #8aa3ad; font-size: 13px; margin: 0; line-height: 1.5;">
Together for a healthier ocean
</p>
<p style="color: #6b8a94; font-size: 12px; margin: 12px 0 0 0;">
&copy; ${new Date().getFullYear()} Monaco Ocean Protection Challenge
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
}
/**
* Generate a styled CTA button
*/
function ctaButton(url: string, text: string): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 24px 0;">
<tr>
<td align="center">
<a href="${url}" target="_blank" style="display: inline-block; background-color: ${BRAND.red}; color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-weight: 600; font-size: 16px; mso-padding-alt: 0;">
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%; mso-text-raise: 30pt;">&nbsp;</i>
<![endif]-->
<span style="mso-text-raise: 15pt;">${text}</span>
<!--[if mso]>
<i style="letter-spacing: 40px; mso-font-width: -100%;">&nbsp;</i>
<![endif]-->
</a>
</td>
</tr>
</table>
`
}
/**
* Generate styled section title
*/
function sectionTitle(text: string): string {
return `<h2 style="color: ${BRAND.darkBlue}; margin: 0 0 16px 0; font-size: 22px; font-weight: 600; line-height: 1.3;">${text}</h2>`
}
/**
* Generate styled paragraph
*/
function paragraph(text: string): string {
return `<p style="color: ${BRAND.textDark}; margin: 0 0 16px 0; font-size: 15px; line-height: 1.6;">${text}</p>`
}
/**
* Generate styled info box
*/
function infoBox(content: string, variant: 'warning' | 'info' | 'success' = 'info'): string {
const colors = {
warning: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
info: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' },
success: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
}
const c = colors[variant]
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${c.bg}; border-left: 4px solid ${c.border}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: ${c.text}; margin: 0; font-size: 14px; font-weight: 500;">${content}</p>
</td>
</tr>
</table>
`
}
/**
* Generate styled stat card
*/
function statCard(label: string, value: string | number): string {
return `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: ${BRAND.lightGray}; border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: ${BRAND.textMuted}; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">${label}</p>
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 42px; font-weight: 700; line-height: 1;">${value}</p>
</td>
</tr>
</table>
`
}
// =============================================================================
// Email Templates
// =============================================================================
interface EmailTemplate {
subject: string
html: string
text: string
}
/**
* Generate magic link email template
*/
function getMagicLinkTemplate(url: string, expiryMinutes: number = 15): EmailTemplate {
const content = `
${sectionTitle('Sign in to your account')}
${paragraph('Click the button below to securely sign in to the MOPC Platform.')}
${infoBox(`<strong>This link expires in ${expiryMinutes} minutes</strong>`, 'warning')}
${ctaButton(url, 'Sign In to MOPC')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you didn't request this email, you can safely ignore it.
</p>
`
return {
subject: 'Sign in to MOPC Platform',
html: getEmailWrapper(content),
text: `
Sign in to MOPC Platform
=========================
Click the link below to sign in to your account:
${url}
This link will expire in ${expiryMinutes} minutes.
If you didn't request this email, you can safely ignore it.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate generic invitation email template (not round-specific)
*/
function getGenericInvitationTemplate(
name: string,
url: string,
role: string
): EmailTemplate {
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a <strong>${roleLabel}</strong>.`)}
${paragraph('Click the button below to set up your account and get started.')}
${ctaButton(url, 'Accept Invitation')}
${infoBox('This link will expire in 24 hours.', 'info')}
`
return {
subject: "You're invited to join the MOPC Platform",
html: getEmailWrapper(content),
text: `
${greeting}
You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.
Click the link below to set up your account and get started:
${url}
This link will expire in 24 hours.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate evaluation reminder email template
*/
function getEvaluationReminderTemplate(
name: string,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
// Deadline alert box (styled differently from info box)
const deadlineBox = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${paragraph(`This is a friendly reminder about your pending evaluations for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${statCard('Pending Evaluations', pendingCount)}
${deadlineBox}
${paragraph('Your expert evaluation helps identify the most promising ocean conservation projects. Please complete your reviews before the deadline.')}
${ctaButton(assignmentsUrl, 'View My Assignments')}
`
return {
subject: `Reminder: ${pendingCount} evaluation${pendingCount !== 1 ? 's' : ''} awaiting your review`,
html: getEmailWrapper(content),
text: `
${greeting}
This is a friendly reminder that you have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${roundName}.
Deadline: ${deadline}
Please complete your evaluations before the deadline to ensure your feedback is included in the selection process.
View your assignments: ${assignmentsUrl}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate announcement email template
*/
function getAnnouncementTemplate(
name: string,
title: string,
message: string,
ctaText?: string,
ctaUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const ctaTextPlain = ctaText && ctaUrl ? `\n${ctaText}: ${ctaUrl}\n` : ''
// Escape HTML in message but preserve line breaks
const formattedMessage = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
// Title card with success styling
const titleCard = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background-color: #ecfdf5; border-left: 4px solid #059669; border-radius: 0 12px 12px 0; padding: 20px 24px;">
<h3 style="color: #065f46; margin: 0; font-size: 18px; font-weight: 700;">${title}</h3>
</td>
</tr>
</table>
`
const content = `
${sectionTitle(greeting)}
${titleCard}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedMessage}
</div>
${ctaText && ctaUrl ? ctaButton(ctaUrl, ctaText) : ''}
`
return {
subject: title,
html: getEmailWrapper(content),
text: `
${greeting}
${title}
${message}
${ctaTextPlain}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate jury invitation email template
*/
function getJuryInvitationTemplate(
name: string,
url: string,
roundName: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${paragraph(`You've been invited to serve as a jury member for <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
${paragraph('As a jury member, you\'ll evaluate innovative ocean protection projects and help select the most promising initiatives.')}
${ctaButton(url, 'Accept Invitation')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
This link will allow you to access the platform and view your assigned projects.
</p>
`
return {
subject: `You're invited to evaluate projects for ${roundName}`,
html: getEmailWrapper(content),
text: `
${greeting}
You've been invited to serve as a jury member for ${roundName}.
As a jury member, you'll evaluate innovative ocean protection projects and help select the most promising initiatives.
Click the link below to accept your invitation:
${url}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
// =============================================================================
// Email Sending Functions
// =============================================================================
/**
* Send magic link email for authentication
*/
export async function sendMagicLinkEmail(
email: string,
url: string
): Promise<void> {
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
const template = getMagicLinkTemplate(url, expiryMinutes)
await transporter.sendMail({
from: defaultFrom,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send generic invitation email (not round-specific)
*/
export async function sendInvitationEmail(
email: string,
name: string | null,
url: string,
role: string
): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role)
await transporter.sendMail({
from: defaultFrom,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send jury invitation email (round-specific)
*/
export async function sendJuryInvitationEmail(
email: string,
name: string | null,
url: string,
roundName: string
): Promise<void> {
const template = getJuryInvitationTemplate(name || '', url, roundName)
await transporter.sendMail({
from: defaultFrom,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send evaluation reminder email
*/
export async function sendEvaluationReminderEmail(
email: string,
name: string | null,
pendingCount: number,
roundName: string,
deadline: string,
assignmentsUrl: string
): Promise<void> {
const template = getEvaluationReminderTemplate(
name || '',
pendingCount,
roundName,
deadline,
assignmentsUrl
)
await transporter.sendMail({
from: defaultFrom,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send announcement email
*/
export async function sendAnnouncementEmail(
email: string,
name: string | null,
title: string,
message: string,
ctaText?: string,
ctaUrl?: string
): Promise<void> {
const template = getAnnouncementTemplate(
name || '',
title,
message,
ctaText,
ctaUrl
)
await transporter.sendMail({
from: defaultFrom,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send a test email to verify SMTP configuration
*/
export async function sendTestEmail(toEmail: string): Promise<boolean> {
try {
const content = `
${sectionTitle('Test Email')}
${paragraph('This is a test email from the MOPC Platform.')}
${infoBox('If you received this, your email configuration is working correctly!', 'success')}
<p style="color: ${BRAND.textMuted}; margin: 20px 0 0 0; font-size: 13px; text-align: center;">
Sent at ${new Date().toISOString()}
</p>
`
await transporter.sendMail({
from: defaultFrom,
to: toEmail,
subject: 'MOPC Platform - Test Email',
text: 'This is a test email from the MOPC Platform. If you received this, your email configuration is working correctly.',
html: getEmailWrapper(content),
})
return true
} catch {
return false
}
}
/**
* Verify SMTP connection
*/
export async function verifyEmailConnection(): Promise<boolean> {
try {
await transporter.verify()
return true
} catch {
return false
}
}

120
src/lib/minio.ts Normal file
View File

@@ -0,0 +1,120 @@
import * as Minio from 'minio'
// MinIO client singleton
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)
return new Minio.Client({
endPoint: url.hostname,
port: parseInt(url.port) || 9000,
useSSL: url.protocol === 'https:',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
})
}
export const minio = globalForMinio.minio ?? createMinioClient()
if (process.env.NODE_ENV !== 'production') globalForMinio.minio = minio
// 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}`
}
/**
* Get file metadata from MinIO
*/
export async function getObjectInfo(
bucket: string,
objectKey: string
): Promise<Minio.BucketItemStat> {
return minio.statObject(bucket, objectKey)
}

258
src/lib/notion.ts Normal file
View File

@@ -0,0 +1,258 @@
import { Client } from '@notionhq/client'
import type {
DatabaseObjectResponse,
PageObjectResponse,
PartialDatabaseObjectResponse,
PartialPageObjectResponse,
} from '@notionhq/client/build/src/api-endpoints'
// Type for Notion database schema
export interface NotionDatabaseSchema {
id: string
title: string
properties: NotionProperty[]
}
export interface NotionProperty {
id: string
name: string
type: string
}
// Type for a Notion page/record
export interface NotionRecord {
id: string
properties: Record<string, unknown>
}
/**
* Create a Notion client with the provided API key
*/
export function createNotionClient(apiKey: string): Client {
return new Client({ auth: apiKey })
}
/**
* Test connection to Notion API
*/
export async function testNotionConnection(
apiKey: string
): Promise<{ success: boolean; error?: string }> {
try {
const client = createNotionClient(apiKey)
// Try to get the bot user to verify the API key
await client.users.me({})
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Notion',
}
}
}
/**
* Get database schema (properties)
*/
export async function getNotionDatabaseSchema(
apiKey: string,
databaseId: string
): Promise<NotionDatabaseSchema> {
const client = createNotionClient(apiKey)
const database = await client.databases.retrieve({
database_id: databaseId,
})
// Type guard for full database object
if (!('properties' in database)) {
throw new Error('Could not retrieve database properties')
}
const db = database as DatabaseObjectResponse
const properties: NotionProperty[] = Object.entries(db.properties).map(
([name, prop]) => ({
id: prop.id,
name,
type: prop.type,
})
)
// Get database title
const titleProp = db.title?.[0]
const title = titleProp?.type === 'text' ? titleProp.plain_text : databaseId
return {
id: db.id,
title,
properties,
}
}
/**
* Query all records from a Notion database
*/
export async function queryNotionDatabase(
apiKey: string,
databaseId: string,
limit?: number
): Promise<NotionRecord[]> {
const client = createNotionClient(apiKey)
const records: NotionRecord[] = []
let cursor: string | undefined
do {
const response = await client.databases.query({
database_id: databaseId,
start_cursor: cursor,
page_size: 100,
})
for (const page of response.results) {
if (isFullPage(page)) {
records.push({
id: page.id,
properties: extractProperties(page.properties),
})
if (limit && records.length >= limit) {
return records.slice(0, limit)
}
}
}
cursor = response.has_more ? response.next_cursor ?? undefined : undefined
} while (cursor)
return records
}
/**
* Type guard for full page response
*/
function isFullPage(
page: PageObjectResponse | PartialPageObjectResponse | DatabaseObjectResponse | PartialDatabaseObjectResponse
): page is PageObjectResponse {
return 'properties' in page && page.object === 'page'
}
/**
* Extract property values from a Notion page
*/
function extractProperties(
properties: PageObjectResponse['properties']
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [name, prop] of Object.entries(properties)) {
result[name] = extractPropertyValue(prop)
}
return result
}
/**
* Extract a single property value
*/
function extractPropertyValue(prop: PageObjectResponse['properties'][string]): unknown {
switch (prop.type) {
case 'title':
return prop.title.map((t) => t.plain_text).join('')
case 'rich_text':
return prop.rich_text.map((t) => t.plain_text).join('')
case 'number':
return prop.number
case 'select':
return prop.select?.name ?? null
case 'multi_select':
return prop.multi_select.map((s) => s.name)
case 'status':
return prop.status?.name ?? null
case 'date':
return prop.date?.start ?? null
case 'checkbox':
return prop.checkbox
case 'url':
return prop.url
case 'email':
return prop.email
case 'phone_number':
return prop.phone_number
case 'files':
return prop.files.map((f) => {
if (f.type === 'file') {
return f.file.url
} else if (f.type === 'external') {
return f.external.url
}
return null
}).filter(Boolean)
case 'relation':
return prop.relation.map((r) => r.id)
case 'people':
return prop.people.map((p) => {
if ('name' in p) {
return p.name
}
return p.id
})
case 'created_time':
return prop.created_time
case 'last_edited_time':
return prop.last_edited_time
case 'created_by':
return 'name' in prop.created_by ? prop.created_by.name : prop.created_by.id
case 'last_edited_by':
return 'name' in prop.last_edited_by ? prop.last_edited_by.name : prop.last_edited_by.id
case 'formula':
return extractFormulaValue(prop.formula)
case 'rollup':
return extractRollupValue(prop.rollup)
case 'unique_id':
return prop.unique_id.prefix
? `${prop.unique_id.prefix}-${prop.unique_id.number}`
: prop.unique_id.number
default:
return null
}
}
/**
* Extract formula value
*/
function extractFormulaValue(
formula: { type: 'string'; string: string | null } | { type: 'number'; number: number | null } | { type: 'boolean'; boolean: boolean | null } | { type: 'date'; date: { start: string } | null }
): unknown {
switch (formula.type) {
case 'string':
return formula.string
case 'number':
return formula.number
case 'boolean':
return formula.boolean
case 'date':
return formula.date?.start ?? null
default:
return null
}
}
/**
* Extract rollup value
*/
function extractRollupValue(
rollup: { type: 'number'; number: number | null; function: string } | { type: 'date'; date: { start: string } | null; function: string } | { type: 'array'; array: Array<unknown>; function: string } | { type: 'incomplete'; incomplete: Record<string, never>; function: string } | { type: 'unsupported'; unsupported: Record<string, never>; function: string }
): unknown {
switch (rollup.type) {
case 'number':
return rollup.number
case 'date':
return rollup.date?.start ?? null
case 'array':
return rollup.array
default:
return null
}
}

109
src/lib/openai.ts Normal file
View File

@@ -0,0 +1,109 @@
import OpenAI from 'openai'
import { prisma } from './prisma'
// OpenAI client singleton with lazy initialization
const globalForOpenAI = globalThis as unknown as {
openai: OpenAI | undefined
openaiInitialized: boolean
}
/**
* Get OpenAI API key from SystemSettings
*/
async function getOpenAIApiKey(): Promise<string | null> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'openai_api_key' },
})
return setting?.value || process.env.OPENAI_API_KEY || null
} catch {
// Fall back to env var if database isn't available
return process.env.OPENAI_API_KEY || null
}
}
/**
* Create OpenAI client instance
*/
async function createOpenAIClient(): Promise<OpenAI | null> {
const apiKey = await getOpenAIApiKey()
if (!apiKey) {
console.warn('OpenAI API key not configured')
return null
}
return new OpenAI({
apiKey,
})
}
/**
* Get the OpenAI client singleton
* Returns null if API key is not configured
*/
export async function getOpenAI(): Promise<OpenAI | null> {
if (globalForOpenAI.openaiInitialized) {
return globalForOpenAI.openai || null
}
const client = await createOpenAIClient()
if (process.env.NODE_ENV !== 'production') {
globalForOpenAI.openai = client || undefined
globalForOpenAI.openaiInitialized = true
}
return client
}
/**
* Check if OpenAI is configured and available
*/
export async function isOpenAIConfigured(): Promise<boolean> {
const apiKey = await getOpenAIApiKey()
return !!apiKey
}
/**
* Test OpenAI connection
*/
export async function testOpenAIConnection(): Promise<{
success: boolean
error?: string
model?: string
}> {
try {
const client = await getOpenAI()
if (!client) {
return {
success: false,
error: 'OpenAI API key not configured',
}
}
// Simple test request
const response = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 5,
})
return {
success: true,
model: response.model,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
// Default models for different use cases
export const AI_MODELS = {
ASSIGNMENT: 'gpt-4o', // Best for complex reasoning
QUICK: 'gpt-4o-mini', // Faster, cheaper for simple tasks
} as const

93
src/lib/password.ts Normal file
View File

@@ -0,0 +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],
}
}

16
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

59
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Simple in-memory rate limiter using fixed window approach.
* Tracks request counts per key (typically IP) within time windows.
*
* For production with multiple instances, replace with Redis-based solution.
*/
type RateLimitEntry = {
count: number
resetAt: number
}
const store = new Map<string, RateLimitEntry>()
export type RateLimitResult = {
success: boolean
remaining: number
resetAt: number
}
/**
* Check rate limit for a given key.
* @param key - Identifier (e.g., IP address)
* @param limit - Max requests per window
* @param windowMs - Window size in milliseconds
*/
export function checkRateLimit(
key: string,
limit: number,
windowMs: number
): RateLimitResult {
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
const resetAt = now + windowMs
store.set(key, { count: 1, resetAt })
return { success: true, remaining: limit - 1, resetAt }
}
if (entry.count >= limit) {
return { success: false, remaining: 0, resetAt: entry.resetAt }
}
entry.count++
return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }
}
// Clean up stale entries every 5 minutes to prevent memory leaks
if (typeof setInterval !== 'undefined') {
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (now > entry.resetAt) {
store.delete(key)
}
}
}, 5 * 60 * 1000).unref?.()
}

138
src/lib/storage/index.ts Normal file
View File

@@ -0,0 +1,138 @@
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)
}
/**
* Validate image file size (default 5MB max)
*/
export function isValidImageSize(sizeBytes: number, maxMB: number = 5): boolean {
return sizeBytes <= maxMB * 1024 * 1024
}

View File

@@ -0,0 +1,127 @@
import { createHmac } from 'crypto'
import * as fs from 'fs/promises'
import * as path from 'path'
import type { StorageProvider } from './types'
const SECRET_KEY = process.env.NEXTAUTH_SECRET || 'local-storage-secret'
const DEFAULT_BASE_PATH = './uploads'
/**
* Local Filesystem Storage Provider
*
* Stores files in the local filesystem with signed URLs for access control.
* Suitable for development/testing or deployments without S3.
*/
export class LocalStorageProvider implements StorageProvider {
private basePath: string
private baseUrl: string
constructor(basePath?: string) {
this.basePath = basePath || process.env.LOCAL_STORAGE_PATH || DEFAULT_BASE_PATH
this.baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
}
/**
* Generate a signed URL for secure file access
*/
private generateSignedUrl(
key: string,
action: 'upload' | 'download',
expirySeconds: number
): string {
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
const payload = `${action}:${key}:${expiresAt}`
const signature = createHmac('sha256', SECRET_KEY)
.update(payload)
.digest('hex')
const params = new URLSearchParams({
key,
action,
expires: expiresAt.toString(),
sig: signature,
})
return `${this.baseUrl}/api/storage/local?${params.toString()}`
}
/**
* Verify a signed URL signature
*/
static verifySignature(
key: string,
action: string,
expiresAt: number,
signature: string
): boolean {
const payload = `${action}:${key}:${expiresAt}`
const expectedSignature = createHmac('sha256', SECRET_KEY)
.update(payload)
.digest('hex')
return signature === expectedSignature && expiresAt > Date.now() / 1000
}
private getFilePath(key: string): string {
// Sanitize key to prevent path traversal
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '')
return path.join(this.basePath, sanitizedKey)
}
private async ensureDirectory(filePath: string): Promise<void> {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
}
async getUploadUrl(
key: string,
_contentType: string,
expirySeconds: number = 900
): Promise<string> {
return this.generateSignedUrl(key, 'upload', expirySeconds)
}
async getDownloadUrl(key: string, expirySeconds: number = 900): Promise<string> {
return this.generateSignedUrl(key, 'download', expirySeconds)
}
async deleteObject(key: string): Promise<void> {
const filePath = this.getFilePath(key)
try {
await fs.unlink(filePath)
} catch (error) {
// Ignore if file doesn't exist
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
}
async putObject(key: string, data: Buffer, _contentType: string): Promise<void> {
const filePath = this.getFilePath(key)
await this.ensureDirectory(filePath)
await fs.writeFile(filePath, data)
}
async getObject(key: string): Promise<Buffer> {
const filePath = this.getFilePath(key)
return fs.readFile(filePath)
}
async objectExists(key: string): Promise<boolean> {
const filePath = this.getFilePath(key)
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
/**
* Get the file path for direct file serving (used by API route)
*/
getAbsoluteFilePath(key: string): string {
return path.resolve(this.getFilePath(key))
}
}

View File

@@ -0,0 +1,64 @@
import type { StorageProvider } from './types'
import {
minio,
BUCKET_NAME,
getPresignedUrl,
deleteObject as minioDeleteObject,
ensureBucket,
} from '@/lib/minio'
/**
* S3/MinIO Storage Provider
*
* Uses the existing MinIO client for S3-compatible storage.
* Pre-signed URLs use MINIO_PUBLIC_ENDPOINT for browser access.
*/
export class S3StorageProvider implements StorageProvider {
private bucket: string
constructor(bucket?: string) {
this.bucket = bucket || BUCKET_NAME
}
async getUploadUrl(
key: string,
contentType: string,
expirySeconds: number = 900
): Promise<string> {
await ensureBucket(this.bucket)
return getPresignedUrl(this.bucket, key, 'PUT', expirySeconds)
}
async getDownloadUrl(key: string, expirySeconds: number = 900): Promise<string> {
return getPresignedUrl(this.bucket, key, 'GET', expirySeconds)
}
async deleteObject(key: string): Promise<void> {
await minioDeleteObject(this.bucket, key)
}
async putObject(key: string, data: Buffer, contentType: string): Promise<void> {
await ensureBucket(this.bucket)
await minio.putObject(this.bucket, key, data, data.length, {
'Content-Type': contentType,
})
}
async getObject(key: string): Promise<Buffer> {
const stream = await minio.getObject(this.bucket, key)
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(chunk as Buffer)
}
return Buffer.concat(chunks)
}
async objectExists(key: string): Promise<boolean> {
try {
await minio.statObject(this.bucket, key)
return true
} catch {
return false
}
}
}

57
src/lib/storage/types.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Storage Provider Interface
*
* Abstracts file storage operations to support multiple backends:
* - S3/MinIO: Production storage with pre-signed URLs
* - Local: Development/testing with filesystem storage
*/
export type StorageProvider = {
/**
* Get a pre-signed URL for uploading a file
*/
getUploadUrl(
key: string,
contentType: string,
expirySeconds?: number
): Promise<string>
/**
* Get a pre-signed URL for downloading/viewing a file
*/
getDownloadUrl(key: string, expirySeconds?: number): Promise<string>
/**
* Delete an object from storage
*/
deleteObject(key: string): Promise<void>
/**
* Upload data directly (server-side upload)
*/
putObject(key: string, data: Buffer, contentType: string): Promise<void>
/**
* Download data directly (server-side download)
*/
getObject(key: string): Promise<Buffer>
/**
* Check if an object exists
*/
objectExists(key: string): Promise<boolean>
}
export type StorageProviderType = 's3' | 'local'
export type StorageConfig = {
provider: StorageProviderType
bucket: string
// S3-specific
s3Endpoint?: string
s3PublicEndpoint?: string
s3AccessKey?: string
s3SecretKey?: string
// Local-specific
localBasePath?: string
}

6
src/lib/trpc/client.ts Normal file
View File

@@ -0,0 +1,6 @@
'use client'
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()

20
src/lib/trpc/server.ts Normal file
View File

@@ -0,0 +1,20 @@
import 'server-only'
import { cache } from 'react'
import { createCallerFactory } from '@/server/trpc'
import { createContext } from '@/server/context'
import { appRouter } from '@/server/routers/_app'
/**
* Create a server-side tRPC caller that can be used in Server Components
*/
const createCaller = createCallerFactory(appRouter)
/**
* Cached tRPC caller for use in Server Components
* Uses React's cache() to deduplicate context creation
*/
export const api = cache(async () => {
const context = await createContext()
return createCaller(context)
})

297
src/lib/typeform.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* Typeform API Client
*
* Uses the Typeform API to fetch form schemas and responses.
* API docs: https://developer.typeform.com/
*/
const TYPEFORM_API_BASE = 'https://api.typeform.com'
// Type definitions for Typeform API responses
export interface TypeformForm {
id: string
title: string
fields: TypeformField[]
}
export interface TypeformField {
id: string
ref: string
title: string
type: string
properties?: {
choices?: Array<{ id: string; label: string }>
allow_multiple_selection?: boolean
description?: string
}
validations?: {
required?: boolean
max_length?: number
}
}
export interface TypeformResponse {
response_id: string
submitted_at: string
answers: TypeformAnswer[]
hidden?: Record<string, string>
metadata?: {
user_agent?: string
platform?: string
referer?: string
}
}
export interface TypeformAnswer {
field: {
id: string
ref: string
type: string
}
type: string
text?: string
number?: number
boolean?: boolean
email?: string
url?: string
phone_number?: string
date?: string
choice?: { id: string; label: string }
choices?: { ids: string[]; labels: string[] }
file_url?: string
}
export interface TypeformResponsesResult {
total_items: number
page_count: number
items: TypeformResponse[]
}
/**
* Test connection to Typeform API
*/
export async function testTypeformConnection(
apiKey: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${TYPEFORM_API_BASE}/me`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
const error = await response.text()
return {
success: false,
error: `API error: ${response.status} - ${error}`,
}
}
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Typeform',
}
}
}
/**
* Get form schema (fields)
*/
export async function getTypeformSchema(
apiKey: string,
formId: string
): Promise<TypeformForm> {
const response = await fetch(`${TYPEFORM_API_BASE}/forms/${formId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to fetch form: ${response.status} - ${error}`)
}
const data = await response.json()
return {
id: data.id,
title: data.title,
fields: (data.fields || []).map((field: TypeformField) => ({
id: field.id,
ref: field.ref,
title: field.title,
type: field.type,
properties: field.properties,
validations: field.validations,
})),
}
}
/**
* Get form responses
*/
export async function getTypeformResponses(
apiKey: string,
formId: string,
options?: {
limit?: number
since?: string // ISO date string
until?: string // ISO date string
}
): Promise<TypeformResponsesResult> {
const params = new URLSearchParams()
if (options?.limit) {
params.set('page_size', String(Math.min(options.limit, 1000)))
}
if (options?.since) {
params.set('since', options.since)
}
if (options?.until) {
params.set('until', options.until)
}
const url = `${TYPEFORM_API_BASE}/forms/${formId}/responses?${params.toString()}`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to fetch responses: ${response.status} - ${error}`)
}
const data = await response.json()
return {
total_items: data.total_items,
page_count: data.page_count,
items: data.items || [],
}
}
/**
* Get all responses with pagination
*/
export async function getAllTypeformResponses(
apiKey: string,
formId: string,
limit?: number
): Promise<TypeformResponse[]> {
const allResponses: TypeformResponse[] = []
let hasMore = true
let before: string | undefined
while (hasMore) {
const params = new URLSearchParams({
page_size: '1000',
})
if (before) {
params.set('before', before)
}
const url = `${TYPEFORM_API_BASE}/forms/${formId}/responses?${params.toString()}`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
break
}
const data = await response.json()
const items = data.items || []
allResponses.push(...items)
if (limit && allResponses.length >= limit) {
return allResponses.slice(0, limit)
}
if (items.length < 1000) {
hasMore = false
} else {
// Get the token for the next page
before = items[items.length - 1]?.token
if (!before) {
hasMore = false
}
}
}
return allResponses
}
/**
* Extract a value from a Typeform answer
*/
export function extractAnswerValue(answer: TypeformAnswer): unknown {
switch (answer.type) {
case 'text':
return answer.text
case 'number':
return answer.number
case 'boolean':
return answer.boolean
case 'email':
return answer.email
case 'url':
return answer.url
case 'phone_number':
return answer.phone_number
case 'date':
return answer.date
case 'choice':
return answer.choice?.label
case 'choices':
return answer.choices?.labels || []
case 'file_url':
return answer.file_url
default:
return null
}
}
/**
* Convert a Typeform response to a flat object using field refs as keys
*/
export function responseToObject(
response: TypeformResponse,
fields: TypeformField[]
): Record<string, unknown> {
const result: Record<string, unknown> = {
_response_id: response.response_id,
_submitted_at: response.submitted_at,
}
// Create a map of field refs to titles for better key naming
const fieldMap = new Map<string, string>()
for (const field of fields) {
fieldMap.set(field.ref, field.title)
}
for (const answer of response.answers) {
const key = fieldMap.get(answer.field.ref) || answer.field.ref
result[key] = extractAnswerValue(answer)
}
// Include hidden fields if present
if (response.hidden) {
for (const [key, value] of Object.entries(response.hidden)) {
result[`_hidden_${key}`] = value
}
}
return result
}

61
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,61 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(date))
}
export function formatDateOnly(date: Date | string): string {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'long',
}).format(new Date(date))
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str
return str.slice(0, length) + '...'
}
export function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export function formatEnumLabel(value: string): string {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function daysUntil(date: Date | string): number {
const target = new Date(date)
const now = new Date()
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}

151
src/lib/whatsapp/index.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* WhatsApp Provider Abstraction Layer
*
* Supports multiple WhatsApp providers with a common interface:
* - Meta WhatsApp Business Cloud API
* - Twilio WhatsApp
*/
import { prisma } from '@/lib/prisma'
import { MetaWhatsAppProvider } from './meta-provider'
import { TwilioWhatsAppProvider } from './twilio-provider'
export interface WhatsAppResult {
success: boolean
messageId?: string
error?: string
}
export interface WhatsAppProvider {
sendText(to: string, body: string): Promise<WhatsAppResult>
sendTemplate(
to: string,
template: string,
params: Record<string, string>
): Promise<WhatsAppResult>
testConnection(): Promise<{ success: boolean; error?: string }>
}
export type WhatsAppProviderType = 'META' | 'TWILIO'
/**
* Get the configured WhatsApp provider
* Returns null if WhatsApp is not enabled or not configured
*/
export async function getWhatsAppProvider(): Promise<WhatsAppProvider | null> {
try {
// Check if WhatsApp is enabled
const enabledSetting = await prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
})
if (enabledSetting?.value !== 'true') {
return null
}
// Get provider type
const providerSetting = await prisma.systemSettings.findUnique({
where: { key: 'whatsapp_provider' },
})
const providerType = (providerSetting?.value || 'META') as WhatsAppProviderType
if (providerType === 'META') {
return await createMetaProvider()
} else if (providerType === 'TWILIO') {
return await createTwilioProvider()
}
return null
} catch (error) {
console.error('Failed to get WhatsApp provider:', error)
return null
}
}
/**
* Create Meta WhatsApp provider from settings
*/
async function createMetaProvider(): Promise<WhatsAppProvider | null> {
const [phoneNumberIdSetting, accessTokenSetting] = await Promise.all([
prisma.systemSettings.findUnique({
where: { key: 'whatsapp_meta_phone_number_id' },
}),
prisma.systemSettings.findUnique({
where: { key: 'whatsapp_meta_access_token' },
}),
])
if (!phoneNumberIdSetting?.value || !accessTokenSetting?.value) {
console.warn('Meta WhatsApp not fully configured')
return null
}
return new MetaWhatsAppProvider(
phoneNumberIdSetting.value,
accessTokenSetting.value
)
}
/**
* Create Twilio WhatsApp provider from settings
*/
async function createTwilioProvider(): Promise<WhatsAppProvider | null> {
const [accountSidSetting, authTokenSetting, phoneNumberSetting] = await Promise.all([
prisma.systemSettings.findUnique({
where: { key: 'whatsapp_twilio_account_sid' },
}),
prisma.systemSettings.findUnique({
where: { key: 'whatsapp_twilio_auth_token' },
}),
prisma.systemSettings.findUnique({
where: { key: 'whatsapp_twilio_phone_number' },
}),
])
if (
!accountSidSetting?.value ||
!authTokenSetting?.value ||
!phoneNumberSetting?.value
) {
console.warn('Twilio WhatsApp not fully configured')
return null
}
return new TwilioWhatsAppProvider(
accountSidSetting.value,
authTokenSetting.value,
phoneNumberSetting.value
)
}
/**
* Check if WhatsApp is configured and available
*/
export async function isWhatsAppEnabled(): Promise<boolean> {
const provider = await getWhatsAppProvider()
return provider !== null
}
/**
* Get the current provider type
*/
export async function getWhatsAppProviderType(): Promise<WhatsAppProviderType | null> {
try {
const enabledSetting = await prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
})
if (enabledSetting?.value !== 'true') {
return null
}
const providerSetting = await prisma.systemSettings.findUnique({
where: { key: 'whatsapp_provider' },
})
return (providerSetting?.value || 'META') as WhatsAppProviderType
} catch {
return null
}
}

View File

@@ -0,0 +1,187 @@
/**
* Meta WhatsApp Business Cloud API Provider
*
* Direct integration with Meta's Graph API for WhatsApp Business.
* Docs: https://developers.facebook.com/docs/whatsapp/cloud-api
*/
import type { WhatsAppProvider, WhatsAppResult } from './index'
const GRAPH_API_VERSION = 'v18.0'
const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`
export class MetaWhatsAppProvider implements WhatsAppProvider {
constructor(
private phoneNumberId: string,
private accessToken: string
) {}
/**
* Send a text message
*/
async sendText(to: string, body: string): Promise<WhatsAppResult> {
try {
const response = await fetch(
`${BASE_URL}/${this.phoneNumberId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: formatPhoneNumber(to),
type: 'text',
text: { body },
}),
}
)
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.error?.message || `API error: ${response.status}`,
}
}
return {
success: true,
messageId: data.messages?.[0]?.id,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Send a template message
*
* Templates must be pre-approved by Meta.
* Params are passed as components to the template.
*/
async sendTemplate(
to: string,
template: string,
params: Record<string, string>
): Promise<WhatsAppResult> {
try {
// Build template components from params
const components = buildTemplateComponents(params)
const response = await fetch(
`${BASE_URL}/${this.phoneNumberId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: formatPhoneNumber(to),
type: 'template',
template: {
name: template,
language: { code: 'en' },
components,
},
}),
}
)
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.error?.message || `API error: ${response.status}`,
}
}
return {
success: true,
messageId: data.messages?.[0]?.id,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Test the connection to Meta API
*/
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
// Try to get phone number info to verify credentials
const response = await fetch(
`${BASE_URL}/${this.phoneNumberId}`,
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
}
)
if (!response.ok) {
const data = await response.json()
return {
success: false,
error: data.error?.message || `API error: ${response.status}`,
}
}
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
}
}
}
}
/**
* Format phone number for WhatsApp API
* Removes + prefix and any non-digit characters
*/
function formatPhoneNumber(phone: string): string {
return phone.replace(/[^\d]/g, '')
}
/**
* Build template components from params
* Converts { param1: "value1", param2: "value2" } to WhatsApp component format
*/
function buildTemplateComponents(
params: Record<string, string>
): Array<{
type: 'body'
parameters: Array<{ type: 'text'; text: string }>
}> {
const paramValues = Object.values(params)
if (paramValues.length === 0) {
return []
}
return [
{
type: 'body',
parameters: paramValues.map((value) => ({
type: 'text',
text: value,
})),
},
]
}

View File

@@ -0,0 +1,155 @@
/**
* Twilio WhatsApp Provider
*
* Uses Twilio's WhatsApp API for sending messages.
* Docs: https://www.twilio.com/docs/whatsapp
*/
import type { WhatsAppProvider, WhatsAppResult } from './index'
export class TwilioWhatsAppProvider implements WhatsAppProvider {
private baseUrl: string
constructor(
private accountSid: string,
private authToken: string,
private fromNumber: string
) {
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
}
/**
* Send a text message via Twilio WhatsApp
*/
async sendText(to: string, body: string): Promise<WhatsAppResult> {
try {
const formData = new URLSearchParams({
From: `whatsapp:${formatPhoneNumber(this.fromNumber)}`,
To: `whatsapp:${formatPhoneNumber(to)}`,
Body: body,
})
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
})
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.message || `API error: ${response.status}`,
}
}
return {
success: true,
messageId: data.sid,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Send a template message via Twilio WhatsApp
*
* Twilio uses Content Templates for WhatsApp templates.
* The template name should be the Content SID.
*/
async sendTemplate(
to: string,
template: string,
params: Record<string, string>
): Promise<WhatsAppResult> {
try {
const formData = new URLSearchParams({
From: `whatsapp:${formatPhoneNumber(this.fromNumber)}`,
To: `whatsapp:${formatPhoneNumber(to)}`,
ContentSid: template,
})
// Add template variables
if (Object.keys(params).length > 0) {
formData.append('ContentVariables', JSON.stringify(params))
}
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
})
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.message || `API error: ${response.status}`,
}
}
return {
success: true,
messageId: data.sid,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Test the connection to Twilio API
*/
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
// Try to fetch account info to verify credentials
const response = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}.json`,
{
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64')}`,
},
}
)
if (!response.ok) {
const data = await response.json()
return {
success: false,
error: data.message || `API error: ${response.status}`,
}
}
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Connection failed',
}
}
}
}
/**
* Format phone number for Twilio WhatsApp
* Ensures + prefix and removes any other non-digit characters
*/
function formatPhoneNumber(phone: string): string {
const digits = phone.replace(/[^\d]/g, '')
return `+${digits}`
}