Skip to content

Safeqq/event-management-system

Repository files navigation

Event Ticketing & Booking System -- Clean Architecture & DDD

Proyek ini dikembangkan untuk EF2344-02 Konstruksi Perangkat Lunak -- Departemen Teknik Informatika, ITS.

Topik: Case Study -- Event Ticketing & Booking System
Arsitektur: Clean Architecture + Domain-Driven Design Tactical Patterns
Paradigma: Functional Programming (ES8 arrow functions, factory functions, no classes)
Stack: Bun + ElysiaJS + TypeScript


Progress Week 8: Project Structure

  • Clean Architecture folder structure (domain/, application/, database/, infrastructure/, presentation/)
  • Initial business rules derived from acceptance criteria
  • Ubiquitous Language Glossary (docs/ubiquitous-language-glossary.md)
  • ElysiaJS app setup with Swagger docs + health check endpoint

Progress Week 9-10: Domain Layer & Unit Tests

1. Aggregates -- domain/aggregates/

File State Lifecycle Domain Events Raised
event.ts Draft -> Published -> Cancelled / Completed EventCreated, EventPublished, EventCancelled, TicketCategoryCreated, TicketCategoryDisabled
booking.ts Pending -> Paid / Cancelled / Expired / Refunded BookingCreated, TicketReserved, BookingPaid, BookingCancelled, BookingExpired
ticket.ts Active -> CheckedIn / Refunded / Cancelled TicketCheckedIn
refund.ts Requested -> Approved / Rejected -> PaidOut RefundRequested, RefundApproved, RefundRejected, RefundPaidOut

2. Entities -- domain/entities/

File Extends Description
entity.ts -- Base Entity interface + equalsEntity() function
ticket-category.ts Entity Child entity of Event (status: active, comingSoon, salesClosed, soldOut)
user.ts Entity Standalone entity (roles: organizer, customer, admin)
promo-code.ts Entity Standalone entity with validation, usage, deactivation logic

3. Value Objects -- domain/value-objects/

File Invariant
email.ts Email format validation
money.ts Amount >= 0, currency default IDR
date-range.ts End >= Start
ticket-code.ts Min 8 chars, generated via nanoid

4. Domain Services -- domain/domain-services/

File Business Logic
booking.service.ts Calculate total price + validate booking request
ticket-availability.service.ts Check ticket availability per category

5. Domain Events -- domain/domain-events/

15 domain events:

Event Trigger
EventCreated Event created
EventPublished Event published
EventCancelled Event cancelled
TicketCategoryCreated Ticket category added
TicketCategoryDisabled Ticket category disabled
BookingCreated Booking created
BookingPaid Booking paid
BookingCancelled Booking cancelled
BookingExpired Booking expired
TicketReserved Tickets reserved
TicketCheckedIn Ticket checked in
RefundRequested Refund requested
RefundApproved Refund approved
RefundRejected Refund rejected
RefundPaidOut Refund paid out

6. Repository Interfaces -- domain/repository-interfaces/

Interface Methods
EventRepository findById, findAll, findByOrganizer, save, update, delete
BookingRepository findById, findByCustomer, findByEvent, save, update
TicketRepository findById, findByBooking, findByEvent, findByCustomer, findByCode, save, update
UserRepository findById, findByEmail, save, update
RefundRepository findById, findByBooking, save, update
PromoCodeRepository findById, findByCode, findAllActive, save, update

7. Domain Unit Tests -- domain/tests/

76 tests -- 0 fail

File Tests Coverage
user-stories.test.ts 68 All 12 minimum test cases + user stories + domain event assertions + PromoCode/TicketCategory logic
entities.test.ts 2 User entity equality
value-objects.test.ts 6 Email, Money, DateRange, TicketCode

12 Minimum Test Cases (AGENT.md SS5):

# Test Case Status
1 Event cannot be created if end date < start date Pass
2 Event cannot be created with capacity <= 0 Pass
3 Event cannot be published without active ticket category Pass
4 Ticket category quota cannot exceed event capacity Pass
5 Booking cannot be created with quantity 0 Pass
6 Booking cannot be paid after payment deadline Pass
7 Booking cannot be paid with incorrect amount Pass
8 Paid booking cannot expire Pass
9 Checked-in ticket cannot be checked in again Pass
10 Refund cannot be requested if ticket is checked in Pass
11 Refund cannot be approved if not in Requested status Pass
12 Rejected refund must have a rejection reason Pass

8. User Story Implementation Map

