Skip to content
Merged
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
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"decimal.js": "^10.6.0",
"express": "^4.21.1",
"express-rate-limit": "^7.5.0",
"helmet": "^7.2.0",
Expand Down
31 changes: 30 additions & 1 deletion src/components/v1/distribution/distribution.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Request, Response } from "express"
import AppDataSource from "../../../config/persistence/data-source"
import { DistributionEntity } from "./distribution.entity"
import { DistributionService } from "./distribution.service"
import type { ApiResponse, DistributionResponseDto, CreateDistributionDto } from "./distribution.dto"
import type { ApiResponse, DistributionResponseDto, CreateDistributionDto, UpdateDistributionDto } from "./distribution.dto"

const getDistributionService = () => {
if (!AppDataSource.isInitialized) {
Expand Down Expand Up @@ -41,6 +41,35 @@ export const createDistribution = async (req: Request, res: Response): Promise<v
}
}

export const updateDistribution = async (req: Request, res: Response): Promise<void> => {
try {
const distributionService = getDistributionService()
const { id } = req.params
const validatedData = req.body as UpdateDistributionDto

const distribution = await distributionService.updateDistribution(id, validatedData)

const response: ApiResponse<DistributionResponseDto> = {
data: distribution,
success: true,
message: "Distribution updated successfully",
}

res.status(200).json(response)
} catch (error) {
console.error("Error in updateDistribution:", error)

const isNotFound = error instanceof Error && error.message === "Distribution not found"
const status = isNotFound ? 404 : 500
const errorResponse: ApiResponse<null> = {
data: null,
success: false,
message: isNotFound && error instanceof Error ? error.message : "Internal server error",
}
res.status(status).json(errorResponse)
Comment thread
DioChuks marked this conversation as resolved.
}
}

