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

284
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,284 @@
import { Hono } from 'hono';
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, gte, sql, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
const adminRouter = new Hono();
// Dashboard overview stats (admin)
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
// Get upcoming events
const upcomingEvents = await (db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(5)
.all();
// Get recent tickets
const recentTickets = await (db as any)
.select()
.from(tickets)
.orderBy(desc((tickets as any).createdAt))
.limit(10)
.all();
// Get total stats
const totalUsers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.get();
const totalEvents = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(events)
.get();
const totalTickets = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.get();
const confirmedTickets = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).status, 'confirmed'))
.get();
const pendingPayments = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending'))
.get();
const paidPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).status, 'paid'))
.all();
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
const newContacts = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(contacts)
.where(eq((contacts as any).status, 'new'))
.get();
const totalSubscribers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(emailSubscribers)
.where(eq((emailSubscribers as any).status, 'active'))
.get();
return c.json({
dashboard: {
stats: {
totalUsers: totalUsers?.count || 0,
totalEvents: totalEvents?.count || 0,
totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0,
totalRevenue,
newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0,
},
upcomingEvents,
recentTickets,
},
});
});
// Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts
const allEvents = await (db as any).select().from(events).all();
const eventStats = await Promise.all(
allEvents.map(async (event: any) => {
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).eventId, event.id))
.get();
const confirmedCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
const checkedInCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in')
)
)
.get();
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: ticketCount?.count || 0,
confirmedBookings: confirmedCount?.count || 0,
checkedIn: checkedInCount?.count || 0,
revenue: (confirmedCount?.count || 0) * event.price,
};
})
);
return c.json({
analytics: {
events: eventStats,
},
});
});
// Export data (admin)
adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
const eventId = c.req.query('eventId');
let query = (db as any).select().from(tickets);
if (eventId) {
query = query.where(eq((tickets as any).eventId, eventId));
}
const ticketList = await query.all();
// Get user and event details for each ticket
const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => {
const user = await (db as any)
.select()
.from(users)
.where(eq((users as any).id, ticket.userId))
.get();
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
return {
ticketId: ticket.id,
ticketStatus: ticket.status,
qrCode: ticket.qrCode,
checkinAt: ticket.checkinAt,
userName: user?.name,
userEmail: user?.email,
userPhone: user?.phone,
eventTitle: event?.title,
eventDate: event?.startDatetime,
paymentStatus: payment?.status,
paymentAmount: payment?.amount,
createdAt: ticket.createdAt,
};
})
);
return c.json({ tickets: enrichedTickets });
});
// Export financial data (admin)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate');
const endDate = c.req.query('endDate');
const eventId = c.req.query('eventId');
// Get all payments
let query = (db as any).select().from(payments);
const allPayments = await query.all();
// Enrich with event and ticket data
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (!ticket) return null;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
// Apply filters
if (eventId && ticket.eventId !== eventId) return null;
if (startDate && payment.createdAt < startDate) return null;
if (endDate && payment.createdAt > endDate) return null;
return {
paymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
provider: payment.provider,
status: payment.status,
reference: payment.reference,
paidAt: payment.paidAt,
createdAt: payment.createdAt,
ticketId: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
eventId: event?.id,
eventTitle: event?.title,
eventDate: event?.startDatetime,
};
})
);
const filteredPayments = enrichedPayments.filter(p => p !== null);
// Calculate summary
const summary = {
totalPayments: filteredPayments.length,
totalPaid: filteredPayments.filter((p: any) => p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
totalPending: filteredPayments.filter((p: any) => p.status === 'pending').reduce((sum: number, p: any) => sum + p.amount, 0),
totalRefunded: filteredPayments.filter((p: any) => p.status === 'refunded').reduce((sum: number, p: any) => sum + p.amount, 0),
byProvider: {
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
},
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
refundedCount: filteredPayments.filter((p: any) => p.status === 'refunded').length,
failedCount: filteredPayments.filter((p: any) => p.status === 'failed').length,
};
return c.json({ payments: filteredPayments, summary });
});
export default adminRouter;

652
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,652 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
requireAuth,
} from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
googleId: z.string().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: true,
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
}, 429);
}
const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
if (!user) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
const token = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions for security
await invalidateAllUserSessions(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 24 hours)
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 24 hours.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
const { token, password, googleId } = c.req.valid('json');
if (!password && !googleId) {
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
}
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const now = getNow();
const updates: Record<string, any> = {
isClaimed: true,
accountStatus: 'active',
updatedAt: now,
};
if (password) {
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
updates.password = await hashPassword(password);
}
if (googleId) {
updates.googleId = googleId;
}
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify Google token
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
};
if (googleData.email_verified !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Check by google_id
user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get();
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: true,
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get();
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: true,
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
if (user.password) {
const validPassword = await verifyPassword(currentPassword, user.password);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password changed successfully' });
});
// Logout (client-side token removal, but we can log the action)
auth.post('/logout', async (c) => {
return c.json({ message: 'Logged out successfully' });
});
export default auth;

View File

