Skip to content

Commit 5b46705

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/epic11-3-schedule10-page
2 parents 7e297fa + de6416d commit 5b46705

63 files changed

Lines changed: 6349 additions & 98 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/analysis.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ jobs:
6868
checks: write
6969
security-events: write
7070
runs-on: ubuntu-24.04
71-
timeout-minutes: 5
71+
# Covers all four commands below — npm ci, lint, format:check AND a coverage run — not the tests
72+
# alone. Raised from 5 after PR #317 was cancelled at 4m58s with zero test failures: the same job
73+
# had passed at 3m22s one commit earlier, so the 5-minute budget was already two thirds spent and
74+
# a growing component suite left no room for runner variance. 10 restores headroom without
75+
# hiding a genuine hang.
76+
timeout-minutes: 10
7277
steps:
7378
- uses: bcgov/action-test-and-analyse@8f699e3fd3fadd9a6adf6f4b1f2638ef7ecfefb9 # v2.0.0
7479
env:

.github/workflows/merge.yml

Lines changed: 55 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,7 @@ jobs:
2929
# Get PR number for squash merges to main
3030
- id: pr
3131
uses: bcgov/action-get-pr@28b0adf8e4d40720d41f9c87356ce24b0a4bd6af # v0.3.1
32-
# TEST deploys run on every merge and fail visibly if misconfigured. PROD
33-
# stays behind ENABLE_OPENSHIFT_DEPLOY until its environment is ready (it
34-
# still lacks prod ORACLEDB_* secrets/vars and would fall back to the
35-
# repo-level test-database values); set the repo variable to "true" once
36-
# they're in place.
32+
# TEST deploys run on every merge and fail visibly if misconfigured.
3733
deploy-test:
3834
name: TEST Deploys (${{ needs.init.outputs.pr }})
3935
needs: [init]
@@ -52,60 +48,63 @@ jobs:
5248
with:
5349
target: test
5450