# User Story Command / Query Domain Logic Domain Event Test
1 Create Event createEventHandler (.commands/event.handlers.ts:19) createEvent() (.aggregates/event.ts:48) EventCreated (.aggregates/event.ts:62) .tests/user-stories.test.ts:119
2 Publish Event publishEventHandler (.commands/event.handlers.ts:43) publishEvent() (.aggregates/event.ts:94) EventPublished (.aggregates/event.ts:103) .tests/user-stories.test.ts:149
3 Cancel Event cancelEventHandler (.commands/event.handlers.ts:53) cancelEvent() (.aggregates/event.ts:107) EventCancelled (.aggregates/event.ts:113) .tests/user-stories.test.ts:185
4 Create Ticket Category addTicketCategoryHandler (.commands/event.handlers.ts:63) addCategory() (.aggregates/event.ts:120) TicketCategoryCreated (.aggregates/event.ts:142) .tests/user-stories.test.ts:204
5 Disable Ticket Category disableTicketCategoryHandler (.commands/event.handlers.ts:84) disableCategory() (.aggregates/event.ts:144) TicketCategoryDisabled (.aggregates/event.ts:149) .tests/user-stories.test.ts:269
6 View Available Events getAvailableEventsHandler (.queries/event.handlers.ts:96) .tests/application.test.ts:316
7 View Event Details getEventDetailsHandler (.queries/event.handlers.ts:89) .tests/application.test.ts:329
8 Create Ticket Booking createBookingHandler (.commands/booking.handlers.ts:24) createBooking() (.aggregates/booking.ts:46) BookingCreated (.aggregates/booking.ts:71), TicketReserved (.aggregates/booking.ts:73) .tests/user-stories.test.ts:284
9 Calculate Booking Total Price inline di createBookingHandler getSubtotal() (.aggregates/booking.ts:83) .tests/user-stories.test.ts:374
10 Pay Booking payBookingHandler (.commands/booking.handlers.ts:78) payBooking() (.aggregates/booking.ts:87) BookingPaid (.aggregates/booking.ts:96) .tests/user-stories.test.ts:406
11 Expire Booking expireBookingHandler (.commands/booking.handlers.ts:148) expireBooking() (.aggregates/booking.ts:105) BookingExpired (.aggregates/booking.ts:111) .tests/user-stories.test.ts:441
12 View Purchased Tickets listTicketsByCustomerHandler (.queries/ticket.handlers.ts:60) .tests/application.test.ts:476
13 Check In Ticket checkInTicketHandler (.commands/ticket.handlers.ts:5) checkInTicket() (.aggregates/ticket.ts:50) TicketCheckedIn (.aggregates/ticket.ts:55) .tests/user-stories.test.ts:462
14 Reject Invalid Ticket Check-in error: wrong event (.aggregates/ticket.ts:52), not active (.aggregates/ticket.ts:53) .tests/user-stories.test.ts:479
15 Request Refund requestRefundHandler (.commands/refund.handlers.ts:22) createRefund() (.aggregates/refund.ts:28) RefundRequested (.aggregates/refund.ts:44) .tests/user-stories.test.ts:502
16 Approve Refund approveRefundHandler (.commands/refund.handlers.ts:54) approveRefund() (.aggregates/refund.ts:55) RefundApproved (.aggregates/refund.ts:60) .tests/user-stories.test.ts:546
17 Reject Refund rejectRefundHandler (.commands/refund.handlers.ts:83) rejectRefund() (.aggregates/refund.ts:63) RefundRejected (.aggregates/refund.ts:70) .tests/user-stories.test.ts:573
18 Mark Refund as Paid Out payoutRefundHandler (.commands/refund.handlers.ts:94) payoutRefund() (.aggregates/refund.ts:77) RefundPaidOut (.aggregates/refund.ts:83) .tests/user-stories.test.ts:591
19 View Event Sales Report getEventSalesReportHandler (.queries/event.handlers.ts:119) .tests/application.test.ts:340
20 View Event Participants getEventParticipantsHandler (.queries/event.handlers.ts:160) .tests/application.test.ts:373

Semua path di atas relatif terhadap src/app/domain/ atau src/app/application/.

8a. Acceptance Criteria → Code Reference

US1: Create Event (src/app/domain/aggregates/event.ts)

AC Implementasi
Event created with title, description, dates, location, capacity event.ts:48-63createEvent() factory
End date cannot be before start date event.ts:54if (endDate < startDate) throw
Capacity must be > 0 event.ts:56if (maxCapacity <= 0) throw
Status = Draft event.ts:48 → parameter status: "draft"
Domain event EventCreated raised event.ts:62addDomainEvent(event, createEventCreated(...))

US2: Publish Event (src/app/domain/aggregates/event.ts)

AC Implementasi
Must have at least one active category event.ts:97-99categories.filter(c ⇒ c.isActive).length === 0
Total quota ≤ max capacity event.ts:100-101getTotalQuota(event) > maxCapacity
Draft → Published event.ts:96 validasi, event.ts:102 status change
Cancelled cannot be published event.ts:96status !== "draft"
Domain event EventPublished raised event.ts:103

US3: Cancel Event (src/app/domain/aggregates/event.ts)

AC Implementasi
Published can be cancelled event.ts:108-114
Completed cannot be cancelled event.ts:109"Completed events cannot be cancelled"
Domain event EventCancelled raised event.ts:113

US4: Create Ticket Category (src/app/domain/aggregates/event.ts + entities/ticket-category.ts)

AC Implementasi
Category has name, price, quota, sales period entities/ticket-category.ts:6-13
Price ≥ 0 entities/ticket-category.ts:26"Ticket price cannot be negative"
Quota > 0 entities/ticket-category.ts:27"Ticket quota must be greater than zero"
Sales period ends ≤ event start event.ts:129"Sales period must end before or at the event start"
Total quota ≤ capacity event.ts:140"Total ticket quota cannot exceed event capacity"
Domain event TicketCategoryCreated raised event.ts:142

