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
76 changes: 76 additions & 0 deletions src/controllers/meal.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
Body,
Controller,
Get,
Post,
Patch,
Delete,
Req,
Param,
Query,
} from '@nestjs/common'
import { AjkTownApiVersion } from './index.interface'
import { Request } from 'express'
import { JwtService } from '@nestjs/jwt'
import { MealService } from '@/services/meal.service'
import { AccessTokenDomain } from '@/domains/auth/access-token.domain'
import { PostMealBodyDTO } from '@/dto/post-meal.dto'
import { PatchMealBodyDTO } from '@/dto/patch-meal.dto'
import { GetMealQueryDTO } from '@/dto/get-meal-query.dto'

export enum MealControllerPath {
PostMeal = `meals`,
GetAllMeals = `meals`,
GetMeal = `meals/:id`,
PatchMeal = `meals/:id`,
DeleteMeal = `meals/:id`,
}

@Controller(AjkTownApiVersion.V1)
export class MealController {
constructor(
private readonly jwtService: JwtService,
private readonly mealService: MealService,
) {}

@Post(MealControllerPath.PostMeal)
async postMeal(@Req() req: Request, @Body() body: PostMealBodyDTO) {
const atd = await AccessTokenDomain.fromReq(req, this.jwtService)
return (await this.mealService.create(atd, body)).toResDTO()
}

@Get(MealControllerPath.GetAllMeals)
async getAllMeals(@Req() req: Request, @Query() query: GetMealQueryDTO) {
const atd = await AccessTokenDomain.fromReq(req, this.jwtService)
const domains = await this.mealService.findAll(atd, query)
return {
meals: domains.map((domain) => domain.toResDTO()),
}
}

@Get(MealControllerPath.GetMeal)
async getMeal(@Req() req: Request, @Param('id') id: string) {
const atd = await AccessTokenDomain.fromReq(req, this.jwtService)
return (await this.mealService.findOne(atd, id)).toResDTO()
}

@Patch(MealControllerPath.PatchMeal)
async patchMeal(
@Req() req: Request,
@Param('id') id: string,
@Body() body: PatchMealBodyDTO,
) {
const atd = await AccessTokenDomain.fromReq(req, this.jwtService)
return (await this.mealService.patch(atd, id, body)).toResDTO()
}

@Delete(MealControllerPath.DeleteMeal)
async deleteMeal(
@Req() req: Request,
@Param('id') id: string,
): Promise<void> {
const atd = await AccessTokenDomain.fromReq(req, this.jwtService)
await this.mealService.remove(atd, id)
// No return value will result in a 204 No Content response
}
}
24 changes: 24 additions & 0 deletions src/domains/meals/index.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { DataBasicsDate } from '@/global.interface'

/**
* @description
* Nested object for nutritional information.
* Can be expanded later with carbs, fat, etc.
*/
export interface IIngredients {
protein: number
// TODO: For now I only include protein
// carbs: number
// fat: number
}

export interface IMeal extends DataBasicsDate {
id: string
ownerId: string
name: string // name of food, e.g., "Chicken Breast"
amount: number // 1
unit: string // e.g., 'g', 'ml', 'serving'
labels: string[] // e.g., 'Breakfast', 'Post-Workout'
kcal: number
ingredients: IIngredients
}
153 changes: 153 additions & 0 deletions src/domains/meals/meal.domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { AccessTokenDomain } from '../auth/access-token.domain'
import { IMeal } from './index.interface'
import { NotExistOrNoPermissionError } from '@/errors/404/not-exist-or-no-permission.error'
import { DeleteForbiddenError } from '@/errors/403/action_forbidden_errors/delete-forbidden.error'
import { UpdateForbiddenError } from '@/errors/403/action_forbidden_errors/update-forbidden.error'
import { MealDoc, MealModel, MealProps } from '@/schemas/meal.schema'
import { PatchMealBodyDTO } from '@/dto/patch-meal.dto'
import { PostMealBodyDTO } from '@/dto/post-meal.dto'
import { GetMealQueryDTO } from '@/dto/get-meal-query.dto'
import { GetMealQueryFactory } from '@/factories/get-meal.factory'

