Skip to content

Commit 3783243

Browse files
authored
Merge branch 'main' into amb/demo-branch
2 parents 2dc87d0 + 6a9de69 commit 3783243

51 files changed

Lines changed: 5581 additions & 119 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:

backend/pom.xml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,40 @@
334334
</annotationProcessorPaths>
335335
</configuration>
336336
</plugin>
337+
<!-- Pre-compile the JasperReports templates (.jrxml -> .jasper) at build time, when the
338+
JVM is a JDK. The compiled .jasper carries the expression bytecode, so the runtime
339+
(a JRE container without javac) only LOADS them — never compiles. Runs after compile,
340+
over the resources already copied to target/classes/reports. -->
341+
<plugin>
342+
<groupId>org.codehaus.mojo</groupId>
343+
<artifactId>exec-maven-plugin</artifactId>
344+
<version>3.6.3</version>
345+
<executions>
346+
<execution>
347+
<id>precompile-jasper-reports</id>
348+
<phase>process-classes</phase>
349+
<goals>
350+
<goal>exec</goal>
351+
</goals>
352+
<configuration>
353+
<!-- Fork a real java (not exec:java) so JasperReports' nested javac
354+
inherits the full project classpath (java.class.path) and can resolve
355+
the JR classes when compiling the report-expression class.
356+
${java.home}/bin/java pins it to Maven's own JVM (guaranteed a JDK
357+
here) rather than whatever `java` PATH resolves to — a PATH JRE would
358+
have no javac and reproduce the very failure this precompile fixes. -->
359+
<executable>${java.home}/bin/java</executable>
360+
<classpathScope>compile</classpathScope>
361+
<arguments>
362+
<argument>-classpath</argument>
363+
<classpath/>
364+
<argument>ca.bc.gov.nrs.ilcr.reporting.ReportPrecompiler</argument>
365+
<argument>${project.build.outputDirectory}/reports</argument>
366+
</arguments>
367+
</configuration>
368+
</execution>
369+
</executions>
370+
</plugin>
337371
<plugin>
338372
<groupId>org.springframework.boot</groupId>
339373
<artifactId>spring-boot-maven-plugin</artifactId>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
package ca.bc.gov.nrs.ilcr;
22

3+
import java.util.TimeZone;
34
import org.springframework.boot.SpringApplication;
45
import org.springframework.boot.autoconfigure.SpringBootApplication;
56