55-
deploy-prod:
56-
name: PROD Deploys (${{ needs.init.outputs.pr }})
57-
if: ${{ vars.ENABLE_OPENSHIFT_DEPLOY == 'true' }}
58-
needs: [tests, init]
59-
uses: ./.github/workflows/reusable-deploy.yml
60-
secrets: inherit
61-
with:
62-
environment: prod
63-
target: prod
64-
tag: ${{ needs.init.outputs.pr }}
65-
66-
# Sync Sysdig email alerts for PROD. No-ops cleanly if SYSDIG_API_TOKEN is
67-
# unset or monitoring/alerts/ is empty — adoption is gradual. Alert
68-
# templates live in monitoring/alerts/ in this repo; add or remove files
69-
# there to customize the alert set.
70-
monitor-prod:
71-
name: Sysdig Monitor (PROD)
72-
if: ${{ vars.ENABLE_OPENSHIFT_DEPLOY == 'true' }}
73-
needs: [deploy-prod]
74-
runs-on: ubuntu-24.04
75-
environment: prod
76-
permissions:
77-
contents: read
78-
timeout-minutes: 5
79-
steps:
80-
- uses: actions/checkout@v7
81-
- uses: bcgov/actions/sysdig-monitor@4ad61a784f1c17765b03d8d6de9737c1d3f4c0f2 # v0.5.0
82-
with:
83-
sysdig_api_token: ${{ secrets.SYSDIG_API_TOKEN }}
84-
oc_namespace: ${{ secrets.oc_namespace }}
85-
app: ${{ github.event.repository.name }}
86-
87-
promote:
88-
name: Promote Images
89-
if: ${{ vars.ENABLE_OPENSHIFT_DEPLOY == 'true' }}
90-
needs: [deploy-prod, init]
91-
runs-on: ubuntu-slim
92-
permissions:
93-
packages: write
94-
strategy:
95-
matrix:
96-
package: [backend, frontend]
97-
timeout-minutes: 1
98-
steps:
99-
- uses: shrink/actions-docker-registry-tag@e6aaef25c595b6e0edd18bf4c7dbfea3abd43299 # v5
100-
with:
101-
registry: ghcr.io
102-
repository: ${{ github.repository }}/${{ matrix.package }}
103-
target: ${{ needs.init.outputs.pr }}
104-
tags: prod
51+
# ── PROD pipeline: commented out until the prod environment is ready ──
52+
# It still lacks prod-scoped ORACLEDB_* secrets and would silently fall
53+
# back to the repo-level TEST database values. Once those are set, restore
54+
# the jobs below and add deploy-prod + promote back to the results job's
55+
# `needs` list.
56+
#
57+
# deploy-prod:
58+
# name: PROD Deploys (${{ needs.init.outputs.pr }})
59+
# needs: [tests, init]
60+
# uses: ./.github/workflows/reusable-deploy.yml
61+
# secrets: inherit
62+
# with:
63+
# environment: prod
64+
# target: prod
65+
# tag: ${{ needs.init.outputs.pr }}
66+
#
67+
# # Sync Sysdig email alerts for PROD. No-ops cleanly if SYSDIG_API_TOKEN is
68+
# # unset or monitoring/alerts/ is empty — adoption is gradual. Alert
69+
# # templates live in monitoring/alerts/ in this repo; add or remove files
70+
# # there to customize the alert set.
71+
# monitor-prod:
72+
# name: Sysdig Monitor (PROD)
73+
# needs: [deploy-prod]
74+
# runs-on: ubuntu-24.04
75+
# environment: prod
76+
# permissions:
77+
# contents: read
78+
# timeout-minutes: 5
79+
# steps:
80+
# - uses: actions/checkout@v7
81+
# - uses: bcgov/actions/sysdig-monitor@4ad61a784f1c17765b03d8d6de9737c1d3f4c0f2 # v0.5.0
82+
# with:
83+
# sysdig_api_token: ${{ secrets.SYSDIG_API_TOKEN }}
84+
# oc_namespace: ${{ secrets.oc_namespace }}
85+
# app: ${{ github.event.repository.name }}
86+
#
87+
# promote:
88+
# name: Promote Images
89+
# needs: [deploy-prod, init]
90+
# runs-on: ubuntu-slim
91+
# permissions:
92+
# packages: write
93+
# strategy:
94+
# matrix:
95+
# package: [backend, frontend]
96+
# timeout-minutes: 1
97+
# steps:
98+
# - uses: shrink/actions-docker-registry-tag@e6aaef25c595b6e0edd18bf4c7dbfea3abd43299 # v5
99+
# with:
100+
# registry: ghcr.io
101+
# repository: ${{ github.repository }}/${{ matrix.package }}
102+
# target: ${{ needs.init.outputs.pr }}
103+
# tags: prod
105104

106105
results:
107106
name: Merge Results
108-
needs: [init, deploy-test, tests, deploy-prod, promote]
107+
needs: [init, deploy-test, tests]
109108
if: always()
110109
runs-on: ubuntu-slim
111110
timeout-minutes: 1

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ FAM authentication is tracked separately. The dashboard currently displays the s
190190

191191
## OpenShift Status
192192

193-
OpenShift Gold is the destination environment, but the Gold project is not required for this local-dev scaffold. Pull requests always deploy a sandbox environment (zone = PR number mod 50). Merges to `main` deploy to TEST and then, if tests pass, to PROD in the same workflow run — but only while the `ENABLE_OPENSHIFT_DEPLOY` repository variable is `true`; it is left unset until the code is ready for those environments.
193+
OpenShift Gold is the destination environment, but the Gold project is not required for this local-dev scaffold. Pull requests always deploy a sandbox environment (zone = PR number mod 50). Merges to `main` deploy to TEST on every merge. The PROD pipeline (deploy, Sysdig monitor, image promotion) is commented out in `.github/workflows/merge.yml` until the `prod` GitHub environment has its own `ORACLEDB_*` secrets; restore those jobs to open PROD.
194194