@@ -0,0 +1,193 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, contacts, emailSubscribers } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
const contactsRouter = new Hono();
const createContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
const subscribeSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
});
const updateContactSchema = z.object({
status: z.enum(['new', 'read', 'replied']),
});
// Submit contact form (public)
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const data = c.req.valid('json');
const now = getNow();
const id = generateId();
const newContact = {
id,
name: data.name,
email: data.email,
message: data.message,
status: 'new' as const,
createdAt: now,
};
await (db as any).insert(contacts).values(newContact);
return c.json({ message: 'Message sent successfully' }, 201);
});
// Subscribe to newsletter (public)
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
const data = c.req.valid('json');
// Check if already subscribed
const existing = await (db as any)
.select()
.from(emailSubscribers)
.where(eq((emailSubscribers as any).email, data.email))
.get();
if (existing) {
if (existing.status === 'unsubscribed') {
// Resubscribe
await (db as any)
.update(emailSubscribers)
.set({ status: 'active' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully resubscribed' });
}
return c.json({ message: 'Already subscribed' });
}
const now = getNow();
const id = generateId();
const newSubscriber = {
id,
email: data.email,
name: data.name || null,
status: 'active' as const,
createdAt: now,
};
await (db as any).insert(emailSubscribers).values(newSubscriber);
return c.json({ message: 'Successfully subscribed' }, 201);
});
// Unsubscribe from newsletter (public)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
const { email } = c.req.valid('json');
const existing = await (db as any)
.select()
.from(emailSubscribers)
.where(eq((emailSubscribers as any).email, email))
.get();
if (!existing) {
return c.json({ error: 'Email not found' }, 404);
}
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully unsubscribed' });
});
// Get all contacts (admin)
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const status = c.req.query('status');
let query = (db as any).select().from(contacts);
if (status) {
query = query.where(eq((contacts as any).status, status));
}
const result = await query.orderBy(desc((contacts as any).createdAt)).all();
return c.json({ contacts: result });
});
// Get single contact (admin)
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const contact = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
if (!contact) {
return c.json({ error: 'Contact not found' }, 404);
}
return c.json({ contact });
});
// Update contact status (admin)
contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateContactSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const existing = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Contact not found' }, 404);
}
await (db as any)
.update(contacts)
.set({ status: data.status })
.where(eq((contacts as any).id, id));
const updated = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
return c.json({ contact: updated });
});
// Delete contact (admin)
contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
await (db as any).delete(contacts).where(eq((contacts as any).id, id));
return c.json({ message: 'Contact deleted successfully' });
});
// Get all subscribers (admin)
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
const status = c.req.query('status');
let query = (db as any).select().from(emailSubscribers);
if (status) {
query = query.where(eq((emailSubscribers as any).status, status));
}
const result = await query.orderBy(desc((emailSubscribers as any).createdAt)).all();
return c.json({ subscribers: result });
});
export default contactsRouter;

View File

@@ -0,0 +1,576 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const dashboard = new Hono();
// Apply authentication to all routes
dashboard.use('*', requireAuth());
// ==================== Profile Routes ====================
const updateProfileSchema = z.object({
name: z.string().min(2).optional(),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
rucNumber: z.string().max(15).optional(),
});
// Get user profile
dashboard.get('/profile', async (c) => {
const user = (c as any).get('user') as AuthUser;
// Get membership duration
const createdDate = new Date(user.createdAt);
const now = new Date();
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
return c.json({
profile: {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone,
languagePreference: user.languagePreference,
rucNumber: user.rucNumber,
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword: !!user.password,
hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt,
membershipDays,
createdAt: user.createdAt,
},
});
});
// Update profile
dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const data = c.req.valid('json');
const now = getNow();
await (db as any)
.update(users)
.set({
...data,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
const updatedUser = await (db as any)
.select()
.from(users)
.where(eq((users as any).id, user.id))
.get();
return c.json({
profile: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
phone: updatedUser.phone,
languagePreference: updatedUser.languagePreference,
rucNumber: updatedUser.rucNumber,
},
message: 'Profile updated successfully',
});
});
// ==================== Tickets Routes ====================
// Get user's tickets
dashboard.get('/tickets', async (c) => {
const user = (c as any).get('user') as AuthUser;
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt))
.all();
// Get event details for each ticket
const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
// Check for invoice
let invoice = null;
if (payment && payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
return {
...ticket,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
price: event.price,
currency: event.currency,
status: event.status,
bannerUrl: event.bannerUrl,
} : null,
payment: payment ? {
id: payment.id,
provider: payment.provider,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
paidAt: payment.paidAt,
} : null,
invoice: invoice ? {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
pdfUrl: invoice.pdfUrl,
createdAt: invoice.createdAt,
} : null,
};
})
);
return c.json({ tickets: ticketsWithEvents });
});
// Get single ticket detail
dashboard.get('/tickets/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const ticketId = c.req.param('id');
const ticket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).id, ticketId),
eq((tickets as any).userId, user.id)
)
)
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
let invoice = null;
if (payment && payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
return c.json({
ticket: {
...ticket,
event,
payment,
invoice,
},
});
});
// ==================== Next Event Route ====================
// Get next upcoming event for user
dashboard.get('/next-event', async (c) => {
const user = (c as any).get('user') as AuthUser;
const now = getNow();
// Get user's tickets for upcoming events
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
if (userTickets.length === 0) {
return c.json({ nextEvent: null });
}
// Find the next upcoming event
let nextEvent = null;
let nextTicket = null;
let nextPayment = null;
for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) continue;
// Check if event is in the future
if (new Date(event.startDatetime) > new Date()) {
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
nextEvent = event;
nextTicket = ticket;
nextPayment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
}
}
}
if (!nextEvent) {
return c.json({ nextEvent: null });
}
return c.json({
nextEvent: {
event: nextEvent,
ticket: nextTicket,
payment: nextPayment,
},
});
});
// ==================== Payments & Invoices Routes ====================
// Get payment history
dashboard.get('/payments', async (c) => {
const user = (c as any).get('user') as AuthUser;
// Get all user's tickets first
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
const ticketIds = userTickets.map((t: any) => t.id);
if (ticketIds.length === 0) {
return c.json({ payments: [] });
}
// Get all payments for user's tickets
const allPayments = [];
for (const ticketId of ticketIds) {
const ticketPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.all();
for (const payment of ticketPayments) {
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
const event = ticket
? await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get()
: null;
let invoice = null;
if (payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
allPayments.push({
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
} : null,
invoice: invoice ? {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
pdfUrl: invoice.pdfUrl,
} : null,
});
}
}
// Sort by createdAt desc
allPayments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return c.json({ payments: allPayments });
});
// Get invoices
dashboard.get('/invoices', async (c) => {
const user = (c as any).get('user') as AuthUser;
const userInvoices = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).userId, user.id))
.orderBy(desc((invoices as any).createdAt))
.all();
// Get payment and event details for each invoice
const invoicesWithDetails = await Promise.all(
userInvoices.map(async (invoice: any) => {
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, invoice.paymentId))
.get();
let event = null;
if (payment) {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
}
return {
...invoice,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ invoices: invoicesWithDetails });
});
// ==================== Security Routes ====================
// Get active sessions
dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessions = await getUserSessions(user.id);
return c.json({
sessions: sessions.map((s: any) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt,
createdAt: s.createdAt,
})),
});
});
// Revoke a specific session
dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id');
await invalidateSession(sessionId, user.id);
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere)
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
return c.json({ message: 'All sessions revoked. Please log in again.' });
});
// Set password (for users without one)
const setPasswordSchema = z.object({
password: z.string().min(10, 'Password must be at least 10 characters'),
});
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { password } = c.req.valid('json');
// Check if user already has a password
if (user.password) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password set successfully' });
});
// Unlink Google account (only if password is set)
dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser;
if (!user.googleId) {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!user.password) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
const now = getNow();
await (db as any)
.update(users)
.set({
googleId: null,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' });
});
// ==================== Dashboard Summary Route ====================
// Get dashboard summary (welcome panel data)
dashboard.get('/summary', async (c) => {
const user = (c as any).get('user') as AuthUser;
const now = new Date();
// Get membership duration
const createdDate = new Date(user.createdAt);
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
// Get ticket count
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
const totalTickets = userTickets.length;
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
const upcomingTickets = [];
for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (event && new Date(event.startDatetime) > now) {
upcomingTickets.push({ ticket, event });
}
}
// Get pending payments count
const ticketIds = userTickets.map((t: any) => t.id);
let pendingPayments = 0;
for (const ticketId of ticketIds) {
const payment = await (db as any)
.select()
.from(payments)
.where(
and(
eq((payments as any).ticketId, ticketId),
eq((payments as any).status, 'pending_approval')
)
)
.get();
if (payment) pendingPayments++;
}
return c.json({
summary: {
user: {
name: user.name,
email: user.email,
accountStatus: user.accountStatus,
memberSince: user.createdAt,
membershipDays,
},
stats: {
totalTickets,
confirmedTickets,
upcomingEvents: upcomingTickets.length,
pendingPayments,
},
},
});
});
export default dashboard;

