refactor(assets): move workflows into use cases

This commit is contained in:
2026-06-04 22:10:43 +02:00
parent e88fb2e6d4
commit 2b908b24f6
8 changed files with 648 additions and 222 deletions
+93
View File
@@ -0,0 +1,93 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import {
type CreateAssetFormType,
createAssetSchema,
type UpdateAssetFormType,
updateAssetSchema,
} from "@/schemas/asset.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
createAssetUseCase,
updateAssetUseCase,
} from "@/use-cases/asset.use-cases"
export async function createAssetAction(formData: CreateAssetFormType) {
try {
const validatedFields = createAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
const userId = await getAuthenticatedUserId()
const result = await createAssetUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return result
}
revalidatePath("/inventory/assets")
revalidatePath("/inventory/items")
revalidatePath("/assignments")
revalidatePath("/movements")
return {
success: true,
message: "Asset created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error creating asset",
}
}
}
export async function updateAssetAction(formData: UpdateAssetFormType) {
const validatedFields = updateAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await updateAssetUseCase({
...validatedFields.data,
actorId: userId,
})
if (!result.success) {
return result
}
revalidatePath("/inventory/assets")
revalidatePath("/inventory/items")
revalidatePath("/assignments")
revalidatePath("/movements")
return {
success: true,
message: "Asset updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error updating asset",
}
}
}
@@ -1,9 +1,9 @@
"use server"
import type { AssetWithAssignment } from "@/lib/types"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import type { AssetWithAssignment } from "@/types"
import EditAssetForm from "../../_components/edit.asset.form"
@@ -4,20 +4,19 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { updateAssetAction } from "@/lib/actions/asset.actions"
import { ITEM_STATUS } from "@/lib/constants"
import {
type UpdateAssetFormType,
updateAssetSchema,
} from "@/lib/schemas/asset.schemas"
} from "@/schemas/asset.schema"
import type {
AssetWithAssignment,
Item,
Recipient,
UpdateAssetStatus,
} from "@/lib/types"
} from "@/types"
interface EditAssetFormProps {
asset: AssetWithAssignment
@@ -42,11 +41,11 @@ export default function EditAssetForm({
resolver: zodResolver(updateAssetSchema),
defaultValues: {
id: asset.id,
itemId: asset.itemId ?? "",
itemId: asset.itemId ?? undefined,
serialNumber: asset.serialNumber,
deliveryNote: asset.deliveryNote ?? "",
deliveryNote: asset.deliveryNote ?? undefined,
status: asset.status as UpdateAssetStatus,
recipientId: asset.assignment?.recipientId ?? "",
recipientId: asset.assignment?.recipientId ?? undefined,
},
shouldFocusError: true,
mode: "onSubmit",
@@ -138,7 +137,7 @@ export default function EditAssetForm({
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
{Object.values(ITEM_STATUS).map((status) => (
<option key={status} value={status}>
{status}
</option>
@@ -4,15 +4,14 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ItemStatus } from "@/generated/prisma/client"
import { createAssetAction } from "@/lib/actions/asset.actions"
import { ITEM_STATUS } from "@/lib/constants"
import {
type CreateAssetFormType,
createAssetSchema,
} from "@/lib/schemas/asset.schemas"
import type { ItemWithoutStock, Recipient } from "@/lib/types"
} from "@/schemas/asset.schema"
import type { ItemWithoutStock, Recipient } from "@/types"
interface NewAssetFormProps {
items: ItemWithoutStock[]
@@ -123,7 +122,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ItemStatus).map((status) => (
{Object.values(ITEM_STATUS).map((status) => (
<option key={status} value={status}>
{status}
</option>
-198
View File
@@ -1,198 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import {
createAssignment,
updateAssignment,
} from "@/lib/actions/assignament.actions"
import {
type CreateAssetFormType,
createAssetSchema,
type UpdateAssetFormType,
updateAssetSchema,
} from "@/lib/schemas/asset.schemas"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
export async function createAssetAction(formData: CreateAssetFormType) {
try {
const validatedFields = createAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { itemId, serialNumber, deliveryNote, status, notes, recipientId } =
validatedFields.data
const item = await ItemService.findByIdWithCategory(itemId)
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
const existentAsset = await AssetService.findBySerialNumber(serialNumber)
if (existentAsset) {
return {
success: false,
errors: {
serialNumber: ["This serial number already exists"],
},
}
}
const newAsset = await AssetService.create({
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
})
await MovementService.create({
itemId,
assetId: newAsset?.id,
quantity: 1,
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
})
if (status === "AVAILABLE") {
await ItemService.update(itemId, {
stock: item.stock + 1,
name: item.name,
category: { connect: { id: item.category?.id } },
})
}
if (status === "ASSIGNED" && recipientId) {
await AssignmentService.create({
notes: "",
itemId,
assetId: newAsset?.id,
quantity: 1,
recipientId,
assignmentDate: new Date(),
})
}
revalidatePath("/inventory/assets")
return {
success: true,
message: "Asset created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error creating asset",
}
}
}
export async function updateAssetAction(formData: UpdateAssetFormType) {
const validatedFields = updateAssetSchema.safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { id, itemId, serialNumber, deliveryNote, status, notes, recipientId } =
validatedFields.data
try {
const item = await ItemService.findByIdWithCategory(itemId)
if (!item) {
return {
success: false,
errors: { itemId: ["Item not found"] },
}
}
const existentAsset = await AssetService.findBySerialNumber(serialNumber)
if (
existentAsset &&
id !== existentAsset.id &&
existentAsset.serialNumber === serialNumber
) {
return {
success: false,
errors: {
serialNumber: ["This serial number already exists"],
},
}
}
await AssetService.update(id, {
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
})
await MovementService.create({
itemId,
assetId: id,
quantity: 1,
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
})
if (status === "AVAILABLE") {
await ItemService.update(itemId, {
stock: item.stock + 1,
name: item.name,
category: { connect: { id: item.category?.id } },
})
}
if (status === "ASSIGNED" && recipientId) {
if (!recipientId) {
return {
success: false,
errors: { recipientId: ["Recipient is required for assignment"] },
}
}
if (existentAsset?.assignment) {
await updateAssignment({
id: existentAsset.assignment.id,
recipientId,
quantity: 1,
})
} else {
await createAssignment({
itemId,
assetId: id,
quantity: 1,
recipientId,
assignmentDate: new Date(),
})
}
}
return {
success: true,
message: "Asset updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error updating asset",
}
}
}
+11 -1
View File
@@ -3,7 +3,17 @@ const isDemo = process.env.DEMO_MODE === "true"
export const ENVIRONMENT = isDemo
? "demo"
: process.env.NODE_ENV || "development"
export const SIGN_IN_URL = "/login"
export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour
export const ITEM_STATUS = {
AVAILABLE: "AVAILABLE",
ASSIGNED: "ASSIGNED",
RESERVED: "RESERVED",
IN_REPAIR: "IN_REPAIR",
BROKEN: "BROKEN",
STOLEN: "STOLEN",
DISPOSED: "DISPOSED",
} as const
@@ -3,11 +3,11 @@ import { z } from "zod"
export const assetSchema = z.object({
id: z.string().optional(),
itemId: z.string().min(1, {
error: "Item is required"
}),
error: "Item is required",
}),
serialNumber: z.string().min(1, {
error: "Serial number is required"
}),
error: "Serial number is required",
}),
deliveryNote: z.string().optional(),
notes: z.string().optional(),
recipientId: z.string().optional(),
@@ -21,9 +21,17 @@ export type CreateAssetFormType = z.infer<typeof createAssetSchema>
export const updateAssetSchema = assetSchema.extend({
id: z.string().min(1, {
error: "ID is required"
}),
status: z.enum(["AVAILABLE", "ASSIGNED", "RESERVED", "IN_REPAIR"]),
error: "ID is required",
}),
status: z.enum([
"AVAILABLE",
"ASSIGNED",
"RESERVED",
"IN_REPAIR",
"BROKEN",
"STOLEN",
"DISPOSED",
]),
})
export type UpdateAssetFormType = z.infer<typeof updateAssetSchema>
+515
View File
@@ -0,0 +1,515 @@
import {
type Assignment,
type ItemStatus,
Prisma,
} from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type {
CreateAssetFormType,
UpdateAssetFormType,
} from "@/schemas/asset.schema"
import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
type FieldErrors = Record<string, string[]>
type CreateAssetUseCaseInput = CreateAssetFormType & {
actorId: string
}
type UpdateAssetUseCaseInput = UpdateAssetFormType & {
actorId: string
}
type CreateAssetUseCaseResult =
| {
success: true
assetId: string
}
| {
success: false
errors: FieldErrors
}
type UpdateAssetUseCaseResult =
| {
success: true
}
| {
success: false
errors: FieldErrors
}
function createAssetError(errors: FieldErrors): CreateAssetUseCaseResult {
return {
success: false,
errors,
}
}
function updateAssetError(errors: FieldErrors): UpdateAssetUseCaseResult {
return {
success: false,
errors,
}
}
class AssetTransitionError extends Error {
constructor(readonly errors: FieldErrors) {
super("Asset transition failed")
}
}
type AssetTransitionInput = {
previousStatus: ItemStatus
nextStatus: ItemStatus
previousItemId: string | null
nextItemId: string
activeAssignment: Assignment | null
nextRecipientId?: string
}
function getAssetTransition({
previousStatus,
nextStatus,
previousItemId,
nextItemId,
activeAssignment,
nextRecipientId,
}: AssetTransitionInput) {
return {
previousStatus,
nextStatus,
previousItemId,
nextItemId,
statusChanged: previousStatus !== nextStatus,
itemChanged: previousItemId !== nextItemId,
activeAssignment,
nextRecipientId,
hasRecipient: Boolean(nextRecipientId),
wasAssigned: previousStatus === "ASSIGNED",
willBeAssigned: nextStatus === "ASSIGNED",
wasAvailable: previousStatus === "AVAILABLE",
willBeAvailable: nextStatus === "AVAILABLE",
recipientChanged: activeAssignment?.recipientId !== nextRecipientId,
}
}
export async function createAssetUseCase(
input: CreateAssetUseCaseInput,
): Promise<CreateAssetUseCaseResult> {
const {
actorId,
itemId,
serialNumber,
deliveryNote,
status,
notes,
recipientId,
} = input
try {
return await prisma.$transaction(async (tx) => {
const item = await ItemService.findByIdWithCategory(itemId, tx)
if (!item) {
return createAssetError({ itemId: ["Item not found"] })
}
const existentAsset = await AssetService.findBySerialNumber(
serialNumber,
tx,
)
if (existentAsset) {
return createAssetError({
serialNumber: ["This serial number already exists"],
})
}
const newAsset = await AssetService.create(
{
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
},
tx,
)
const createdAssignment =
status === "ASSIGNED" && recipientId
? await AssignmentService.create(
{
notes: "",
itemId,
assetId: newAsset.id,
quantity: 1,
recipientId,
assignmentDate: new Date(),
createdBy: actorId,
},
tx,
)
: null
await MovementService.create(
{
itemId,
assetId: newAsset.id,
quantity: 1,
type: status === "ASSIGNED" ? "ASSIGNMENT" : "IN",
recipientId: createdAssignment?.recipientId || undefined,
assignmentId: createdAssignment?.id,
userId: actorId,
},
tx,
)
if (status === "AVAILABLE") {
await ItemService.updateStock(itemId, 1, tx)
}
return {
success: true,
assetId: newAsset.id,
}
})
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return createAssetError({
serialNumber: ["This serial number already exists"],
})
}
throw error
}
}
export async function updateAssetUseCase(
input: UpdateAssetUseCaseInput,
): Promise<UpdateAssetUseCaseResult> {
const {
actorId,
id,
itemId,
serialNumber,
deliveryNote,
status,
notes,
recipientId,
} = input
try {
return await prisma.$transaction(async (tx) => {
const item = await ItemService.findByIdWithCategory(itemId, tx)
if (!item) {
return updateAssetError({ itemId: ["Item not found"] })
}
const currentAsset = await AssetService.findById(id, tx)
if (!currentAsset) {
return updateAssetError({ id: ["Asset not found"] })
}
const transition = getAssetTransition({
previousStatus: currentAsset.status,
nextStatus: status,
previousItemId: currentAsset.itemId,
nextItemId: itemId,
activeAssignment: currentAsset.assignment,
nextRecipientId: recipientId,
})
const existentAsset = await AssetService.findBySerialNumber(
serialNumber,
tx,
)
if (
existentAsset &&
id !== existentAsset.id &&
existentAsset.serialNumber === serialNumber
) {
return updateAssetError({
serialNumber: ["This serial number already exists"],
})
}
await AssetService.update(
id,
{
item: { connect: { id: itemId } },
serialNumber,
deliveryNote,
status,
notes,
},
tx,
)
let closedActiveAssignment = false
if (transition.activeAssignment && !transition.willBeAssigned) {
const activeAssignment = transition.activeAssignment
const assignmentWasReturned =
await AssignmentService.markReturnedIfActive(activeAssignment.id, tx)
if (!assignmentWasReturned) {
throw new AssetTransitionError({
id: ["Assignment already returned"],
})
}
await MovementService.create(
{
type: "RETURN",
quantity: activeAssignment.quantity || 1,
itemId: transition.willBeAvailable
? transition.nextItemId
: activeAssignment.itemId || undefined,
assetId: activeAssignment.assetId || undefined,
recipientId: activeAssignment.recipientId || undefined,
assignmentId: activeAssignment.id,
userId: actorId,
},
tx,
)
closedActiveAssignment = true
}
const shouldIncrementNextItemStock =
transition.willBeAvailable &&
(!transition.wasAvailable || transition.itemChanged)
const shouldDecrementPreviousItemStock =
transition.wasAvailable &&
(!transition.willBeAvailable || transition.itemChanged)
if (
transition.statusChanged &&
!transition.hasRecipient &&
!closedActiveAssignment
) {
const statusMovementItemId = transition.willBeAssigned
? transition.nextItemId
: shouldDecrementPreviousItemStock &&
!shouldIncrementNextItemStock &&
transition.previousItemId
? transition.previousItemId
: transition.nextItemId
await MovementService.create(
{
itemId: statusMovementItemId,
assetId: id,
quantity: 1,
type: transition.willBeAssigned
? "ASSIGNMENT"
: transition.nextStatus === "AVAILABLE"
? "IN"
: "ADJUSTMENT",
details: `Status changed from ${transition.previousStatus} to ${transition.nextStatus}`,
userId: actorId,
},
tx,
)
}
if (
transition.itemChanged &&
transition.wasAvailable &&
transition.willBeAvailable
) {
if (!transition.previousItemId) {
throw new AssetTransitionError({
itemId: ["Previous item not found for available asset"],
})
}
await MovementService.create(
{
itemId: transition.previousItemId,
assetId: id,
quantity: 1,
type: "OUT",
details: `Asset moved from item ${transition.previousItemId} to ${transition.nextItemId}`,
userId: actorId,
},
tx,
)
await MovementService.create(
{
itemId: transition.nextItemId,
assetId: id,
quantity: 1,
type: "IN",
details: `Asset moved from item ${transition.previousItemId} to ${transition.nextItemId}`,
userId: actorId,
},
tx,
)
}
if (
transition.itemChanged &&
transition.wasAvailable &&
transition.willBeAssigned
) {
if (!transition.previousItemId) {
throw new AssetTransitionError({
itemId: ["Previous item not found for available asset"],
})
}
await MovementService.create(
{
itemId: transition.previousItemId,
assetId: id,
quantity: 1,
type: "OUT",
details: `Asset assigned from item ${transition.previousItemId} to item ${transition.nextItemId}`,
userId: actorId,
},
tx,
)
}
if (shouldIncrementNextItemStock) {
await ItemService.updateStock(transition.nextItemId, 1, tx)
}
if (shouldDecrementPreviousItemStock) {
if (!transition.previousItemId) {
throw new AssetTransitionError({
itemId: ["Previous item not found for available asset"],
})
}
const stockWasDecremented = await ItemService.decrementStockIfAvailable(
transition.previousItemId,
1,
tx,
)
if (!stockWasDecremented) {
throw new AssetTransitionError({
stock: ["Item does not have enough stock"],
})
}
}
if (transition.willBeAssigned && transition.nextRecipientId) {
const activeAssignment = transition.activeAssignment
if (activeAssignment) {
if (transition.recipientChanged) {
await MovementService.create(
{
type: "RETURN",
quantity: activeAssignment.quantity || 1,
itemId: activeAssignment.itemId || undefined,
assetId: activeAssignment.assetId || undefined,
recipientId: activeAssignment.recipientId || undefined,
assignmentId: activeAssignment.id,
userId: actorId,
},
tx,
)
await MovementService.create(
{
type: "ASSIGNMENT",
quantity: 1,
itemId,
assetId: id,
recipientId: transition.nextRecipientId,
assignmentId: activeAssignment.id,
userId: actorId,
},
tx,
)
await AssignmentService.update(
activeAssignment.id,
{
createdBy: actorId,
itemId,
assetId: id,
recipientId: transition.nextRecipientId,
quantity: 1,
returnDate: null,
},
tx,
)
} else {
await AssignmentService.update(
activeAssignment.id,
{
itemId,
assetId: id,
recipientId: transition.nextRecipientId,
quantity: 1,
returnDate: null,
},
tx,
)
}
} else {
const createdAssignment = await AssignmentService.create(
{
itemId,
assetId: id,
quantity: 1,
recipientId: transition.nextRecipientId,
assignmentDate: new Date(),
createdBy: actorId,
},
tx,
)
await MovementService.create(
{
type: "ASSIGNMENT",
quantity: 1,
itemId,
assetId: id,
recipientId: transition.nextRecipientId,
assignmentId: createdAssignment.id,
userId: actorId,
},
tx,
)
}
}
return {
success: true,
}
})
} catch (error) {
if (error instanceof AssetTransitionError) {
return updateAssetError(error.errors)
}
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return updateAssetError({
serialNumber: ["This serial number already exists"],
})
}
throw error
}
}