195195
Deployed pods fail closed on authentication: JWT enforcement (`ILCR_SECURITY_ENABLED`) and the Oracle datasource (`ILCR_DATASOURCE_ENABLED`) both default to `true` and can be overridden per scope with GitHub variables (environment-first, then repository). The backend refuses to start a deployed pod with security off while the datasource is on (`DeployedSecurityGuard`), so mock auth can never serve real data from a public route; setting both variables to `false` yields a data-less mock-auth smoke deployment.
196196

backend/openshift.deploy.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@ objects:
251251
apiVersion: apps/v1
252252
kind: Deployment
253253
name: ${NAME}-${ZONE}-${COMPONENT}
254-
minReplicas: ${MIN_REPLICAS}
255-
maxReplicas: ${MAX_REPLICAS}
254+
minReplicas: ${{MIN_REPLICAS}}
255+
maxReplicas: ${{MAX_REPLICAS}}
256256
metrics:
257257
- type: Resource
258258
resource:

backend/src/main/java/ca/bc/gov/nrs/ilcr/exception/GlobalExceptionHandler.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,37 @@ public ResponseEntity<ProblemDetail> handleFieldValuesRequired(
406406
.body(problem);
407407
}
408408

409+
/**
410+
* Handles a business rejection carrying MORE THAN ONE legacy message (AD-8) — resolves each key to
411+
* its verbatim text and returns them together in the {@code messages} extension (same shape as the
412+
* required-field handler), with the exception's status. Used e.g. by the reporting-year open when
413+
* zero active mills exist (INF-001 + ERR-002 together).
414+
*
415+
* @param ex the exception carrying the ordered message keys and target status
416+
* @param request the current HTTP request
417+
* @return a {@link ProblemDetail} with the exception's status and a {@code messages} array
418+
*/
419+
@ExceptionHandler(MultiMessageException.class)
420+
public ResponseEntity<ProblemDetail> handleMultiMessage(
421+
MultiMessageException ex, HttpServletRequest request) {
422+
log.info("Multi-message business rejection ({}): {}", ex.getStatus(), ex.getMessageKeys());
423+
424+
var messages = ex.getMessageKeys().stream()
425+
.map(key -> new FieldMessage(
426+
key, messageSource.getMessage(key, null, key, LocaleContextHolder.getLocale())))
427+
.toList();
428+
429+
ProblemDetail problem = ProblemDetail.forStatus(ex.getStatus());
430+
problem.setTitle(ex.getStatus().getReasonPhrase());
431+
problem.setDetail(messages.stream().map(FieldMessage::text).collect(Collectors.joining("; ")));
432+
problem.setInstance(URI.create(request.getRequestURI()));
433+
problem.setProperty("messages", messages);
434+
435+
return ResponseEntity.status(ex.getStatus())
436+
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
437+
.body(problem);
438+
}
439+
409440
/**
410441
* Handles a missing required request parameter (e.g. absent {@code millId}/{@code year}) and
411442
* returns a 400 problem response. Without this handler these fall through to the generic 500
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package ca.bc.gov.nrs.ilcr.exception;
2+
3+
import java.util.List;
4+
import org.springframework.http.HttpStatus;
5+
6+
/**
7+
* A business rejection that surfaces MORE THAN ONE legacy message at once (AD-8) — for the cases where
8+
* legacy emitted several bundle messages together for a single outcome. {@link GlobalExceptionHandler}
9+
* resolves each key to its verbatim text and returns them together in the {@code messages} extension
10+
* (the same {@code {key, text}} shape as {@link FieldValuesRequiredException}), with the given status.
11+
* Keys are non-parameterized (the handler resolves them without arguments).
12+
*/
13+
public class MultiMessageException extends RuntimeException {
14+
15+
private final transient HttpStatus status;
16+
private final transient List<String> messageKeys;
17+
18+
public MultiMessageException(HttpStatus status, List<String> messageKeys) {
19+
super("Business rejection: " + (messageKeys == null ? "none" : String.join(", ", messageKeys)));
20+
this.status = status;
21+
this.messageKeys = messageKeys == null ? List.of() : List.copyOf(messageKeys);
22+
}
23+
24+
public HttpStatus getStatus() {
25+
return status;
26+
}
27+
28+
public List<String> getMessageKeys() {
29+
return messageKeys;
30+
}
31+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent;
2+
3+
import ca.bc.gov.nrs.ilcr.dto.base.Role;
4+
import ca.bc.gov.nrs.ilcr.homecontent.api.HomeContentApi;
5+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentEntry;
6+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentSaveRequest;
7+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentSaveResponse;
8+
import java.util.List;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
10+
import org.springframework.context.MessageSource;
11+
import org.springframework.context.i18n.LocaleContextHolder;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.access.prepost.PreAuthorize;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.core.GrantedAuthority;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
/**
19+
* Content Editing endpoints (Story 24.2 / UC-CNT-001). {@code list}/{@code save} are gated on the
20+
* ADMIN-only {@code EDIT_HOME_CONTENT} action (S13); {@code mine} is authenticated-only so the Home
21+
* page can render the viewer's role message. Resolves the verbatim success text here (AD-8).
22+
*/
23+
@RestController
24+
@ConditionalOnProperty(name = "ilcr.datasource.enabled", havingValue = "true")
25+
public class HomeContentController implements HomeContentApi {
26+
27+
private static final String MSG_SAVED = "dataSavedSuccesfullyInfoMsg";
28+
29+
private final HomeContentService service;
30+
private final MessageSource messageSource;
31+
32+
public HomeContentController(HomeContentService service, MessageSource messageSource) {
33+
this.service = service;
34+
this.messageSource = messageSource;
35+
}
36+
37+
@Override
38+
@PreAuthorize("@permissions.hasPermission(authentication, 'EDIT_HOME_CONTENT')")
39+
public ResponseEntity<List<HomeContentEntry>> list(Authentication authentication) {
40+
return ResponseEntity.ok(service.readAll());
41+
}
42+
43+
@Override
44+
@PreAuthorize("isAuthenticated()")
45+
public ResponseEntity<HomeContentEntry> mine(Authentication authentication) {
46+
return ResponseEntity.ok(service.readForRole(contentRoleOf(authentication)));
47+
}
48+
49+
@Override
50+
@PreAuthorize("@permissions.hasPermission(authentication, 'EDIT_HOME_CONTENT')")
51+
public ResponseEntity<HomeContentSaveResponse> save(
52+
HomeContentSaveRequest request, Authentication authentication) {
53+
service.saveAll(request, authentication.getName());
54+
String message =
55+
messageSource.getMessage(MSG_SAVED, null, MSG_SAVED, LocaleContextHolder.getLocale());
56+
return ResponseEntity.ok(new HomeContentSaveResponse(MSG_SAVED, message, service.readAll()));
57+
}
58+
59+
/** ILCR_ADMIN → the Administrator message; everyone else (Licensee/submitter) → the Licensee one. */
60+
private static String contentRoleOf(Authentication authentication) {
61+
boolean admin = authentication != null && authentication.getAuthorities().stream()
62+
.map(GrantedAuthority::getAuthority)
63+
.map(Role::fromValue)
64+
.anyMatch(role -> role == Role.ADMIN);
65+
return admin ? HomeContentService.ROLE_ADMIN : HomeContentService.ROLE_LICENSEE;
66+
}
67+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent;
2+
3+
import ca.bc.gov.nrs.ilcr.exception.BusinessException;
4+
import org.springframework.http.HttpStatus;
5+
6+
/**
7+
* Business failures for Home content editing (Story 24.2 / UC-CNT-001): a role's message record
8+
* missing at load/save (404, ERR-002) or a message exceeding the column cap (400). Carries a
9+
* {@code messages.properties} key the {@code GlobalExceptionHandler} resolves (AD-8). Blank-editor
10+
* rejections use {@code FieldValuesRequiredException} (all blanks reported together, FLD-001).
11+
*/
12+
public class HomeContentException extends BusinessException {
13+
14+
private HomeContentException(HttpStatus status, String messageKey) {
15+
super(status, messageKey);
16+
}
17+
18+
/** A role's message record does not exist (S10). */
19+
public static HomeContentException contentNotFound() {
20+
return new HomeContentException(HttpStatus.NOT_FOUND, "homeContentNotFoundErrorMsg");
21+
}
22+
23+
/** A message exceeds the {@code MESSAGE_TEXT} column cap (4000). */
24+
public static HomeContentException tooLong() {
25+
return new HomeContentException(HttpStatus.BAD_REQUEST, "homeContentTooLongErrorMsg");
26+
}
27+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent;
2+
3+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentEntry;
4+
import java.util.List;
5+
import java.util.Optional;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.jdbc.core.RowMapper;
8+
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
9+
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
10+
import org.springframework.stereotype.Repository;
11+
12+
/**
13+
* Reads and updates the role-keyed Home messages in the legacy {@code THE.ILCR_ROLE} table (Story 24.2
14+
* / UC-CNT-001): PK {@code ILCR_ROLE_NAME} ({@code LICENSEE}/{@code AUDITOR}/{@code ADMIN}), the
15+
* rich-text {@code MESSAGE_TEXT VARCHAR2(4000)}, and the NOT NULL audit quartet. Every value is a bound
16+
* named parameter; the acting admin + {@code SYSTIMESTAMP} are stamped on each update (AD-11).
17+
*/
18+
@Repository
19+
@ConditionalOnProperty(name = "ilcr.datasource.enabled", havingValue = "true")
20+
public class HomeContentRepository {
21+
22+
private static final RowMapper<HomeContentEntry> MAPPER =
23+
(rs, rowNum) -> new HomeContentEntry(rs.getString("ILCR_ROLE_NAME"), rs.getString("MESSAGE_TEXT"));
24+
25+
private final NamedParameterJdbcTemplate jdbc;
26+
27+
public HomeContentRepository(NamedParameterJdbcTemplate jdbc) {
28+
this.jdbc = jdbc;
29+
}
30+
31+
/** All role messages, role-ordered (the Content Editing page loads all three). */
32+
public List<HomeContentEntry> findAll() {
33+
return jdbc.query(
34+
"SELECT ILCR_ROLE_NAME, MESSAGE_TEXT FROM THE.ILCR_ROLE ORDER BY ILCR_ROLE_NAME", MAPPER);
35+
}
36+
37+
/** One role's message (the Home render of the viewer's role), or empty when the row is absent. */
38+
public Optional<HomeContentEntry> findByRole(String role) {
39+
return jdbc.query(
40+
"SELECT ILCR_ROLE_NAME, MESSAGE_TEXT FROM THE.ILCR_ROLE WHERE ILCR_ROLE_NAME = :role",
41+
new MapSqlParameterSource("role", role), MAPPER).stream().findFirst();
42+
}
43+
44+
/** Update one role's message + audit columns; returns rows affected (0 when the role is absent). */
45+
public int updateMessage(String role, String messageText, String user) {
46+
return jdbc.update(
47+
"UPDATE THE.ILCR_ROLE SET MESSAGE_TEXT = :text, UPDATE_USERID = :user, "
48+
+ "UPDATE_TIMESTAMP = SYSTIMESTAMP, REVISION_COUNT = REVISION_COUNT + 1 "
49+
+ "WHERE ILCR_ROLE_NAME = :role",
50+
new MapSqlParameterSource()
51+
.addValue("text", messageText)
52+
.addValue("user", user)
53+
.addValue("role", role));
54+
}
55+
}

0 commit comments

Comments
 (0)