View File

@@ -0,0 +1,419 @@
import { Hono } from 'hono';
import { db, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { nanoid } from 'nanoid';
import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
const emailsRouter = new Hono();
// ==================== Template Routes ====================
// Get all email templates
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
const templates = await (db as any)
.select()
.from(emailTemplates)
.orderBy(desc((emailTemplates as any).createdAt))
.all();
// Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({
...t,
variables: t.variables ? JSON.parse(t.variables) : [],
isSystem: Boolean(t.isSystem),
isActive: Boolean(t.isActive),
}));
return c.json({ templates: parsedTemplates });
});
// Get single email template
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const template = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
return c.json({
template: {
...template,
variables: template.variables ? JSON.parse(template.variables) : [],
isSystem: Boolean(template.isSystem),
isActive: Boolean(template.isActive),
}
});
});
// Create new email template
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
if (!name || !slug || !subject || !bodyHtml) {
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
}
// Check if slug already exists
const existing = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
.get();
if (existing) {
return c.json({ error: 'Template with this slug already exists' }, 400);
}
const now = getNow();
const template = {
id: nanoid(),
name,
slug,
subject,
subjectEs: subjectEs || null,
bodyHtml,
bodyHtmlEs: bodyHtmlEs || null,
bodyText: bodyText || null,
bodyTextEs: bodyTextEs || null,
description: description || null,
variables: variables ? JSON.stringify(variables) : null,
isSystem: 0,
isActive: 1,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(emailTemplates).values(template);
return c.json({
template: {
...template,
variables: variables || [],
isSystem: false,
isActive: true,
},
message: 'Template created successfully'
}, 201);
});
// Update email template
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const existing = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Template not found' }, 404);
}
const updateData: any = { updatedAt: getNow() };
// Only allow updating certain fields for system templates
const systemProtectedFields = ['slug', 'isSystem'];
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
if (!existing.isSystem) {
allowedFields.push('slug');
}
for (const field of allowedFields) {
if (body[field] !== undefined) {
if (field === 'variables') {
updateData[field] = JSON.stringify(body[field]);
} else if (field === 'isActive') {
updateData[field] = body[field] ? 1 : 0;
} else {
updateData[field] = body[field];
}
}
}
await (db as any)
.update(emailTemplates)
.set(updateData)
.where(eq((emailTemplates as any).id, id));
const updated = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
return c.json({
template: {
...updated,
variables: updated.variables ? JSON.parse(updated.variables) : [],
isSystem: Boolean(updated.isSystem),
isActive: Boolean(updated.isActive),
},
message: 'Template updated successfully'
});
});
// Delete email template (only non-system templates)
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const template = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
if (template.isSystem) {
return c.json({ error: 'Cannot delete system templates' }, 400);
}
await (db as any)
.delete(emailTemplates)
.where(eq((emailTemplates as any).id, id));
return c.json({ message: 'Template deleted successfully' });
});
// Get available template variables
emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer']), async (c) => {
const { slug } = c.req.param();
const variables = getTemplateVariables(slug);
return c.json({ variables });
});
// ==================== Email Sending Routes ====================
// Send email using template to event attendees
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
const { eventId } = c.req.param();
const user = (c as any).get('user');
const body = await c.req.json();
const { templateSlug, customVariables, recipientFilter } = body;
if (!templateSlug) {
return c.json({ error: 'Template slug is required' }, 400);
}
const result = await emailService.sendToEventAttendees({
eventId,
templateSlug,
customVariables,
recipientFilter: recipientFilter || 'confirmed',
sentBy: user?.id,
});
return c.json(result);
});
// Send custom email to specific recipients
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
const user = (c as any).get('user');
const body = await c.req.json();
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
if (!to || !subject || !bodyHtml) {
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
}
const result = await emailService.sendCustomEmail({
to,
toName,
subject,
bodyHtml,
bodyText,
eventId,
sentBy: user?.id,
});
return c.json(result);
});
// Preview email (render template without sending)
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
const body = await c.req.json();
const { templateSlug, variables, locale } = body;
if (!templateSlug) {
return c.json({ error: 'Template slug is required' }, 400);
}
const template = await emailService.getTemplate(templateSlug);
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
const { replaceTemplateVariables, wrapInBaseTemplate } = await import('../lib/emailTemplates.js');
const allVariables = {
...emailService.getCommonVariables(),
lang: locale || 'en',
...variables,
};
const subject = locale === 'es' && template.subjectEs
? template.subjectEs
: template.subject;
const bodyHtml = locale === 'es' && template.bodyHtmlEs
? template.bodyHtmlEs
: template.bodyHtml;
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
return c.json({
subject: finalSubject,
bodyHtml: finalBodyHtml,
});
});
// ==================== Email Logs Routes ====================
// Get email logs
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0');
let query = (db as any).select().from(emailLogs);
const conditions = [];
if (eventId) {
conditions.push(eq((emailLogs as any).eventId, eventId));
}
if (status) {
conditions.push(eq((emailLogs as any).status, status));
}
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
const logs = await query
.orderBy(desc((emailLogs as any).createdAt))
.limit(limit)
.offset(offset)
.all();
// Get total count
let countQuery = (db as any)
.select({ count: sql<number>`count(*)` })
.from(emailLogs);
if (conditions.length > 0) {
countQuery = countQuery.where(and(...conditions));
}
const totalResult = await countQuery.get();
const total = totalResult?.count || 0;
return c.json({
logs,
pagination: {
total,
limit,
offset,
hasMore: offset + logs.length < total,
}
});
});
// Get single email log
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const log = await (db as any)
.select()
.from(emailLogs)
.where(eq((emailLogs as any).id, id))
.get();
if (!log) {
return c.json({ error: 'Email log not found' }, 404);
}
return c.json({ log });
});
// Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
const totalQuery = baseCondition
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
const total = (await totalQuery.get())?.count || 0;
const sentCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'sent'))
: eq((emailLogs as any).status, 'sent');
const sent = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0;
const failedCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'failed'))
: eq((emailLogs as any).status, 'failed');
const failed = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0;
const pendingCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'pending'))
: eq((emailLogs as any).status, 'pending');
const pending = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0;
return c.json({
stats: {
total,
sent,
failed,
pending,
}
});
});
// Seed default templates (admin only)
emailsRouter.post('/seed-templates', requireAuth(['admin']), async (c) => {
await emailService.seedDefaultTemplates();
return c.json({ message: 'Default templates seeded successfully' });
});
// ==================== Configuration Routes ====================
// Get email provider info
emailsRouter.get('/config', requireAuth(['admin']), async (c) => {
const providerInfo = emailService.getProviderInfo();
return c.json(providerInfo);
});
// Test email configuration
emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { to } = body;
if (!to) {
return c.json({ error: 'Recipient email (to) is required' }, 400);
}
const result = await emailService.testConnection(to);
return c.json(result);
});
export default emailsRouter;

