first commit
This commit is contained in:
576
backend/src/routes/dashboard.ts
Normal file
576
backend/src/routes/dashboard.ts
Normal 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;
|
||||
Reference in New Issue
Block a user