export const listDistributions = async (_req: Request, res: Response): Promise<void> => {
try {
const distributionService = getDistributionService()
Expand Down
3 changes: 2 additions & 1 deletion src/components/v1/distribution/distribution.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { DistributionType, Network } from "../../../types/enums"
import type { CreateDistributionInput } from "./distribution.validation"
import type { CreateDistributionInput, UpdateDistributionInput } from "./distribution.validation"

export type CreateDistributionDto = CreateDistributionInput
export type UpdateDistributionDto = UpdateDistributionInput

export interface DistributionResponseDto {
id: string
Expand Down
51 changes: 46 additions & 5 deletions src/components/v1/distribution/distribution.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Decimal } from "decimal.js"
import type { Repository } from "typeorm"
import type { DistributionEntity } from "./distribution.entity"
import type { CreateDistributionDto, DistributionResponseDto } from "./distribution.dto"
import type { CreateDistributionDto, DistributionResponseDto, UpdateDistributionDto } from "./distribution.dto"
import { DistributionStatus, Network } from "../../../types/enums"

export class DistributionService {
Expand All @@ -20,6 +21,47 @@ export class DistributionService {
}
}

async updateDistribution(id: string, updateData: UpdateDistributionDto): Promise<DistributionResponseDto> {
try {
const distribution = await this.distributionRepository.findOne({ where: { id } })
if (!distribution) {
throw new Error("Distribution not found")
}

const updatedFields: Partial<DistributionEntity> = { ...updateData }

Comment on lines +24 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent lost updates in concurrent PATCH requests.

Line 26 + Line 55-Line 56 currently do a read-modify-write without optimistic/pessimistic concurrency control. Two overlapping updates to the same distribution can overwrite each other unintentionally.

Consider protecting this flow with row locking in a transaction or optimistic versioning (@VersionColumn) so concurrent PATCH calls cannot silently clobber fields.

Also applies to: 55-57

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/v1/distribution/distribution.service.ts` around lines 24 - 32,
The updateDistribution flow does an unprotected read-modify-write which can
cause lost updates; fix by either (A) using a database transaction and obtaining
a row lock when reading the entity (e.g., in updateDistribution call use a
transactional EntityManager and distributionRepository.findOne with a
pessimistic write lock) before applying updateData and saving, or (B) add
optimistic concurrency control to DistributionEntity (add a `@VersionColumn`) and
rely on optimistic locking during repository.save to surface conflicts so
callers can retry; update updateDistribution to use one of these approaches
(reference updateDistribution, distributionRepository.findOne,
DistributionEntity, UpdateDistributionDto).

if (updateData.userAddress !== undefined) {
updatedFields.userAddress = updateData.userAddress.toLowerCase()
}
if (updateData.tokenAddress !== undefined) {
updatedFields.tokenAddress = updateData.tokenAddress.toLowerCase()
}
if (updateData.tokenSymbol !== undefined) {
updatedFields.tokenSymbol = updateData.tokenSymbol.toUpperCase()
}

if (updateData.totalAmount || updateData.usdRate) {
const totalAmount = updateData.totalAmount ?? distribution.totalAmount
const usdRate = updateData.usdRate ?? distribution.usdRate
updatedFields.totalUsdAmount = this.calculateTotalUsdAmount(totalAmount, usdRate)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (updateData.metadata === null) {
updatedFields.metadata = null
} else if (updateData.metadata !== undefined) {
updatedFields.metadata = this.processMetadata(updateData.metadata)
}

Object.assign(distribution, updatedFields)
const savedDistribution = await this.distributionRepository.save(distribution)
Comment thread
DioChuks marked this conversation as resolved.

return this.formatDistributionResponse(savedDistribution)
} catch (error) {
console.error("Error updating distribution:", error)
throw error instanceof Error ? error : new Error("Failed to update distribution")
}
}

async listDistributions(limit = 50): Promise<DistributionResponseDto[]> {
try {
const distributions = await this.distributionRepository.find({
Expand Down Expand Up @@ -69,10 +111,9 @@ export class DistributionService {

private calculateTotalUsdAmount(totalAmount: string, usdRate: string): string {
try {
const amount = Number.parseFloat(totalAmount)
const rate = Number.parseFloat(usdRate)
const totalUsd = amount * rate
return totalUsd.toString()
const amount = new Decimal(totalAmount)
const rate = new Decimal(usdRate)
return amount.mul(rate).toString()
} catch (error) {
console.warn("Error calculating total USD amount:", error)
return "0"
Expand Down
15 changes: 13 additions & 2 deletions src/components/v1/distribution/distribution.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from "zod"
import { DistributionType, Network } from "../../../types/enums"
import { DistributionType, DistributionStatus, Network } from "../../../types/enums"

export class ValidationError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -46,7 +46,18 @@ export const createDistributionSchema = z.object({
})
.optional(),

metadata: z.record(z.any()).optional(),
metadata: z.record(z.any()).nullable().optional(),
})

export const updateDistributionSchema = createDistributionSchema.partial().extend({
status: z.nativeEnum(DistributionStatus, {
errorMap: () => ({ message: "status must be a valid DistributionStatus" }),
}).optional(),
})

export const updateDistributionParamsSchema = z.object({
id: z.string().uuid("id must be a valid UUID"),
})

export type CreateDistributionInput = z.infer<typeof createDistributionSchema>
export type UpdateDistributionInput = z.infer<typeof updateDistributionSchema>
13 changes: 12 additions & 1 deletion src/components/v1/distribution/distrubtion.routes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import EnhancedRouter from "../../../utils/enhancedRouter"
import policyMiddleware from "../../../appMiddlewares/policy.middleware"
import { createDistributionSchema } from "./distribution.validation"
import {
createDistributionSchema,
updateDistributionSchema,
updateDistributionParamsSchema,
} from "./distribution.validation"
import {
createDistribution,
updateDistribution,
listDistributions,
} from "./distribution.controller"

const distributionRouter = new EnhancedRouter()

distributionRouter.get("/", listDistributions)
distributionRouter.post("/", policyMiddleware(createDistributionSchema), createDistribution)
distributionRouter.patch(
"/:id",
policyMiddleware(updateDistributionParamsSchema, "params"),
policyMiddleware(updateDistributionSchema),
updateDistribution,
)

export default distributionRouter.getRouter()