View File

@@ -0,0 +1,269 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, events, tickets } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Custom validation error handler
const validationHook = (result: any, c: any) => {
if (!result.success) {
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
return c.json({ error: errors }, 400);
}
};
const createEventSchema = z.object({
title: z.string().min(1),
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
descriptionEs: z.string().optional().nullable(),
startDatetime: z.string(),
endDatetime: z.string().optional().nullable(),
location: z.string().min(1),
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
price: z.number().min(0).default(0),
currency: z.string().default('PYG'),
capacity: z.number().min(1).default(50),
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
});
const updateEventSchema = createEventSchema.partial();
// Get all events (public)
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
let query = (db as any).select().from(events);
if (status) {
query = query.where(eq((events as any).status, status));
}
if (upcoming === 'true') {
const now = getNow();
query = query.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
);
}
const result = await query.orderBy(desc((events as any).startDatetime)).all();
// Get ticket counts for each event
const eventsWithCounts = await Promise.all(
result.map(async (event: any) => {
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
};
})
);
return c.json({ events: eventsWithCounts });
});
// Get single event (public)
eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const event = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Get ticket count
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return c.json({
event: {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
},
});
});
// Get next upcoming event (public)
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
const event = await (db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
.get();
if (!event) {
return c.json({ event: null });
}
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return c.json({
event: {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
},
});
});
// Create event (admin/organizer only)
eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', createEventSchema, validationHook), async (c) => {
const data = c.req.valid('json');
const user = c.get('user');
const now = getNow();
const id = generateId();
const newEvent = {
id,
...data,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(events).values(newEvent);
return c.json({ event: newEvent }, 201);
});
// Update event (admin/organizer only)
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const now = getNow();
await (db as any)
.update(events)
.set({ ...data, updatedAt: now })
.where(eq((events as any).id, id));
const updated = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
return c.json({ event: updated });
});
// Delete event (admin only)
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
await (db as any).delete(events).where(eq((events as any).id, id));
return c.json({ message: 'Event deleted successfully' });
});
// Get event attendees (admin/organizer only)
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const attendees = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, id))
.all();
return c.json({ attendees });
});
// Duplicate event (admin/organizer only)
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const now = getNow();
const newId = generateId();
// Create a copy with modified title and draft status
const duplicatedEvent = {
id: newId,
title: `${existing.title} (Copy)`,
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description,
descriptionEs: existing.descriptionEs,
startDatetime: existing.startDatetime,
endDatetime: existing.endDatetime,
location: existing.location,
locationUrl: existing.locationUrl,
price: existing.price,
currency: existing.currency,
capacity: existing.capacity,
status: 'draft',
bannerUrl: existing.bannerUrl,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(events).values(duplicatedEvent);
return c.json({ event: duplicatedEvent, message: 'Event duplicated successfully' }, 201);
});
export default eventsRouter;

