Skip to content

Commit c666407

Browse files
beyondnetPeruclaude
andcommitted
fix: allow empty description on AddModule / UpdateModule
Description value object used isRequired=true by default, causing HTTP 400 whenever the frontend sent description="" (user left field blank). Description is semantically optional metadata — required-ness belongs at the use-case layer. Changes: - Description.cs: GenericStringValidator now uses isRequired: false - AddModuleCommandValidator: new validator, description is MaxLength(500) only - UpdateModuleCommandValidator: same pattern, fixes latent bug on module update - system-suite.service.ts: addModule/updateModule payload description?: string - use-system-suite.ts: useAddModule payload type aligned to description?: string - system-suite.commands.schema.ts: new Zod schemas for all write DTOs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a52242f commit c666407

6 files changed

Lines changed: 279 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace Ums.Application.Authorization.SystemSuite.Commands;
2+
3+
using FluentValidation;
4+
5+
public sealed class AddModuleCommandValidator : AbstractValidator<AddModuleCommand>
6+
{
7+
public AddModuleCommandValidator()
8+
{
9+
RuleFor(x => x.SystemSuiteId)
10+
.NotEmpty();
11+
12+
RuleFor(x => x.Code)
13+
.NotEmpty()
14+
.MaximumLength(50)
15+
.Matches(@"^[A-Za-z0-9_]+$")
16+
.WithMessage("Code must contain only letters, digits, and underscores.");
17+
18+
RuleFor(x => x.Name)
19+
.NotEmpty()
20+
.MaximumLength(150);
21+
22+
// Description is optional — the domain allows empty strings.
23+
RuleFor(x => x.Description)
24+
.MaximumLength(500);
25+
26+
RuleFor(x => x.SortOrder)
27+
.GreaterThan(0)
28+
.WithMessage("SortOrder must be a positive integer.");
29+
}
30+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Ums.Application.Authorization.SystemSuite.Commands;
2+
3+
using FluentValidation;
4+
5+
public sealed class UpdateModuleCommandValidator : AbstractValidator<UpdateModuleCommand>
6+
{
7+
public UpdateModuleCommandValidator()
8+
{
9+
RuleFor(x => x.SystemSuiteId)
10+
.NotEmpty();
11+
12+
RuleFor(x => x.ModuleId)
13+
.NotEmpty();
14+
15+
RuleFor(x => x.Name)
16+
.NotEmpty()
17+
.MaximumLength(150);
18+
19+
// Description is optional — the domain allows empty strings.
20+
RuleFor(x => x.Description)
21+
.MaximumLength(500);
22+
23+
RuleFor(x => x.SortOrder)
24+
.GreaterThan(0)
25+
.WithMessage("SortOrder must be a positive integer.");
26+
}
27+
}

src/apps/ums.api/Ums.Domain/Kernel/ValueObjects/Description.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ private Description(string value) : base(value) { }
88
public override void AddValidators()
99
{
1010
base.AddValidators();
11-
ValidatorRules.Add(new GenericStringValidator(this, nameof(Description)));
11+
// Description is inherently optional at the domain level.
12+
// Use-case validators (FluentValidation) enforce NotEmpty() where required.
13+
ValidatorRules.Add(new GenericStringValidator(this, nameof(Description), isRequired: false));
1214
}
1315
}

