-
Notifications
You must be signed in to change notification settings - Fork 19
feat: add TicketOrder model and ticket-orders API for user/organizer transparency #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wasn't requested for, remove this. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { Response } from 'express'; | ||
| import { UserAuthenticatedReq } from '../utils/types'; | ||
| import { TicketOrderService } from '../services/ticket-order.service'; | ||
|
|
||
| /** | ||
| * GET /ticket-orders/me – current user's ticket orders (buyer view) | ||
| */ | ||
| export const getMyOrders = async ( | ||
| req: UserAuthenticatedReq, | ||
| res: Response, | ||
| ): Promise<void> => { | ||
| try { | ||
| const userId = (req.user as any)?._id?.toString() ?? (req.user as any)?.id; | ||
| if (!userId) { | ||
| res.status(401).json({ error: 'Unauthorized' }); | ||
| return; | ||
| } | ||
|
|
||
| const page = parseInt(req.query.page as string) || 1; | ||
| const limit = parseInt(req.query.limit as string) || 20; | ||
|
|
||
| const result = await TicketOrderService.getOrdersForUser( | ||
| String(userId), | ||
| page, | ||
| limit, | ||
| ); | ||
| res.status(200).json(result); | ||
| } catch (error) { | ||
| console.error('Error fetching user ticket orders:', error); | ||
| res.status(500).json({ | ||
| error: 'Internal server error', | ||
| message: | ||
| error instanceof Error | ||
| ? error.message | ||
| : 'Failed to fetch ticket orders', | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * GET /ticket-orders/organizer – ticket orders for events organized by current user | ||
| */ | ||
| export const getOrganizerOrders = async ( | ||
| req: UserAuthenticatedReq, | ||
| res: Response, | ||
| ): Promise<void> => { | ||
| try { | ||
| const organizerId = | ||
| (req.user as any)?._id?.toString() ?? (req.user as any)?.id; | ||
| if (!organizerId) { | ||
| res.status(401).json({ error: 'Unauthorized' }); | ||
| return; | ||
| } | ||
|
|
||
| const page = parseInt(req.query.page as string) || 1; | ||
| const limit = parseInt(req.query.limit as string) || 20; | ||
|
|
||
| const result = await TicketOrderService.getOrdersForOrganizer( | ||
| String(organizerId), | ||
| page, | ||
| limit, | ||
| ); | ||
| res.status(200).json(result); | ||
| } catch (error) { | ||
| console.error('Error fetching organizer ticket orders:', error); | ||
| res.status(500).json({ | ||
| error: 'Internal server error', | ||
| message: | ||
| error instanceof Error | ||
| ? error.message | ||
| : 'Failed to fetch organizer ticket orders', | ||
| }); | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import mongoose, { Schema, Document } from 'mongoose'; | ||
|
|
||
| /** Order/payment status: 0 = pending, 1 = completed, 3 = failed */ | ||
| export const TICKET_ORDER_STATUS = { | ||
| PENDING: 0, | ||
| COMPLETED: 1, | ||
| FAILED: 3, | ||
| } as const; | ||
|
|
||
| export type TicketOrderStatusValue = | ||
| (typeof TICKET_ORDER_STATUS)[keyof typeof TICKET_ORDER_STATUS]; | ||
|
|
||
| export interface ITicketOrder extends Document { | ||
| /** Buyer (User) */ | ||
| user: mongoose.Types.ObjectId; | ||
| /** Event (EventTicket) this order belongs to */ | ||
| eventTicket: mongoose.Types.ObjectId; | ||
| /** Ticket type name, e.g. "free", "paid", "vip" */ | ||
| ticketType: string; | ||
| /** Event name (denormalized for display) */ | ||
| eventName: string; | ||
| /** 0 = pending, 1 = completed, 3 = failed */ | ||
| status: TicketOrderStatusValue; | ||
| /** Number of tickets in this order */ | ||
| quantity: number; | ||
| /** Total amount (decimal) */ | ||
| amount: number; | ||
| /** Whether ZK identity was matched for this order */ | ||
| zkIdMatch: boolean; | ||
| /** Privacy level label, e.g. "anonymous", "wallet-required", "verified-access" */ | ||
| privacyLevel: string; | ||
| /** Whether a receipt was generated */ | ||
| hasReceipt: boolean; | ||
| /** When the order was placed/paid */ | ||
| datePurchased: Date; | ||
| /** Optional external payment/transaction id from gateway */ | ||
| transactionId?: string; | ||
| } | ||
|
|
||
| const ticketOrderSchema = new Schema<ITicketOrder>( | ||
| { | ||
| user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, | ||
| eventTicket: { | ||
| type: Schema.Types.ObjectId, | ||
| ref: 'EventTicket', | ||
| required: true, | ||
| }, | ||
| ticketType: { type: String, required: true }, | ||
| eventName: { type: String, required: true }, | ||
| status: { | ||
| type: Number, | ||
| required: true, | ||
| enum: [0, 1, 3], | ||
| default: TICKET_ORDER_STATUS.PENDING, | ||
| }, | ||
| quantity: { type: Number, required: true, min: 1 }, | ||
| amount: { type: Number, required: true, min: 0 }, | ||
| zkIdMatch: { type: Boolean, required: true, default: false }, | ||
| privacyLevel: { type: String, required: true }, | ||
| hasReceipt: { type: Boolean, required: true, default: false }, | ||
| datePurchased: { type: Date, required: true, default: Date.now }, | ||
| transactionId: { type: String, required: false }, | ||
| }, | ||
| { timestamps: true }, | ||
| ); | ||
|
|
||
| // Indexes for common queries: user orders, organizer orders (via eventTicket) | ||
| ticketOrderSchema.index({ user: 1, datePurchased: -1 }); | ||
| ticketOrderSchema.index({ eventTicket: 1, datePurchased: -1 }); | ||
|
|
||
| const TicketOrder = mongoose.model<ITicketOrder>( | ||
| 'TicketOrder', | ||
| ticketOrderSchema, | ||
| ); | ||
| export default TicketOrder; |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wasnt part of issue description. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { Router } from 'express'; | ||
| import { authGuard } from '../middlewares/auth'; | ||
| import { | ||
| getMyOrders, | ||
| getOrganizerOrders, | ||
| } from '../controllers/ticket-order.controller'; | ||
|
|
||
| const ticketOrderRoutes = Router(); | ||
|
|
||
| // All ticket-order routes require authentication | ||
| ticketOrderRoutes.use(authGuard); | ||
|
|
||
| // GET /ticket-orders/me – current user's orders (buyer) | ||
| ticketOrderRoutes.get('/me', getMyOrders); | ||
|
|
||
| // GET /ticket-orders/organizer – orders for events organized by current user | ||
| ticketOrderRoutes.get('/organizer', getOrganizerOrders); | ||
|
|
||
| export default ticketOrderRoutes; |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did not request for this. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| import mongoose from 'mongoose'; | ||
| import TicketOrder, { | ||
| ITicketOrder, | ||
| TICKET_ORDER_STATUS, | ||
| type TicketOrderStatusValue, | ||
| } from '../models/ticket-order'; | ||
| import EventTicket from '../models/event-ticket'; | ||
|
|
||
| export interface TicketOrderResponse { | ||
| id: string; | ||
| ticketType: string; | ||
| eventName: string; | ||
| status: number; | ||
| quantity: number; | ||
| amount: number; | ||
| zkIdMatch: boolean; | ||
| privacyLevel: string; | ||
| hasReceipt: boolean; | ||
| datePurchased: string; | ||
| transactionId?: string; | ||
| } | ||
|
|
||
| export interface PaginatedOrdersResponse { | ||
| page: number; | ||
| limit: number; | ||
| total: number; | ||
| orders: TicketOrderResponse[]; | ||
| } | ||
|
|
||
| const DEFAULT_LIMIT = 20; | ||
| const MAX_LIMIT = 50; | ||
|
|
||
| /** Shape needed to build TicketOrderResponse (works with both Document and lean query results) */ | ||
| type OrderDoc = Pick< | ||
| ITicketOrder, | ||
| | 'ticketType' | ||
| | 'eventName' | ||
| | 'status' | ||
| | 'quantity' | ||
| | 'amount' | ||
| | 'zkIdMatch' | ||
| | 'privacyLevel' | ||
| | 'hasReceipt' | ||
| | 'datePurchased' | ||
| > & { _id: mongoose.Types.ObjectId; transactionId?: string }; | ||
|
|
||
| function toOrderResponse(doc: OrderDoc): TicketOrderResponse { | ||
| return { | ||
| id: doc._id.toString(), | ||
| ticketType: doc.ticketType, | ||
| eventName: doc.eventName, | ||
| status: doc.status, | ||
| quantity: doc.quantity, | ||
| amount: doc.amount, | ||
| zkIdMatch: doc.zkIdMatch, | ||
| privacyLevel: doc.privacyLevel, | ||
| hasReceipt: doc.hasReceipt, | ||
| datePurchased: doc.datePurchased.toISOString(), | ||
| ...(doc.transactionId && { transactionId: doc.transactionId }), | ||
| }; | ||
| } | ||
|
|
||
| /** Maps event privacy level number to display string */ | ||
| export function privacyLevelToString(level: number): string { | ||
| switch (level) { | ||
| case 0: | ||
| return 'anonymous'; | ||
| case 1: | ||
| return 'wallet-required'; | ||
| case 2: | ||
| return 'verified-access'; | ||
| default: | ||
| return 'unknown'; | ||
| } | ||
| } | ||
|
|
||
| export interface CreateOrderInput { | ||
| userId: string; | ||
| eventTicketId: string; | ||
| ticketType: string; | ||
| quantity: number; | ||
| amount: number; | ||
| status?: TicketOrderStatusValue; | ||
| zkIdMatch?: boolean; | ||
| hasReceipt?: boolean; | ||
| transactionId?: string; | ||
| } | ||
|
|
||
| export class TicketOrderService { | ||
| /** | ||
| * Create a ticket order (call from payment flow after purchase). | ||
| * Fetches event name and privacy level from EventTicket. | ||
| */ | ||
| static async createOrder(input: CreateOrderInput): Promise<ITicketOrder> { | ||
| const event = await EventTicket.findById(input.eventTicketId).lean(); | ||
| if (!event) throw new Error('Event ticket not found'); | ||
|
|
||
| const order = await TicketOrder.create({ | ||
| user: new mongoose.Types.ObjectId(input.userId), | ||
| eventTicket: new mongoose.Types.ObjectId(input.eventTicketId), | ||
| ticketType: input.ticketType, | ||
| eventName: event.name, | ||
| status: input.status ?? TICKET_ORDER_STATUS.PENDING, | ||
| quantity: input.quantity, | ||
| amount: input.amount, | ||
| zkIdMatch: input.zkIdMatch ?? false, | ||
| privacyLevel: privacyLevelToString(event.privacyLevel), | ||
| hasReceipt: input.hasReceipt ?? false, | ||
| datePurchased: new Date(), | ||
| transactionId: input.transactionId, | ||
| }); | ||
| return order; | ||
| } | ||
|
|
||
| /** | ||
| * Get ticket orders for a user (buyer) – "my orders" | ||
| */ | ||
| static async getOrdersForUser( | ||
| userId: string, | ||
| page: number = 1, | ||
| limit: number = DEFAULT_LIMIT, | ||
| ): Promise<PaginatedOrdersResponse> { | ||
| const validPage = Math.max(1, page); | ||
| const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); | ||
| const skip = (validPage - 1) * validLimit; | ||
|
|
||
| const [orders, total] = await Promise.all([ | ||
| TicketOrder.find({ user: new mongoose.Types.ObjectId(userId) }) | ||
| .sort({ datePurchased: -1 }) | ||
| .skip(skip) | ||
| .limit(validLimit) | ||
| .lean(), | ||
| TicketOrder.countDocuments({ user: new mongoose.Types.ObjectId(userId) }), | ||
| ]); | ||
|
|
||
| return { | ||
| page: validPage, | ||
| limit: validLimit, | ||
| total, | ||
| orders: orders.map((o) => toOrderResponse(o as OrderDoc)), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Get ticket orders for an organizer – orders for events they organize | ||
| */ | ||
| static async getOrdersForOrganizer( | ||
| organizerId: string, | ||
| page: number = 1, | ||
| limit: number = DEFAULT_LIMIT, | ||
| ): Promise<PaginatedOrdersResponse> { | ||
| const validPage = Math.max(1, page); | ||
| const validLimit = Math.min(Math.max(1, limit), MAX_LIMIT); | ||
| const skip = (validPage - 1) * validLimit; | ||
|
|
||
| const eventIds = await EventTicket.find({ | ||
| organizedBy: new mongoose.Types.ObjectId(organizerId), | ||
| }) | ||
| .select('_id') | ||
| .lean(); | ||
| const eventIdList = eventIds.map((e) => e._id); | ||
|
|
||
| const [orders, total] = await Promise.all([ | ||
| TicketOrder.find({ eventTicket: { $in: eventIdList } }) | ||
| .sort({ datePurchased: -1 }) | ||
| .skip(skip) | ||
| .limit(validLimit) | ||
| .lean(), | ||
| TicketOrder.countDocuments({ eventTicket: { $in: eventIdList } }), | ||
| ]); | ||
|
|
||
| return { | ||
| page: validPage, | ||
| limit: validLimit, | ||
| total, | ||
| orders: orders.map((o) => toOrderResponse(o as OrderDoc)), | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not needed too.