View File

@@ -0,0 +1,340 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
const activeConnections = new Map<string, Set<(data: any) => void>>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/**
* LNbits webhook payload structure
*/
interface LNbitsWebhookPayload {
payment_hash: string;
payment_request?: string;
amount: number;
memo?: string;
status: string;
preimage?: string;
extra?: {
ticketId?: string;
eventId?: string;
[key: string]: any;
};
}
/**
* Notify all connected clients for a ticket
*/
function notifyClients(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
connections.forEach(send => {
try {
send(data);
} catch (e) {
// Connection might be closed
}
});
}
}
/**
* Start background payment checking for a ticket
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
const checkInterval = setInterval(async () => {
checkCount++;
const elapsed = Date.now() - startTime;
// Stop if expired
if (elapsed >= expiryMs) {
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
try {
const status = await getPaymentStatus(paymentHash);
if (status?.paid) {
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
}
} catch (error) {
console.error(`Error checking payment for ticket ${ticketId}:`, error);
}
}, 3000); // Check every 3 seconds
activeCheckers.set(ticketId, checkInterval);
}
/**
* Stop background checker for a ticket
*/
function stopBackgroundChecker(ticketId: string) {
const interval = activeCheckers.get(ticketId);
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
}
}
/**
* LNbits webhook endpoint
* Called by LNbits when a payment is received
*/
lnbitsRouter.post('/webhook', async (c) => {
try {
const payload: LNbitsWebhookPayload = await c.req.json();
console.log('LNbits webhook received:', {
paymentHash: payload.payment_hash,
status: payload.status,
amount: payload.amount,
extra: payload.extra,
});
// Verify the payment is actually complete by checking with LNbits
const isVerified = await verifyWebhookPayment(payload.payment_hash);
if (!isVerified) {
console.warn('LNbits webhook payment not verified:', payload.payment_hash);
return c.json({ received: true, processed: false }, 200);
}
const ticketId = payload.extra?.ticketId;
if (!ticketId) {
console.error('No ticketId in LNbits webhook extra data');
return c.json({ received: true, processed: false }, 200);
}
// Stop background checker since webhook confirmed payment
stopBackgroundChecker(ticketId);
await handlePaymentComplete(ticketId, payload.payment_hash);
// Notify connected clients via SSE
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
return c.json({ received: true, processed: true }, 200);
} catch (error) {
console.error('LNbits webhook error:', error);
return c.json({ error: 'Webhook processing failed' }, 500);
}
});
/**
* Handle successful payment
*/
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
const now = getNow();
// Check if already confirmed to avoid duplicate updates
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (existingTicket?.status === 'confirmed') {
console.log(`Ticket ${ticketId} already confirmed, skipping update`);
return;
}
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticketId));
// Update payment status to paid
await (db as any)
.update(payments)
.set({
status: 'paid',
reference: paymentHash,
paidAt: now,
updatedAt: now,
})
.where(eq((payments as any).ticketId, ticketId));
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
// Get payment for sending receipt
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
// Send confirmation emails asynchronously
Promise.all([
emailService.sendBookingConfirmation(ticketId),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
}
/**
* SSE endpoint for real-time payment status updates
* Frontend connects here to receive instant payment notifications
*/
lnbitsRouter.get('/stream/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId');
// Verify ticket exists
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// If already paid, return immediately
if (ticket.status === 'confirmed') {
return c.json({ type: 'already_paid', ticketId }, 200);
}
// Get payment to start background checker
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
// Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
return streamSSE(c, async (stream) => {
// Register this connection
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
}
const sendEvent = (data: any) => {
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
activeConnections.get(ticketId)!.add(sendEvent);
// Send initial status
await stream.writeSSE({
data: JSON.stringify({ type: 'connected', ticketId }),
event: 'payment'
});
// Keep connection alive with heartbeat
const heartbeat = setInterval(async () => {
try {
await stream.writeSSE({ data: 'ping', event: 'heartbeat' });
} catch (e) {
clearInterval(heartbeat);
}
}, 15000);
// Clean up on disconnect
stream.onAbort(() => {
clearInterval(heartbeat);
const connections = activeConnections.get(ticketId);
if (connections) {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
}
}
});
// Keep the stream open
while (true) {
await stream.sleep(30000);
}
});
});
/**
* Get payment status for a ticket (fallback polling endpoint)
*/
lnbitsRouter.get('/status/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId');
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
return c.json({
ticketStatus: ticket.status,
paymentStatus: payment?.status || 'unknown',
isPaid: ticket.status === 'confirmed' || payment?.status === 'paid',
});
});
/**
* Manual payment check endpoint
*/
lnbitsRouter.post('/check/:paymentHash', async (c) => {
const paymentHash = c.req.param('paymentHash');
try {
const status = await getPaymentStatus(paymentHash);
if (!status) {
return c.json({ error: 'Payment not found' }, 404);
}
return c.json({
paymentHash,
paid: status.paid,
status: status.status,
});
} catch (error) {
console.error('Error checking payment:', error);
return c.json({ error: 'Failed to check payment status' }, 500);
}
});
export default lnbitsRouter;

148
backend/src/routes/media.ts Normal file
View File

