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 @@ -6,6 +6,7 @@
export const controllers = {
Auth: () => import('#controllers/auth_controller'),
Categories: () => import('#controllers/categories_controller'),
Customers: () => import('#controllers/customers_controller'),
Health: () => import('#controllers/health_controller'),
Permissions: () => import('#controllers/permissions_controller'),
Products: () => import('#controllers/products_controller'),
Expand Down
108 changes: 108 additions & 0 deletions app/controllers/customers_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

import { respond } from '#helpers/api_response'
import CustomersService from '#services/customers_service'
import {
createCustomerSchema,
updateCustomerSchema,
customerSearchSchema,
} from '#validators/customer_validator'
import { paginationSchema } from '#validators/pagination_validator'

/**
* Thin HTTP layer over CustomersService. Unlike CategoriesController and
* ProductsController, every route here requires authentication: index and
* show 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 CustomersController {
constructor(private customersService: CustomersService) {}

/**
* @index
* @summary List customers
* @description Returns a paginated list of customers, optionally filtered by ?search= matching first name, last name or email. Requires authentication.
* @paramQuery page - Page number - @type(number)
* @paramQuery limit - Items per page, capped at 100 - @type(number)
* @paramQuery search - Case-insensitive match against first name, last name or email - @type(string)
* @responseBody 200 - <Customer[]>.paginated()
* @responseBody 401 - Missing or invalid token
*/
async index({ request, response }: HttpContext) {
// NOTE: same reasoning as ProductsController.index, pagination and
// search are independently reusable concerns validated with two
// separate request.validateUsing() calls against the same query string.
const { page, limit } = await request.validateUsing(paginationSchema)
const { search } = await request.validateUsing(customerSearchSchema)
const result = await this.customersService.findAll(search, page ?? 1, limit ?? 10)
return response.ok(respond(result))
}

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

/**
* @store
* @summary Create a customer
* @description Creates a new customer. ADMIN only. Fails with 409 when the email is already registered to another customer.
* @requestBody <createCustomerSchema>
* @responseBody 201 - <Customer>
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 409 - Email is already registered to another customer
*/
async store({ request, response }: HttpContext) {
const data = await request.validateUsing(createCustomerSchema)
const customer = await this.customersService.create(data)
return response.created(respond(customer, 'Customer created'))
}

/**
* @update
* @summary Update a customer
* @description Partially updates an existing customer. ADMIN only. Fails with 409 when the new email is already registered to another customer.
* @paramPath id - The customer id - @type(number) @required
* @requestBody <updateCustomerSchema>
* @responseBody 200 - <Customer>
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Customer not found
* @responseBody 409 - Email is already registered to another customer
*/
async update({ params, request, response }: HttpContext) {
const data = await request.validateUsing(updateCustomerSchema)
const customer = await this.customersService.update(Number(params.id), data)
return response.ok(respond(customer, 'Customer updated'))
}

/**
* @destroy
* @summary Delete a customer
* @description Deletes a customer. ADMIN only. Fails with 409 when the customer still has orders associated with it.
* @paramPath id - The customer id - @type(number) @required
* @responseBody 200 - Customer deleted
* @responseBody 401 - Missing or invalid token
* @responseBody 403 - Caller is not an ADMIN
* @responseBody 404 - Customer not found
* @responseBody 409 - Customer still has orders associated with it
*/
async destroy({ params, response }: HttpContext) {
await this.customersService.remove(Number(params.id))
return response.ok(respond(null, 'Customer deleted'))
}
}
134 changes: 134 additions & 0 deletions app/services/customers_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { createError } from '@adonisjs/core/exceptions'
import type { Infer } from '@vinejs/vine/types'

import Customer from '#models/customer'
import { toPageResponse, type PageResponse } from '#helpers/page_response'
import type { createCustomerSchema, updateCustomerSchema } from '#validators/customer_validator'

type CreateCustomerPayload = Infer<typeof createCustomerSchema>
type UpdateCustomerPayload = Infer<typeof updateCustomerSchema>