US5: Disable Ticket Category (src/app/domain/aggregates/event.ts)

AC Implementasi
Can disable if not completed event.ts:145"Cannot disable category on completed event"
Historical data preserved event.ts:148deactivateCategory(category) (hanya set flag)
Cannot purchase inactive event.ts:166"Ticket category is not active"
Domain event TicketCategoryDisabled raised event.ts:149

US6: View Available Events (src/app/application/queries/event.handlers.ts)

AC Implementasi
Only published events shown event.handlers.ts:99filter(e ⇒ e.status === "published")
Cancelled not displayed same filter
Filter by date/location event.handlers.ts:101-108

US7: View Event Details (src/app/application/queries/event.handlers.ts)

AC Implementasi
Display name, description, date, location, categories event.handlers.ts:89-94getEventDetailsHandler
Sales status (ComingSoon/SalesClosed/SoldOut) src/app/domain/entities/ticket-category.ts:33-40getSalesStatus()

US8: Create Ticket Booking (src/app/domain/aggregates/booking.ts)

AC Implementasi
Only for published events booking.ts:53"Booking can only be created for published events"
Items must have quantity > 0 booking.ts:55-58
Payment deadline > creation time booking.ts:61
Status = Pending booking.ts:65 → parameter "pending"
Domain event TicketReserved raised booking.ts:73-74

US9: Calculate Booking Total Price (src/app/domain/aggregates/booking.ts)

AC Implementasi
Price = unitPrice × quantity booking.ts:83-85getSubtotal()
Service fee included booking.ts:66 → parameter serviceFee
Money value object src/app/domain/value-objects/money.ts

US10: Pay Booking (src/app/domain/aggregates/booking.ts + application/commands/booking.handlers.ts)

AC Implementasi
Must be Pending booking.ts:89"Only pending bookings can be paid"
Cannot pay past deadline booking.ts:91"Payment deadline has passed"
Amount must match total booking.ts:93"Payment amount must equal total booking price"
Status → Paid booking.ts:95
Tickets issued with unique codes booking.handlers.ts:99-117generateTicketCode()

US11: Expire Booking (src/app/domain/aggregates/booking.ts + commands/booking.handlers.ts)

AC Implementasi
Pending → Expired booking.ts:106-110
Paid cannot expire booking.ts:108"Only pending bookings can expire"
Quota released booking.handlers.ts:159-165releaseCategory()
Domain event BookingExpired raised booking.ts:111

US12: View Purchased Tickets (src/app/application/queries/ticket.handlers.ts)

AC Implementasi
Query by customer email ticket.handlers.ts:60-66listTicketsByCustomerHandler
Unique ticket code src/app/domain/value-objects/ticket-code.ts:7-10generateTicketCode()

US13: Check In Ticket (src/app/domain/aggregates/ticket.ts)

AC Implementasi
Must match event ticket.ts:52"Ticket does not match the event"
Must be Active ticket.ts:53"Ticket is not active"
Already checked-in rejected ticket.ts:53
Status → CheckedIn ticket.ts:54
Domain event TicketCheckedIn raised ticket.ts:55

US14: Reject Invalid Ticket Check-in (src/app/domain/aggregates/ticket.ts)

AC Implementasi
Wrong event → error ticket.ts:52
Already used → error ticket.ts:53
Status unchanged (throw before mutation) ticket.ts:52-53 (throw duluan)

US15: Request Refund (src/app/application/commands/refund.handlers.ts + domain/aggregates/refund.ts)

AC Implementasi
Must be Paid booking refund.handlers.ts:30"Only paid bookings can be refunded"
No checked-in tickets refund.handlers.ts:35"Cannot refund booking with checked-in tickets"
Status: Requested / Approved / Rejected / PaidOut refund.ts:7-8
Domain event RefundRequested raised refund.ts:44

US16: Approve Refund (src/app/domain/aggregates/refund.ts + commands/refund.handlers.ts)

AC Implementasi
Must be Requested refund.ts:57"Only requested refunds can be approved"
Status → Approved refund.ts:58
Tickets → Cancelled refund.handlers.ts:74-79
Booking → Refunded refund.handlers.ts:68-71markBookingRefunded()
Domain event RefundApproved raised refund.ts:60

US17: Reject Refund (src/app/domain/aggregates/refund.ts + commands/refund.handlers.ts)

AC Implementasi
Must be Requested refund.ts:65"Only requested refunds can be rejected"
Rejection reason required refund.ts:66 → parameter reason
Status → Rejected refund.ts:67
Booking remains Paid refund.handlers.ts:84-91 → tidak sentuh booking
Domain event RefundRejected raised refund.ts:70

US18: Mark Refund as Paid Out (src/app/domain/aggregates/refund.ts)

AC Implementasi
Must be Approved refund.ts:80"Only approved refunds can be paid out"
Payment reference recorded refund.ts:82paymentReference
Status → PaidOut refund.ts:81
Cannot be re-modified (terminal state) refund.ts:80 → hanya Approved → PaidOut
Domain event RefundPaidOut raised refund.ts:83

US19: View Event Sales Report (src/app/application/queries/event.handlers.ts)

AC Implementasi
Tickets sold per category event.handlers.ts:132-142categoryReports[]
Total revenue event.handlers.ts:144-145sum(revenue)