@@ -0,0 +1,148 @@
import { Hono } from 'hono';
import { db, media } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join, extname } from 'path';
const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Ensure upload directory exists
async function ensureUploadDir() {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
// Upload image
mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
try {
const body = await c.req.parseBody();
const file = body['file'] as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
}
await ensureUploadDir();
// Generate unique filename
const id = generateId();
const ext = extname(file.name) || '.jpg';
const filename = `${id}${ext}`;
const filepath = join(UPLOAD_DIR, filename);
// Write file
const arrayBuffer = await file.arrayBuffer();
await writeFile(filepath, Buffer.from(arrayBuffer));
// Get related info from form data
const relatedId = body['relatedId'] as string | undefined;
const relatedType = body['relatedType'] as string | undefined;
// Save to database
const now = getNow();
const mediaRecord = {
id,
fileUrl: `/uploads/${filename}`,
type: 'image' as const,
relatedId: relatedId || null,
relatedType: relatedType || null,
createdAt: now,
};
await (db as any).insert(media).values(mediaRecord);
return c.json({
media: mediaRecord,
url: mediaRecord.fileUrl,
}, 201);
} catch (error) {
console.error('Upload error:', error);
return c.json({ error: 'Failed to upload file' }, 500);
}
});
// Get media by ID
mediaRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const mediaRecord = await (db as any)
.select()
.from(media)
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404);
}
return c.json({ media: mediaRecord });
});
// Delete media
mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const mediaRecord = await (db as any)
.select()
.from(media)
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404);
}
// Delete file from disk
try {
const filepath = join('.', mediaRecord.fileUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
} catch (error) {
console.error('Failed to delete file:', error);
}
// Delete from database
await (db as any).delete(media).where(eq((media as any).id, id));
return c.json({ message: 'Media deleted successfully' });
});
// List media (admin)
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const relatedType = c.req.query('relatedType');
const relatedId = c.req.query('relatedId');
let query = (db as any).select().from(media);
if (relatedType) {
query = query.where(eq((media as any).relatedType, relatedType));
}
if (relatedId) {
query = query.where(eq((media as any).relatedId, relatedId));
}
const result = await query.all();
return c.json({ media: result });
});
export default mediaRouter;

View File

@@ -0,0 +1,278 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
const paymentOptionsRouter = new Hono();
// Schema for updating global payment options
const updatePaymentOptionsSchema = z.object({
tpagoEnabled: z.boolean().optional(),
tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional(),
bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(),
bankAlias: z.string().optional().nullable(),
bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional(),
cashEnabled: z.boolean().optional(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
});
// Schema for event-level overrides
const updateEventOverridesSchema = z.object({
tpagoEnabled: z.boolean().optional().nullable(),
tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional().nullable(),
bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(),
bankAlias: z.string().optional().nullable(),
bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional().nullable(),
cashEnabled: z.boolean().optional().nullable(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
});
// Get global payment options
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
const options = await (db as any)
.select()
.from(paymentOptions)
.get();
// If no options exist yet, return defaults
if (!options) {
return c.json({
paymentOptions: {
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
},
});
}
return c.json({ paymentOptions: options });
});
// Update global payment options
paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updatePaymentOptionsSchema), async (c) => {
const data = c.req.valid('json');
const user = (c as any).get('user');
const now = getNow();
// Check if options exist
const existing = await (db as any)
.select()
.from(paymentOptions)
.get();
if (existing) {
// Update existing
await (db as any)
.update(paymentOptions)
.set({
...data,
updatedAt: now,
updatedBy: user.id,
})
.where(eq((paymentOptions as any).id, existing.id));
} else {
// Create new
const id = generateId();
await (db as any).insert(paymentOptions).values({
id,
...data,
updatedAt: now,
updatedBy: user.id,
});
}
const updated = await (db as any)
.select()
.from(paymentOptions)
.get();
return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' });
});
// Get payment options for a specific event (merged with global)
paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId');
// Get the event first to verify it exists
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
.get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Get global options
const globalOptions = await (db as any)
.select()
.from(paymentOptions)
.get();
// Get event overrides
const overrides = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
// Merge global with overrides (override takes precedence if not null)
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
const merged = {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
bankName: overrides?.bankName ?? global.bankName,
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
bankAlias: overrides?.bankAlias ?? global.bankAlias,
bankPhone: overrides?.bankPhone ?? global.bankPhone,
bankNotes: overrides?.bankNotes ?? global.bankNotes,
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
lightningEnabled: overrides?.lightningEnabled ?? global.lightningEnabled,
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
};
return c.json({
paymentOptions: merged,
hasOverrides: !!overrides,
});
});
// Get event payment overrides (admin only)
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId');
const overrides = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: overrides || null });
});
// Update event payment overrides
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const now = getNow();
// Verify event exists
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
.get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Check if overrides exist
const existing = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
if (existing) {
await (db as any)
.update(eventPaymentOverrides)
.set({
...data,
updatedAt: now,
})
.where(eq((eventPaymentOverrides as any).id, existing.id));
} else {
const id = generateId();
await (db as any).insert(eventPaymentOverrides).values({
id,
eventId,
...data,
createdAt: now,
updatedAt: now,
});
}
const updated = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
});
// Delete event payment overrides (revert to global)
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId');
await (db as any)
.delete(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId));
return c.json({ message: 'Event payment overrides removed' });
});
export default paymentOptionsRouter;

View File

