Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a4e31f2
feat(orders): add createOrderSchema, updateOrderSchema and orderFilte…
EdgarEldy Aug 26, 2026
2269235
feat(orders): add OrderCreated event
EdgarEldy Aug 26, 2026
196648e
feat(orders): add SendOrderNotification listener
EdgarEldy Aug 26, 2026
be6f608
feat(orders): register OrderCreated to SendOrderNotification binding
EdgarEldy Aug 26, 2026
2af7674
feat(orders): preload start/events for the OrderCreated binding
EdgarEldy Aug 26, 2026
cf4c5aa
feat(orders): add OrdersService with computed total and OrderCreated …
EdgarEldy Aug 26, 2026
b611e7b
feat(orders): add OrdersController with Swagger annotations
EdgarEldy Aug 26, 2026
2c200c3
feat(orders): declare the order CRUD routes and the nested customer-o…
EdgarEldy Aug 26, 2026
18fae11
chore(codegen): regenerate the controllers, events and listeners regi…
EdgarEldy Aug 26, 2026
8c8cc9f
test(orders): add unit tests for OrdersService
EdgarEldy Aug 26, 2026
2c58a30
test(orders): add functional tests for the orders HTTP surface
EdgarEldy Aug 26, 2026
aedfeb7
fix(orders): sequence kernel resolution before the emitter in start/e…
EdgarEldy Aug 26, 2026
601fceb
revert: remove the unnecessary kernel import from start/events.ts
EdgarEldy Aug 26, 2026
e1fcd28
fix(orders): reject non-integer customerId, productId and quantity
EdgarEldy Aug 26, 2026
998695a
fix(orders): catch listener errors instead of letting them crash the …
EdgarEldy Aug 26, 2026
86bc934
fix(auth): type GUARD_KNOWN_EVENTS as an empty object, not unknown
EdgarEldy Aug 26, 2026
99efe53
fix(orders): wrap emitter.fake() in try/finally in the OrderCreated test
EdgarEldy Aug 26, 2026
bdf557f
fix(adonisrc): preload start/kernel.ts as a dependency of routes, not…
EdgarEldy Aug 26, 2026
091926d
fix(config): raise THROTTLE_AUTH_MAX for the test and CI environments
EdgarEldy Aug 26, 2026
888880e
docs(tests): document why loginAsSeeded does not reset the rate limit…
EdgarEldy Aug 26, 2026
b2ead6a
fix(orders): reset the auth rate limiter through the real store befor…
EdgarEldy Aug 26, 2026
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
1 change: 1 addition & 0 deletions .adonisjs/server/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const controllers = {
Categories: () => import('#controllers/categories_controller'),
Customers: () => import('#controllers/customers_controller'),
Health: () => import('#controllers/health_controller'),
Orders: () => import('#controllers/orders_controller'),
Permissions: () => import('#controllers/permissions_controller'),
Products: () => import('#controllers/products_controller'),
Roles: () => import('#controllers/roles_controller'),
Expand Down
6 changes: 5 additions & 1 deletion .adonisjs/server/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
* DO NOT EDIT manually
*/

export const events = {}
import OrderCreated from '#events/order_created'

export const events = {
OrderCreated: OrderCreated,
}
4 changes: 3 additions & 1 deletion .adonisjs/server/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
* DO NOT EDIT manually
*/

export const listeners = {}
export const listeners = {
SendOrderNotification: () => import('#listeners/send_order_notification'),
}
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ JWT_EXPIRY=7d
HASH_DRIVER=argon

LIMITER_STORE=database
THROTTLE_AUTH_MAX=10
THROTTLE_AUTH_MAX=100
THROTTLE_AUTH_WINDOW=60

MAIL_MAILER=smtp
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ env:
JWT_EXPIRY: 1d
HASH_DRIVER: argon
LIMITER_STORE: database
THROTTLE_AUTH_MAX: 5
THROTTLE_AUTH_MAX: 100
THROTTLE_AUTH_WINDOW: 60
MAIL_MAILER: smtp
MAIL_FROM_NAME: AdonisJS Tutorial
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ env:
JWT_EXPIRY: 1d
HASH_DRIVER: argon
LIMITER_STORE: database
THROTTLE_AUTH_MAX: 5
THROTTLE_AUTH_MAX: 100
THROTTLE_AUTH_WINDOW: 60
MAIL_MAILER: smtp
MAIL_FROM_NAME: AdonisJS Tutorial
Expand Down
19 changes: 18 additions & 1 deletion adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,26 @@ export default defineConfig({
|
*/
preloads: [
// No separate entry for start/kernel.ts: routes.ts is the only file
// that imports it (`import { middleware } from '#start/kernel'`), so
// its own static import already guarantees kernel.ts is fully
// evaluated before routes.ts's body runs, per normal ES module
// dependency ordering. A prior version of this array preloaded kernel
// and routes as two independent entries; since every entry here is
// started concurrently (Promise.all over the whole array, not run in
// sequence), that gave kernel.ts two competing load paths and produced
// an intermittent "Cannot access 'middleware' before initialization"
// TDZ error whenever the loader's two concurrent requests for the same
// module raced. Removing the redundant entry removes the race, since
// there is now exactly one load path for kernel.ts left.
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
// Event -> listener bindings (OrderCreated -> SendOrderNotification).
// Loaded last: listener registration has no dependency on kernel,
// routes or the validator preload either way, so appending it here is
// the least disruptive place to add it to the array that is already
// here.
() => import('#start/events'),
],

/*
Expand Down
10 changes: 9 additions & 1 deletion app/auth/jwt_guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ export class JwtGuard implements GuardContract<User> {
isAuthenticated = false
authenticationAttempted = false;

[symbols.GUARD_KNOWN_EVENTS]: unknown
// This guard emits no custom events, unlike the built in session guard.
// Typed as {} (an object type with no members) rather than unknown:
// InferAuthEvents<Authenticators> in config/auth.ts reads this symbol to
// build up EventsList's own type, and `interface X extends unknown {}`
// is not valid TypeScript ("can only extend an object type"), a latent
// error that nothing forced tsc to actually evaluate until an unrelated
// change (start/events.ts's emitter.onError callback, which needs
// `keyof EventsList`) required EventsList to be fully resolved.
declare [symbols.GUARD_KNOWN_EVENTS]: {}

constructor(ctx: HttpContext) {
this.#ctx = ctx
Expand Down
129 changes: 129 additions & 0 deletions app/controllers/orders_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { respond } from '#helpers/api_response'
import OrdersService from '#services/orders_service'
import {
createOrderSchema,
updateOrderSchema,
orderFilterSchema,
} from '#validators/order_validator'
import { paginationSchema } from '#validators/pagination_validator'

/**
* Thin HTTP layer over OrdersService. Like CustomersController, every
* route here requires authentication: index, show and forCustomer are
* declared behind `middleware.auth()` alone in start/routes.ts, while
* store, update and destroy additionally require
* `middleware.role({ roles: ['ADMIN'] })`, matching the README's
* Endpoints table for this branch.
*/
@inject()
export default class OrdersController {
constructor(private ordersService: OrdersService) {}

/**
* @index
* @summary List orders
* @description Returns a paginated list of orders, optionally filtered by customerId and/or productId. Requires authentication.
* @paramQuery page - Page number - @type(number)
* @paramQuery limit - Items per page, capped at 100 - @type(number)
* @paramQuery customerId - Filter results to this customer id - @type(number)
* @paramQuery productId - Filter results to this product id - @type(number)
* @responseBody 200 - <Order[]>.paginated()
* @responseBody 401 - Missing or invalid token
*/
async index({ request, response }: HttpContext) {
// NOTE: same reasoning as ProductsController.index and
// CustomersController.index, pagination and filtering are two
// independently reusable concerns validated with two separate
// request.validateUsing() calls against the same query string.
const { page, limit } = await request.validateUsing(paginationSchema)
const filter = await request.validateUsing(orderFilterSchema)
const result = await this.ordersService.findAll(filter, page ?? 1, limit ?? 10)
return response.ok(respond(result))
}

/**
* @show
* @summary Get an order
* @description Returns a single order by id, including its customer and product. Requires authentication.
* @paramPath id - The order id - @type(number) @required
* @responseBody 200 - <Order>
* @responseBody 401 - Missing or invalid token
* @responseBody 404 - Order not found
*/
async show({ params, response }: HttpContext) {
const order = await this.ordersService.findOne(Number(params.id))
return response.ok(respond(order, 'Order detail'))
}

/**
* @forCustomer
* @summary List a customer's orders
* @description Returns a paginated list of every order placed by the given customer. Requires authentication.
* @paramPath id - The customer id - @type(number) @required
* @paramQuery page - Page number - @type(number)
* @paramQuery limit - Items per page, capped at 100 - @type(number)
* @responseBody 200 - <Order[]>.paginated()
* @responseBody 401 - Missing or invalid token
* @responseBody 404 - Customer not found
*/
async forCustomer({ params, request, response }: HttpContext) {
const { page, limit } = await request.validateUsing(paginationSchema)
const result = await this.ordersService.findAllForCustomer(
Number(params.id),
page ?? 1,
limit ?? 10
)
return response.ok(respond(result))
}

/**
* @store
* @summary Create an order
* @description Creates a new order for an existing customer and product. total is computed automatically as quantity x product.unitPrice. ADMIN only.
* @requestBody <createOrderSchema>
* @responseBody 201 - <Order>
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Customer or product not found
*/
async store({ request, response }: HttpContext) {
const data = await request.validateUsing(createOrderSchema)
const order = await this.ordersService.create(data)
return response.created(respond(order, 'Order created'))
}

/**
* @update
* @summary Update an order
* @description Partially updates an existing order. total is recomputed when quantity changes. ADMIN only.
* @paramPath id - The order id - @type(number) @required
* @requestBody <updateOrderSchema>
* @responseBody 200 - <Order>
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Order, customer or product not found
*/
async update({ params, request, response }: HttpContext) {
const data = await request.validateUsing(updateOrderSchema)
const order = await this.ordersService.update(Number(params.id), data)
return response.ok(respond(order, 'Order updated'))
}

/**
* @destroy
* @summary Delete an order
* @description Deletes an order. ADMIN only.
* @paramPath id - The order id - @type(number) @required
* @responseBody 200 - Order deleted
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Order not found
*/
async destroy({ params, response }: HttpContext) {
await this.ordersService.remove(Number(params.id))
return response.ok(respond(null, 'Order deleted'))
}
}
13 changes: 13 additions & 0 deletions app/events/order_created.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Fired once an order row has actually been persisted. Carries only the
* two ids a listener needs to react (send a notification, log an audit
* entry, and so on) without forcing every listener to re-fetch or receive
* the full Order model, matching the README's own code sample for this
* event exactly.
*/
export default class OrderCreated {
constructor(
public orderId: number,
public customerId: number
) {}
}
29 changes: 29 additions & 0 deletions app/listeners/send_order_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import logger from '@adonisjs/core/services/logger'
import type OrderCreated from '#events/order_created'

/**
* Reacts to OrderCreated by logging that an order was placed.
*
* NOTE: the README's Tasks checklist for this listener only asks to "log
* the event + placeholder for email delivery", and explicitly calls out
* that a log-only implementation with a clear placeholder is acceptable
* and simpler than actually wiring up a confirmation email. This project
* does have `@adonisjs/mail` configured with Mailhog available, but
* standing up a real mailable (template, `Mail.send()`, a functional test
* asserting on the Mailhog inbox) is meaningfully more surface area than
* this task asks for, so it is left as the placeholder below rather than
* built out here.
*/
export default class SendOrderNotification {
async handle(event: OrderCreated) {
logger.info(
{ orderId: event.orderId, customerId: event.customerId },
'order created, notification pending'
)

// NOTE: placeholder for real email delivery. A real implementation
// would load the Order (with its customer and product preloaded) and
// send a confirmation mailable via @adonisjs/mail, e.g.
// await mail.send((message) => message.to(order.customer.email).subject(...))
}
}
Loading
Loading