US20: View Event Participants (src/app/application/queries/event.handlers.ts)

AC Implementasi
Participants from Paid bookings event.handlers.ts:160-184getEventParticipantsHandler
Data: name, category, code, check-in status event.handlers.ts:171-178ParticipantDTO
Retrieved via application query event.handlers.ts:160

9. Domain Flows by User Story

US1: Create Event

Input: title, description, startDate, endDate, location, maxCapacity
       │
       ▼
  [src/app/domain/aggregates/event.ts] createEvent()
       │
       ├── Validasi: endDate >= startDate, maxCapacity > 0
       ├── Status: "draft"
       └── Raise: EventCreated

US2: Publish Event

Input: event (status "draft")
       │
       ▼
  [src/app/domain/aggregates/event.ts] publishEvent()
       │
       ├── Validasi: minimal 1 kategori aktif
       ├── Validasi: totalQuota <= maxCapacity
       ├── Status: "published"
       └── Raise: EventPublished

US3: Cancel Event

Input: event (status "draft" | "published")
       │
       ▼
  [src/app/domain/aggregates/event.ts] cancelEvent()
       │
       ├── Validasi: bukan "completed" atau "cancelled"
       ├── Status: "cancelled"
       └── Raise: EventCancelled

US4: Create Ticket Category (child entity)

Input: event, name, price, quota, salesStart, salesEnd
       │
       ▼
  [src/app/domain/aggregates/event.ts] addCategory()
       │
       ├── Validasi: event masih "draft" | "published"
       ├── Validasi: salesEnd <= event.startDate
       ├── Validasi: quota + currentTotal <= maxCapacity
       ├──   [src/app/domain/entities/ticket-category.ts] createTicketCategory()
       └── Raise: TicketCategoryCreated

US5: Disable Ticket Category

Input: event, categoryId
       │
       ▼
  [src/app/domain/aggregates/event.ts] disableCategory()
       │
       ├── Validasi: event belum "completed"
       ├──   [src/app/domain/entities/ticket-category.ts] deactivateCategory()
       └── Raise: TicketCategoryDisabled

US8: Create Ticket Booking

Input: eventId, customerId, items[{categoryId, qty}], totalAmount, serviceFee
       │
       ▼
  [src/app/domain/aggregates/booking.ts] createBooking()
       │
       ├── Validasi: items.length > 0
       ├── Validasi: setiap item.quantity > 0
       ├── Validasi: paymentDeadline > createdAt
       ├── Status: "pending"
       ├── Raise: BookingCreated, TicketReserved
       └── [event.ts] reserveCategory() →   [src/app/domain/entities/ticket-category.ts] reserveCategoryQuota()

US9: Calculate Booking Total Price

Input: booking items
       │
       ▼
  [src/app/domain/aggregates/booking.ts] getSubtotal()
       │
       └── Sum(item.unitPrice × item.quantity) + serviceFee

US10: Pay Booking

Input: booking (status "pending"), amount
       │
       ▼
  [src/app/domain/aggregates/booking.ts] payBooking()
       │
       ├── Validasi: status === "pending"
       ├── Validasi: now <= paymentDeadline
       ├── Validasi: amount === totalAmount
       ├── Status: "paid", paidAt: now
       └── Raise: BookingPaid

US11: Expire Booking

Input: booking (status "pending")
       │
       ▼
  [src/app/domain/aggregates/booking.ts] expireBooking()
       │
       ├── Validasi: status === "pending"
       ├── Status: "expired"
       └── Raise: BookingExpired

US13: Check In Ticket

Input: ticket (status "active"), eventId
       │
       ▼
  [src/app/domain/aggregates/ticket.ts] checkInTicket()
       │
       ├── Validasi: ticket.eventId === eventId
       ├── Validasi: status === "active"
       ├── Status: "checkedIn", checkedInAt: now
       └── Raise: TicketCheckedIn

US14: Reject Invalid Ticket Check-in

Input: ticket (status "checkedIn" | "refunded" | "cancelled"), eventId
       │
       ▼
  [src/app/domain/aggregates/ticket.ts] checkInTicket()
       │
       ├── ❌ Validasi gagal: ticket.eventId !== eventId  → throw "Ticket does not match the event"
       └── ❌ Validasi gagal: status !== "active"        → throw "Ticket is not active"

US15: Request Refund

Input: bookingId, amount, reason
       │
       ▼
  [src/app/domain/aggregates/refund.ts] createRefund()
       │
       ├── [booking.ts] markBookingRefunded() → validasi status "paid"
       ├── [ticket.ts] markTicketRefunded()   → validasi status "active"
       ├── Status: "requested"
       └── Raise: RefundRequested

US16: Approve Refund

Input: refund (status "requested")
       │
       ▼
  [src/app/domain/aggregates/refund.ts] approveRefund()
       │
       ├── Validasi: status === "requested"
       ├── Status: "approved", resolvedAt: now
       └── Raise: RefundApproved

US17: Reject Refund

Input: refund (status "requested"), reason
       │
       ▼
  [src/app/domain/aggregates/refund.ts] rejectRefund()
       │
       ├── Validasi: status === "requested"
       ├── Status: "rejected", resolvedAt: now, rejectionReason: reason
       └── Raise: RefundRejected