@@ -0,0 +1,431 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import emailService from '../lib/email.js';
const paymentsRouter = new Hono();
const updatePaymentSchema = z.object({
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
reference: z.string().optional(),
adminNote: z.string().optional(),
});
const approvePaymentSchema = z.object({
adminNote: z.string().optional(),
});
const rejectPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status');
const provider = c.req.query('provider');
const pendingApproval = c.req.query('pendingApproval');
// Get all payments with their associated tickets
let allPayments = await (db as any)
.select()
.from(payments)
.orderBy(desc((payments as any).createdAt))
.all();
// Filter by status
if (status) {
allPayments = allPayments.filter((p: any) => p.status === status);
}
// Filter for pending approval specifically
if (pendingApproval === 'true') {
allPayments = allPayments.filter((p: any) => p.status === 'pending_approval');
}
// Filter by provider
if (provider) {
allPayments = allPayments.filter((p: any) => p.provider === provider);
}
// Enrich with ticket and event data
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
return {
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ payments: enrichedPayments });
});
// Get payments pending approval (admin dashboard view)
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
const pendingPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
.orderBy(desc((payments as any).userMarkedPaidAt))
.all();
// Enrich with ticket and event data
const enrichedPayments = await Promise.all(
pendingPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
return {
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ payments: enrichedPayments });
});
// Get payment by ID (admin)
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Get associated ticket
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
return c.json({ payment: { ...payment, ticket } });
});
// Update payment (admin) - for manual payment confirmation
paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updatePaymentSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const user = (c as any).get('user');
const existing = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Payment not found' }, 404);
}
const now = getNow();
const updateData: any = { ...data, updatedAt: now };
// If marking as paid, record who approved it and when
if (data.status === 'paid' && existing.status !== 'paid') {
updateData.paidAt = now;
updateData.paidByAdminId = user.id;
}
await (db as any)
.update(payments)
.set(updateData)
.where(eq((payments as any).id, id));
// If payment confirmed, update ticket status and send emails
if (data.status === 'paid') {
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, existing.ticketId));
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(existing.ticketId),
emailService.sendPaymentReceipt(id),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
}
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated });
});
// Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Can approve pending or pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
const now = getNow();
// Update payment status to paid
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, payment.ticketId));
// Send confirmation emails asynchronously
Promise.all([
emailService.sendBookingConfirmation(payment.ticketId),
emailService.sendPaymentReceipt(id),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Payment approved successfully' });
});
// Reject payment (admin)
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
}
const now = getNow();
// Update payment status to failed
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Note: We don't cancel the ticket automatically - admin can do that separately if needed
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Payment rejected' });
});
// Update admin note
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const { adminNote } = body;
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
const now = getNow();
await (db as any)
.update(payments)
.set({
adminNote: adminNote || null,
updatedAt: now,
})
.where(eq((payments as any).id, id));
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Note updated' });
});
// Process refund (admin)
paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'paid') {
return c.json({ error: 'Can only refund paid payments' }, 400);
}
const now = getNow();
// Update payment status
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(eq((payments as any).id, id));
// Cancel associated ticket
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, payment.ticketId));
return c.json({ message: 'Refund processed successfully' });
});
// Payment webhook (for Stripe/MercadoPago)
paymentsRouter.post('/webhook', async (c) => {
// This would handle webhook notifications from payment providers
// Implementation depends on which provider is used
const body = await c.req.json();
// Log webhook for debugging
console.log('Payment webhook received:', body);
// TODO: Implement provider-specific webhook handling
// - Verify webhook signature
// - Update payment status
// - Update ticket status
return c.json({ received: true });
});
// Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await (db as any).select().from(payments).all();
const stats = {
total: allPayments.length,
pending: allPayments.filter((p: any) => p.status === 'pending').length,
paid: allPayments.filter((p: any) => p.status === 'paid').length,
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments
.filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0),
};
return c.json({ stats });
});
export default paymentsRouter;

View File

