Files
Spanglish/backend/src/routes/users.ts
T
MichilisandClaude Opus 5.5 b51c1360e2 Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price):
null falls back to the ticket price, 0 is a free walk-in. It is set in
the Create/Edit Event modal (EN/ES) and carried through create, update
and duplicate, but it is internal: public event responses and the
attendee-facing ticket/dashboard endpoints drop it, and only admin,
organizer and staff callers receive it.

The door screen no longer trusts the amount the client sends. It sends
a quantity and the server prices the charge from the event record:
walk-ins pay the walk-in price, existing tickets the ticket price. A
typed amount is only honoured with amountOverride from admin/organizer
and is written to audit_logs in the same transaction. The charged
amount stays snapshotted on the payment row.

POS is a new door tender for the physical card terminal. Staff pick
POS, see the amount to key in, charge the card, then confirm with
"Mark as paid"; it is then recorded like any other door payment
(provider and method 'pos'). It can be switched off globally or per
event via payment options, and shows up in the payments filters,
bookings, revenue summaries and door takings. PosChargePanel is where
an automatic terminal push would go later; nothing calls a terminal
today.

tickets.booking_source (online | walk_in | admin) records how a booking
was made. The migration backfills walk-ins from the door screen's
idempotency records; everything else stays online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:10:50 +00:00

370 lines
12 KiB
TypeScript

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js';
import { omitWalkInPrice } from '../lib/walkInPrice.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(),
email: z.string().email().optional(),
phone: z.string().optional(),
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
languagePreference: z.enum(['en', 'es']).optional(),
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
});
// Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role');
const search = c.req.query('search');
const accountStatus = c.req.query('accountStatus');
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
const registeredAfter = c.req.query('registeredAfter');
const registeredBefore = c.req.query('registeredBefore');
const eventId = c.req.query('eventId');
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
const conditions: any[] = [];
if (role) conditions.push(eq((users as any).role, role));
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
if (search) {
const like = `%${search.toLowerCase()}%`;
conditions.push(sql`(
LOWER(${(users as any).name}) LIKE ${like}
OR LOWER(${(users as any).email}) LIKE ${like}
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
)`);
}
if (hasBookings === 'yes') {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
} else if (hasBookings === 'no') {
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
}
if (eventId) {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const totalQuery = whereClause
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
const totalRow = await dbGet<any>(totalQuery);
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,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
}).from(users);
if (whereClause) {
query = query.where(whereClause);
}
const result = await dbAll(
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
);
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
});
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
);
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
// 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 dbGet(
(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,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
})
.from(users)
.where(eq((users as any).id, id))
);
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, email, and account status
if (data.role && currentUser.role !== 'admin') {
delete data.role;
}
if (data.email && currentUser.role !== 'admin') {
delete data.email;
}
if (data.accountStatus && currentUser.role !== 'admin') {
delete data.accountStatus;
}
const existing = await dbGet(
(db as any).select().from(users).where(eq((users as any).id, id))
);
if (!existing) {
return c.json({ error: 'User not found' }, 404);
}
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
// sign-in is blocked at the auth layer too, and kill live sessions on
// suspension so it takes effect immediately (sessions are DB-validated on
// every request by both the backend and the photo API).
const statusMirror: Record<string, any> = {};
if (data.accountStatus === 'suspended') {
statusMirror.banned = true;
statusMirror.banReason = 'Suspended by admin';
} else if (data.accountStatus) {
statusMirror.banned = false;
statusMirror.banReason = null;
}
await (db as any)
.update(users)
.set({ ...data, ...statusMirror, updatedAt: getNow() })
.where(eq((users as any).id, id));
if (data.accountStatus === 'suspended') {
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
}
const updated = await dbGet(
(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,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
})
.from(users)
.where(eq((users as any).id, id))
);
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 dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
.orderBy(desc((tickets as any).createdAt))
);
// Get event details for each ticket
const history = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await dbGet(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
return {
...ticket,
event: omitWalkInPrice(event as any),
};
})
);
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 dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, id))
);
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 dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
);
// Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
for (const ticket of userTickets) {
// Get payments for this ticket
const ticketPayments = await dbAll<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Delete invoices for each payment
for (const payment of ticketPayments) {
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
}
// Delete payments for this ticket
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
}
// Delete invoices directly associated with the user (if any)
await (db as any).delete(invoices).where(eq((invoices as any).userId, id));
// Delete user's tickets
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
// Delete magic link tokens for the user
await (db as any).delete(magicLinkTokens).where(eq((magicLinkTokens as any).userId, id));
// Delete user sessions
await (db as any).delete(userSessions).where(eq((userSessions as any).userId, id));
// Set userId to null in audit_logs (nullable reference)
await (db as any)
.update(auditLogs)
.set({ userId: null })
.where(eq((auditLogs as any).userId, id));
// Set sentBy to null in email_logs (nullable reference)
await (db as any)
.update(emailLogs)
.set({ sentBy: null })
.where(eq((emailLogs as any).sentBy, id));
// Set updatedBy to null in payment_options (nullable reference)
await (db as any)
.update(paymentOptions)
.set({ updatedBy: null })
.where(eq((paymentOptions as any).updatedBy, id));
// Set updatedBy to null in legal_pages (nullable reference)
await (db as any)
.update(legalPages)
.set({ updatedBy: null })
.where(eq((legalPages as any).updatedBy, id));
// Set updatedBy to null in site_settings (nullable reference)
await (db as any)
.update(siteSettings)
.set({ updatedBy: null })
.where(eq((siteSettings as any).updatedBy, id));
// Clear checkedInByAdminId references in tickets
await (db as any)
.update(tickets)
.set({ checkedInByAdminId: null })
.where(eq((tickets as any).checkedInByAdminId, 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);
}
});
export default usersRouter;