feat(geschichte): bound title and JOURNEY intro at all three layers
Title: requireTitle() turns the VARCHAR(255) DB bound into a friendly 400 (GESCHICHTE_TITLE_TOO_LONG). JOURNEY intro: MAX_INTRO_LENGTH = 4000 in bodyForType() plus a V75 CHECK as atomic backstop (defensive clamp first, STORY bodies exempt) — GESCHICHTE_INTRO_TOO_LONG. Both codes wired through ErrorCode.java, errors.ts, getErrorMessage and de/en/es. DB-layer boundary pins added: exactly-2000 note insert (V74 CHECK) and 4000/4001 intro inserts against real Postgres. Docs: error-code lists, db puml diagrams. The matching maxlength attributes land with the component commits. Review round 3: Markus (b), Nora (1), Sara (DB 2000 boundary). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ public enum ErrorCode {
|
|||||||
GESCHICHTE_TYPE_IMMUTABLE,
|
GESCHICHTE_TYPE_IMMUTABLE,
|
||||||
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||||
JOURNEY_NOTE_TOO_LONG,
|
JOURNEY_NOTE_TOO_LONG,
|
||||||
|
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||||
|
GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||||
|
GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ public class GeschichteService {
|
|||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
private static final int MAX_LIMIT = 200;
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||||
|
// turns what would be a DB-level 500 into a friendly 400.
|
||||||
|
static final int MAX_TITLE_LENGTH = 255;
|
||||||
|
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||||
|
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||||
|
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||||
|
// unbounded on purpose.
|
||||||
|
static final int MAX_INTRO_LENGTH = 4000;
|
||||||
|
|
||||||
// ─── Read API ────────────────────────────────────────────────────────────
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public long countPublished() {
|
public long countPublished() {
|
||||||
@@ -205,6 +214,10 @@ public class GeschichteService {
|
|||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||||
}
|
}
|
||||||
|
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,7 +227,14 @@ public class GeschichteService {
|
|||||||
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||||
*/
|
*/
|
||||||
private String bodyForType(GeschichteType type, String body) {
|
private String bodyForType(GeschichteType type, String body) {
|
||||||
return type == GeschichteType.JOURNEY ? body : sanitize(body);
|
if (type != GeschichteType.JOURNEY) {
|
||||||
|
return sanitize(body);
|
||||||
|
}
|
||||||
|
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sanitize(String body) {
|
private String sanitize(String body) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
|
||||||
|
-- three-layer bound as journey notes: frontend maxlength, the
|
||||||
|
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
|
||||||
|
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
|
||||||
|
--
|
||||||
|
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
|
||||||
|
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
|
||||||
|
|
||||||
|
-- Defensive clamp first: intros written before this migration may exceed the
|
||||||
|
-- cap. No-op on a clean database.
|
||||||
|
UPDATE geschichten SET body = left(body, 4000)
|
||||||
|
WHERE type = 'JOURNEY' AND length(body) > 4000;
|
||||||
|
|
||||||
|
ALTER TABLE geschichten
|
||||||
|
ADD CONSTRAINT chk_geschichte_journey_intro_length
|
||||||
|
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
|
||||||
|
* class level (see JourneyItemConstraintsTest for the rationale).
|
||||||
|
*
|
||||||
|
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
|
||||||
|
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class GeschichteConstraintsTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private UUID insertGeschichte(String type, String body) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
|
||||||
|
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
|
||||||
|
id, "Constraints-Test", body, type);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void journey_intro_check_rejects_4001_chars() {
|
||||||
|
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
|
||||||
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void journey_intro_check_accepts_exactly_4000_chars() {
|
||||||
|
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void story_bodies_are_not_constrained_by_the_intro_check() {
|
||||||
|
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -477,6 +477,114 @@ class GeschichteServiceTest {
|
|||||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── length caps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(256));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_accepts_title_of_exactly_255_chars() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(255));
|
||||||
|
|
||||||
|
assertThat(geschichteService.create(dto).title()).hasSize(255);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("x".repeat(256));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Winterbriefe");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
dto.setBody("x".repeat(4001));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Winterbriefe");
|
||||||
|
dto.setType(GeschichteType.JOURNEY);
|
||||||
|
dto.setBody("x".repeat(4000));
|
||||||
|
|
||||||
|
assertThat(geschichteService.create(dto).body()).hasSize(4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.JOURNEY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("x".repeat(4001));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
|
||||||
|
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
|
||||||
|
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setType(GeschichteType.STORY);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
|
||||||
|
|
||||||
|
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── update ──────────────────────────────────────────────────────────────
|
// ─── update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ class JourneyItemConstraintsTest {
|
|||||||
.isInstanceOf(DataIntegrityViolationException.class);
|
.isInstanceOf(DataIntegrityViolationException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void note_length_check_accepts_exactly_2000_chars() {
|
||||||
|
// Pins the boundary at the DB layer too — a future <= vs < migration edit
|
||||||
|
// must fail here, not only in the mock-based service test.
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||||
|
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void position_check_rejects_nonpositive() {
|
void position_check_rejects_nonpositive() {
|
||||||
UUID itemId = UUID.randomUUID();
|
UUID itemId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`. |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ package "Supporting" {
|
|||||||
id : UUID <<PK>>
|
id : UUID <<PK>>
|
||||||
--
|
--
|
||||||
title : VARCHAR(255) NOT NULL
|
title : VARCHAR(255) NOT NULL
|
||||||
body : TEXT
|
body : TEXT CHECK (JOURNEY: length <= 4000)
|
||||||
status : VARCHAR(32) NOT NULL
|
status : VARCHAR(32) NOT NULL
|
||||||
type : VARCHAR(32) NOT NULL
|
type : VARCHAR(32) NOT NULL
|
||||||
author_id : UUID <<FK>>
|
author_id : UUID <<FK>>
|
||||||
|
|||||||
@@ -132,5 +132,6 @@ geschichten_persons }o--|| persons : person_id
|
|||||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||||
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
||||||
|
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@
|
|||||||
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
|
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
|
||||||
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
|
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
|
||||||
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
||||||
|
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
||||||
|
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
|
||||||
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
||||||
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@
|
|||||||
"journey_publish_disabled_title": "Title and at least one entry required",
|
"journey_publish_disabled_title": "Title and at least one entry required",
|
||||||
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
|
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
|
||||||
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
||||||
|
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
|
||||||
|
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
|
||||||
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
||||||
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@
|
|||||||
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
|
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
|
||||||
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
|
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
|
||||||
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
||||||
|
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
||||||
|
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
|
||||||
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
||||||
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export type ErrorCode =
|
|||||||
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
|
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
|
||||||
| 'GESCHICHTE_TYPE_MISMATCH'
|
| 'GESCHICHTE_TYPE_MISMATCH'
|
||||||
| 'GESCHICHTE_TYPE_IMMUTABLE'
|
| 'GESCHICHTE_TYPE_IMMUTABLE'
|
||||||
|
| 'GESCHICHTE_TITLE_TOO_LONG'
|
||||||
|
| 'GESCHICHTE_INTRO_TOO_LONG'
|
||||||
| 'INVALID_CREDENTIALS'
|
| 'INVALID_CREDENTIALS'
|
||||||
| 'SESSION_EXPIRED'
|
| 'SESSION_EXPIRED'
|
||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
@@ -185,6 +187,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_geschichte_type_mismatch();
|
return m.error_geschichte_type_mismatch();
|
||||||
case 'GESCHICHTE_TYPE_IMMUTABLE':
|
case 'GESCHICHTE_TYPE_IMMUTABLE':
|
||||||
return m.error_geschichte_type_immutable();
|
return m.error_geschichte_type_immutable();
|
||||||
|
case 'GESCHICHTE_TITLE_TOO_LONG':
|
||||||
|
return m.error_geschichte_title_too_long();
|
||||||
|
case 'GESCHICHTE_INTRO_TOO_LONG':
|
||||||
|
return m.error_geschichte_intro_too_long();
|
||||||
case 'INVALID_CREDENTIALS':
|
case 'INVALID_CREDENTIALS':
|
||||||
return m.error_invalid_credentials();
|
return m.error_invalid_credentials();
|
||||||
case 'SESSION_EXPIRED':
|
case 'SESSION_EXPIRED':
|
||||||
|
|||||||
Reference in New Issue
Block a user