US18: Mark Refund as Paid Out

Input: refund (status "approved"), paymentReference
       │
       ▼
  [src/app/domain/aggregates/refund.ts] payoutRefund()
       │
       ├── Validasi: status === "approved"
       ├── Status: "paidOut", paymentReference: ref
       └── Raise: RefundPaidOut

Progress Week 11: Application Layer

1. Commands -- application/commands/

File Command Types Handler
event.commands.ts CreateEventCommand, PublishEventCommand, CancelEventCommand, AddTicketCategoryCommand, DisableTicketCategoryCommand event.handlers.ts: createEventHandler, publishEventHandler, cancelEventHandler, addTicketCategoryHandler, disableTicketCategoryHandler
booking.commands.ts CreateBookingCommand, PayBookingCommand, CancelBookingCommand, ExpireBookingCommand booking.handlers.ts: createBookingHandler, payBookingHandler, cancelBookingHandler, expireBookingHandler
ticket.commands.ts CheckInTicketCommand ticket.handlers.ts: checkInTicketHandler
refund.commands.ts RequestRefundCommand, ApproveRefundCommand, RejectRefundCommand, PayoutRefundCommand refund.handlers.ts: requestRefundHandler, approveRefundHandler, rejectRefundHandler, payoutRefundHandler

Setiap handler memanggil fungsi domain (createEvent, payBooking, dll) dan menyimpan via Repository.

2. Queries -- application/queries/

File Query Types Handler
event.queries.ts GetEventQuery, ListEventsQuery, GetEventDetailsQuery, GetAvailableEventsQuery, GetEventSalesReportQuery, GetEventParticipantsQuery event.handlers.ts: mapping domain → EventDTO, SalesReportDTO, EventParticipantsDTO
booking.queries.ts GetBookingQuery, ListBookingsByCustomerQuery, ListBookingsByEventQuery booking.handlers.ts: mapping domain → BookingDTO
ticket.queries.ts GetTicketQuery, GetTicketByCodeQuery, ListTicketsByBookingQuery, ListTicketsByEventQuery, ListTicketsByCustomerQuery ticket.handlers.ts: mapping domain → TicketDTO
refund.queries.ts GetRefundQuery, ListRefundsByBookingQuery refund.handlers.ts: mapping domain → RefundDTO

3. DTOs -- application/dtos/

File DTOs
event.dto.ts EventDTO, TicketCategoryDTO, CreateEventDTO, AddTicketCategoryDTO, ParticipantDTO, EventParticipantsDTO, SalesReportCategoryDTO, SalesReportDTO
booking.dto.ts BookingDTO, BookingItemDTO, CreateBookingDTO, PayBookingDTO, ListBookingsQueryDTO, BookingSummaryDTO
ticket.dto.ts TicketDTO, CheckInTicketDTO
refund.dto.ts RefundDTO, RequestRefundDTO, ApproveRefundDTO, RejectRefundDTO, PayoutRefundDTO

4. Application Service Interfaces -- application/services/

Interface Methods
PaymentGateway processPayment, refundPayment
NotificationService sendBookingConfirmation, sendTicketDetails, sendPaymentConfirmation, sendRefundNotification
RefundPaymentService processRefund

5. Application Flow per User Story

US1: Create Event

[CreateEventCommand]
  title, description, startDate, endDate, location, maxCapacity, organizerId
       │
       ▼
  [event.handlers.ts] createEventHandler()
       │
       ├── nanoid() → eventId
       ├── createEvent(eventId, ...) → domain
       │     └── domain: validasi endDate >= startDate, maxCapacity > 0
       └── eventRepository.save(event)

US2: Publish Event

[PublishEventCommand] { eventId }
       │
       ▼
  [event.handlers.ts] publishEventHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── publishEvent(event) → domain
       │     └── domain: validasi minimal 1 kategori aktif, quota <= capacity
       └── eventRepository.update(event)

US3: Cancel Event

[CancelEventCommand] { eventId }
       │
       ▼
  [event.handlers.ts] cancelEventHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── cancelEvent(event) → domain
       └── eventRepository.update(event)

US4: Create Ticket Category

[AddTicketCategoryCommand] { eventId, name, price, quota, salesStart, salesEnd }
       │
       ▼
  [event.handlers.ts] addTicketCategoryHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── nanoid() → categoryId
       ├── createMoney(price) → value object
       ├── addCategory(event, categoryId, ...) → domain
       └── eventRepository.update(event)

US5: Disable Ticket Category

[DisableTicketCategoryCommand] { eventId, categoryId }
       │
       ▼
  [event.handlers.ts] disableTicketCategoryHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── disableCategory(event, categoryId) → domain
       └── eventRepository.update(event)

US6: View Available Events

[GetAvailableEventsQuery] { location?, startDate?, endDate?, limit?, offset? }
       │
       ▼
  [event.handlers.ts] getAvailableEventsHandler()
       │
       ├── eventRepository.findAll()
       ├── filter: status === "published"
       ├── filter opsional: location, startDate, endDate
       ├── pagination: offset + limit
       └── map to EventDTO[]

US7: View Event Details

