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:
29
src/lib/auth-redirect.ts
Normal file
29
src/lib/auth-redirect.ts
Normal 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
87
src/lib/auth.config.ts
Normal 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
224
src/lib/auth.ts
Normal 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
223
src/lib/countries.ts
Normal 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
590
src/lib/email.ts
Normal 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;">
|
||||
© ${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;"> </i>
|
||||
<![endif]-->
|
||||
<span style="mso-text-raise: 15pt;">${text}</span>
|
||||
<!--[if mso]>
|
||||
<i style="letter-spacing: 40px; mso-font-width: -100%;"> </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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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
120
src/lib/minio.ts
Normal 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
258
src/lib/notion.ts
Normal 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
109
src/lib/openai.ts
Normal 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
93
src/lib/password.ts
Normal 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
16
src/lib/prisma.ts
Normal 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
59
src/lib/rate-limit.ts
Normal 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
138
src/lib/storage/index.ts
Normal 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
|
||||
}
|
||||
127
src/lib/storage/local-provider.ts
Normal file
127
src/lib/storage/local-provider.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
64
src/lib/storage/s3-provider.ts
Normal file
64
src/lib/storage/s3-provider.ts
Normal 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
57
src/lib/storage/types.ts
Normal 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
6
src/lib/trpc/client.ts
Normal 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
20
src/lib/trpc/server.ts
Normal 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
297
src/lib/typeform.ts
Normal 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
61
src/lib/utils.ts
Normal 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
151
src/lib/whatsapp/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
187
src/lib/whatsapp/meta-provider.ts
Normal file
187
src/lib/whatsapp/meta-provider.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
155
src/lib/whatsapp/twilio-provider.ts
Normal file
155
src/lib/whatsapp/twilio-provider.ts
Normal 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}`
|
||||
}
|
||||
Reference in New Issue
Block a user