From 981f7f7683d5d4e785a16ae76f25eac1447277d1 Mon Sep 17 00:00:00 2001 From: emmanuelknafo <48259636+emmanuelknafo@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:18:05 -0500 Subject: [PATCH] AB#1876 Add program_budget field across all layers (database, API, UI, tests, docs) --- .../ontario/program/dto/ProgramResponse.java | 4 +- .../program/dto/ProgramSubmitRequest.java | 8 +++- .../com/ontario/program/model/Program.java | 12 ++++++ .../program/service/ProgramService.java | 4 +- .../controller/ProgramControllerTests.java | 41 ++++++++++++++++--- .../migrations/V005__add_program_budget.sql | 2 + docs/data-dictionary.md | 3 ++ docs/design-document.md | 2 + frontend/src/locales/en.json | 7 +++- frontend/src/locales/fr.json | 7 +++- frontend/src/pages/ReviewDashboard.test.tsx | 2 + frontend/src/pages/ReviewDetail.test.tsx | 1 + frontend/src/pages/ReviewDetail.tsx | 6 +++ frontend/src/pages/SearchPrograms.test.tsx | 2 + .../src/pages/SubmitConfirmation.test.tsx | 1 + frontend/src/pages/SubmitConfirmation.tsx | 6 +++ frontend/src/pages/SubmitProgram.test.tsx | 2 + frontend/src/pages/SubmitProgram.tsx | 34 +++++++++++++++ frontend/src/services/programService.ts | 2 + 19 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 database/migrations/V005__add_program_budget.sql diff --git a/backend/src/main/java/com/ontario/program/dto/ProgramResponse.java b/backend/src/main/java/com/ontario/program/dto/ProgramResponse.java index dfbf2ed..6410235 100644 --- a/backend/src/main/java/com/ontario/program/dto/ProgramResponse.java +++ b/backend/src/main/java/com/ontario/program/dto/ProgramResponse.java @@ -1,5 +1,6 @@ package com.ontario.program.dto; +import java.math.BigDecimal; import java.time.LocalDateTime; /** @@ -18,6 +19,7 @@ public record ProgramResponse( LocalDateTime reviewedAt, LocalDateTime createdAt, LocalDateTime updatedAt, - String createdBy + String createdBy, + BigDecimal programBudget ) { } diff --git a/backend/src/main/java/com/ontario/program/dto/ProgramSubmitRequest.java b/backend/src/main/java/com/ontario/program/dto/ProgramSubmitRequest.java index 73a435b..5eb9084 100644 --- a/backend/src/main/java/com/ontario/program/dto/ProgramSubmitRequest.java +++ b/backend/src/main/java/com/ontario/program/dto/ProgramSubmitRequest.java @@ -1,5 +1,8 @@ package com.ontario.program.dto; +import java.math.BigDecimal; + +import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -21,6 +24,9 @@ public record ProgramSubmitRequest( @NotBlank(message = "Created by is required") @Size(max = 100, message = "Created by must not exceed 100 characters") - String createdBy + String createdBy, + + @DecimalMin(value = "0", message = "Program budget must be zero or positive") + BigDecimal programBudget ) { } diff --git a/backend/src/main/java/com/ontario/program/model/Program.java b/backend/src/main/java/com/ontario/program/model/Program.java index 5e122f3..5fdcf47 100644 --- a/backend/src/main/java/com/ontario/program/model/Program.java +++ b/backend/src/main/java/com/ontario/program/model/Program.java @@ -1,5 +1,6 @@ package com.ontario.program.model; +import java.math.BigDecimal; import java.time.LocalDateTime; import jakarta.persistence.Column; @@ -60,6 +61,9 @@ public class Program { @Column(name = "created_by", nullable = false, length = 100) private String createdBy; + @Column(name = "program_budget", precision = 15, scale = 2) + private BigDecimal programBudget; + protected Program() { // JPA requires a no-arg constructor } @@ -162,4 +166,12 @@ public String getCreatedBy() { public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + + public BigDecimal getProgramBudget() { + return programBudget; + } + + public void setProgramBudget(BigDecimal programBudget) { + this.programBudget = programBudget; + } } diff --git a/backend/src/main/java/com/ontario/program/service/ProgramService.java b/backend/src/main/java/com/ontario/program/service/ProgramService.java index 037a3dc..9d697ca 100644 --- a/backend/src/main/java/com/ontario/program/service/ProgramService.java +++ b/backend/src/main/java/com/ontario/program/service/ProgramService.java @@ -48,6 +48,7 @@ public ProgramResponse submitProgram(ProgramSubmitRequest request) { ); program.setStatus("SUBMITTED"); program.setSubmittedAt(LocalDateTime.now()); + program.setProgramBudget(request.programBudget()); Program saved = programRepository.save(program); return toResponse(saved); @@ -113,7 +114,8 @@ private ProgramResponse toResponse(Program program) { program.getReviewedAt(), program.getCreatedAt(), program.getUpdatedAt(), - program.getCreatedBy() + program.getCreatedBy(), + program.getProgramBudget() ); } diff --git a/backend/src/test/java/com/ontario/program/controller/ProgramControllerTests.java b/backend/src/test/java/com/ontario/program/controller/ProgramControllerTests.java index ea9c60a..6dc1ae2 100644 --- a/backend/src/test/java/com/ontario/program/controller/ProgramControllerTests.java +++ b/backend/src/test/java/com/ontario/program/controller/ProgramControllerTests.java @@ -1,5 +1,7 @@ package com.ontario.program.controller; +import java.math.BigDecimal; + import com.fasterxml.jackson.databind.ObjectMapper; import com.ontario.program.dto.ProgramReviewRequest; import com.ontario.program.dto.ProgramSubmitRequest; @@ -46,7 +48,7 @@ void shouldReturnProgramTypes() throws Exception { @Test void shouldSubmitProgram() throws Exception { ProgramSubmitRequest request = new ProgramSubmitRequest( - "Youth Arts Program", "A program for youth arts", 1, "citizen1"); + "Youth Arts Program", "A program for youth arts", 1, "citizen1", null); mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) @@ -62,7 +64,7 @@ void shouldSubmitProgram() throws Exception { @Test void shouldReturnValidationErrorForBlankName() throws Exception { ProgramSubmitRequest request = new ProgramSubmitRequest( - "", "A description", 1, "citizen1"); + "", "A description", 1, "citizen1", null); mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) @@ -75,7 +77,7 @@ void shouldReturnValidationErrorForBlankName() throws Exception { void shouldGetAllPrograms() throws Exception { // Submit a program first ProgramSubmitRequest request = new ProgramSubmitRequest( - "List Test Program", "Description", 1, "citizen1"); + "List Test Program", "Description", 1, "citizen1", null); mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); @@ -89,7 +91,7 @@ void shouldGetAllPrograms() throws Exception { void shouldGetProgramById() throws Exception { // Submit a program first ProgramSubmitRequest request = new ProgramSubmitRequest( - "Get By ID Test", "Description", 1, "citizen1"); + "Get By ID Test", "Description", 1, "citizen1", null); MvcResult result = mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -115,7 +117,7 @@ void shouldReturn404ForNonExistentProgram() throws Exception { void shouldApproveProgram() throws Exception { // Submit a program first ProgramSubmitRequest submitRequest = new ProgramSubmitRequest( - "Approve Test Program", "Description", 1, "citizen1"); + "Approve Test Program", "Description", 1, "citizen1", null); MvcResult result = mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(submitRequest))) @@ -141,7 +143,7 @@ void shouldApproveProgram() throws Exception { void shouldRejectProgram() throws Exception { // Submit a program first ProgramSubmitRequest submitRequest = new ProgramSubmitRequest( - "Reject Test Program", "Description", 1, "citizen1"); + "Reject Test Program", "Description", 1, "citizen1", null); MvcResult result = mockMvc.perform(post("/api/programs") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(submitRequest))) @@ -184,6 +186,33 @@ void shouldReturnValidationErrorForInvalidReviewStatus() throws Exception { .andExpect(jsonPath("$.title", is("Validation Failed"))); } + @Test + void shouldSubmitProgramWithBudget() throws Exception { + ProgramSubmitRequest request = new ProgramSubmitRequest( + "Budget Program", "A program with budget", 1, "citizen1", + new BigDecimal("50000.00")); + + mockMvc.perform(post("/api/programs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.programName", is("Budget Program"))) + .andExpect(jsonPath("$.programBudget", is(50000.00))); + } + + @Test + void shouldReturnValidationErrorForNegativeBudget() throws Exception { + ProgramSubmitRequest request = new ProgramSubmitRequest( + "Negative Budget", "Description", 1, "citizen1", + new BigDecimal("-100.00")); + + mockMvc.perform(post("/api/programs") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title", is("Validation Failed"))); + } + @Test void shouldReturnHealthStatus() throws Exception { mockMvc.perform(get("/api/health")) diff --git a/database/migrations/V005__add_program_budget.sql b/database/migrations/V005__add_program_budget.sql new file mode 100644 index 0000000..04817f8 --- /dev/null +++ b/database/migrations/V005__add_program_budget.sql @@ -0,0 +1,2 @@ +ALTER TABLE program + ADD program_budget DECIMAL(15, 2) NULL; diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 4675384..646defa 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -16,6 +16,7 @@ erDiagram INT program_type_id FK "FK to program_type" NVARCHAR status "DEFAULT DRAFT" NVARCHAR reviewer_comments "Ministry comments" + DECIMAL program_budget "Budget amount (15,2)" DATETIME2 submitted_at "Submission timestamp" DATETIME2 reviewed_at "Review timestamp" DATETIME2 created_at "Record created" @@ -69,6 +70,7 @@ The main transactional table storing citizen program submissions and their revie | `created_at` | `DATETIME2` | `NOT NULL`, `DEFAULT GETDATE()` | Record creation timestamp | | `updated_at` | `DATETIME2` | `NOT NULL`, `DEFAULT GETDATE()` | Last update timestamp | | `created_by` | `NVARCHAR(100)` | `NOT NULL` | Identifier of the submitting citizen | +| `program_budget` | `DECIMAL(15,2)` | `NULL` | Optional budget amount for the program | **Relationships**: - Many-to-one with `program_type` via `program_type_id` @@ -126,3 +128,4 @@ Flyway versioned migrations create and seed the database schema: | `V002__create_program_table.sql` | Creates the `program` table with FK to `program_type` | | `V003__create_notification_table.sql` | Creates the `notification` table with FK to `program` | | `V004__seed_program_types.sql` | Seeds the 5 program types with EN/FR names | +| `V005__add_program_budget.sql` | Adds `program_budget` column to the `program` table | diff --git a/docs/design-document.md b/docs/design-document.md index aa2814e..20b7e76 100644 --- a/docs/design-document.md +++ b/docs/design-document.md @@ -20,6 +20,7 @@ | `programDescription` | `String` | `@NotBlank` | Detailed description of the program | | `programTypeId` | `Integer` | `@NotNull` | Foreign key reference to `program_type` | | `createdBy` | `String` | `@NotBlank`, `@Size(max=100)` | Identifier of the submitting citizen | +| `programBudget` | `BigDecimal` | `@DecimalMin("0")` | Optional budget amount for the program | ### `ProgramReviewRequest` @@ -47,6 +48,7 @@ | `createdAt` | `String` (ISO 8601) | Record creation timestamp | | `updatedAt` | `String` (ISO 8601) | Last update timestamp | | `createdBy` | `String` | Submitter identifier | +| `programBudget` | `BigDecimal` | Optional budget amount | ### `ProgramTypeResponse` diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 0145e90..a0de19c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -33,6 +33,8 @@ "submitButton": "Submit Request", "submitting": "Submitting...", "required": "required", + "programBudget": "Program Budget", + "programBudgetPlaceholder": "Enter budget amount", "validation": { "nameRequired": "Program name is required", "nameMaxLength": "Program name must be 255 characters or fewer", @@ -40,7 +42,8 @@ "descriptionMaxLength": "Program description must be 4000 characters or fewer", "typeRequired": "Program type is required", "createdByRequired": "Your name is required", - "createdByMaxLength": "Your name must be 255 characters or fewer" + "createdByMaxLength": "Your name must be 255 characters or fewer", + "budgetMin": "Budget must be zero or a positive amount" } }, "confirmation": { @@ -52,6 +55,7 @@ "status": "Status", "submittedAt": "Submitted At", "submittedBy": "Submitted By", + "programBudget": "Program Budget", "submitAnother": "Submit Another Request", "searchPrograms": "Search Programs" }, @@ -114,6 +118,7 @@ "status": "Status", "submittedAt": "Submitted At", "submittedBy": "Submitted By", + "programBudget": "Program Budget", "reviewedAt": "Reviewed At", "reviewAction": "Review Action", "comments": "Reviewer Comments", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 84d39c3..162c877 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -33,6 +33,8 @@ "submitButton": "Soumettre la demande", "submitting": "Soumission en cours...", "required": "requis", + "programBudget": "Budget du programme", + "programBudgetPlaceholder": "Entrez le montant du budget", "validation": { "nameRequired": "Le nom du programme est requis", "nameMaxLength": "Le nom du programme doit comporter 255 caractères ou moins", @@ -40,7 +42,8 @@ "descriptionMaxLength": "La description du programme doit comporter 4000 caractères ou moins", "typeRequired": "Le type de programme est requis", "createdByRequired": "Votre nom est requis", - "createdByMaxLength": "Votre nom doit comporter 255 caractères ou moins" + "createdByMaxLength": "Votre nom doit comporter 255 caractères ou moins", + "budgetMin": "Le budget doit être zéro ou un montant positif" } }, "confirmation": { @@ -52,6 +55,7 @@ "status": "Statut", "submittedAt": "Soumis le", "submittedBy": "Soumis par", + "programBudget": "Budget du programme", "submitAnother": "Soumettre une autre demande", "searchPrograms": "Rechercher des programmes" }, @@ -114,6 +118,7 @@ "status": "Statut", "submittedAt": "Soumis le", "submittedBy": "Soumis par", + "programBudget": "Budget du programme", "reviewedAt": "Révisé le", "reviewAction": "Action de révision", "comments": "Commentaires du réviseur", diff --git a/frontend/src/pages/ReviewDashboard.test.tsx b/frontend/src/pages/ReviewDashboard.test.tsx index afbe07d..ba63217 100644 --- a/frontend/src/pages/ReviewDashboard.test.tsx +++ b/frontend/src/pages/ReviewDashboard.test.tsx @@ -25,6 +25,7 @@ const mockPrograms = [ createdAt: '2026-01-15T14:30:00', updatedAt: '2026-01-15T14:30:00', createdBy: 'John Smith', + programBudget: null, }, { id: 2, @@ -40,6 +41,7 @@ const mockPrograms = [ createdAt: '2026-01-16T10:00:00', updatedAt: '2026-01-17T09:00:00', createdBy: 'Mary Jane', + programBudget: null, }, ]; diff --git a/frontend/src/pages/ReviewDetail.test.tsx b/frontend/src/pages/ReviewDetail.test.tsx index c0d5cab..249d252 100644 --- a/frontend/src/pages/ReviewDetail.test.tsx +++ b/frontend/src/pages/ReviewDetail.test.tsx @@ -25,6 +25,7 @@ const mockProgram = { createdAt: '2026-01-15T14:30:00', updatedAt: '2026-01-15T14:30:00', createdBy: 'John Smith', + programBudget: null, }; describe('ReviewDetail', () => { diff --git a/frontend/src/pages/ReviewDetail.tsx b/frontend/src/pages/ReviewDetail.tsx index 04886e8..52533bd 100644 --- a/frontend/src/pages/ReviewDetail.tsx +++ b/frontend/src/pages/ReviewDetail.tsx @@ -139,6 +139,12 @@ const ReviewDetail: React.FC = () => { {t('review.detail.submittedBy')} {program.createdBy} + {program.programBudget !== null && ( + + {t('review.detail.programBudget')} + ${program.programBudget.toLocaleString(i18n.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + )} {program.reviewedAt && ( {t('review.detail.reviewedAt')} diff --git a/frontend/src/pages/SearchPrograms.test.tsx b/frontend/src/pages/SearchPrograms.test.tsx index 398da99..955f054 100644 --- a/frontend/src/pages/SearchPrograms.test.tsx +++ b/frontend/src/pages/SearchPrograms.test.tsx @@ -25,6 +25,7 @@ const mockPrograms = [ createdAt: '2026-01-15T14:30:00', updatedAt: '2026-01-15T14:30:00', createdBy: 'John Smith', + programBudget: null, }, { id: 2, @@ -40,6 +41,7 @@ const mockPrograms = [ createdAt: '2026-01-16T10:00:00', updatedAt: '2026-01-17T09:00:00', createdBy: 'Mary Jane', + programBudget: null, }, ]; diff --git a/frontend/src/pages/SubmitConfirmation.test.tsx b/frontend/src/pages/SubmitConfirmation.test.tsx index a280dd0..02ce15c 100644 --- a/frontend/src/pages/SubmitConfirmation.test.tsx +++ b/frontend/src/pages/SubmitConfirmation.test.tsx @@ -17,6 +17,7 @@ const mockProgram = { createdAt: '2026-01-15T14:30:00', updatedAt: '2026-01-15T14:30:00', createdBy: 'Jane Doe', + programBudget: null, }; vi.mock('react-router-dom', async () => { diff --git a/frontend/src/pages/SubmitConfirmation.tsx b/frontend/src/pages/SubmitConfirmation.tsx index 65dd665..3559d7d 100644 --- a/frontend/src/pages/SubmitConfirmation.tsx +++ b/frontend/src/pages/SubmitConfirmation.tsx @@ -57,6 +57,12 @@ const SubmitConfirmation: React.FC = () => { {t('confirmation.submittedBy')} {program.createdBy} + {program.programBudget !== null && ( + + {t('confirmation.programBudget')} + ${program.programBudget.toLocaleString(i18n.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + )} diff --git a/frontend/src/pages/SubmitProgram.test.tsx b/frontend/src/pages/SubmitProgram.test.tsx index b97066a..94a32d6 100644 --- a/frontend/src/pages/SubmitProgram.test.tsx +++ b/frontend/src/pages/SubmitProgram.test.tsx @@ -78,6 +78,7 @@ describe('SubmitProgram', () => { createdAt: '2026-01-01T12:00:00', updatedAt: '2026-01-01T12:00:00', createdBy: 'Jane Doe', + programBudget: null, }; (programService.submitProgram as ReturnType).mockResolvedValue(mockResponse); @@ -100,6 +101,7 @@ describe('SubmitProgram', () => { programDescription: 'A test program', programTypeId: 1, createdBy: 'Jane Doe', + programBudget: null, }); expect(mockNavigate).toHaveBeenCalledWith('/submit/confirmation', { state: { program: mockResponse }, diff --git a/frontend/src/pages/SubmitProgram.tsx b/frontend/src/pages/SubmitProgram.tsx index 6e100f6..14fc191 100644 --- a/frontend/src/pages/SubmitProgram.tsx +++ b/frontend/src/pages/SubmitProgram.tsx @@ -12,6 +12,7 @@ interface FormErrors { programDescription?: string; programTypeId?: string; createdBy?: string; + programBudget?: string; } const SubmitProgram: React.FC = () => { @@ -24,6 +25,7 @@ const SubmitProgram: React.FC = () => { programDescription: '', programTypeId: 0, createdBy: '', + programBudget: null, }); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); @@ -52,6 +54,9 @@ const SubmitProgram: React.FC = () => { } else if (formData.createdBy.length > 255) { newErrors.createdBy = t('submit.validation.createdByMaxLength'); } + if (formData.programBudget !== null && formData.programBudget < 0) { + newErrors.programBudget = t('submit.validation.budgetMin'); + } return newErrors; }; @@ -193,6 +198,35 @@ const SubmitProgram: React.FC = () => { /> +
+ + {errors.programBudget && ( + + )} + + setFormData({ + ...formData, + programBudget: e.target.value ? Number(e.target.value) : null, + }) + } + placeholder={t('submit.programBudgetPlaceholder')} + min="0" + step="0.01" + aria-invalid={!!errors.programBudget} + /> +
+