Initial commit: MOPC platform with Docker deployment setup

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

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

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

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

View File

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

View File

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