[GetEventDetailsQuery] { eventId }
       │
       ▼
  [event.handlers.ts] getEventDetailsHandler()
       │
       ├── eventRepository.findById(eventId)
       └── map to EventDTO (dengan kategori + salesStatus)

US8: Create Ticket Booking

[CreateBookingCommand] { eventId, customerId, items[{categoryId, quantity}] }
       │
       ▼
  [booking.handlers.ts] createBookingHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── reserveCategory(event, categoryId, quantity) → domain (kurangi quota)
       ├── hitung subtotal per item
       ├── nanoid() → bookingId
       ├── createBooking(...) → domain
       │     └── domain: validasi items.length > 0, quantity > 0
       ├── bookingRepository.save(booking)
       └── eventRepository.update(event)  → simpan quota terbaru

US9: Calculate Booking Total Price

Proses di createBookingHandler():
  items.map(item → unitPrice = category.price.amount)
  subtotal = sum(unitPrice × quantity)
  totalAmount = subtotal + serviceFee
  createBooking(..., totalAmount, serviceFee, ...)

US10: Pay Booking

[PayBookingCommand] { bookingId, amount, paymentReference }
       │
       ▼
  [booking.handlers.ts] payBookingHandler()
       │
       ├── bookingRepository.findById(bookingId)
       ├── paymentGateway.processPayment(bookingId, amount, paymentReference)
       ├── payBooking(booking, amount) → domain
       │     └── domain: validasi pending, deadline, amount
       ├── bookingRepository.update(booking)
       ├── generate ticket untuk setiap item × quantity
       │     └── createTicket() + ticketRepository.save()
       └── notificationService.sendBookingConfirmation()

US11: Expire Booking

[ExpireBookingCommand] { bookingId }
       │
       ▼
  [booking.handlers.ts] expireBookingHandler()
       │
       ├── bookingRepository.findById(bookingId)
       ├── expireBooking(booking) → domain
       ├── bookingRepository.update(booking)
       ├── eventRepository.findById(booking.eventId)
       └── releaseCategory(event, categoryId, quantity) → domain (kembalikan quota)

US12: View Purchased Tickets

[ListTicketsByCustomerQuery] { customerEmail }
       │
       ▼
  [ticket.handlers.ts] listTicketsByCustomerHandler()
       │
       ├── ticketRepository.findByCustomer(customerEmail)
       └── map to TicketDTO[]

US13: Check In Ticket

[CheckInTicketCommand] { ticketCode, eventId }
       │
       ▼
  [ticket.handlers.ts] checkInTicketHandler()
       │
       ├── ticketRepository.findByCode(ticketCode)
       ├── checkInTicket(ticket, eventId) → domain
       │     └── domain: validasi eventId cocok, status === "active"
       └── ticketRepository.update(ticket)

US14: Reject Invalid Ticket Check-in

(checkInTicketHandler → domain checkInTicket)
       │
       ├── ❌ ticket.eventId !== eventId → throw "Ticket does not match the event"
       └── ❌ ticket.status !== "active" → throw "Ticket is not active"

US15: Request Refund

[RequestRefundCommand] { bookingId, reason }
       │
       ▼
  [refund.handlers.ts] requestRefundHandler()
       │
       ├── bookingRepository.findById(bookingId)
       ├── validasi: booking.status === "paid"
       ├── ticketRepository.findByBooking(bookingId)
       ├── validasi: tidak ada ticket dengan status "checkedIn"
       ├── nanoid() → refundId
       ├── createRefund(...) → domain
       └── refundRepository.save(refund)

US16: Approve Refund

[ApproveRefundCommand] { refundId }
       │
       ▼
  [refund.handlers.ts] approveRefundHandler()
       │
       ├── refundRepository.findById(refundId)
       ├── approveRefund(refund) → domain
       ├── refundRepository.update(refund)
       ├── bookingRepository.findById(refund.bookingId)
       ├── markBookingRefunded(booking)
       ├── bookingRepository.update(booking)
       ├── ticketRepository.findByBooking(refund.bookingId)
       └── untuk setiap ticket active → markTicketRefunded(ticket) + update

US17: Reject Refund

[RejectRefundCommand] { refundId, reason }
       │
       ▼
  [refund.handlers.ts] rejectRefundHandler()
       │
       ├── refundRepository.findById(refundId)
       ├── rejectRefund(refund, reason) → domain
       └── refundRepository.update(refund)

US18: Mark Refund as Paid Out

[PayoutRefundCommand] { refundId, paymentReference }
       │
       ▼
  [refund.handlers.ts] payoutRefundHandler()
       │
       ├── refundRepository.findById(refundId)
       ├── refundPaymentService.processRefund(refundId, amount, paymentReference)
       ├── payoutRefund(refund, paymentReference) → domain
       └── refundRepository.update(refund)

US19: View Event Sales Report

[GetEventSalesReportQuery] { eventId }
       │
       ▼
  [event.handlers.ts] getEventSalesReportHandler()
       │
       ├── eventRepository.findById(eventId)
       ├── ticketRepository.findByEvent(eventId)
       ├── hitung sold per kategori
       ├── totalSold = sum(sold), totalRevenue = sum(revenue)
       └── map to SalesReportDTO

US20: View Event Participants

