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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ontario.program.dto;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
Expand All @@ -18,6 +19,7 @@ public record ProgramResponse(
LocalDateTime reviewedAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
String createdBy
String createdBy,
BigDecimal programBudget
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
) {
}
12 changes: 12 additions & 0 deletions backend/src/main/java/com/ontario/program/model/Program.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.ontario.program.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

import jakarta.persistence.Column;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -113,7 +114,8 @@ private ProgramResponse toResponse(Program program) {
program.getReviewedAt(),
program.getCreatedAt(),
program.getUpdatedAt(),
program.getCreatedBy()
program.getCreatedBy(),
program.getProgramBudget()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)));
Expand All @@ -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)))
Expand All @@ -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)))
Expand All @@ -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)))
Expand Down Expand Up @@ -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"))
Expand Down
2 changes: 2 additions & 0 deletions database/migrations/V005__add_program_budget.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE program
ADD program_budget DECIMAL(15, 2) NULL;
3 changes: 3 additions & 0 deletions docs/data-dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 |
2 changes: 2 additions & 0 deletions docs/design-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`

Expand Down
7 changes: 6 additions & 1 deletion frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@
"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",
"descriptionRequired": "Program description is required",
"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": {
Expand All @@ -52,6 +55,7 @@
"status": "Status",
"submittedAt": "Submitted At",
"submittedBy": "Submitted By",
"programBudget": "Program Budget",
"submitAnother": "Submit Another Request",
"searchPrograms": "Search Programs"
},
Expand Down Expand Up @@ -114,6 +118,7 @@
"status": "Status",
"submittedAt": "Submitted At",
"submittedBy": "Submitted By",
"programBudget": "Program Budget",
"reviewedAt": "Reviewed At",
"reviewAction": "Review Action",
"comments": "Reviewer Comments",
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@
"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",
"descriptionRequired": "La description du programme est requise",
"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": {
Expand All @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/ReviewDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ const mockPrograms = [
createdAt: '2026-01-16T10:00:00',
updatedAt: '2026-01-17T09:00:00',
createdBy: 'Mary Jane',
programBudget: null,
},
];

Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/ReviewDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/pages/ReviewDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ const ReviewDetail: React.FC = () => {
<th scope="row">{t('review.detail.submittedBy')}</th>
<td>{program.createdBy}</td>
</tr>
{program.programBudget !== null && (
<tr>
<th scope="row">{t('review.detail.programBudget')}</th>
<td>${program.programBudget.toLocaleString(i18n.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
</tr>
)}
{program.reviewedAt && (
<tr>
<th scope="row">{t('review.detail.reviewedAt')}</th>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/SearchPrograms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ const mockPrograms = [
createdAt: '2026-01-16T10:00:00',
updatedAt: '2026-01-17T09:00:00',
createdBy: 'Mary Jane',
programBudget: null,
},
];

Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/SubmitConfirmation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/pages/SubmitConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ const SubmitConfirmation: React.FC = () => {
<th scope="row">{t('confirmation.submittedBy')}</th>
<td>{program.createdBy}</td>
</tr>
{program.programBudget !== null && (
<tr>
<th scope="row">{t('confirmation.programBudget')}</th>
<td>${program.programBudget.toLocaleString(i18n.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
</tr>
)}
</tbody>
</table>

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/SubmitProgram.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).mockResolvedValue(mockResponse);

Expand All @@ -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 },
Expand Down
Loading
Loading