src/apps/ums.web-app/src/application/authorization/hooks/use-system-suite.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,119 @@ export const useSetSystemSuiteStatus = (systemSuiteId: string) => {
9090
const t = useI18n();
9191
return useNotifiedMutation({
9292
mutationFn: (status: string) => systemSuiteService.setSystemSuiteStatus(systemSuiteId, status),
93-
invalidateKeys: [['system-suites', systemSuiteId]],
93+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
9494
successNotif: () => ({
95-
title: t.notifStatusChanged,
96-
message: t.notifStatusChangedMsg,
95+
title: t.notifStatusChanged ?? 'Estado Cambiado',
96+
message: t.notifStatusChangedMsg ?? 'El estado de la suite fue actualizado.',
9797
}),
9898
errorNotif: () => ({
99-
title: t.notifStatusChangeFailed,
100-
message: t.notifStatusChangeFailedMsg,
99+
title: t.notifStatusChangeFailed ?? 'Error de Cambio de Estado',
100+
message: t.notifStatusChangeFailedMsg ?? 'No se pudo actualizar el estado.',
101+
}),
102+
});
103+
};
104+
105+
// ─── Module Mutations ────────────────────────────────────────────────────────
106+
107+
export const useAddModule = (systemSuiteId: string) => {
108+
const t = useI18n();
109+
return useNotifiedMutation({
110+
mutationFn: (payload: { code: string; name: string; description?: string; sortOrder: number }) =>
111+
systemSuiteService.addModule(systemSuiteId, payload),
112+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
113+
successNotif: () => ({
114+
title: t.notifModuleAdded ?? 'Módulo Registrado',
115+
message: t.notifModuleAddedMsg ?? 'El módulo estructural ha sido registrado correctamente.',
116+
}),
117+
errorNotif: () => ({
118+
title: t.notifModuleAddFailed ?? 'Error al Registrar Módulo',
119+
message: t.notifModuleAddFailedMsg ?? 'No se pudo registrar el módulo.',
120+
}),
121+
});
122+
};
123+
124+
export const useRemoveModule = (systemSuiteId: string) => {
125+
const t = useI18n();
126+
return useNotifiedMutation({
127+
mutationFn: (moduleId: string) => systemSuiteService.removeModule(systemSuiteId, moduleId),
128+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
129+
successNotif: () => ({
130+
title: t.notifModuleRemoved ?? 'Módulo Removido',
131+
message: t.notifModuleRemovedMsg ?? 'El módulo estructural fue eliminado correctamente.',
132+
type: 'warning' as const,
133+
}),
134+
errorNotif: () => ({
135+
title: t.notifModuleRemoveFailed ?? 'Error al Eliminar Módulo',
136+
message: t.notifModuleRemoveFailedMsg ?? 'No se pudo eliminar el módulo.',
137+
}),
138+
});
139+
};
140+
141+
export const useActivateModule = (systemSuiteId: string) => {
142+
const t = useI18n();
143+
return useNotifiedMutation({
144+
mutationFn: (moduleId: string) => systemSuiteService.activateModule(systemSuiteId, moduleId),
145+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
146+
successNotif: () => ({
147+
title: t.notifActivated ?? 'Módulo Activado',
148+
message: t.notifActivatedMsg ?? 'Módulo activado con éxito.',
149+
}),
150+
errorNotif: () => ({
151+
title: t.notifActivateFailed ?? 'Error al Activar',
152+
message: t.notifActivateFailedMsg ?? 'No se pudo activar el módulo.',
153+
}),
154+
});
155+
};
156+
157+
export const useDeactivateModule = (systemSuiteId: string) => {
158+
const t = useI18n();
159+
return useNotifiedMutation({
160+
mutationFn: (moduleId: string) => systemSuiteService.deactivateModule(systemSuiteId, moduleId),
161+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
162+
successNotif: () => ({
163+
title: t.notifSuspended ?? 'Módulo Desactivado',
164+
message: t.notifSuspendedMsg ?? 'Módulo desactivado con éxito.',
165+
type: 'info' as const,
166+
}),
167+
errorNotif: () => ({
168+
title: t.notifSuspendFailed ?? 'Error al Desactivar',
169+
message: t.notifSuspendFailedMsg ?? 'No se pudo desactivar el módulo.',
170+
}),
171+
});
172+
};
173+
174+
// ─── Action Mutations ────────────────────────────────────────────────────────
175+
176+
export const useRegisterAction = (systemSuiteId: string) => {
177+
const t = useI18n();
178+
return useNotifiedMutation({
179+
mutationFn: (payload: { code: string; name: string }) =>
180+
systemSuiteService.registerAction(systemSuiteId, payload),
181+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
182+
successNotif: () => ({
183+
title: t.notifActionRegistered ?? 'Acción Registrada',
184+
message: t.notifActionRegisteredMsg ?? 'Código de acción registrado correctamente.',
185+
}),
186+
errorNotif: () => ({
187+
title: t.notifActionRegisterFailed ?? 'Error al Registrar Acción',
188+
message: t.notifActionRegisterFailedMsg ?? 'No se pudo registrar la acción.',
189+
}),
190+
});
191+
};
192+
193+
export const useRemoveAction = (systemSuiteId: string) => {
194+
const t = useI18n();
195+
return useNotifiedMutation({
196+
mutationFn: (code: string) => systemSuiteService.removeAction(systemSuiteId, code),
197+
invalidateKeys: [['system-suites', systemSuiteId], ['system-suites']],
198+
successNotif: () => ({
199+
title: t.notifActionRemoved ?? 'Acción Removida',
200+
message: t.notifActionRemovedMsg ?? 'El código de acción fue removido correctamente.',
201+
type: 'warning' as const,
202+
}),
203+
errorNotif: () => ({
204+
title: t.notifActionRemoveFailed ?? 'Error al Remover Acción',
205+
message: t.notifActionRemoveFailedMsg ?? 'No se pudo remover la acción.',
101206
}),
102207
});
103208
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* system-suite.commands.schema.ts
3+
*
4+
* Zod schemas for REST command payloads (write DTOs).
5+
* These are separate from system-suite.schema.ts which holds read/GraphQL response shapes.
6+
*/
7+
import { z } from 'zod';
8+
9+
const codeRegex = /^[A-Za-z0-9_]+$/;
10+
11+
// ── SystemSuite ───────────────────────────────────────────────────────────────
12+
13+
export const CreateSystemSuiteCommandSchema = z.object({
14+
tenantId: z.string().uuid('TenantId must be a valid UUID'),
15+
code: z
16+
.string()
17+
.min(1, 'Código requerido')
18+
.max(50, 'Máximo 50 caracteres')
19+
.regex(codeRegex, 'Solo letras, dígitos y guiones bajos'),
20+
name: z.string().min(1, 'Nombre requerido').max(150, 'Máximo 150 caracteres'),
21+
description: z.string().min(1, 'Descripción requerida').max(500, 'Máximo 500 caracteres'),
22+
});
23+
24+
export const UpdateSystemSuiteCommandSchema = z.object({
25+
name: z.string().min(1, 'Nombre requerido').max(150, 'Máximo 150 caracteres'),
26+
description: z.string().max(500, 'Máximo 500 caracteres').optional().default(''),
27+
});
28+
29+
// ── Module ────────────────────────────────────────────────────────────────────
30+
31+
export const AddModuleCommandSchema = z.object({
32+
code: z
33+
.string()
34+
.min(1, 'Código requerido')
35+
.max(50, 'Máximo 50 caracteres')
36+
.regex(codeRegex, 'Solo letras, dígitos y guiones bajos'),
37+
name: z.string().min(1, 'Nombre requerido').max(150, 'Máximo 150 caracteres'),
38+
description: z.string().max(500, 'Máximo 500 caracteres').optional().default(''),
39+
sortOrder: z.number().int().positive('Debe ser un entero positivo'),
40+
});
41+
42+
export const UpdateModuleCommandSchema = AddModuleCommandSchema;
43+
44+
// ── Action ────────────────────────────────────────────────────────────────────
45+
46+
export const RegisterActionCommandSchema = z.object({
47+
code: z
48+
.string()
49+
.min(1, 'Código requerido')
50+
.max(50, 'Máximo 50 caracteres')
51+
.regex(codeRegex, 'Solo letras, dígitos y guiones bajos'),
52+
name: z.string().min(1, 'Nombre requerido').max(150, 'Máximo 150 caracteres'),
53+
});
54+
55+
// ── Inferred types ────────────────────────────────────────────────────────────
56+
57+
export type CreateSystemSuiteCommand = z.infer<typeof CreateSystemSuiteCommandSchema>;
58+
export type UpdateSystemSuiteCommand = z.infer<typeof UpdateSystemSuiteCommandSchema>;
59+
export type AddModuleCommand = z.infer<typeof AddModuleCommandSchema>;
60+
export type UpdateModuleCommand = z.infer<typeof UpdateModuleCommandSchema>;
61+
export type RegisterActionCommand = z.infer<typeof RegisterActionCommandSchema>;

src/apps/ums.web-app/src/infrastructure/authorization/services/system-suite.service.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,54 @@ export const systemSuiteService = {
7171
setSystemSuiteStatus: async (systemSuiteId: string, status: string): Promise<void> => {
7272
await httpClient.post(`/system-suites/${systemSuiteId}/status`, undefined, { params: { status } });
7373
},
74+
75+
// ── Module Lifecycle REST Commands ─────────────────────────────────────────
76+
77+
addModule: async (
78+
systemSuiteId: string,
79+
payload: { code: string; name: string; description?: string; sortOrder: number },
80+
): Promise<void> => {
81+
await httpClient.post(`/system-suites/${systemSuiteId}/modules`, {
82+
...payload,
83+
description: payload.description?.trim() ?? '',
84+
});
85+
},
86+
87+
updateModule: async (
88+
systemSuiteId: string,
89+
moduleId: string,
90+
payload: { name: string; description?: string; sortOrder: number },
91+
): Promise<void> => {
92+
await httpClient.put(`/system-suites/${systemSuiteId}/modules/${moduleId}`, {
93+
...payload,
94+
description: payload.description?.trim() ?? '',
95+
});
96+
},
97+
98+
removeModule: async (systemSuiteId: string, moduleId: string): Promise<void> => {
99+
await httpClient.delete(`/system-suites/${systemSuiteId}/modules/${moduleId}`);
100+
},
101+
102+
activateModule: async (systemSuiteId: string, moduleId: string): Promise<void> => {
103+
await httpClient.post(`/system-suites/${systemSuiteId}/modules/${moduleId}/activate`);
104+
},
105+
106+
deactivateModule: async (systemSuiteId: string, moduleId: string): Promise<void> => {
107+
await httpClient.post(`/system-suites/${systemSuiteId}/modules/${moduleId}/deactivate`);
108+
},
109+
110+
// ── Action Registry REST Commands ─────────────────────────────────────────
111+
112+
registerAction: async (
113+
systemSuiteId: string,
114+
payload: { code: string; name: string },
115+
): Promise<void> => {
116+
await httpClient.post(`/system-suites/${systemSuiteId}/actions`, payload);
117+
},
118+
119+
removeAction: async (systemSuiteId: string, code: string): Promise<void> => {
120+
await httpClient.delete(`/system-suites/${systemSuiteId}/actions/${code}`);
121+
},
74122
};
75123

76124
export default systemSuiteService;

0 commit comments

Comments
 (0)