@@ -0,0 +1,652 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, tickets, events, users, payments } from '../db/index.js';
import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
const ticketsRouter = new Hono();
const createTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
phone: z.string().min(6, 'Phone number is required'),
preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
});
const updateTicketSchema = z.object({
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
adminNote: z.string().optional(),
});
const updateNoteSchema = z.object({
note: z.string().max(1000),
});
const adminCreateTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
autoCheckin: z.boolean().optional().default(false),
adminNote: z.string().max(1000).optional(),
});
// Book a ticket (public)
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
if (event.status !== 'published') {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Check capacity
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
eq((tickets as any).status, 'confirmed')
)
)
.get();
if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is sold out' }, 400);
}
// Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
const now = getNow();
const fullName = `${data.firstName} ${data.lastName}`.trim();
if (!user) {
const userId = generateId();
user = {
id: userId,
email: data.email,
password: '', // No password for guest bookings
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for duplicate booking
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'You have already booked this event' }, 400);
}
// Create ticket
const ticketId = generateId();
const qrCode = generateTicketCode();
// Cash payments start as pending, card/lightning start as pending until payment confirmed
const ticketStatus = 'pending';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName,
attendeeEmail: data.email,
attendeePhone: data.phone,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
qrCode,
checkinAt: null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// If Lightning payment, create LNbits invoice
let lnbitsInvoice = null;
if (data.paymentMethod === 'lightning' && event.price > 0) {
if (!isLNbitsConfigured()) {
// Delete the ticket and payment we just created
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
return c.json({
error: 'Bitcoin Lightning payments are not available at this time'
}, 400);
}
try {
const apiUrl = process.env.API_URL || 'http://localhost:3001';
// Pass the fiat currency directly to LNbits - it handles conversion automatically
lnbitsInvoice = await createInvoice({
amount: event.price,
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}`,
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
expiry: 900, // 15 minutes expiry for faster UX
extra: {
ticketId,
eventId: event.id,
eventTitle: event.title,
attendeeName: fullName,
attendeeEmail: data.email,
},
});
// Update payment with LNbits payment hash reference
await (db as any)
.update(payments)
.set({ reference: lnbitsInvoice.paymentHash })
.where(eq((payments as any).id, paymentId));
(newPayment as any).reference = lnbitsInvoice.paymentHash;
} catch (error: any) {
console.error('Failed to create Lightning invoice:', error);
// Delete the ticket and payment we just created since Lightning payment failed
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
return c.json({
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
}, 500);
}
}
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
lightningInvoice: lnbitsInvoice ? {
paymentHash: lnbitsInvoice.paymentHash,
paymentRequest: lnbitsInvoice.paymentRequest,
amount: lnbitsInvoice.amount, // Amount in satoshis
fiatAmount: lnbitsInvoice.fiatAmount,
fiatCurrency: lnbitsInvoice.fiatCurrency,
expiry: lnbitsInvoice.expiry,
} : null,
message: 'Booking created successfully',
}, 201);
});
// Get ticket by ID
ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Get associated event
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
// Get payment
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get();
return c.json({
ticket: {
...ticket,
event,
payment,
},
});
});
// Update ticket status (admin/organizer)
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const updates: any = {};
if (data.status) {
updates.status = data.status;
if (data.status === 'checked_in') {
updates.checkinAt = getNow();
}
}
if (Object.keys(updates).length > 0) {
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
}
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated });
});
// Check-in ticket
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'checked_in') {
return c.json({ error: 'Ticket already checked in' }, 400);
}
if (ticket.status !== 'confirmed') {
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
}
await (db as any)
.update(tickets)
.set({ status: 'checked_in', checkinAt: getNow() })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Check-in successful' });
});
// Mark payment as received (for cash payments - admin only)
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const user = (c as any).get('user');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'confirmed') {
return c.json({ error: 'Ticket already confirmed' }, 400);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
}
const now = getNow();
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, id));
// Update payment status
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
updatedAt: now,
})
.where(eq((payments as any).ticketId, id));
// Get payment for sending receipt
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, id))
.get();
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Payment marked as received' });
});
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
// This sets status to "pending_approval" and notifies admin
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Get the payment
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Only allow for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
}
// Only allow if currently pending
if (payment.status !== 'pending') {
return c.json({ error: 'Payment has already been processed' }, 400);
}
const now = getNow();
// Update payment status to pending_approval
await (db as any)
.update(payments)
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
updatedAt: now,
})
.where(eq((payments as any).id, payment.id));
// Get updated payment
const updatedPayment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, payment.id))
.get();
// TODO: Send notification to admin about pending payment approval
return c.json({
payment: updatedPayment,
message: 'Payment marked as sent. Waiting for admin approval.'
});
});
// Cancel ticket
ticketsRouter.post('/:id/cancel', async (c) => {
const id = c.req.param('id');
const user = await getAuthUser(c);
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Check authorization (admin or ticket owner)
if (!user || (user.role !== 'admin' && user.id !== ticket.userId)) {
return c.json({ error: 'Unauthorized' }, 403);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Ticket already cancelled' }, 400);
}
await (db as any).update(tickets).set({ status: 'cancelled' }).where(eq((tickets as any).id, id));
return c.json({ message: 'Ticket cancelled successfully' });
});
// Remove check-in (reset to confirmed)
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status !== 'checked_in') {
return c.json({ error: 'Ticket is not checked in' }, 400);
}
await (db as any)
.update(tickets)
.set({ status: 'confirmed', checkinAt: null })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Check-in removed successfully' });
});
// Update admin note
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
const id = c.req.param('id');
const { note } = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
await (db as any)
.update(tickets)
.set({ adminNote: note || null })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Note updated successfully' });
});
// Admin create ticket (at the door)
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Check capacity
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
.get();
if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is at capacity' }, 400);
}
const now = getNow();
// For door sales, email might be empty - use a generated placeholder
const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get();
const adminFullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
password: '',
name: adminFullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket for this user and event (only if real email provided)
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400);
}
}
// Create ticket
const ticketId = generateId();
const qrCode = generateTicketCode();
// For door sales, mark as confirmed (or checked_in if auto-checkin)
const ticketStatus = data.autoCheckin ? 'checked_in' : 'confirmed';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
qrCode,
checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record (marked as paid for door sales)
const paymentId = generateId();
const adminUser = (c as any).get('user');
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: event.price,
currency: event.currency,
status: 'paid',
reference: 'Door sale',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: data.autoCheckin
? 'Attendee added and checked in successfully'
: 'Attendee added successfully',
}, 201);
});
// Get all tickets (admin)
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
let query = (db as any).select().from(tickets);
const conditions = [];
if (eventId) {
conditions.push(eq((tickets as any).eventId, eventId));
}
if (status) {
conditions.push(eq((tickets as any).status, status));
}
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
const result = await query.all();
return c.json({ tickets: result });
});
export default ticketsRouter;

224
backend/src/routes/users.ts Normal file
View File

@@ -0,0 +1,224 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, tickets, events, payments } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
const updateUserSchema = z.object({
name: z.string().min(2).optional(),
phone: z.string().optional(),
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
// Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role');
let query = (db as any).select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
createdAt: (users as any).createdAt,
}).from(users);
if (role) {
query = query.where(eq((users as any).role, role));
}
const result = await query.orderBy(desc((users as any).createdAt)).all();
return c.json({ users: result });
});
// Get user by ID (admin or self)
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Users can only view their own profile unless admin
if (currentUser.role !== 'admin' && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
const user = await (db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
createdAt: (users as any).createdAt,
})
.from(users)
.where(eq((users as any).id, id))
.get();
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user });
});
// Update user (admin or self)
usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), zValidator('json', updateUserSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const currentUser = c.get('user');
// Users can only update their own profile unless admin
if (currentUser.role !== 'admin' && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
// Only admin can change roles
if (data.role && currentUser.role !== 'admin') {
delete data.role;
}
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
if (!existing) {
return c.json({ error: 'User not found' }, 404);
}
await (db as any)
.update(users)
.set({ ...data, updatedAt: getNow() })
.where(eq((users as any).id, id));
const updated = await (db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
})
.from(users)
.where(eq((users as any).id, id))
.get();
return c.json({ user: updated });
});
// Get user's ticket history
usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Users can only view their own history unless admin/organizer
if (!['admin', 'organizer'].includes(currentUser.role) && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
.orderBy(desc((tickets as any).createdAt))
.all();
// Get event details for each ticket
const history = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
return {
...ticket,
event,
};
})
);
return c.json({ history });
});
// Delete user (admin only)
usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Prevent self-deletion
if (currentUser.id === id) {
return c.json({ error: 'Cannot delete your own account' }, 400);
}
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
if (!existing) {
return c.json({ error: 'User not found' }, 404);
}
// Prevent deleting admin users
if (existing.role === 'admin') {
return c.json({ error: 'Cannot delete admin users' }, 400);
}
try {
// Get all tickets for this user
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
.all();
// Delete payments associated with user's tickets
for (const ticket of userTickets) {
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
}
// Delete user's tickets
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
// Delete the user
await (db as any).delete(users).where(eq((users as any).id, id));
return c.json({ message: 'User deleted successfully' });
} catch (error) {
console.error('Error deleting user:', error);
return c.json({ error: 'Failed to delete user. They may have related records.' }, 500);
}
});
// Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.get();
const adminCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
.get();
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
export default usersRouter;