Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Expand Up @@ -7,6 +7,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
Expand All @@ -27,6 +28,15 @@ public class SecurityConfiguration {
"/api/prometheus"
};

// Home-page option-list endpoints (Story 1.1). Pre-selection reads with no action gate and no
// @PreAuthorize; permitted even when security is enabled. The per-user mill-association filter
// arrives with the FAM auth story (AR4); until then these are open like the other pre-auth reads.
private static final String[] HOME_PUBLIC_PATHS = {
Comment thread
SScholefield marked this conversation as resolved.
Outdated
"/api/v1/mills",
"/api/v1/reporting-years",
"/api/v1/mill-context"
};

@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
Expand Down Expand Up @@ -58,6 +68,7 @@ public SecurityFilterChain securityFilterChain(
jwt.jwtAuthenticationConverter(cognitoGroupsConverter)))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/health", "/api/health/**", "/api/info").permitAll()
.requestMatchers(HttpMethod.GET, HOME_PUBLIC_PATHS).permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated());
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ca.bc.gov.nrs.ilcr.exception;

import java.util.List;

/**
* One or more required selection fields are missing/blank/invalid (UC-SEC-001 S04/S05/S08). Carries
* the ordered field labels (screen order — e.g. {@code Mill} before {@code Reporting Year}) so
* {@link GlobalExceptionHandler} can resolve the verbatim legacy required-field text
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field and
* return ALL messages together on a single 400 (S08) — unlike a typed {@code @RequestParam}, which
* would fail on the first field only.
*/
public class FieldValuesRequiredException extends RuntimeException {

private final transient List<String> fieldLabels;

public FieldValuesRequiredException(List<String> fieldLabels) {
super("Required fields missing: " + String.join(", ", fieldLabels));
this.fieldLabels = List.copyOf(fieldLabels);
}

public List<String> getFieldLabels() {
return fieldLabels;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
@Slf4j
public class GlobalExceptionHandler {

/** Legacy JSF required-field bundle key, ported verbatim (Story 1.2, AD-8). */
private static final String REQUIRED_FIELD_KEY = "javax.faces.component.UIInput.REQUIRED";

private final MessageSource messageSource;

public GlobalExceptionHandler(MessageSource messageSource) {
Expand Down Expand Up @@ -216,6 +219,45 @@ public ResponseEntity<ProblemDetail> handleAccessDenied(
.body(problem);
}

/**
* Handles missing/blank/invalid required selection fields (UC-SEC-001 S04/S05/S08, Story 1.2).
* Resolves the verbatim legacy required-field template
* ({@code javax.faces.component.UIInput.REQUIRED = "{0}: Value is required."}) once per field —
* passing the field label as the {@code {0}} argument (parameterized keys MUST get an args array)
* — and returns ALL field messages together on one 400: {@code detail} joins the texts and the
* {@code messages} extension property carries each {@code {key, text}} pair (the pinned shape the
* frontend renders per field, mirroring {@code MessageInfo}).
*
* @param ex the exception carrying the ordered missing-field labels
* @param request the current HTTP request
* @return a {@link ProblemDetail} response with HTTP 400 status and a {@code messages} array
*/
@ExceptionHandler(FieldValuesRequiredException.class)
public ResponseEntity<ProblemDetail> handleFieldValuesRequired(
FieldValuesRequiredException ex, HttpServletRequest request) {
log.debug("Required selection fields missing: {}", ex.getFieldLabels());

var messages = ex.getFieldLabels().stream()
.map(label -> new FieldMessage(
REQUIRED_FIELD_KEY,
messageSource.getMessage(
REQUIRED_FIELD_KEY,
new Object[] {label},
REQUIRED_FIELD_KEY,
LocaleContextHolder.getLocale())))
.toList();

ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation Failed");
problem.setDetail(messages.stream().map(FieldMessage::text).collect(Collectors.joining("; ")));
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("messages", messages);

return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}

/**
* Handles a missing required request parameter (e.g. absent {@code millId}/{@code year}) and
* returns a 400 problem response. Without this handler these fall through to the generic 500
Expand Down Expand Up @@ -343,6 +385,15 @@ public ResponseEntity<ProblemDetail> handleGenericException(
.body(problem);
}

/**
* One resolved field-level message on a 400 {@code messages} array: the legacy bundle key plus
* its resolved verbatim text (mirrors {@code MessageInfo}; pinned in Story 1.2's wire contract).
*
* @param key the legacy bundle key (e.g. {@code javax.faces.component.UIInput.REQUIRED})
* @param text the resolved verbatim text (e.g. {@code Mill: Value is required.})
*/
public record FieldMessage(String key, String text) {}

/**
* Attempts to extract the most useful message from a DataIntegrityViolationException.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ca.bc.gov.nrs.ilcr.millcontext;

import ca.bc.gov.nrs.ilcr.millcontext.api.MillContextApi;
import ca.bc.gov.nrs.ilcr.millcontext.dto.MillSummary;
import ca.bc.gov.nrs.ilcr.millcontext.dto.ReportingYear;
import ca.bc.gov.nrs.ilcr.millcontext.dto.WorkingContext;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

/**
* Home-page option-list endpoints (Story 1.1). Delegates to {@link MillContextService} and never
* touches the repository directly (AD-1 layering). These are pre-selection reads with NO
* {@code @PreAuthorize} — there are no roles/authorization yet (see the story's Authorization note);
* the security filter chain permits the two paths even when {@code ilcr.security.enabled=true}.
*/
@RestController
@RequiredArgsConstructor
public class MillContextController implements MillContextApi {

private final MillContextService millContextService;

@Override
public ResponseEntity<List<MillSummary>> listMills() {
return ResponseEntity.ok(millContextService.listMills());
}

@Override
public ResponseEntity<List<ReportingYear>> listReportingYears() {
return ResponseEntity.ok(millContextService.listReportingYears());
}

@Override
public ResponseEntity<WorkingContext> getMillContext(String millId, String year) {
return ResponseEntity.ok(millContextService.resolveWorkingContext(millId, year));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package ca.bc.gov.nrs.ilcr.millcontext;

import ca.bc.gov.nrs.ilcr.millcontext.dto.MillSummary;
import ca.bc.gov.nrs.ilcr.millcontext.dto.ReportingYear;
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
Expand All @@ -22,6 +25,218 @@ public MillContextRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}

/**
* Shared projection for the {@code MILL} ⋈ {@code ILCR_MILL_STATUS_XREF} selection columns, used
* by both {@link #findAllMills()} and {@link #findSelectableMillById(long)} so the two queries
* cannot drift. Reads by {@code THE} column name; {@code MILL_NUMBER} ({@code NUMBER(15)}) is read
* as a String display identifier (contract-pinned).
*/
private static final org.springframework.jdbc.core.RowMapper<MillSummary> MILL_SUMMARY_MAPPER =
(rs, rowNum) -> new MillSummary(
rs.getLong("MILL_ID"),
rs.getString("MILL_NUMBER"),
rs.getString("MILL_NAME"),
rs.getString("ILCR_MILL_STATUS_CODE"));

/**
* The mills for the Home page selection list, ordered by mill number ascending — full legacy
* {@code getMills()} parity: {@code from Mill m join fetch m.millStatusXref x join fetch
* x.millReportStatuses order by m.mill_number}. Both legacy inner joins are reproduced: a mill
* must have its one-to-one {@code ILCR_MILL_STATUS_XREF} row AND at least one
* {@code ILCR_MILL_REPORT_STATUS} row (any year, i.e. ever enrolled in reporting) to be listed
* (2026-07-21 review decision: match legacy exactly). Closed ({@code CLS}) mills are included —
* no status filter — so the closed-mill selection path (S06) stays reachable; no per-user
* association filter is applied (deferred to the auth story, AR4).
*
* <p>The status code comes from {@code THE.ILCR_MILL_STATUS_XREF}, whose PK
* {@code ILCR_MILL_STATUS_XREF_ID} is the one-to-one mill id (legacy
* {@code ILCRMillStatusXref} maps {@code @OneToOne @PrimaryKeyJoinColumn} to {@code Mill};
* consistent with the existing {@link #findMillStatusCodeForYear} join). Reads columns by their
* {@code THE} names via an explicit {@code RowMapper} (avoids Oracle uppercase-alias mapping
* pitfalls). {@code MILL_ID} tiebreaker keeps equal/NULL mill numbers deterministically ordered.
*
* @return the listable mills as {@link MillSummary}, ordered by mill number ascending
*/
public List<MillSummary> findAllMills() {
return jdbcClient.sql(
"""
SELECT m.MILL_ID, m.MILL_NUMBER, m.MILL_NAME, x.ILCR_MILL_STATUS_CODE
FROM THE.MILL m
JOIN THE.ILCR_MILL_STATUS_XREF x
ON x.ILCR_MILL_STATUS_XREF_ID = m.MILL_ID
WHERE EXISTS (SELECT 1
FROM THE.ILCR_MILL_REPORT_STATUS s
WHERE s.ILCR_MILL_ID = m.MILL_ID)
ORDER BY m.MILL_NUMBER, m.MILL_ID
""")
.query(MILL_SUMMARY_MAPPER)
.list();
}

/**
* The opened reporting years for the Home page selection list — every existing
* {@code THE.ILCR_REPORTING_PERIOD} row — ordered by {@code REPORT_YEAR} descending (BR-03,
* legacy {@code getReportingPeriods()}).
*
* @return the opened years as {@link ReportingYear}, most recent first
*/
public List<ReportingYear> findAllReportingYears() {
return jdbcClient.sql(
"""
SELECT REPORT_YEAR
FROM THE.ILCR_REPORTING_PERIOD
ORDER BY REPORT_YEAR DESC
""")
.query((rs, rowNum) -> new ReportingYear(rs.getInt("REPORT_YEAR")))
.list();
}

/**
* The selectable mill with this id — same join and enrollment predicate as {@link #findAllMills()}
* (legacy {@code getMills()} parity: status xref present AND at least one
* {@code ILCR_MILL_REPORT_STATUS} row for any year). Empty when the id is unknown or the mill is
* not selectable (Story 1.2 resolves that to 404, matching what legacy's server-controlled
* dropdown made unreachable).
*
* @param millId the mill id
* @return the mill as {@link MillSummary}, or empty when not selectable
*/
public Optional<MillSummary> findSelectableMillById(long millId) {
return jdbcClient.sql(
"""
SELECT m.MILL_ID, m.MILL_NUMBER, m.MILL_NAME, x.ILCR_MILL_STATUS_CODE
FROM THE.MILL m
JOIN THE.ILCR_MILL_STATUS_XREF x
ON x.ILCR_MILL_STATUS_XREF_ID = m.MILL_ID
WHERE m.MILL_ID = :millId
AND EXISTS (SELECT 1
FROM THE.ILCR_MILL_REPORT_STATUS s
WHERE s.ILCR_MILL_ID = m.MILL_ID)
""")
.param("millId", millId)
.query(MILL_SUMMARY_MAPPER)
.optional();
}

/**
* Whether the reporting year is opened (an {@code THE.ILCR_REPORTING_PERIOD} row exists).
*
* @param year the reporting year
* @return true when the year is opened
*/
public boolean reportingYearExists(int year) {
Integer count = jdbcClient.sql(
"""
SELECT COUNT(*)
FROM THE.ILCR_REPORTING_PERIOD
WHERE REPORT_YEAR = :year
""")
.param("year", year)
.query(Integer.class)
.single();
return count != null && count > 0;
}

/**
* The two independent track status codes for a (mill, year) pair — 0..1 row (PK). Either column
* may be NULL (legacy would NPE on a NULL silviculture code; Story 1.2 tolerates it — the track
* simply has no status).
*
* @param millId the mill id
* @param year the reporting year
* @return the pair of codes, or empty when no {@code ILCR_MILL_REPORT_STATUS} row exists (S07)
*/
public Optional<TrackCodes> findTrackStatusCodes(long millId, int year) {
return jdbcClient.sql(
"""
SELECT s.ILCR_MILL_REPORT_STATUS_CODE, s.MILL_SILVICULTUR_STATUS_CODE
FROM THE.ILCR_MILL_REPORT_STATUS s
WHERE s.ILCR_MILL_ID = :millId
AND s.REPORT_YEAR = :year
""")
.param("millId", millId)
.param("year", year)
.query((rs, rowNum) -> new TrackCodes(
rs.getString("ILCR_MILL_REPORT_STATUS_CODE"),
rs.getString("MILL_SILVICULTUR_STATUS_CODE")))
.optional();
}

/**
* The description for a report-status code from {@code THE.ILCR_MILL_REPORT_STATUS_CODE}
* (legacy {@code ILCRMillReportStatusCode} lookup cache).
*
* @param code the status code ({@code D}/{@code S}/{@code V}/{@code O})
* @return the description, or empty when the code has no lookup row
*/
public Optional<String> findStatusDescription(String code) {
return jdbcClient.sql(
"""
SELECT DESCRIPTION
FROM THE.ILCR_MILL_REPORT_STATUS_CODE
WHERE ILCR_MILL_REPORT_STATUS_CODE = :code
""")
.param("code", code)
.query(String.class)
.optional();
}

/**
* The per-status display-date strings for a (mill, year) pair from
* {@code THE.ILCR_MILL_REPORT_STATUS_RPT_VW} (a VIEW on the delivery DB; the test snapshot stands
* it in as a table — see {@code V6}). Values carry the legacy 3-character prefix; stripping is the
* service's job (mirrors {@code UserSessionMB.substring(3)}). Empty when the view has no row.
*
* <p>Uses first-row semantics ({@code .list()} + {@code findFirst}) rather than {@code .optional()}
* because {@code ILCR_MILL_REPORT_STATUS_RPT_VW} is a VIEW with no PK/uniqueness guarantee: legacy
* read it as a list and took {@code get(0)} ({@code MillReportStatusDAO.getMillReportStatusList}),
* so a multi-row view must resolve to the first row, not throw {@code IncorrectResultSizeDataAccessException}.
*
* @param millId the mill id
* @param year the reporting year
* @return the raw date strings, or empty when the view has no row for the pair
*/
public Optional<StatusDates> findStatusDates(long millId, int year) {
return jdbcClient.sql(
"""
SELECT MILL_STATUS_OPEN_DATE, MILL_STATUS_DRAFT_DATE, MILL_STATUS_SUBMIT_DATE,
MILL_STATUS_VERIFY_DATE, SILVI_STATUS_DRAFT_DATE, SILVI_STATUS_SUBMIT_DATE,
SILVI_STATUS_VERIFY_DATE
FROM THE.ILCR_MILL_REPORT_STATUS_RPT_VW
WHERE ILCR_MILL_ID = :millId
AND REPORT_YEAR = :year
""")
.param("millId", millId)
.param("year", year)
.query((rs, rowNum) -> new StatusDates(
rs.getString("MILL_STATUS_OPEN_DATE"),
rs.getString("MILL_STATUS_DRAFT_DATE"),
rs.getString("MILL_STATUS_SUBMIT_DATE"),
rs.getString("MILL_STATUS_VERIFY_DATE"),
rs.getString("SILVI_STATUS_DRAFT_DATE"),
rs.getString("SILVI_STATUS_SUBMIT_DATE"),
rs.getString("SILVI_STATUS_VERIFY_DATE")))
.list()
.stream()
.findFirst();
}

/**
* Projection: the two independent track status codes of one {@code ILCR_MILL_REPORT_STATUS} row.
*
* @param schedules1To10Code the Schedules 1–10 code ({@code ILCR_MILL_REPORT_STATUS_CODE}); nullable
* @param schedule11Code the Schedule 11 code ({@code MILL_SILVICULTUR_STATUS_CODE}); nullable
*/
public record TrackCodes(String schedules1To10Code, String schedule11Code) {}

/**
* Projection: the raw (still-prefixed) per-status date strings of one
* {@code ILCR_MILL_REPORT_STATUS_RPT_VW} row. Any component may be null.
*/
public record StatusDates(
String open1To10, String draft1To10, String submit1To10, String verify1To10,
String draftSilvi, String submitSilvi, String verifySilvi) {}

/**
* The mill's XREF status code ({@code ACT}/{@code CLS}) when an {@code ILCR_MILL_REPORT_STATUS}
* row exists for this mill and year; empty when the mill is unknown or has no status row for the
Expand Down
Loading
Loading