67
@SpringBootApplication
78
public class IlcrBackendApplication {
89

910
public static void main(String[] args) {
11+
// Anchor the JVM to Pacific BEFORE the context refreshes. The app serves the BC Ministry of
12+
// Forests, but its OpenShift containers default to UTC, which shifts date boundaries
13+
// (reporting-year rollover, SYSDATE/audit timestamps read as java.util.Date). Setting it here
14+
// — ahead of SpringApplication.run — is deterministic: logging init, the Hikari pool, and every
15+
// bean's @PostConstruct all observe Pacific, with no startup window left in UTC. Deploy may
16+
// also set TZ=America/Vancouver on the container; this guarantees it even when it doesn't.
17+
TimeZone.setDefault(TimeZone.getTimeZone("America/Vancouver"));
1018
SpringApplication.run(IlcrBackendApplication.class, args);
1119
}
1220
}
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+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent;
2+
3+
import ca.bc.gov.nrs.ilcr.exception.FieldValuesRequiredException;
4+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentEntry;
5+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentSaveRequest;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
/**
15+
* Content Editing service (Story 24.2 / UC-CNT-001): reads the three role messages, serves one role's
16+
* message for the Home render, and saves all three ATOMICALLY (A-3 — the legacy per-role
17+
* non-atomic save S09 is fixed: one transaction, all-or-nothing).
18+
*
19+
* <p>Each editor is required (FLD-001) — validated before any write so a rejection saves nothing, with
20+
* ALL blank editors reported together. On save the legacy transform is applied (tabs/newlines/{@code
21+
* &nbsp;}, D-3). Rich text is stored raw (legacy stored the WYSIWYG HTML unsanitized); the Home render
22+
* sanitizes with DOMPurify (defence-in-depth), so no server-side HTML rewrite happens here.
23+
*/
24+
@Slf4j
25+
@Service
26+
@ConditionalOnProperty(name = "ilcr.datasource.enabled", havingValue = "true")
27+
public class HomeContentService {
28+
29+
static final String ROLE_LICENSEE = "LICENSEE";
30+
static final String ROLE_AUDITOR = "AUDITOR";
31+
static final String ROLE_ADMIN = "ADMIN";
32+
private static final int MAX_MESSAGE_LENGTH = 4000;
33+
// Field labels for the FLD-001 required-field messages — verbatim from legacy content.xhtml.
34+
private static final String LABEL_LICENSEE = "Licensee Welcome Message";
35+
private static final String LABEL_AUDITOR = "Auditor Welcome Message";
36+
private static final String LABEL_ADMIN = "Administrator Welcome Message";
37+
38+
private final HomeContentRepository repository;
39+
40+
public HomeContentService(HomeContentRepository repository) {
41+
this.repository = repository;
42+
}
43+
44+
/** All three role messages for the Content Editing page. */
45+
public List<HomeContentEntry> readAll() {
46+
return repository.findAll();
47+
}
48+
49+
/** The message for one role — the Home render of the viewer's role (empty text when none). */
50+
public HomeContentEntry readForRole(String role) {
51+
return repository.findByRole(role).orElse(new HomeContentEntry(role, null));
52+
}
53+
54+
/**
55+
* Save all three role messages in one transaction (A-3). Validate every editor first (FLD-001, all
56+
* blanks together), then transform + update each; any failure rolls the whole save back.
57+
*
58+
* @param request the three messages
59+
* @param user the acting administrator (audit)
60+
*/
61+
@Transactional
62+
public void saveAll(HomeContentSaveRequest request, String user) {
63+
List<RoleMessage> messages = List.of(
64+
new RoleMessage(ROLE_LICENSEE, LABEL_LICENSEE, request.licensee()),
65+
new RoleMessage(ROLE_AUDITOR, LABEL_AUDITOR, request.auditor()),
66+
new RoleMessage(ROLE_ADMIN, LABEL_ADMIN, request.administrator()));
67+
68+
List<String> blankLabels = new ArrayList<>();
69+
for (RoleMessage message : messages) {
70+
if (isBlankHtml(message.text())) {
71+
blankLabels.add(message.label());
72+
}
73+
}
74+
if (!blankLabels.isEmpty()) {
75+
throw new FieldValuesRequiredException(blankLabels);
76+
}
77+
78+
for (RoleMessage message : messages) {
79+
String transformed = transform(message.text());
80+
// Cap by BYTES: MESSAGE_TEXT is VARCHAR2(4000 BYTE), so multi-byte content (smart quotes,
81+
// em dashes from paste) could pass a char-count check and then fail the insert with ORA-12899.
82+
if (transformed.getBytes(StandardCharsets.UTF_8).length > MAX_MESSAGE_LENGTH) {
83+
throw HomeContentException.tooLong();
84+
}
85+
if (repository.updateMessage(message.role(), transformed, user) == 0) {
86+
throw HomeContentException.contentNotFound();
87+
}
88+
}
89+
log.info("Home content updated (3 role messages) by {}", user);
90+
}
91+
92+
/** Empty once tags, {@code &nbsp;} and whitespace are stripped — the required-editor check. */
93+
private static boolean isBlankHtml(String html) {
94+
if (html == null) {
95+
return true;
96+
}
97+
return html.replaceAll("<[^>]*>", "").replace("&nbsp;", " ").strip().isEmpty();
98+
}
99+
100+
/**
101+
* Save-transform after {@code CoreUtil.replaceCharsForExtractFormat}: tab &rarr; two spaces, newline
102+
* &rarr; one space. Legacy dropped {@code &nbsp;} entirely (CoreUtil.java:972), but the legacy
103+
* PrimeFaces editor rarely emitted it; TipTap emits {@code &nbsp;} for leading/consecutive spaces,
104+
* so dropping it would silently delete word breaks. We map it to a space instead (deliberate,
105+
* editor-driven deviation from legacy).
106+
*/
107+
private static String transform(String text) {
108+
return text.replace("\t", " ").replace("\n", " ").replace("&nbsp;", " ");
109+
}
110+
111+
private record RoleMessage(String role, String label, String text) {}
112+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent.api;
2+
3+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentEntry;
4+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentSaveRequest;
5+
import ca.bc.gov.nrs.ilcr.homecontent.dto.HomeContentSaveResponse;
6+
import java.util.List;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PutMapping;
11+
import org.springframework.web.bind.annotation.RequestBody;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
14+
/**
15+
* Content Editing API contract (Story 24.2 / UC-CNT-001; controller + api-interface split). The
16+
* interface owns the request mapping; {@code HomeContentController} adds authorization. {@code list}
17+
* and {@code save} are ADMIN-only ({@code EDIT_HOME_CONTENT}, 403 for a submitter, S13); {@code mine}
18+
* is authenticated-only — any signed-in user reads their own role's message for the Home render.
19+
*/
20+
@RequestMapping("/api/v1/home-content")
21+
public interface HomeContentApi {
22+
23+
/**
24+
* The three role messages for the Content Editing page.
25+
*
26+
* @param authentication the caller (must hold {@code EDIT_HOME_CONTENT})
27+
* @return 200 with the role messages
28+
*/
29+
@GetMapping
30+
ResponseEntity<List<HomeContentEntry>> list(Authentication authentication);
31+
32+
/**
33+
* The message for the CALLER's role — the Home page render (Licensee for a submitter, Administrator
34+
* for an admin). Authenticated but not admin-gated.
35+
*
36+
* @param authentication the caller
37+
* @return 200 with the caller-role message
38+
*/
39+
@GetMapping("/mine")
40+
ResponseEntity<HomeContentEntry> mine(Authentication authentication);
41+
42+
/**
43+
* Save all three role messages atomically. Any blank editor → 400 with all blanks reported together
44+
* (FLD-001) and nothing saved; a message over the column cap → 400; a missing role row → 404.
45+
*
46+
* @param request the three messages
47+
* @param authentication the caller (must hold {@code EDIT_HOME_CONTENT}; drives the audit user)
48+
* @return 200 with the verbatim success message + reloaded messages
49+
*/
50+
@PutMapping
51+
ResponseEntity<HomeContentSaveResponse> save(
52+
@RequestBody HomeContentSaveRequest request, Authentication authentication);
53+
}

0 commit comments

Comments
 (0)