Skip to content

Commit 8c5d5f2

Browse files
authored
Merge branch 'main' into fix/home-content-followups
2 parents 5a7cfe7 + de6416d commit 8c5d5f2

40 files changed

Lines changed: 5173 additions & 35 deletions

.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:
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+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent.dto;
2+
3+
/**
4+
* One role-keyed Home message (Story 24.2 / UC-CNT-001). {@code role} is the {@code THE.ILCR_ROLE}
5+
* key ({@code LICENSEE} / {@code AUDITOR} / {@code ADMIN}); {@code messageText} is the stored rich-text
6+
* (HTML) message, or {@code null} when the role has no message yet.
7+
*
8+
* @param role the role key
9+
* @param messageText the rich-text message (HTML), may be {@code null}
10+
*/
11+
public record HomeContentEntry(String role, String messageText) {
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent.dto;
2+
3+
/**
4+
* The Content Editing save payload (Story 24.2 / UC-CNT-001): all three role messages, saved together
5+
* atomically (A-3). Each is required rich-text (HTML); a blank editor is rejected per-field (FLD-001).
6+
*
7+
* @param licensee the Licensee welcome message
8+
* @param auditor the Auditor-keyed welcome message
9+
* @param administrator the Administrator welcome message
10+
*/
11+
public record HomeContentSaveRequest(String licensee, String auditor, String administrator) {
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package ca.bc.gov.nrs.ilcr.homecontent.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* The response to a Content Editing save (Story 24.2 / UC-CNT-001): the verbatim success message
7+
* (AD-8) and the reloaded messages so the editors refresh in a single round-trip.
8+
*
9+
* @param messageKey the {@code messages.properties} key of the success message
10+
* @param message the verbatim success text (SUC-001)
11+
* @param entries the three role messages after the save
12+
*/
13+
public record HomeContentSaveResponse(String messageKey, String message, List<HomeContentEntry> entries) {
14+
}

backend/src/main/java/ca/bc/gov/nrs/ilcr/security/Action.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,11 @@ public enum Action {
2020
* Open a new reporting year (UC-RY-001) — the Administration ▸ Open Reporting Year surface. ADMIN-only,
2121
* like {@link #MAINTAIN_CODE_TABLES}: a SUBMITTER hitting the open-year API is denied 403.
2222
*/
23-
OPEN_REPORTING_YEAR
23+
OPEN_REPORTING_YEAR,
24+
/**
25+
* Edit the role-keyed Home welcome messages (Story 24.2, UC-CNT-001) — the Administration ▸ Home
26+
* Content surface. ADMIN-only: a SUBMITTER hitting the save API is denied 403. The read for Home
27+
* rendering is a separate, authenticated (non-admin) endpoint.
28+
*/
29+
EDIT_HOME_CONTENT
2430
}

0 commit comments

Comments
 (0)