export class MealDomain {
private readonly props: IMeal

private constructor(props: IMeal) {
this.props = props
}

get id() {
return this.props.id
}

/**
* Creates a new meal domain and saves it to the database.
* @returns The created MealDomain instance from the database.
*/
static async create(
atd: AccessTokenDomain,
dto: PostMealBodyDTO,
model: MealModel,
): Promise<MealDomain> {
const meal = new MealDomain({
// A temporary ID for the constructor, will be replaced by the DB one.
id: 'temp-id',
ownerId: atd.userId,
name: dto.name,
amount: dto.amount,
unit: dto.unit,
labels: dto.labels,
kcal: dto.kcal,
ingredients: {
protein: dto.protein,
},
createdAt: new Date(),
updatedAt: new Date(),
})

const newDoc = await meal.toDoc(model).save()
return MealDomain.fromMdb(newDoc)
}

/**
* Fetches a single meal by its ID, ensuring the user has permission.
* @throws NotExistOrNoPermissionError if the meal doesn't exist or the user is not the owner.
*/
static async findById(
id: string,
atd: AccessTokenDomain,
model: MealModel,
): Promise<MealDomain> {
const doc = await model.findById(id).exec()
if (!doc || doc.ownerID !== atd.userId)
throw new NotExistOrNoPermissionError()

return MealDomain.fromMdb(doc)
}

/**
* Fetches all meals owned by the user.
*/
static async findAllByOwner(
atd: AccessTokenDomain,
query: GetMealQueryDTO,
factory: GetMealQueryFactory,
model: MealModel,
): Promise<MealDomain[]> {
const docs = await model.find(factory.getFilter(atd, query)).exec()
return docs.map(MealDomain.fromMdb)
}

/**
* Creates a MealDomain instance from a Mongoose document.
*/
static fromMdb(doc: MealDoc): MealDomain {
return new MealDomain({
id: doc.id,
ownerId: doc.ownerID,
name: doc.name,
amount: doc.amount,
unit: doc.unit,
labels: doc.labels,
kcal: doc.kcal,
ingredients: {
protein: doc.ingredients.protein,
},
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
})
}

/**
* Converts the domain object into a new Mongoose document.
*/
toDoc(mealModel: MealModel): MealDoc {
const mealProps: MealProps = {
ownerID: this.props.ownerId,
name: this.props.name,
amount: this.props.amount,
unit: this.props.unit,
labels: this.props.labels,
kcal: this.props.kcal,
ingredients: this.props.ingredients,
}
return new mealModel(mealProps)
}

/**
* Converts the domain object to a Response DTO.
*/
toResDTO(): IMeal {
return this.props
}

/**
* Updates the meal record in the database.
* @throws UpdateForbiddenError if the user is not the owner.
*/
async update(
atd: AccessTokenDomain,
dto: PatchMealBodyDTO,
model: MealModel,
): Promise<MealDomain> {
if (atd.userId !== this.props.ownerId) {
throw new UpdateForbiddenError(atd, `Meal`)
}

const updatedDoc = await model
.findByIdAndUpdate(this.id, dto, { new: true })
.exec()
return MealDomain.fromMdb(updatedDoc)
}

/**
* Deletes the meal record from the database.
* @throws DeleteForbiddenError if the user is not the owner.
*/
async delete(atd: AccessTokenDomain, model: MealModel): Promise<void> {
if (atd.userId !== this.props.ownerId) {
throw new DeleteForbiddenError(atd, `Meal`)
}
await model.findByIdAndDelete(this.props.id).exec()
}
}
11 changes: 11 additions & 0 deletions src/dto/get-meal-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsOptional, IsDateString } from 'class-validator'

export class GetMealQueryDTO {
@IsDateString()
@IsOptional()
fromDate?: string // Date range filter (e.g., "2025-09-23T00:00:00.000Z")

@IsDateString()
@IsOptional()
toDate?: string // Date range filter (e.g., "2025-09-23T23:59:59.999Z")
}
12 changes: 12 additions & 0 deletions src/dto/index.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,15 @@ export const intoArray = ({ value }: TransformFnParams): string[] => {
export const intoUniqueArray = (params: TransformFnParams): string[] => {
return Array.from(new Set(intoArray(params)))
}

export const intoUniqueNonEmptyTrimmedArray = (
params: TransformFnParams,
): string[] => {
return Array.from(
new Set(
intoArray(params)
.map((item) => item.trim())
.filter((item) => item !== ''),
),
)
}
34 changes: 34 additions & 0 deletions src/dto/patch-meal.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Transform } from 'class-transformer'
import { IsArray, IsNumber, IsOptional, IsString, Min } from 'class-validator'
import { intoArray } from './index.validator'

class PatchMealIngredientsBodyDTO {
@IsNumber()
@IsOptional()
protein?: number
}

export class PatchMealBodyDTO extends PatchMealIngredientsBodyDTO {
@IsString()
@IsOptional()
name?: string

@IsNumber()
@IsOptional()
amount?: number

@IsString()
@IsOptional()
unit?: string

@Transform(intoArray) // from your example
@IsArray()
@IsString({ each: true })
@IsOptional()
labels?: string[]

@IsNumber()
@Min(0)
@IsOptional()
kcal?: number
}
30 changes: 30 additions & 0 deletions src/dto/post-meal.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer'
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'
import { intoNumber, intoUniqueNonEmptyTrimmedArray } from './index.validator'

class PostMealIngredientsBodyDTO {
@Transform(intoNumber)
@IsNumber()
protein: number
}

export class PostMealBodyDTO extends PostMealIngredientsBodyDTO {
@IsString()
name: string

@Transform(intoNumber)
@IsNumber()
amount: number

@IsString()
unit: string

@Transform(intoUniqueNonEmptyTrimmedArray)
@IsOptional()
@IsArray()
labels?: string[]

@Transform(intoNumber)
@IsNumber()
kcal: number
}
Loading