[GetEventParticipantsQuery] { eventId }
       │
       ▼
  [event.handlers.ts] getEventParticipantsHandler()
       │
       ├── ticketRepository.findByEvent(eventId)
       ├── hitung checkedInCount
       └── map to EventParticipantsDTO (daftar partisipan + status)

6. Application Layer Tests -- application/tests/

29 tests -- 0 fail

Test Group Tests Coverage
Event Command Handlers 5 create, publish, cancel, addCategory, disableCategory
Booking Command Handlers 1 createBooking
Ticket Command Handlers 1 checkInTicket
Refund Command Handlers 1 requestRefund
Event Query Handlers 6 getById, list filters, available events, details, sales report, participants
Booking Query Handlers 2 getById, listByCustomer
Ticket Query Handlers 2 getByCode, listByCustomer
Refund Query Handlers 2 getById, listByBooking
DTO Mappings 4 Event, Booking, Ticket, Refund DTO structure
Service Mocks 3 PaymentGateway, NotificationService, RefundPaymentService

Total: 126 tests (71 domain + 29 application + 12 infrastructure + 14 presentation) -- 0 fail

# Run all tests
bun run test

# Run domain tests only
bun run test:domain

# Run application tests
bun test src/app/application/tests/

# Run specific file
bun test src/app/domain/tests/user-stories.test.ts

Progress Week 12: Infrastructure Layer

1. Database Schema -- database/drizzle/schema/schema.ts

8 PostgreSQL tables + 6 enums menggunakan Drizzle ORM:

Table Columns
users id, name, email, role (enum: organizer/customer/admin), createdAt
events id, title, description, startDate, endDate, location, maxCapacity, status (enum: draft/published/cancelled/completed), organizerId (FK → users), createdAt, updatedAt
ticket_categories id, eventId (FK → events), name, priceAmount, priceCurrency, quota, remaining, salesStart, salesEnd, isActive
bookings id, eventId (FK → events), customerId (FK → users), totalAmount, serviceFee, status (enum: pending/paid/cancelled/expired/refunded), createdAt, paidAt, paymentDeadline
booking_items id, bookingId (FK → bookings), ticketCategoryId, categoryName, quantity, unitPrice
tickets id, bookingId (FK → bookings), eventId (FK → events), categoryName, code (unique), priceAmount, priceCurrency, status (enum: active/checkedIn/refunded/cancelled), customerName, customerEmail, createdAt, checkedInAt
refunds id, bookingId (FK → bookings), amount, currency, reason, status (enum: requested/approved/rejected/paidOut), requestedAt, resolvedAt, rejectionReason, paymentReference
promo_codes id, code (unique), discountType (enum: percentage/fixed), discountValue, maxUsage, usedCount, minPurchaseAmount, minPurchaseCurrency, validPeriodStart, validPeriodEnd, isActive

2. Database Connection -- database/drizzle/index/connection.ts

getDatabase()  Drizzle instance (PostgreSQL via postgres.js)
  • Menggunakan Supabase connection pooling (port 6543) via env DATABASE_URL
  • Singleton pattern: koneksi dibuat sekali lalu di-cache

3. Relations -- database/drizzle/relations/relations.ts

Relasi Drizzle ORM:

  • Event → TicketCategories (one-to-many)
  • Booking → BookingItems (one-to-many)
  • Booking → Tickets (one-to-many)
  • Booking → Refunds (one-to-many)
  • User → Events (one-to-many, sebagai organizer)
  • User → Bookings (one-to-many, sebagai customer)

4. Repository Implementations -- infrastructure/repositories/

Repository File Methods Notes
EventRepository event.repository.ts findById, findAll, findByOrganizer, save, update, delete Map domain ↔ DB (Money, TicketCategory)
BookingRepository booking.repository.ts findById, findByCustomer, findByEvent, save, update item serialization
TicketRepository ticket.repository.ts findById, findByBooking, findByEvent, findByCustomer, findByCode, save, update ticket code lookup
RefundRepository refund.repository.ts findById, findByBooking, save, update full refund state

Semua repository adalah factory functions: createEventRepository(db)EventRepository.

5. Application Service Implementations -- infrastructure/services/

Service File Methods
PaymentGateway payment-gateway.service.ts processPayment, refundPayment
NotificationService notification.service.ts sendBookingConfirmation, sendTicketDetails, sendPaymentConfirmation, sendRefundNotification
RefundPaymentService refund-payment.service.ts processRefund

Catatan: Implementasi saat ini menggunakan console.log (stub). Untuk production, integrasikan dengan payment gateway (Midtrans/Xendit) dan notification service (SendGrid/WhatsApp API) yang sesungguhnya.

6. Seed Data -- database/drizzle/seeds/seed.ts

import { getDatabase } from "../index/connection";
import { seedDatabase } from "./seeds/seed";

const db = getDatabase();
await seedDatabase(db);

Men-generate: 2 user, 2 event, 3 kategori tiket, 1 booking, 2 tiket, 1 promo code.

7. Infrastructure Tests -- infrastructure/tests/

12 tests -- 0 fail

