first commit
This commit is contained in:
248
backend/src/lib/auth.ts
Normal file
248
backend/src/lib/auth.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import * as jose from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
import { generateId, getNow } from './utils.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
const JWT_AUDIENCE = 'spanglish-app';
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Generate secure random token for magic links
|
||||
export function generateSecureToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Create magic link token
|
||||
export async function createMagicLinkToken(
|
||||
userId: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||
expiresInMinutes: number = 10
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString();
|
||||
|
||||
await (db as any).insert(magicLinkTokens).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify and consume magic link token
|
||||
export async function verifyMagicLinkToken(
|
||||
token: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await (db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
return { valid: false, error: 'Token already used' };
|
||||
}
|
||||
|
||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||
return { valid: false, error: 'Token expired' };
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await (db as any)
|
||||
.update(magicLinkTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
||||
|
||||
return { valid: true, userId: tokenRecord.userId };
|
||||
}
|
||||
|
||||
// Create user session
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
|
||||
|
||||
await (db as any).insert(userSessions).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token: sessionToken,
|
||||
userAgent: userAgent || null,
|
||||
ipAddress: ipAddress || null,
|
||||
lastActiveAt: now,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
// Get user's active sessions
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return (db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
// Invalidate a specific session
|
||||
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
||||
const result = await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).id, sessionId),
|
||||
eq((userSessions as any).userId, userId)
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invalidate all user sessions (logout everywhere)
|
||||
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(eq((userSessions as any).userId, userId));
|
||||
}
|
||||
|
||||
// Password validation (min 10 characters per spec)
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime('7d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
return payload as unknown as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthUser(c: Context) {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get();
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function requireAuth(roles?: string[]) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export async function isFirstUser(): Promise<boolean> {
|
||||
const result = await (db as any).select().from(users).limit(1).all();
|
||||
return !result || result.length === 0;
|
||||
}
|
||||
784
backend/src/lib/email.ts
Normal file
784
backend/src/lib/email.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
// Email service for Spanglish platform
|
||||
// Supports multiple email providers: Resend, SMTP (Nodemailer)
|
||||
|
||||
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getNow } from './utils.js';
|
||||
import {
|
||||
replaceTemplateVariables,
|
||||
wrapInBaseTemplate,
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.js';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface SendEmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type EmailProvider = 'resend' | 'smtp' | 'console';
|
||||
|
||||
// ==================== Provider Configuration ====================
|
||||
|
||||
function getEmailProvider(): EmailProvider {
|
||||
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
|
||||
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
|
||||
return provider;
|
||||
}
|
||||
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
|
||||
return 'console';
|
||||
}
|
||||
|
||||
function getFromEmail(): string {
|
||||
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
|
||||
}
|
||||
|
||||
function getFromName(): string {
|
||||
return process.env.EMAIL_FROM_NAME || 'Spanglish';
|
||||
}
|
||||
|
||||
// ==================== SMTP Configuration ====================
|
||||
|
||||
interface SMTPConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth?: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getSMTPConfig(): SMTPConfig | null {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587');
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config: SMTPConfig = {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
};
|
||||
|
||||
if (user && pass) {
|
||||
config.auth = { user, pass };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Cached SMTP transporter
|
||||
let smtpTransporter: Transporter | null = null;
|
||||
|
||||
function getSMTPTransporter(): Transporter | null {
|
||||
if (smtpTransporter) {
|
||||
return smtpTransporter;
|
||||
}
|
||||
|
||||
const config = getSMTPConfig();
|
||||
if (!config) {
|
||||
console.error('[Email] SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
smtpTransporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.auth,
|
||||
// Additional options for better deliverability
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
// TLS options
|
||||
tls: {
|
||||
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify connection configuration
|
||||
smtpTransporter.verify((error, success) => {
|
||||
if (error) {
|
||||
console.error('[Email] SMTP connection verification failed:', error.message);
|
||||
} else {
|
||||
console.log('[Email] SMTP server is ready to send emails');
|
||||
}
|
||||
});
|
||||
|
||||
return smtpTransporter;
|
||||
}
|
||||
|
||||
// ==================== Email Providers ====================
|
||||
|
||||
/**
|
||||
* Send email using Resend API
|
||||
*/
|
||||
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
|
||||
const fromEmail = getFromEmail();
|
||||
const fromName = getFromName();
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('[Email] Resend API key not configured');
|
||||
return { success: false, error: 'Resend API key not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
reply_to: options.replyTo,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Email] Resend API error:', data);
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || data.error || 'Failed to send email'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[Email] Email sent via Resend:', data.id);
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.id
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[Email] Resend error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to send email via Resend'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email using SMTP (Nodemailer)
|
||||
*/
|
||||
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const transporter = getSMTPTransporter();
|
||||
|
||||
if (!transporter) {
|
||||
return { success: false, error: 'SMTP not configured' };
|
||||
}
|
||||
|
||||
const fromEmail = getFromEmail();
|
||||
const fromName = getFromName();
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||
replyTo: options.replyTo,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
});
|
||||
|
||||
console.log('[Email] Email sent via SMTP:', info.messageId);
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[Email] SMTP error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to send email via SMTP'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console logger for development/testing (no actual email sent)
|
||||
*/
|
||||
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('[Email] Console Mode - Email Preview');
|
||||
console.log('========================================');
|
||||
console.log(`To: ${to}`);
|
||||
console.log(`Subject: ${options.subject}`);
|
||||
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
|
||||
console.log('----------------------------------------');
|
||||
console.log('HTML Body (truncated):');
|
||||
console.log(options.html?.substring(0, 500) + '...');
|
||||
console.log('========================================\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: `console-${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main send function that routes to the appropriate provider
|
||||
*/
|
||||
async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const provider = getEmailProvider();
|
||||
|
||||
console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`);
|
||||
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
return sendWithResend(options);
|
||||
case 'smtp':
|
||||
return sendWithSMTP(options);
|
||||
case 'console':
|
||||
default:
|
||||
return sendWithConsole(options);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Email Service ====================
|
||||
|
||||
export const emailService = {
|
||||
/**
|
||||
* Get current email provider info
|
||||
*/
|
||||
getProviderInfo(): { provider: EmailProvider; configured: boolean } {
|
||||
const provider = getEmailProvider();
|
||||
let configured = false;
|
||||
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
|
||||
break;
|
||||
case 'smtp':
|
||||
configured = !!process.env.SMTP_HOST;
|
||||
break;
|
||||
case 'console':
|
||||
configured = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return { provider, configured };
|
||||
},
|
||||
|
||||
/**
|
||||
* Test email configuration by sending a test email
|
||||
*/
|
||||
async testConnection(to: string): Promise<SendEmailResult> {
|
||||
const { provider, configured } = this.getProviderInfo();
|
||||
|
||||
if (!configured) {
|
||||
return { success: false, error: `Email provider "${provider}" is not configured` };
|
||||
}
|
||||
|
||||
return sendEmail({
|
||||
to,
|
||||
subject: 'Spanglish - Email Test',
|
||||
html: `
|
||||
<h2>Email Configuration Test</h2>
|
||||
<p>This is a test email from your Spanglish platform.</p>
|
||||
<p><strong>Provider:</strong> ${provider}</p>
|
||||
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
|
||||
<p>If you received this email, your email configuration is working correctly!</p>
|
||||
`,
|
||||
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get common variables for all emails
|
||||
*/
|
||||
getCommonVariables(): Record<string, string> {
|
||||
return {
|
||||
siteName: 'Spanglish',
|
||||
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
|
||||
currentYear: new Date().getFullYear().toString(),
|
||||
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Format date for emails
|
||||
*/
|
||||
formatDate(dateStr: string, locale: string = 'en'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format time for emails
|
||||
*/
|
||||
formatTime(dateStr: string, locale: string = 'en'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a template by slug
|
||||
*/
|
||||
async getTemplate(slug: string): Promise<any | null> {
|
||||
const template = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
.get();
|
||||
|
||||
return template || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed default templates if they don't exist
|
||||
*/
|
||||
async seedDefaultTemplates(): Promise<void> {
|
||||
console.log('[Email] Checking for default templates...');
|
||||
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await this.getTemplate(template.slug);
|
||||
|
||||
if (!existing) {
|
||||
console.log(`[Email] Creating template: ${template.name}`);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailTemplates).values({
|
||||
id: nanoid(),
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs,
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs,
|
||||
bodyText: template.bodyText,
|
||||
bodyTextEs: template.bodyTextEs,
|
||||
description: template.description,
|
||||
variables: JSON.stringify(template.variables),
|
||||
isSystem: template.isSystem ? 1 : 0,
|
||||
isActive: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Email] Default templates check complete');
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
*/
|
||||
async sendTemplateEmail(params: {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
toName?: string;
|
||||
variables: Record<string, any>;
|
||||
locale?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
||||
|
||||
// Get template
|
||||
const template = await this.getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Build variables
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
lang: locale,
|
||||
...variables,
|
||||
};
|
||||
|
||||
// Get localized content
|
||||
const subject = locale === 'es' && template.subjectEs
|
||||
? template.subjectEs
|
||||
: template.subject;
|
||||
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
||||
? template.bodyHtmlEs
|
||||
: template.bodyHtml;
|
||||
const bodyText = locale === 'es' && template.bodyTextEs
|
||||
? template.bodyTextEs
|
||||
: template.bodyText;
|
||||
|
||||
// Replace variables
|
||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
||||
|
||||
// Create log entry
|
||||
const logId = nanoid();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
id: logId,
|
||||
templateId: template.id,
|
||||
eventId: eventId || null,
|
||||
recipientEmail: to,
|
||||
recipientName: toName || null,
|
||||
subject: finalSubject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject: finalSubject,
|
||||
html: finalBodyHtml,
|
||||
text: finalBodyText,
|
||||
});
|
||||
|
||||
// Update log with result
|
||||
if (result.success) {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'sent',
|
||||
sentAt: getNow(),
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
} else {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
logId,
|
||||
error: result.error
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send booking confirmation email
|
||||
*/
|
||||
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket with event info
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
eventPrice: this.formatCurrency(event.price, event.currency),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send payment receipt email
|
||||
*/
|
||||
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment with ticket and event info
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: receiptFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
paymentReference: payment.reference || payment.id,
|
||||
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send custom email to event attendees
|
||||
*/
|
||||
async sendToEventAttendees(params: {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }> {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
|
||||
if (!event) {
|
||||
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
|
||||
}
|
||||
|
||||
// Get tickets based on filter
|
||||
let ticketQuery = (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId));
|
||||
|
||||
if (recipientFilter !== 'all') {
|
||||
ticketQuery = ticketQuery.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, recipientFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const eventTickets = await ticketQuery.all();
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Send to each attendee
|
||||
for (const ticket of eventTickets) {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const bulkFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
const result = await this.sendTemplateEmail({
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: bulkFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
sentBy,
|
||||
variables: {
|
||||
attendeeName: bulkFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
errors.push(`Failed to send to ${ticket.attendeeEmail}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
sentCount,
|
||||
failedCount,
|
||||
errors,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
*/
|
||||
async sendCustomEmail(params: {
|
||||
to: string;
|
||||
toName?: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
subject,
|
||||
};
|
||||
|
||||
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
||||
|
||||
// Create log entry
|
||||
const logId = nanoid();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
id: logId,
|
||||
templateId: null,
|
||||
eventId: eventId || null,
|
||||
recipientEmail: to,
|
||||
recipientName: toName || null,
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
});
|
||||
|
||||
// Update log
|
||||
if (result.success) {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'sent',
|
||||
sentAt: getNow(),
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
} else {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
logId,
|
||||
error: result.error
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export the main sendEmail function for direct use
|
||||
export { sendEmail };
|
||||
|
||||
export default emailService;
|
||||
675
backend/src/lib/emailTemplates.ts
Normal file
675
backend/src/lib/emailTemplates.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
// Email templates for Spanglish platform
|
||||
// These are the default templates that get seeded into the database
|
||||
|
||||
export interface EmailVariable {
|
||||
name: string;
|
||||
description: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface DefaultTemplate {
|
||||
name: string;
|
||||
slug: string;
|
||||
subject: string;
|
||||
subjectEs: string;
|
||||
bodyHtml: string;
|
||||
bodyHtmlEs: string;
|
||||
bodyText: string;
|
||||
bodyTextEs: string;
|
||||
description: string;
|
||||
variables: EmailVariable[];
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
// Common variables available in all templates
|
||||
export const commonVariables: EmailVariable[] = [
|
||||
{ name: 'siteName', description: 'Website name', example: 'Spanglish' },
|
||||
{ name: 'siteUrl', description: 'Website URL', example: 'https://spanglish.com' },
|
||||
{ name: 'currentYear', description: 'Current year', example: '2026' },
|
||||
{ name: 'supportEmail', description: 'Support email address', example: 'hello@spanglish.com' },
|
||||
];
|
||||
|
||||
// Booking-specific variables
|
||||
export const bookingVariables: EmailVariable[] = [
|
||||
{ name: 'attendeeName', description: 'Attendee full name', example: 'John Doe' },
|
||||
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
||||
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
||||
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
||||
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
||||
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
||||
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
||||
{ name: 'eventLocation', description: 'Event location', example: 'Casa Cultural, Asunción' },
|
||||
{ name: 'eventLocationUrl', description: 'Google Maps link', example: 'https://maps.google.com/...' },
|
||||
{ name: 'eventPrice', description: 'Event price with currency', example: '50,000 PYG' },
|
||||
];
|
||||
|
||||
// Payment-specific variables
|
||||
export const paymentVariables: EmailVariable[] = [
|
||||
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
|
||||
{ name: 'paymentMethod', description: 'Payment method used', example: 'Lightning' },
|
||||
{ name: 'paymentReference', description: 'Payment reference ID', example: 'PAY-XYZ789' },
|
||||
{ name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' },
|
||||
];
|
||||
|
||||
// Base HTML wrapper for all emails
|
||||
export const baseEmailWrapper = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{lang}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{subject}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.header {
|
||||
background-color: #1a1a1a;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
.header h1 span {
|
||||
color: #f4d03f;
|
||||
}
|
||||
.content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.content h2 {
|
||||
color: #1a1a1a;
|
||||
margin-top: 0;
|
||||
}
|
||||
.event-card {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.event-card h3 {
|
||||
margin-top: 0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.event-detail {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.event-detail strong {
|
||||
min-width: 80px;
|
||||
color: #666;
|
||||
}
|
||||
.ticket-box {
|
||||
background-color: #f4d03f;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.ticket-box p {
|
||||
margin: 4px 0;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.ticket-id {
|
||||
font-size: 20px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background-color: #f4d03f;
|
||||
color: #1a1a1a;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #e6c230;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f5f5f5;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.footer a {
|
||||
color: #333;
|
||||
}
|
||||
.qr-code {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.qr-code img {
|
||||
max-width: 150px;
|
||||
height: auto;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #eee;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.note {
|
||||
background-color: #fff9e6;
|
||||
border-left: 4px solid #f4d03f;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Span<span>glish</span></h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{{content}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>{{siteName}} - Language Exchange Community in Asunción</p>
|
||||
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>
|
||||
<p>Questions? Contact us at <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
|
||||
<p>© {{currentYear}} {{siteName}}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Default templates
|
||||
export const defaultTemplates: DefaultTemplate[] = [
|
||||
{
|
||||
name: 'Booking Confirmation',
|
||||
slug: 'booking-confirmation',
|
||||
subject: 'Your Spanglish ticket is confirmed 🎉',
|
||||
subjectEs: 'Tu entrada de Spanglish está confirmada 🎉',
|
||||
bodyHtml: `
|
||||
<h2>Your Booking is Confirmed!</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>Great news! Your spot for <strong>{{eventTitle}}</strong> has been confirmed. We can't wait to see you there!</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Event Details</h3>
|
||||
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
|
||||
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
|
||||
{{#if eventLocationUrl}}
|
||||
<p><a href="{{eventLocationUrl}}" class="btn">📍 View on Map</a></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Your Ticket ID</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Show this QR code at check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Check-in QR Code" />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||
</div>
|
||||
|
||||
<p>See you at Spanglish!</p>
|
||||
<p>The Spanglish Team</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>¡Tu Reserva está Confirmada!</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>¡Excelentes noticias! Tu lugar para <strong>{{eventTitle}}</strong> ha sido confirmado. ¡No podemos esperar a verte ahí!</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Detalles del Evento</h3>
|
||||
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
|
||||
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
|
||||
{{#if eventLocationUrl}}
|
||||
<p><a href="{{eventLocationUrl}}" class="btn">📍 Ver en el Mapa</a></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Tu ID de Ticket</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Muestra este código QR en el check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Código QR de Check-in" />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||
</div>
|
||||
|
||||
<p>¡Nos vemos en Spanglish!</p>
|
||||
<p>El Equipo de Spanglish</p>
|
||||
`,
|
||||
bodyText: `Your Booking is Confirmed!
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
Great news! Your spot for {{eventTitle}} has been confirmed.
|
||||
|
||||
Event Details:
|
||||
- Event: {{eventTitle}}
|
||||
- Date: {{eventDate}}
|
||||
- Time: {{eventTime}}
|
||||
- Location: {{eventLocation}}
|
||||
|
||||
Your Ticket ID: {{ticketId}}
|
||||
|
||||
Important: Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||
|
||||
See you at Spanglish!
|
||||
The Spanglish Team`,
|
||||
bodyTextEs: `¡Tu Reserva está Confirmada!
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
¡Excelentes noticias! Tu lugar para {{eventTitle}} ha sido confirmado.
|
||||
|
||||
Detalles del Evento:
|
||||
- Evento: {{eventTitle}}
|
||||
- Fecha: {{eventDate}}
|
||||
- Hora: {{eventTime}}
|
||||
- Ubicación: {{eventLocation}}
|
||||
|
||||
Tu ID de Ticket: {{ticketId}}
|
||||
|
||||
Importante: Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||
|
||||
¡Nos vemos en Spanglish!
|
||||
El Equipo de Spanglish`,
|
||||
description: 'Sent automatically when a booking is confirmed after payment',
|
||||
variables: [...commonVariables, ...bookingVariables],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Payment Receipt',
|
||||
slug: 'payment-receipt',
|
||||
subject: 'Payment Receipt - Spanglish',
|
||||
subjectEs: 'Recibo de Pago - Spanglish',
|
||||
bodyHtml: `
|
||||
<h2>Payment Received</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>Thank you for your payment! Here's your receipt for your records.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>💳 Payment Details</h3>
|
||||
<div class="event-detail"><strong>Amount:</strong> {{paymentAmount}}</div>
|
||||
<div class="event-detail"><strong>Method:</strong> {{paymentMethod}}</div>
|
||||
<div class="event-detail"><strong>Reference:</strong> {{paymentReference}}</div>
|
||||
<div class="event-detail"><strong>Date:</strong> {{paymentDate}}</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Event</h3>
|
||||
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>Ticket ID:</strong> {{ticketId}}</div>
|
||||
</div>
|
||||
|
||||
<p>Keep this email as your payment confirmation.</p>
|
||||
<p>The Spanglish Team</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>Pago Recibido</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>¡Gracias por tu pago! Aquí está tu recibo para tus registros.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>💳 Detalles del Pago</h3>
|
||||
<div class="event-detail"><strong>Monto:</strong> {{paymentAmount}}</div>
|
||||
<div class="event-detail"><strong>Método:</strong> {{paymentMethod}}</div>
|
||||
<div class="event-detail"><strong>Referencia:</strong> {{paymentReference}}</div>
|
||||
<div class="event-detail"><strong>Fecha:</strong> {{paymentDate}}</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Evento</h3>
|
||||
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>ID de Ticket:</strong> {{ticketId}}</div>
|
||||
</div>
|
||||
|
||||
<p>Guarda este email como tu confirmación de pago.</p>
|
||||
<p>El Equipo de Spanglish</p>
|
||||
`,
|
||||
bodyText: `Payment Received
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
Thank you for your payment! Here's your receipt:
|
||||
|
||||
Payment Details:
|
||||
- Amount: {{paymentAmount}}
|
||||
- Method: {{paymentMethod}}
|
||||
- Reference: {{paymentReference}}
|
||||
- Date: {{paymentDate}}
|
||||
|
||||
Event: {{eventTitle}}
|
||||
Date: {{eventDate}}
|
||||
Ticket ID: {{ticketId}}
|
||||
|
||||
Keep this email as your payment confirmation.
|
||||
|
||||
The Spanglish Team`,
|
||||
bodyTextEs: `Pago Recibido
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
¡Gracias por tu pago! Aquí está tu recibo:
|
||||
|
||||
Detalles del Pago:
|
||||
- Monto: {{paymentAmount}}
|
||||
- Método: {{paymentMethod}}
|
||||
- Referencia: {{paymentReference}}
|
||||
- Fecha: {{paymentDate}}
|
||||
|
||||
Evento: {{eventTitle}}
|
||||
Fecha: {{eventDate}}
|
||||
ID de Ticket: {{ticketId}}
|
||||
|
||||
Guarda este email como tu confirmación de pago.
|
||||
|
||||
El Equipo de Spanglish`,
|
||||
description: 'Sent automatically after payment is processed',
|
||||
variables: [...commonVariables, ...bookingVariables, ...paymentVariables],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Event Update',
|
||||
slug: 'event-update',
|
||||
subject: 'Important Update: {{eventTitle}}',
|
||||
subjectEs: 'Actualización Importante: {{eventTitle}}',
|
||||
bodyHtml: `
|
||||
<h2>Important Event Update</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>We have an important update regarding <strong>{{eventTitle}}</strong>.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📢 Message</h3>
|
||||
<p>{{customMessage}}</p>
|
||||
</div>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Event Details</h3>
|
||||
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
|
||||
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
|
||||
</div>
|
||||
|
||||
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||
<p>The Spanglish Team</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>Actualización Importante del Evento</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>Tenemos una actualización importante sobre <strong>{{eventTitle}}</strong>.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📢 Mensaje</h3>
|
||||
<p>{{customMessage}}</p>
|
||||
</div>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>📅 Detalles del Evento</h3>
|
||||
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
|
||||
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
|
||||
</div>
|
||||
|
||||
<p>Si tienes alguna pregunta, no dudes en contactarnos.</p>
|
||||
<p>El Equipo de Spanglish</p>
|
||||
`,
|
||||
bodyText: `Important Event Update
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
We have an important update regarding {{eventTitle}}.
|
||||
|
||||
Message:
|
||||
{{customMessage}}
|
||||
|
||||
Event Details:
|
||||
- Event: {{eventTitle}}
|
||||
- Date: {{eventDate}}
|
||||
- Time: {{eventTime}}
|
||||
- Location: {{eventLocation}}
|
||||
|
||||
If you have any questions, please don't hesitate to contact us.
|
||||
|
||||
The Spanglish Team`,
|
||||
bodyTextEs: `Actualización Importante del Evento
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
Tenemos una actualización importante sobre {{eventTitle}}.
|
||||
|
||||
Mensaje:
|
||||
{{customMessage}}
|
||||
|
||||
Detalles del Evento:
|
||||
- Evento: {{eventTitle}}
|
||||
- Fecha: {{eventDate}}
|
||||
- Hora: {{eventTime}}
|
||||
- Ubicación: {{eventLocation}}
|
||||
|
||||
Si tienes alguna pregunta, no dudes en contactarnos.
|
||||
|
||||
El Equipo de Spanglish`,
|
||||
description: 'Template for sending event updates to attendees (sent manually)',
|
||||
variables: [
|
||||
...commonVariables,
|
||||
...bookingVariables,
|
||||
{ name: 'customMessage', description: 'Custom message from admin', example: 'The venue has changed...' }
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Post-Event Follow-Up',
|
||||
slug: 'post-event-followup',
|
||||
subject: 'Thanks for joining {{eventTitle}}! 🙏',
|
||||
subjectEs: '¡Gracias por asistir a {{eventTitle}}! 🙏',
|
||||
bodyHtml: `
|
||||
<h2>Thank You for Joining Us!</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
<p>Thank you so much for being part of <strong>{{eventTitle}}</strong>! We hope you had a great time practicing languages and meeting new people.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>💬 Share Your Experience</h3>
|
||||
<p>{{customMessage}}</p>
|
||||
</div>
|
||||
|
||||
{{#if nextEventTitle}}
|
||||
<div class="event-card">
|
||||
<h3>📅 Next Event</h3>
|
||||
<div class="event-detail"><strong>Event:</strong> {{nextEventTitle}}</div>
|
||||
<div class="event-detail"><strong>Date:</strong> {{nextEventDate}}</div>
|
||||
<p style="text-align: center; margin-top: 16px;">
|
||||
<a href="{{nextEventUrl}}" class="btn">Reserve Your Spot</a>
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>Follow us on social media for updates and photos from the event!</p>
|
||||
<p>See you at the next Spanglish!</p>
|
||||
<p>The Spanglish Team</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>¡Gracias por Unirte!</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
<p>¡Muchas gracias por ser parte de <strong>{{eventTitle}}</strong>! Esperamos que hayas pasado un gran momento practicando idiomas y conociendo gente nueva.</p>
|
||||
|
||||
<div class="event-card">
|
||||
<h3>💬 Comparte tu Experiencia</h3>
|
||||
<p>{{customMessage}}</p>
|
||||
</div>
|
||||
|
||||
{{#if nextEventTitle}}
|
||||
<div class="event-card">
|
||||
<h3>📅 Próximo Evento</h3>
|
||||
<div class="event-detail"><strong>Evento:</strong> {{nextEventTitle}}</div>
|
||||
<div class="event-detail"><strong>Fecha:</strong> {{nextEventDate}}</div>
|
||||
<p style="text-align: center; margin-top: 16px;">
|
||||
<a href="{{nextEventUrl}}" class="btn">Reserva tu Lugar</a>
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>¡Síguenos en redes sociales para actualizaciones y fotos del evento!</p>
|
||||
<p>¡Nos vemos en el próximo Spanglish!</p>
|
||||
<p>El Equipo de Spanglish</p>
|
||||
`,
|
||||
bodyText: `Thank You for Joining Us!
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
Thank you so much for being part of {{eventTitle}}! We hope you had a great time.
|
||||
|
||||
{{customMessage}}
|
||||
|
||||
Follow us on social media for updates and photos from the event!
|
||||
|
||||
See you at the next Spanglish!
|
||||
The Spanglish Team`,
|
||||
bodyTextEs: `¡Gracias por Unirte!
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
¡Muchas gracias por ser parte de {{eventTitle}}! Esperamos que hayas pasado un gran momento.
|
||||
|
||||
{{customMessage}}
|
||||
|
||||
¡Síguenos en redes sociales para actualizaciones y fotos del evento!
|
||||
|
||||
¡Nos vemos en el próximo Spanglish!
|
||||
El Equipo de Spanglish`,
|
||||
description: 'Template for post-event follow-up emails (sent manually)',
|
||||
variables: [
|
||||
...commonVariables,
|
||||
...bookingVariables,
|
||||
{ name: 'customMessage', description: 'Custom message from admin', example: 'We would love to hear your feedback!' },
|
||||
{ name: 'nextEventTitle', description: 'Next event title (optional)', example: 'Spanglish Night - February' },
|
||||
{ name: 'nextEventDate', description: 'Next event date (optional)', example: 'February 25, 2026' },
|
||||
{ name: 'nextEventUrl', description: 'Next event booking URL (optional)', example: 'https://spanglish.com/book/...' },
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'Custom Email',
|
||||
slug: 'custom-email',
|
||||
subject: '{{customSubject}}',
|
||||
subjectEs: '{{customSubject}}',
|
||||
bodyHtml: `
|
||||
<h2>{{customTitle}}</h2>
|
||||
<p>Hi {{attendeeName}},</p>
|
||||
|
||||
<div class="event-card">
|
||||
{{customMessage}}
|
||||
</div>
|
||||
|
||||
<p>The Spanglish Team</p>
|
||||
`,
|
||||
bodyHtmlEs: `
|
||||
<h2>{{customTitle}}</h2>
|
||||
<p>Hola {{attendeeName}},</p>
|
||||
|
||||
<div class="event-card">
|
||||
{{customMessage}}
|
||||
</div>
|
||||
|
||||
<p>El Equipo de Spanglish</p>
|
||||
`,
|
||||
bodyText: `{{customTitle}}
|
||||
|
||||
Hi {{attendeeName}},
|
||||
|
||||
{{customMessage}}
|
||||
|
||||
The Spanglish Team`,
|
||||
bodyTextEs: `{{customTitle}}
|
||||
|
||||
Hola {{attendeeName}},
|
||||
|
||||
{{customMessage}}
|
||||
|
||||
El Equipo de Spanglish`,
|
||||
description: 'Blank template for fully custom emails',
|
||||
variables: [
|
||||
...commonVariables,
|
||||
{ name: 'attendeeName', description: 'Recipient name', example: 'John Doe' },
|
||||
{ name: 'customSubject', description: 'Email subject', example: 'Special Announcement' },
|
||||
{ name: 'customTitle', description: 'Email title/heading', example: 'Special Announcement' },
|
||||
{ name: 'customMessage', description: 'Email body content (supports HTML)', example: '<p>Your message here...</p>' },
|
||||
],
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to replace template variables
|
||||
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||
let result = template;
|
||||
|
||||
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||
const conditionalRegex = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||
result = result.replace(conditionalRegex, (match, varName, content) => {
|
||||
return variables[varName] ? content : '';
|
||||
});
|
||||
|
||||
// Replace simple variables {{variable}}
|
||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||
result = result.replace(variableRegex, (match, varName) => {
|
||||
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to wrap content in the base template
|
||||
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||
return replaceTemplateVariables(wrappedContent, variables);
|
||||
}
|
||||
|
||||
// Get all available variables for a template by slug
|
||||
export function getTemplateVariables(slug: string): EmailVariable[] {
|
||||
const template = defaultTemplates.find(t => t.slug === slug);
|
||||
return template?.variables || commonVariables;
|
||||
}
|
||||
212
backend/src/lib/lnbits.ts
Normal file
212
backend/src/lib/lnbits.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* LNbits API client for Lightning Network payments
|
||||
*
|
||||
* Uses LNbits API to create and manage Lightning invoices with webhook support
|
||||
* for payment detection.
|
||||
*/
|
||||
|
||||
// Read environment variables dynamically to ensure dotenv has loaded
|
||||
function getConfig() {
|
||||
return {
|
||||
url: process.env.LNBITS_URL || '',
|
||||
apiKey: process.env.LNBITS_API_KEY || '', // Invoice/read key
|
||||
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface LNbitsInvoice {
|
||||
paymentHash: string;
|
||||
paymentRequest: string; // BOLT11 invoice string
|
||||
checkingId: string;
|
||||
amount: number; // Amount in satoshis (after conversion)
|
||||
fiatAmount?: number; // Original fiat amount
|
||||
fiatCurrency?: string; // Original fiat currency
|
||||
memo: string;
|
||||
expiry: string;
|
||||
status: string;
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateInvoiceParams {
|
||||
amount: number; // Amount in the specified unit
|
||||
unit?: string; // Currency unit: 'sat', 'USD', 'PYG', etc. (default: 'sat')
|
||||
memo: string;
|
||||
webhookUrl?: string;
|
||||
expiry?: number; // Expiry in seconds (default 3600 = 1 hour)
|
||||
extra?: Record<string, any>; // Additional metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LNbits is configured
|
||||
*/
|
||||
export function isLNbitsConfigured(): boolean {
|
||||
const config = getConfig();
|
||||
return !!(config.url && config.apiKey);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice using LNbits
|
||||
* LNbits supports fiat currencies directly - it will convert to sats automatically
|
||||
*/
|
||||
export async function createInvoice(params: CreateInvoiceParams): Promise<LNbitsInvoice> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.url || !config.apiKey) {
|
||||
throw new Error('LNbits is not configured. Please set LNBITS_URL and LNBITS_API_KEY.');
|
||||
}
|
||||
|
||||
const apiEndpoint = '/api/v1/payments';
|
||||
|
||||
// LNbits supports fiat currencies via the 'unit' parameter
|
||||
// It will automatically convert to sats using its exchange rate provider
|
||||
const payload: any = {
|
||||
out: false, // false = create invoice for receiving payment
|
||||
amount: params.amount,
|
||||
unit: params.unit || 'sat', // Support fiat currencies like 'USD', 'PYG', etc.
|
||||
memo: params.memo,
|
||||
};
|
||||
|
||||
if (params.webhookUrl) {
|
||||
payload.webhook = params.webhookUrl;
|
||||
}
|
||||
|
||||
if (params.expiry) {
|
||||
payload.expiry = params.expiry;
|
||||
}
|
||||
|
||||
if (params.extra) {
|
||||
payload.extra = params.extra;
|
||||
}
|
||||
|
||||
console.log('Creating LNbits invoice:', {
|
||||
url: `${config.url}${apiEndpoint}`,
|
||||
amount: params.amount,
|
||||
unit: payload.unit,
|
||||
memo: params.memo,
|
||||
webhook: params.webhookUrl,
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': config.apiKey,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('LNbits invoice creation failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`Failed to create Lightning invoice: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// LNbits returns amount in millisatoshis for the actual invoice
|
||||
// Convert to satoshis for display
|
||||
const amountSats = data.amount ? Math.round(data.amount / 1000) : params.amount;
|
||||
|
||||
console.log('LNbits invoice created successfully:', {
|
||||
paymentHash: data.payment_hash,
|
||||
amountMsats: data.amount,
|
||||
amountSats,
|
||||
hasPaymentRequest: !!data.payment_request,
|
||||
});
|
||||
|
||||
return {
|
||||
paymentHash: data.payment_hash,
|
||||
paymentRequest: data.payment_request || data.bolt11,
|
||||
checkingId: data.checking_id,
|
||||
amount: amountSats, // Amount in satoshis
|
||||
fiatAmount: params.unit && params.unit !== 'sat' ? params.amount : undefined,
|
||||
fiatCurrency: params.unit && params.unit !== 'sat' ? params.unit : undefined,
|
||||
memo: params.memo,
|
||||
expiry: data.expiry || '',
|
||||
status: data.status || 'pending',
|
||||
extra: params.extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice/payment status from LNbits
|
||||
*/
|
||||
export async function getPaymentStatus(paymentHash: string): Promise<{
|
||||
paid: boolean;
|
||||
status: string;
|
||||
preimage?: string;
|
||||
} | null> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.url || !config.apiKey) {
|
||||
throw new Error('LNbits is not configured');
|
||||
}
|
||||
|
||||
const apiEndpoint = `/api/v1/payments/${paymentHash}`;
|
||||
|
||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': config.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error('Failed to get payment status from LNbits');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// LNbits payment status: "pending", "complete", "failed"
|
||||
// For incoming payments, "complete" means paid
|
||||
const isPaid = data.status === 'complete' || data.paid === true;
|
||||
|
||||
return {
|
||||
paid: isPaid,
|
||||
status: data.status,
|
||||
preimage: data.preimage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook payload from LNbits
|
||||
* LNbits webhooks don't have signature verification by default,
|
||||
* but we can verify the payment hash matches and check payment status
|
||||
*/
|
||||
export async function verifyWebhookPayment(paymentHash: string): Promise<boolean> {
|
||||
try {
|
||||
const status = await getPaymentStatus(paymentHash);
|
||||
return status?.paid === true;
|
||||
} catch (error) {
|
||||
console.error('Error verifying webhook payment:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment status types
|
||||
*/
|
||||
export type LNbitsPaymentStatus = 'pending' | 'complete' | 'failed' | 'expired';
|
||||
|
||||
/**
|
||||
* Check if payment is complete
|
||||
*/
|
||||
export function isPaymentComplete(status: string): boolean {
|
||||
return status === 'complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if payment is still pending
|
||||
*/
|
||||
export function isPaymentPending(status: string): boolean {
|
||||
return status === 'pending';
|
||||
}
|
||||
37
backend/src/lib/utils.ts
Normal file
37
backend/src/lib/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export function generateId(): string {
|
||||
return nanoid(21);
|
||||
}
|
||||
|
||||
export function generateTicketCode(): string {
|
||||
return `TKT-${nanoid(8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function getNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
return new Intl.NumberFormat('es-PY', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function calculateAvailableSeats(capacity: number, bookedCount: number): number {
|
||||
return Math.max(0, capacity - bookedCount);
|
||||
}
|
||||
|
||||
export function isEventSoldOut(capacity: number, bookedCount: number): boolean {
|
||||
return bookedCount >= capacity;
|
||||
}
|
||||
|
||||
export function sanitizeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user