Skip to content
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'),
Health: () => import('#controllers/health_controller'),
Permissions: () => import('#controllers/permissions_controller'),
Products: () => import('#controllers/products_controller'),
Roles: () => import('#controllers/roles_controller'),
Users: () => import('#controllers/users_controller'),
}
110 changes: 110 additions & 0 deletions app/controllers/products_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { respond } from '#helpers/api_response'
import ProductsService from '#services/products_service'
import {
createProductSchema,
updateProductSchema,
productFilterSchema,
} from '#validators/product_validator'
import { paginationSchema } from '#validators/pagination_validator'

/**
* Thin HTTP layer over ProductsService. GET routes are public; the
* mutation routes (store, update, destroy) are declared behind
* `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` in
* start/routes.ts, the same pattern CategoriesController already uses for
* its own mutation routes.
*/
@inject()
export default class ProductsController {
constructor(private productsService: ProductsService) {}

/**
* @index
* @summary List products
* @description Returns a paginated list of products, optionally filtered by categoryId and sorted by productName or unitPrice. Public endpoint.
* @paramQuery page - Page number - @type(number)
* @paramQuery limit - Items per page, capped at 100 - @type(number)
* @paramQuery categoryId - Filter results to this category id - @type(number)
* @paramQuery sortBy - Field to sort by (productName or unitPrice) - @type(string)
* @paramQuery order - Sort order (asc or desc) - @type(string)
* @responseBody 200 - <Product[]>.paginated()
*/
async index({ request, response }: HttpContext) {
// NOTE: pagination and filtering are two independently reusable
// concerns (paginationSchema is already shared by every other list
// endpoint), so they are validated with two separate
// request.validateUsing() calls rather than one schema that merges
// page/limit/categoryId/sortBy/order together. Both run against the
// same request query string, VineJS does not care that they overlap,
// and this keeps paginationSchema reusable as-is instead of forking it.
const { page, limit } = await request.validateUsing(paginationSchema)
const filter = await request.validateUsing(productFilterSchema)
const result = await this.productsService.findAll(filter, page ?? 1, limit ?? 10)
return response.ok(respond(result))
}

/**
* @show
* @summary Get a product
* @description Returns a single product by id, including its category. Public endpoint.
* @paramPath id - The product id - @type(number) @required
* @responseBody 200 - <Product>
* @responseBody 404 - Product not found
*/
async show({ params, response }: HttpContext) {
const product = await this.productsService.findOne(Number(params.id))
return response.ok(respond(product, 'Product detail'))
}

/**
* @store
* @summary Create a product
* @description Creates a new product under an existing category. ADMIN only.
* @requestBody <createProductSchema>
* @responseBody 201 - <Product>
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Category not found
*/
async store({ request, response }: HttpContext) {
const data = await request.validateUsing(createProductSchema)
const product = await this.productsService.create(data)
return response.created(respond(product, 'Product created'))
}

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

/**
* @destroy
* @summary Delete a product
* @description Deletes a product. ADMIN only. Fails with 409 when the product still has orders associated with it.
* @paramPath id - The product id - @type(number) @required
* @responseBody 200 - Product deleted
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Product not found
* @responseBody 409 - Product still has orders associated with it
*/
async destroy({ params, response }: HttpContext) {
await this.productsService.remove(Number(params.id))
return response.ok(respond(null, 'Product deleted'))
}
}
18 changes: 16 additions & 2 deletions app/models/product.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import Category from '#models/category'
import Order from '#models/order'