Test Group Tests
Schema exports 2 (tables + enums)
Connection 1 (getDatabase export)
Relations 1 (all relation exports)
Event Repository 1 (interface methods)
Booking Repository 1 (interface methods)
Ticket Repository 1 (interface methods)
Refund Repository 1 (interface methods)
Payment Gateway 1 (interface methods)
Notification Service 1 (interface methods)
Refund Payment Service 1 (interface methods)
Seed Script 1 (export check)

Progress Week 13: Presentation Layer

1. REST API Controllers -- presentation/controllers/

Event Controller (event.controller.ts) — US1-US7, US19-US20:

Method Path User Story Body/Params
POST /events US1: Create Event { title, description, startDate, endDate, location, maxCapacity, organizerId }
POST /events/:eventId/publish US2: Publish Event
POST /events/:eventId/cancel US3: Cancel Event
POST /events/:eventId/categories US4: Create Ticket Category { name, price, quota, salesStart, salesEnd }
PATCH /events/:eventId/categories/:categoryId/disable US5: Disable Ticket Category
GET /events US6: View Available Events ?location=&startDate=&endDate=
GET /events/:eventId US7: View Event Details
GET /events/:eventId/sales-report US19: Sales Report
GET /events/:eventId/participants US20: Participants

Booking Controller (booking.controller.ts) — US8-US11:

Method Path User Story Body/Params
POST /bookings US8: Create Booking { eventId, customerId, items[{ ticketCategoryId, quantity }] }
GET /bookings/:bookingId US9: Booking Total
POST /bookings/:bookingId/pay US10: Pay Booking { amount, paymentReference }
POST /bookings/:bookingId/expire US11: Expire Booking
GET /bookings/customer/:customerId ?status=
GET /bookings/event/:eventId ?status=

Ticket Controller (ticket.controller.ts) — US12-US14:

Method Path User Story Body/Params
GET /tickets/customer/:email US12: View Purchased Tickets
GET /tickets/booking/:bookingId
GET /tickets/validate/:code US14: Validate Ticket
POST /tickets/check-in US13: Check In Ticket { ticketCode, eventId }

Refund Controller (refund.controller.ts) — US15-US18:

Method Path User Story Body/Params
POST /refunds US15: Request Refund { bookingId, reason }
POST /refunds/:refundId/approve US16: Approve Refund
POST /refunds/:refundId/reject US17: Reject Refund { reason }
POST /refunds/:refundId/payout US18: Payout Refund { paymentReference }
GET /refunds/booking/:bookingId
GET /refunds/:refundId

2. Dependency Injection

createApp(deps?) di create-app.ts menyediakan dependency injection opsional:

// Default: in-memory repositories (development / testing)
const app = createApp();

// Production: inject Supabase repositories + services
const app = createApp({
  eventRepository: drizzleEventRepo,
  bookingRepository: drizzleBookingRepo,
  ticketRepository: drizzleTicketRepo,
  refundRepository: drizzleRefundRepo,
  paymentGateway: realPaymentGateway,
  notificationService: realNotificationService,
  refundPaymentService: realRefundPaymentService,
});

In-memory repository factories juga di-export untuk testing:

  • createInMemoryEventRepository()
  • createInMemoryBookingRepository()
  • createInMemoryTicketRepository()
  • createInMemoryRefundRepository()

3. Error Handling

Global onError handler:

  • VALIDATION errors → { error: "Validation error", details: "<message>" }
  • Domain errors → { error: "<domain error message>" }

4. API Documentation

Swagger UI tersedia di GET /docs (via @elysiajs/swagger).

5. Presentation Tests -- presentation/tests/

14 tests -- 0 fail

Test Coverage
Controller exports 4 (Event, Booking, Ticket, Refund)
createApp 1 (app creation with default config)
Health check 1 (GET /health)
Create Event 1 (POST /events)
Publish Event 1 (POST /events/:id/publish with category)
List Events 1 (GET /events)
Event Details 1 (GET /events/:id)
Create Booking 1 (POST /bookings with published event)
Get Booking 1 (GET /bookings/:id)
Validate Ticket 1 (GET /tickets/validate/:code)
Check-in 1 (POST /tickets/check-in invalid)

Run Tests

# Run all 126 tests
bun run test

# Run domain tests only
bun run test:domain

# Run application tests
bun test src/app/application/tests/

# Run infrastructure tests
bun test src/app/infrastructure/tests/

# Run presentation tests
bun test src/app/presentation/tests/

# Run specific file
bun test src/app/domain/tests/user-stories.test.ts

Supabase Database Setup

  1. Buat project di Supabase
  2. Salin connection string dari Project Settings → Database → Connection String (URI)
    • Format: postgresql://postgres.[PROJECT_REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
  3. Set environment variable:
    # Windows PowerShell
    $env:DATABASE_URL = "postgresql://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres"
    
    # atau via .env file
  4. Generate migration:
    bunx drizzle-kit generate
  5. Run migration:
    bunx drizzle-kit migrate
  6. Seed database:
    bun run src/app/database/drizzle/seeds/seed.ts

Tech Stack

  • Runtime: Bun
  • Framework: ElysiaJS
  • Language: TypeScript
  • Database: PostgreSQL via Supabase
  • ORM: Drizzle
  • Architecture: Clean Architecture + DDD Tactical Patterns
  • Paradigm: Functional Programming

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors