diff --git a/src/actions/assignment.actions.ts b/src/actions/assignment.actions.ts index 9888461..4b9af46 100644 --- a/src/actions/assignment.actions.ts +++ b/src/actions/assignment.actions.ts @@ -1,12 +1,15 @@ "use server" import { revalidatePath } from "next/cache" +import { flattenError } from "zod" +import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages" +import { getI18n } from "@/i18n/server" import { - assignmentSchema, + buildCreateAssignmentSchema, + buildUpdateAssignmentSchema, type CreateAssignmentFormType, type ReturnAssignmentFormType, type UpdateAssignmentFormType, - updateAssignmentSchema, } from "@/schemas/assignment.schema" import { getAuthenticatedUserId } from "@/services/auth.service" import { @@ -16,52 +19,60 @@ import { } from "@/use-cases/assignment.use-cases" export async function createAssignment(formData: CreateAssignmentFormType) { - const createdBy = await getAuthenticatedUserId() + const { dictionary } = await getI18n() + const copy = dictionary.inventory.assignments - const validatedFields = assignmentSchema.safeParse({ - ...formData, - createdBy, - }) + const validatedFields = buildCreateAssignmentSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { - success: false, - errors: validatedFields.error.flatten().fieldErrors, + errors: flattenError(validatedFields.error).fieldErrors, } } try { + const createdBy = await getAuthenticatedUserId() + const result = await createAssignmentUseCase({ ...validatedFields.data, actorId: createdBy, }) if (!result.success) { - return result + return { + ...result, + errors: localizeAssignmentFieldErrors(result.errors, copy.actions), + } } revalidatePath("/assignments") return { - success: true, - message: "Assignment created successfully", + success: true as const, + message: copy.actions.createSuccess, } } catch (error) { console.error("Database error:", error) return { - success: false, - errors: { error: ["Error creating assignment"] }, + success: false as const, + message: copy.actions.createFailure, } } } export async function updateAssignment(formData: UpdateAssignmentFormType) { - const validatedFields = updateAssignmentSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.assignments + + const validatedFields = buildUpdateAssignmentSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { - success: false, - errors: validatedFields.error.flatten().fieldErrors, + errors: flattenError(validatedFields.error).fieldErrors, } } @@ -74,37 +85,42 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeAssignmentFieldErrors(result.errors, copy.actions), + } } revalidatePath("/assignments") return { - success: true, - message: "Assignment updated successfully", + success: true as const, + message: copy.actions.updateSuccess, } } catch (error) { console.error("Database error:", error) return { - success: false, - errors: { error: ["Error updating assignment"] }, + success: false as const, + message: copy.actions.updateFailure, } } } export async function returnAssignment(formData: ReturnAssignmentFormType) { - const { id } = formData + const { dictionary } = await getI18n() + const copy = dictionary.inventory.assignments const userId = await getAuthenticatedUserId() const result = await returnAssignmentUseCase({ - id, + id: formData.id, actorId: userId, }) if (!result.success) { return { ...result, - message: "Error returning assignment", + errors: localizeAssignmentFieldErrors(result.errors, copy.actions), + message: copy.actions.returnFailure, } } @@ -112,6 +128,6 @@ export async function returnAssignment(formData: ReturnAssignmentFormType) { return { success: true as const, - message: "Assignment returned successfully", + message: copy.actions.returnSuccess, } } diff --git a/src/actions/assignment.messages.ts b/src/actions/assignment.messages.ts new file mode 100644 index 0000000..8687a24 --- /dev/null +++ b/src/actions/assignment.messages.ts @@ -0,0 +1,44 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type AssignmentActionCopy = Dictionary["inventory"]["assignments"]["actions"] + +type FieldErrors = Record + +const assignmentErrorMessageKeys = { + "Item not found": "itemNotFound", + "Item does not have enough stock": "itemInsufficientStock", + "Asset not found": "assetNotFound", + "Asset does not belong to item": "assetItemMismatch", + "Assignment not found": "notFound", + "Assignment already returned": "assignmentAlreadyReturned", + "Invalid assignment data": "invalidData", +} as const satisfies Record + +function isAssignmentErrorMessage( + message: string, +): message is keyof typeof assignmentErrorMessageKeys { + return message in assignmentErrorMessageKeys +} + +function localizeAssignmentMessage( + message: string, + copy: AssignmentActionCopy, +): string { + if (!isAssignmentErrorMessage(message)) return message + + return copy[assignmentErrorMessageKeys[message]] +} + +export function localizeAssignmentFieldErrors( + errors: FieldErrors | undefined, + copy: AssignmentActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeAssignmentMessage(message, copy)), + ]), + ) +} diff --git a/src/app/(dashboard)/assignments/[assignmentId]/edit/page.tsx b/src/app/(dashboard)/assignments/[assignmentId]/edit/page.tsx index 15fc87b..6b0894b 100644 --- a/src/app/(dashboard)/assignments/[assignmentId]/edit/page.tsx +++ b/src/app/(dashboard)/assignments/[assignmentId]/edit/page.tsx @@ -42,6 +42,7 @@ export default async function EditAssignmentPage({ assets={assets} initialData={assignment as UpdateAssignmentFormType} formCopy={copy.form} + schemaCopy={copy.schema} submitButtonCopy={dictionary.common.submitButton} /> diff --git a/src/app/(dashboard)/assignments/_components/edit.assignment.form.tsx b/src/app/(dashboard)/assignments/_components/edit.assignment.form.tsx index b8f38f6..32ac2bf 100644 --- a/src/app/(dashboard)/assignments/_components/edit.assignment.form.tsx +++ b/src/app/(dashboard)/assignments/_components/edit.assignment.form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useRouter } from "next/navigation" +import { useMemo } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { updateAssignment } from "@/actions/assignment.actions" @@ -11,12 +12,13 @@ import { } from "@/components/forms/submitButton" import type { Dictionary } from "@/i18n/dictionaries" import { + buildUpdateAssignmentSchema, type UpdateAssignmentFormType, - updateAssignmentSchema, } from "@/schemas/assignment.schema" import type { Asset, Item, Recipient } from "@/types" type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"] +type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"] interface Props { recipients: Recipient[] @@ -24,6 +26,7 @@ interface Props { assets: Asset[] initialData: UpdateAssignmentFormType formCopy: AssignmentFormCopy + schemaCopy: AssignmentSchemaCopy submitButtonCopy: SubmitButtonCopy } @@ -33,17 +36,23 @@ export default function EditAssignmentForm({ assets, initialData, formCopy, + schemaCopy, submitButtonCopy, }: Props) { const router = useRouter() + const schema = useMemo( + () => buildUpdateAssignmentSchema(schemaCopy), + [schemaCopy], + ) + const { register, handleSubmit, formState: { errors, isSubmitting, isSubmitSuccessful }, watch, } = useForm({ - resolver: zodResolver(updateAssignmentSchema), + resolver: zodResolver(schema), defaultValues: { ...initialData, id: initialData.id || undefined, diff --git a/src/app/(dashboard)/assignments/_components/new.assignment.form.tsx b/src/app/(dashboard)/assignments/_components/new.assignment.form.tsx index da0d5c0..b90a3db 100644 --- a/src/app/(dashboard)/assignments/_components/new.assignment.form.tsx +++ b/src/app/(dashboard)/assignments/_components/new.assignment.form.tsx @@ -12,18 +12,20 @@ import { } from "@/components/forms/submitButton" import type { Dictionary } from "@/i18n/dictionaries" import { + buildCreateAssignmentSchema, type CreateAssignmentFormType, - createAssignmentSchema, } from "@/schemas/assignment.schema" import type { Asset, Item, Recipient } from "@/types" type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"] +type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"] interface Props { recipients: Recipient[] items: Item[] assets: Asset[] formCopy: AssignmentFormCopy + schemaCopy: AssignmentSchemaCopy submitButtonCopy: SubmitButtonCopy } @@ -32,17 +34,23 @@ export default function CreateAssignmentForm({ items, assets, formCopy, + schemaCopy, submitButtonCopy, }: Props) { const router = useRouter() + const schema = useMemo( + () => buildCreateAssignmentSchema(schemaCopy), + [schemaCopy], + ) + const { register, handleSubmit, formState: { errors, isSubmitting, isSubmitSuccessful }, watch, } = useForm({ - resolver: zodResolver(createAssignmentSchema), + resolver: zodResolver(schema), mode: "onSubmit", }) diff --git a/src/app/(dashboard)/assignments/new/page.tsx b/src/app/(dashboard)/assignments/new/page.tsx index 5097a61..0b5cd51 100644 --- a/src/app/(dashboard)/assignments/new/page.tsx +++ b/src/app/(dashboard)/assignments/new/page.tsx @@ -22,6 +22,7 @@ export default async function NewAssignmentPage() { items={items} assets={assets} formCopy={copy.form} + schemaCopy={copy.schema} submitButtonCopy={dictionary.common.submitButton} /> diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 4a3808e..c7b1412 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -294,6 +294,29 @@ export const en = { fallback: { missingValue: "N/A", }, + actions: { + createSuccess: "Assignment created successfully", + createFailure: "Error creating assignment", + updateSuccess: "Assignment updated successfully", + updateFailure: "Error updating assignment", + returnSuccess: "Assignment returned successfully", + returnFailure: "Error returning assignment", + notFound: "Assignment not found", + itemNotFound: "Item not found", + itemInsufficientStock: "Item does not have enough stock", + assetNotFound: "Asset not found", + assetItemMismatch: "Asset does not belong to item", + assignmentAlreadyReturned: "Assignment already returned", + invalidData: "Invalid assignment data", + genericFailure: "Error processing assignment", + }, + schema: { + recipientRequired: "Recipient is required", + itemIdRequired: "Item is required", + quantityMinOne: "Quantity must be at least 1", + assetIdRequired: "Asset ID is required when item ID is provided", + idRequired: "Assignment ID is required", + }, }, recipients: { list: { diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index abe45e6..0e1c819 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -298,6 +298,30 @@ export const es = { fallback: { missingValue: "No disponible", }, + actions: { + createSuccess: "Asignación creada correctamente", + createFailure: "Error al crear la asignación", + updateSuccess: "Asignación actualizada correctamente", + updateFailure: "Error al actualizar la asignación", + returnSuccess: "Asignación devuelta correctamente", + returnFailure: "Error al devolver la asignación", + notFound: "Asignación no encontrada", + itemNotFound: "Artículo no encontrado", + itemInsufficientStock: "El artículo no tiene stock suficiente", + assetNotFound: "Activo no encontrado", + assetItemMismatch: "El activo no pertenece al artículo", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + invalidData: "Datos de asignación inválidos", + genericFailure: "Error al procesar la asignación", + }, + schema: { + recipientRequired: "El destinatario es obligatorio", + itemIdRequired: "El artículo es obligatorio", + quantityMinOne: "La cantidad debe ser al menos 1", + assetIdRequired: + "El activo es obligatorio cuando se especifica el artículo", + idRequired: "El ID de asignación es obligatorio", + }, }, recipients: { list: { diff --git a/src/schemas/assignment.schema.ts b/src/schemas/assignment.schema.ts index 42e53c0..f58d578 100644 --- a/src/schemas/assignment.schema.ts +++ b/src/schemas/assignment.schema.ts @@ -1,52 +1,87 @@ import { z } from "zod" -export const assignmentSchema = z.object({ - id: z.string().optional(), - quantity: z.coerce.number().int().nonnegative().min(1, { - error: "Quantity is required", - }), - notes: z.string().optional(), - itemId: z - .string() - .min(1, { - error: "Item is required", - }) - .optional(), - assetId: z.string().optional(), - recipientId: z.string().min(1, { - error: "Recipient is required", - }), - assignmentDate: z.date().optional(), - returnDate: z.date().optional(), -}) +import type { Dictionary } from "@/i18n/dictionaries" -export const createAssignmentSchema = assignmentSchema.omit({ - id: true, - returnDate: true, -}) +export type AssignmentSchemaCopy = + Dictionary["inventory"]["assignments"]["schema"] + +const defaultAssignmentSchemaCopy: AssignmentSchemaCopy = { + recipientRequired: "Recipient is required", + itemIdRequired: "Item is required", + quantityMinOne: "Quantity must be at least 1", + assetIdRequired: "Asset ID is required when item ID is provided", + idRequired: "Assignment ID is required", +} + +function buildAssignmentBaseSchema(copy: AssignmentSchemaCopy) { + return z.object({ + id: z.string().optional(), + quantity: z.coerce.number().int().nonnegative().min(1, { + error: copy.quantityMinOne, + }), + notes: z.string().optional(), + itemId: z + .string() + .min(1, { + error: copy.itemIdRequired, + }) + .optional(), + assetId: z.string().optional(), + recipientId: z.string().min(1, { + error: copy.recipientRequired, + }), + assignmentDate: z.date().optional(), + returnDate: z.date().optional(), + }) +} + +export const assignmentSchema = buildAssignmentBaseSchema( + defaultAssignmentSchemaCopy, +) + +export function buildCreateAssignmentSchema(copy: AssignmentSchemaCopy) { + return buildAssignmentBaseSchema(copy).omit({ + id: true, + returnDate: true, + }) +} + +export const createAssignmentSchema = buildCreateAssignmentSchema( + defaultAssignmentSchemaCopy, +) export type CreateAssignmentFormType = z.input export type CreateAssignmentData = z.output -export const updateAssignmentSchema = assignmentSchema - .omit({ - returnDate: true, - }) - .superRefine((data, ctx) => { - if (data.itemId && !data.assetId) { - ctx.addIssue({ - code: "custom", - message: "Asset ID is required when item ID is provided", - path: ["assetId"], - }) - } - }) +export function buildUpdateAssignmentSchema(copy: AssignmentSchemaCopy) { + return buildAssignmentBaseSchema(copy) + .omit({ + returnDate: true, + }) + .extend({ + id: z.string().min(1, { + error: copy.idRequired, + }), + }) + .superRefine((data, ctx) => { + if (data.itemId && !data.assetId) { + ctx.addIssue({ + code: "custom", + message: copy.assetIdRequired, + path: ["assetId"], + }) + } + }) +} +export const updateAssignmentSchema = buildUpdateAssignmentSchema( + defaultAssignmentSchemaCopy, +) export type UpdateAssignmentFormType = z.input export type UpdateAssignmentData = z.output export const returnAssignmentSchema = z.object({ id: z.string().min(1, { - error: "Assignment ID is required", + error: defaultAssignmentSchemaCopy.idRequired, }), }) export type ReturnAssignmentFormType = z.infer diff --git a/tests/unit/actions/assignment.messages.test.ts b/tests/unit/actions/assignment.messages.test.ts new file mode 100644 index 0000000..e205baa --- /dev/null +++ b/tests/unit/actions/assignment.messages.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" + +import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages" + +const actionCopy = { + createSuccess: "Asignación creada correctamente", + createFailure: "Error al crear la asignación", + updateSuccess: "Asignación actualizada correctamente", + updateFailure: "Error al actualizar la asignación", + returnSuccess: "Asignación devuelta correctamente", + returnFailure: "Error al devolver la asignación", + notFound: "Asignación no encontrada", + itemNotFound: "Artículo no encontrado", + itemInsufficientStock: "El artículo no tiene stock suficiente", + assetNotFound: "Activo no encontrado", + assetItemMismatch: "El activo no pertenece al artículo", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + invalidData: "Datos de asignación inválidos", + genericFailure: "Error al procesar la asignación", +} + +describe("assignment action message localization", () => { + it("localizes known assignment field errors from use-case output", () => { + expect( + localizeAssignmentFieldErrors( + { + itemId: ["Item not found"], + quantity: ["Item does not have enough stock"], + assetId: ["Asset not found"], + id: ["Assignment not found", "Assignment already returned"], + }, + actionCopy, + ), + ).toEqual({ + itemId: [actionCopy.itemNotFound], + quantity: [actionCopy.itemInsufficientStock], + assetId: [actionCopy.assetNotFound], + id: [actionCopy.notFound, actionCopy.assignmentAlreadyReturned], + }) + }) + + it("localizes asset-item mismatch and generic data errors", () => { + expect( + localizeAssignmentFieldErrors( + { + assetId: ["Asset does not belong to item"], + error: ["Invalid assignment data"], + }, + actionCopy, + ), + ).toEqual({ + assetId: [actionCopy.assetItemMismatch], + error: [actionCopy.invalidData], + }) + }) + + it("keeps unknown messages unchanged", () => { + expect( + localizeAssignmentFieldErrors( + { error: ["Unexpected assignment issue"] }, + actionCopy, + ), + ).toEqual({ error: ["Unexpected assignment issue"] }) + }) + + it("returns undefined when no errors are provided", () => { + expect(localizeAssignmentFieldErrors(undefined, actionCopy)).toBeUndefined() + }) + + it("returns undefined when errors object is empty", () => { + expect(localizeAssignmentFieldErrors({}, actionCopy)).toEqual({}) + }) +}) diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index b05a2a5..6ef135b 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -448,6 +448,29 @@ describe("i18n dictionaries", () => { fallback: { missingValue: "N/A", }, + actions: { + createSuccess: "Assignment created successfully", + createFailure: "Error creating assignment", + updateSuccess: "Assignment updated successfully", + updateFailure: "Error updating assignment", + returnSuccess: "Assignment returned successfully", + returnFailure: "Error returning assignment", + notFound: "Assignment not found", + itemNotFound: "Item not found", + itemInsufficientStock: "Item does not have enough stock", + assetNotFound: "Asset not found", + assetItemMismatch: "Asset does not belong to item", + assignmentAlreadyReturned: "Assignment already returned", + invalidData: "Invalid assignment data", + genericFailure: "Error processing assignment", + }, + schema: { + recipientRequired: "Recipient is required", + itemIdRequired: "Item is required", + quantityMinOne: "Quantity must be at least 1", + assetIdRequired: "Asset ID is required when item ID is provided", + idRequired: "Assignment ID is required", + }, }) expect(getDictionary("es").inventory.assignments).toEqual({ @@ -489,6 +512,30 @@ describe("i18n dictionaries", () => { fallback: { missingValue: "No disponible", }, + actions: { + createSuccess: "Asignación creada correctamente", + createFailure: "Error al crear la asignación", + updateSuccess: "Asignación actualizada correctamente", + updateFailure: "Error al actualizar la asignación", + returnSuccess: "Asignación devuelta correctamente", + returnFailure: "Error al devolver la asignación", + notFound: "Asignación no encontrada", + itemNotFound: "Artículo no encontrado", + itemInsufficientStock: "El artículo no tiene stock suficiente", + assetNotFound: "Activo no encontrado", + assetItemMismatch: "El activo no pertenece al artículo", + assignmentAlreadyReturned: "La asignación ya fue devuelta", + invalidData: "Datos de asignación inválidos", + genericFailure: "Error al procesar la asignación", + }, + schema: { + recipientRequired: "El destinatario es obligatorio", + itemIdRequired: "El artículo es obligatorio", + quantityMinOne: "La cantidad debe ser al menos 1", + assetIdRequired: + "El activo es obligatorio cuando se especifica el artículo", + idRequired: "El ID de asignación es obligatorio", + }, }) }) diff --git a/tests/unit/schemas/assignment.schema.test.ts b/tests/unit/schemas/assignment.schema.test.ts new file mode 100644 index 0000000..4b187fd --- /dev/null +++ b/tests/unit/schemas/assignment.schema.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateAssignmentSchema, + buildUpdateAssignmentSchema, +} from "@/schemas/assignment.schema" + +const schemaCopy = { + recipientRequired: "El destinatario es obligatorio", + itemIdRequired: "El artículo es obligatorio", + quantityMinOne: "La cantidad debe ser al menos 1", + assetIdRequired: "El activo es obligatorio cuando se especifica el artículo", + idRequired: "El ID de asignación es obligatorio", +} + +describe("assignment schema localization", () => { + it("uses localized create validation messages for missing required fields", () => { + const result = buildCreateAssignmentSchema(schemaCopy).safeParse({ + recipientId: "", + quantity: 0, + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + + expect(errors.recipientId).toContain(schemaCopy.recipientRequired) + expect(errors.quantity).toContain(schemaCopy.quantityMinOne) + } + }) + + it("uses localized update validation messages for missing identifier and invalid item-asset combination", () => { + const result = buildUpdateAssignmentSchema(schemaCopy).safeParse({ + id: "", + itemId: "item-1", + recipientId: "recipient-1", + quantity: 1, + assetId: "", + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + + expect(errors.id).toContain(schemaCopy.idRequired) + expect(errors.assetId).toContain(schemaCopy.assetIdRequired) + } + }) + + it("preserves valid create and update payloads without errors", () => { + const createResult = buildCreateAssignmentSchema(schemaCopy).safeParse({ + recipientId: "recipient-1", + itemId: "item-1", + quantity: 2, + }) + + expect(createResult.success).toBe(true) + if (createResult.success) { + expect(createResult.data.recipientId).toBe("recipient-1") + expect(createResult.data.quantity).toBe(2) + } + + const updateResult = buildUpdateAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + recipientId: "recipient-1", + itemId: "item-1", + assetId: "asset-1", + quantity: 1, + }) + + expect(updateResult.success).toBe(true) + if (updateResult.success) { + expect(updateResult.data.id).toBe("assignment-1") + } + }) + + it("keeps optional assignment fields optional in create", () => { + const result = buildCreateAssignmentSchema(schemaCopy).safeParse({ + recipientId: "recipient-1", + quantity: 1, + }) + + expect(result.success).toBe(true) + }) + + it("allows update without itemId when no assetId is required", () => { + const result = buildUpdateAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + recipientId: "recipient-1", + quantity: 1, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.id).toBe("assignment-1") + } + }) +})