export default class Product extends BaseModel {
@column({ isPrimary: true })
Expand All @@ -17,4 +18,17 @@ export default class Product extends BaseModel {

@belongsTo(() => Category)
declare category: BelongsTo<typeof Category>

// NOTE: the README's Data Model diagram shows products 1 -- N orders, and
// the Column Details table only defines orders.product_id as the FK
// side. The inverse hasMany here is not explicitly requested by the
// feature/products Tasks checklist, but is added the same way and for the
// same reason the Customer model already added its own `orders` hasMany:
// ProductsService.remove needs to check "does at least one order still
// reference this product" for its 409 delete guard, and `related('orders')`
// is the same query idiom CategoriesService.remove already uses for its
// own `products` relation, kept consistent rather than querying Order
// directly with a hand-written where('productId', ...).
@hasMany(() => Order)
declare orders: HasMany<typeof Order>
}
111 changes: 111 additions & 0 deletions app/services/products_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { createError } from '@adonisjs/core/exceptions'
import type { Infer } from '@vinejs/vine/types'

import Product from '#models/product'
import Category from '#models/category'
import { toPageResponse, type PageResponse } from '#helpers/page_response'
import type {
createProductSchema,
updateProductSchema,
productFilterSchema,
} from '#validators/product_validator'

type CreateProductPayload = Infer<typeof createProductSchema>
type UpdateProductPayload = Infer<typeof updateProductSchema>
type ProductFilter = Infer<typeof productFilterSchema>

const E_PRODUCT_IN_USE = createError(
'Product still has orders associated with it',
'E_PRODUCT_IN_USE',
409
)

/**
* Owns every read and write to the `products` table. No constructor
* dependencies, so no @inject(), matching CategoriesService's own
* reasoning for the same omission. Unlike CategoriesService.findAll, this
* branch's README does not ask for caching on the products list, so
* findAll below queries straight through to the database on every call.
*/
export default class ProductsService {
/**
* Builds a dynamic query per the README's own "Dynamic QueryBuilder with
* optional filters" code sample: `where('category_id', ...)` and
* `orderBy(filter.sortBy, ...)` are only chained on when the
* corresponding filter field is present, and `category` is always
* preloaded so the list response matches the detail response's shape.
*/
async findAll(
filter: ProductFilter,
page: number,
limit: number
): Promise<PageResponse<Product>> {
const query = Product.query().preload('category')

if (filter.categoryId) {
query.where('category_id', filter.categoryId)
}

if (filter.sortBy) {
query.orderBy(filter.sortBy, filter.order ?? 'asc')
}

const paginator = await query.paginate(page, limit)
return toPageResponse(paginator)
}

async findOne(id: number): Promise<Product> {
const product = await Product.findOrFail(id)
await product.load('category')
return product
}

/**
* Checks the referenced category exists before writing the product row.
* Without this, a bad categoryId would only surface as a raw Postgres FK
* violation with no clean .status, the same class of bug the code review
* on the previous branch caught for a different case, instead of the
* clean 404 a client can actually act on.
*/
async create(data: CreateProductPayload): Promise<Product> {
await Category.findOrFail(data.categoryId)

const product = await Product.create(data)
await product.load('category')
return product
}

async update(id: number, data: UpdateProductPayload): Promise<Product> {
const product = await Product.findOrFail(id)

if (data.categoryId !== undefined) {
await Category.findOrFail(data.categoryId)
}

product.merge(data)
await product.save()
await product.load('category')
return product
}

/**
* Business rule from the README: a product linked to at least one order
* cannot be deleted, 409 instead. Checked by querying the `orders`
* relation for a single match, the same `related(...).query().first()`
* idiom CategoriesService.remove uses for its own "still in use" guard
* against `products`, made possible by the `orders` hasMany added to the
* Product model for exactly this check. This also doubles as the guard
* that keeps the orders table's own `product_id` FK (ON DELETE CASCADE)
* from ever actually cascading away order history through this service.
*/
async remove(id: number): Promise<void> {
const product = await Product.findOrFail(id)

const stillInUse = await product.related('orders').query().select('id').first()
if (stillInUse) {
throw new E_PRODUCT_IN_USE()
}

await product.delete()
}
}
65 changes: 65 additions & 0 deletions app/validators/product_validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import vine from '@vinejs/vine'

/**
* POST /api/v1/products
*
* Mirrors the `products` table from the README's Data Model section:
* `category_id BIGINT FK -> categories.id, NOT NULL`, `product_name
* VARCHAR(255) NOT NULL`, `unit_price FLOAT NOT NULL, > 0`. `positive()`
* on unitPrice enforces the same "> 0" constraint the migration already
* enforces at the database level (`products_unit_price_positive` check),
* so a bad value is rejected with a clean 422 before ever reaching the DB.
* `categoryId` is only checked for shape/type here; whether that id
* actually refers to an existing category is a FK existence check that
* belongs in ProductsService.create/update, not in this schema.
*/
export const createProductSchema = vine.compile(
vine.object({
categoryId: vine.number().positive(),
productName: vine.string().trim().minLength(1).maxLength(255),
unitPrice: vine.number().positive(),
})
)

/**
* PUT /api/v1/products/:id
*
* Same fields as createProductSchema, all optional for partial update.
*/
export const updateProductSchema = vine.compile(
vine.object({
categoryId: vine.number().positive().optional(),
productName: vine.string().trim().minLength(1).maxLength(255).optional(),
unitPrice: vine.number().positive().optional(),
})
)

/**
* GET /api/v1/products query params.
*
* NOTE: sortBy allowlist. The README's Endpoints table says "sort by
* productName/unitPrice" and the Key AdonisJS patterns code sample passes
* `filter.sortBy` straight into `query.orderBy(filter.sortBy, ...)`
* without specifying how that value is restricted. A bare
* `vine.string().optional()` would let a client pass any string through
* to `.orderBy()`, including a column that does not exist (an unhandled
* DB error) or one that does but was never meant to be sortable. vine.enum
* closes that off at the validation layer, the same allowlist role
* router.matchers.number() plays for the :id param convention documented
* in start/routes.ts. 'productName' and 'unitPrice' (not 'product_name' /
* 'unit_price') are used deliberately: they are the camelCase model
* attribute names, and Lucid's ModelQueryBuilder resolves a known
* attribute name to its snake_case column automatically wherever a raw
* where()/orderBy() key is a directly a Lucid model attribute name,
* exactly the same way RolesService already relies on for
* `.where('roleName', ...)`. Keeping the query param camelCase also keeps
* the whole public API surface (request and response bodies alike)
* consistently camelCase.
*/
export const productFilterSchema = vine.compile(
vine.object({
categoryId: vine.number().positive().optional(),
sortBy: vine.enum(['productName', 'unitPrice'] as const).optional(),
order: vine.enum(['asc', 'desc'] as const).optional(),
})
)
28 changes: 28 additions & 0 deletions start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const UsersController = () => import('#controllers/users_controller')
const RolesController = () => import('#controllers/roles_controller')
const PermissionsController = () => import('#controllers/permissions_controller')
const CategoriesController = () => import('#controllers/categories_controller')
const ProductsController = () => import('#controllers/products_controller')

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -180,3 +181,30 @@ router
router
.delete('/api/v1/categories/:id', [CategoriesController, 'destroy'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])

/*
|--------------------------------------------------------------------------
| Products
|--------------------------------------------------------------------------
|
| The list and detail routes are public, matching the README's Endpoints
| table for this branch. The three mutation routes are ADMIN only, applied
| individually at route declaration time with
| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]), the
| same pattern the categories routes above use. The :id param here is
| already covered by the router.where('id', router.matchers.number())
| matcher declared at the top of this file, so a non-numeric product id
| 404s at the routing layer without any extra registration.
|
*/
router.get('/api/v1/products', [ProductsController, 'index'])
router.get('/api/v1/products/:id', [ProductsController, 'show'])
router
.post('/api/v1/products', [ProductsController, 'store'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router
.put('/api/v1/products/:id', [ProductsController, 'update'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router
.delete('/api/v1/products/:id', [ProductsController, 'destroy'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
Loading
Loading