const E_EMAIL_TAKEN = createError(
'Email is already registered to another customer',
'E_EMAIL_TAKEN',
409
)

const E_CUSTOMER_HAS_ORDERS = createError(
'Customer still has orders associated with it',
'E_CUSTOMER_HAS_ORDERS',
409
)

/**
* Owns every read and write to the `customers` table. No constructor
* dependencies, so no @inject(), matching CategoriesService and
* ProductsService's own reasoning for the same omission. Like
* ProductsService, the README does not ask for caching on the customers
* list, so findAll below queries straight through to the database on every
* call.
*/
export default class CustomersService {
/**
* Builds the search query per the README's own "ILIKE search in the
* service" code sample: when `search` is present, it is matched
* case-insensitively against firstName, lastName and email with a single
* `where((q) => ...)` group so the ORs stay scoped to that one condition
* and do not leak into any filter added later.
*/
async findAll(
search: string | undefined,
page: number,
limit: number
): Promise<PageResponse<Customer>> {
const query = Customer.query()

if (search) {
query.where((q) => {
q.whereILike('first_name', `%${search}%`)
.orWhereILike('last_name', `%${search}%`)
.orWhereILike('email', `%${search}%`)
})
}

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

async findOne(id: number): Promise<Customer> {
return Customer.findOrFail(id)
}

/**
* Enforces email uniqueness at the service layer before writing the row,
* the same reasoning UsersService.update documents for its own
* E_EMAIL_TAKEN check: the database's own unique index on
* customers.email would already reject a duplicate, but only as a raw
* constraint violation with no clean .status a controller could act on.
*
* The email is lowercased before both the uniqueness check and the write,
* matching AuthService and UsersService's own normalization. Without it,
* Jane@Example.com and jane@example.com would pass this check as distinct
* values and register as two customers, defeating the "email uniqueness
* enforcement" this branch is specifically asked to provide, since the
* database's own unique index is exact-string and equally case-sensitive.
*/
async create(data: CreateCustomerPayload): Promise<Customer> {
const email = data.email.toLowerCase()
const existing = await Customer.query().where('email', email).first()
if (existing) {
throw new E_EMAIL_TAKEN()
}

return Customer.create({ ...data, email })
}

/**
* Only re-checks email uniqueness when `email` is present in the payload
* and actually differs from the customer's current value, the exact
* `if (data.email && data.email !== existing.email) { ... }` idiom
* UsersService.update uses for the same problem: updating a customer
* without touching their email (for example just their address) should
* never trip a false conflict against the customer's own row. Lowercased
* for the same reason create() lowercases it.
*/
async update(id: number, data: UpdateCustomerPayload): Promise<Customer> {
const customer = await Customer.findOrFail(id)

if (data.email) {
const email = data.email.toLowerCase()
if (email !== customer.email) {
const existing = await Customer.query().where('email', email).whereNot('id', id).first()
if (existing) {
throw new E_EMAIL_TAKEN()
}
}
data = { ...data, email }
}

customer.merge(data)
await customer.save()
return customer
}

/**
* Business rule from the README: a customer with at least one order
* still associated with it cannot be deleted, 409 instead. Checked with
* the same `related('orders').query().select('id').first()` idiom
* ProductsService.remove uses for its own "still in use" guard, made
* possible by the `orders` hasMany already declared on the Customer
* model. This also doubles as the guard that keeps the orders table's
* own `customer_id` FK from ever actually cascading away order history
* through this service.
*/
async remove(id: number): Promise<void> {
const customer = await Customer.findOrFail(id)

const stillHasOrders = await customer.related('orders').query().select('id').first()
if (stillHasOrders) {
throw new E_CUSTOMER_HAS_ORDERS()
}

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

/**
* Telephone format rule shared by create and update.
*
* The README's Tasks checklist asks for `telephone: vine.string().regex(...)`
* without naming the exact pattern, so this project settles on a permissive
* international-friendly shape: an optional leading `+`, then digits,
* spaces, hyphens, dots or parentheses, 7 to 20 characters long. This
* accepts common formats like `+1 (555) 010-0100` or `555-0100` without
* trying to fully validate a specific country's numbering plan, which is a
* much larger problem than this branch needs to solve.
*
* NOTE: this decision (the exact telephone pattern) is not spelled out in
* the README and is left to this validator's own judgment, the same way
* auth_validator.ts documents its own choice of password complexity rule.
*/
const telephonePattern = /^\+?[0-9()\-.\s]{7,20}$/

/**
* POST /api/v1/customers
*
* Field lengths mirror the `customers` table constraints from the README's
* Column Details table: first_name VARCHAR(255), last_name VARCHAR(255),
* telephone VARCHAR(50), email VARCHAR(255), address VARCHAR(255). Email
* uniqueness itself is enforced in CustomersService, not here, since a
* schema has no way to query the database.
*/
export const createCustomerSchema = vine.compile(
vine.object({
firstName: vine.string().trim().minLength(1).maxLength(255),
lastName: vine.string().trim().minLength(1).maxLength(255),
telephone: vine.string().trim().regex(telephonePattern).maxLength(50),
email: vine.string().trim().maxLength(255).email(),
address: vine.string().trim().minLength(1).maxLength(255),
})
)

/**
* PUT /api/v1/customers/:id
*
* Same fields as createCustomerSchema, all optional for partial update.
*/
export const updateCustomerSchema = vine.compile(
vine.object({
firstName: vine.string().trim().minLength(1).maxLength(255).optional(),
lastName: vine.string().trim().minLength(1).maxLength(255).optional(),
telephone: vine.string().trim().regex(telephonePattern).maxLength(50).optional(),
email: vine.string().trim().maxLength(255).email().optional(),
address: vine.string().trim().minLength(1).maxLength(255).optional(),
})
)

/**
* GET /api/v1/customers query params.
*
* NOTE: the README's own "ILIKE search in the service" code sample takes
* `search: string | undefined` straight into CustomersService.findAll, but
* does not say whether the controller reads it with its own compiled schema
* or a bare `request.input('search')`. Per this project's hard rule against
* inline VineJS schemas in controllers, `search` still gets a schema of its
* own here rather than being read directly, kept optional and unbounded in
* content (a search term is not a stored column, there is no natural max
* length to mirror), the same way paginationSchema is a small dedicated
* schema shared by the list endpoint.
*/
export const customerSearchSchema = vine.compile(
vine.object({
search: vine.string().trim().optional(),
})
)
30 changes: 30 additions & 0 deletions start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const RolesController = () => import('#controllers/roles_controller')
const PermissionsController = () => import('#controllers/permissions_controller')
const CategoriesController = () => import('#controllers/categories_controller')
const ProductsController = () => import('#controllers/products_controller')
const CustomersController = () => import('#controllers/customers_controller')

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -208,3 +209,32 @@ router
router
.delete('/api/v1/products/:id', [ProductsController, 'destroy'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])

/*
|--------------------------------------------------------------------------
| Customers
|--------------------------------------------------------------------------
|
| Unlike categories and products, every customers route requires
| authentication, matching the README's Endpoints table and Authorization
| Rules for this branch ("customers, orders -> Authenticated" for GET,
| "ADMIN role only" for mutations). The list and detail routes are declared
| with .use(middleware.auth()) alone; the three mutation routes add
| middleware.role({ roles: ['ADMIN'] }) on top, the same
| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) pattern
| the categories and products mutation routes above already use. The :id
| param here is already covered by the router.where('id',
| router.matchers.number()) matcher declared at the top of this file.
|
*/
router.get('/api/v1/customers', [CustomersController, 'index']).use(middleware.auth())
router.get('/api/v1/customers/:id', [CustomersController, 'show']).use(middleware.auth())
router
.post('/api/v1/customers', [CustomersController, 'store'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router
.put('/api/v1/customers/:id', [CustomersController, 'update'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router
.delete('/api/v1/customers/:id', [CustomersController, 'destroy'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
Loading
Loading