Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed too.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import passport from './config/passport';
import { authLimiter } from './middlewares/rateLimiter';
import eventTicketRoutes from './routes/event-ticket.route';
import messageCenterRoutes from './routes/message-center.route';
import ticketOrderRoutes from './routes/ticket-order.route';

const app = express();

Expand All @@ -31,6 +32,7 @@ app.use('/auth', authRoute);
app.use('/auth', otpRoute);
app.use('/event-tickets', eventTicketRoutes);
app.use('/zk-message-center', messageCenterRoutes);
app.use('/ticket-orders', ticketOrderRoutes);
app.use(protectedRoute);

// Global error handler for rate limiting
Expand Down
74 changes: 74 additions & 0 deletions src/controllers/ticket-order.controller.ts
Copy link
Contributor

Choose a reason for hiding this comment

The 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',
});
}
};
75 changes: 75 additions & 0 deletions src/models/ticket-order.ts
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;
19 changes: 19 additions & 0 deletions src/routes/ticket-order.route.ts
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
179 changes: 179 additions & 0 deletions src/services/ticket-order.service.ts
Copy link
Contributor

Choose a reason for hiding this comment

The 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)),
};
}
}
2 changes: 1 addition & 1 deletion src/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const extractToken = (req: any): string | null => {

const validateAndGetUser = async (token: string) => {
const decoded = JwtVerify(token);
const user = await User.findOne({ id: decoded.id });
const user = await User.findById(decoded.id);

if (!user) {
throw new Error('Token is blacklisted');
Expand Down