first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions

248
backend/src/lib/auth.ts Normal file
View 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
View 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;

View 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>&copy; {{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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}