docs(sprint-1-plan, chunk 2/4): W2 domain (Idea entity + OnboardingHook) and W3 API

Patrick Plate
2026-06-24 14:50:39 +02:00
parent 3b88b603b1
commit c856a6f87d
+392
@@ -0,0 +1,392 @@
# Sprint 1 — Plan, Part 2 ("Spark")
_Continued from [Sprint-1-Plan](Sprint-1-Plan.md). Covers W2 (domain) and W3 (API)._
---
### W2 — Domain
**Goal:** Sparkboard owns its database schema. The `spark_org` and `ideas` tables exist; the single `Family Spark` org is seeded; `SparkboardOnboardingHook` creates a `memberships` row for every newly-signed-in user.
**Pre-requisite:** W0 and W1 complete. The backend boots and can talk to Postgres. plate-auth's Flyway has already created `auth_identities`, `memberships`, `invitations`, `access_requests`.
**Deliverables:**
1. [`backend/src/main/resources/db/migration/V1__init.sql`](../backend/src/main/resources/db/migration/V1__init.sql) — Sparkboard's first migration (`spark_org` + `ideas`).
2. [`backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql`](../backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql) — seed the single org row.
3. [`backend/src/main/java/de/plate/sparkboard/idea/Idea.java`](../backend/src/main/java/de/plate/sparkboard/idea/Idea.java) — JPA entity.
4. [`backend/src/main/java/de/plate/sparkboard/idea/IdeaStatus.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaStatus.java) — enum (`RAW`, `EXPLORING`, `BUILDING`, `SHIPPED`, `DEAD`).
5. [`backend/src/main/java/de/plate/sparkboard/idea/IdeaRepository.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaRepository.java) — `JpaRepository`.
6. [`backend/src/main/java/de/plate/sparkboard/idea/IdeaService.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaService.java).
7. [`backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java`](../backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java) — implements plate-auth's `OnboardingHook`.
8. [`backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardAdminProperties.java`](../backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardAdminProperties.java) — `@ConfigurationProperties("sparkboard")`.
**Acceptance gate:**
- Backend boots cleanly against an empty Postgres; Flyway applies V1 + V2 successfully.
- Run a fresh login through W1's `/login` flow → query DB → exactly one row exists in `memberships` for that user with `org_type='SPARK_ORG'`, `org_id='00000000-0000-0000-0000-000000000001'`, and `role` matching the `sparkboard.admins[]` list.
- Second login by the same user does **not** create a duplicate `memberships` row.
- Satisfies the membership half of **A3**.
**Code sketch — `V1__init.sql`:**
```sql
-- Sparkboard's first Flyway migration. plate-auth has already
-- created its own tables under flyway_schema_history_auth.
CREATE TABLE spark_org (
id UUID PRIMARY KEY,
name VARCHAR(80) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ideas (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES spark_org(id),
author_id UUID NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'RAW',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
archived_at TIMESTAMPTZ,
CONSTRAINT ck_ideas_status
CHECK (status IN ('RAW','EXPLORING','BUILDING','SHIPPED','DEAD'))
);
CREATE INDEX ix_ideas_org_id_created_at ON ideas(org_id, created_at DESC);
CREATE INDEX ix_ideas_author_id ON ideas(author_id);
```
Note: `author_id` is NOT a foreign key to `auth_identities.user_id`. The cross-history FK is intentionally avoided so that Sparkboard's Flyway is independent of plate-auth's table existence at migration time. Referential integrity is enforced at the application layer.
**Code sketch — `V2__seed_family_spark_org.sql`:**
```sql
INSERT INTO spark_org (id, name) VALUES
('00000000-0000-0000-0000-000000000001', 'Family Spark')
ON CONFLICT (id) DO NOTHING;
```
**Code sketch — [`Idea.java`](../backend/src/main/java/de/plate/sparkboard/idea/Idea.java):**
```java
package de.plate.sparkboard.idea;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "ideas")
public class Idea {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "org_id", nullable = false)
private UUID orgId;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private IdeaStatus status = IdeaStatus.RAW;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "archived_at")
private Instant archivedAt;
@PrePersist
void onPersist() {
Instant now = Instant.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// getters/setters omitted for brevity
}
```
**Code sketch — [`IdeaStatus.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaStatus.java):**
```java
package de.plate.sparkboard.idea;
public enum IdeaStatus {
RAW, EXPLORING, BUILDING, SHIPPED, DEAD;
}
```
**Code sketch — [`IdeaRepository.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaRepository.java):**
```java
package de.plate.sparkboard.idea;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface IdeaRepository extends JpaRepository<Idea, UUID> {
List<Idea> findByOrgIdAndArchivedAtIsNullOrderByCreatedAtDesc(UUID orgId);
}
```
**Code sketch — [`IdeaService.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaService.java):**
```java
package de.plate.sparkboard.idea;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
public class IdeaService {
private final IdeaRepository repo;
public IdeaService(IdeaRepository repo) {
this.repo = repo;
}
@Transactional
public Idea create(UUID orgId, UUID authorId, String title, String description) {
Idea idea = new Idea();
idea.setOrgId(orgId);
idea.setAuthorId(authorId);
idea.setTitle(title);
idea.setDescription(description);
idea.setStatus(IdeaStatus.RAW);
return repo.save(idea);
}
@Transactional(readOnly = true)
public List<Idea> listForOrg(UUID orgId) {
return repo.findByOrgIdAndArchivedAtIsNullOrderByCreatedAtDesc(orgId);
}
}
```
**Code sketch — [`SparkboardAdminProperties.java`](../backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardAdminProperties.java):**
```java
package de.plate.sparkboard.onboarding;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
import java.util.Locale;
@ConfigurationProperties(prefix = "sparkboard")
public class SparkboardAdminProperties {
private List<String> admins = List.of();
public List<String> getAdmins() { return admins; }
public void setAdmins(List<String> admins) { this.admins = admins; }
public boolean isAdminEmail(String email) {
if (email == null) return false;
String normalised = email.trim().toLowerCase(Locale.ROOT);
return admins.stream()
.anyMatch(a -> a.trim().toLowerCase(Locale.ROOT).equals(normalised));
}
}
```
Activated in `SparkboardApplication` with `@ConfigurationPropertiesScan` or `@EnableConfigurationProperties(SparkboardAdminProperties.class)`.
**Code sketch — [`SparkboardOnboardingHook.java`](../backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java):**
```java
package de.plate.sparkboard.onboarding;
import de.platesoft.auth.spi.OnboardingHook;
import de.platesoft.auth.spi.OnboardingContext;
import de.platesoft.auth.membership.MembershipService;
import de.platesoft.auth.membership.Role;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* The one and only Sparkboard customisation of plate-auth's SPI.
* Runs after a successful first login, before NextAuth's signIn
* callback returns to the user.
*
* Idempotent: runs every login (plate-auth promises first-login-only
* but we do not depend on that promise being bug-free).
*/
@Component
public class SparkboardOnboardingHook implements OnboardingHook {
public static final String ORG_TYPE = "SPARK_ORG";
public static final UUID FAMILY_SPARK_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
private final MembershipService memberships;
private final SparkboardAdminProperties admins;
public SparkboardOnboardingHook(MembershipService memberships,
SparkboardAdminProperties admins) {
this.memberships = memberships;
this.admins = admins;
}
@Override
public void afterFirstLogin(OnboardingContext ctx) {
Role role = admins.isAdminEmail(ctx.email()) ? Role.ADMIN : Role.MEMBER;
memberships.upsert(ctx.userId(), ORG_TYPE, FAMILY_SPARK_ID, role);
}
}
```
**Why this is all the code Sparkboard writes for membership management:**
- No `User` entity — plate-auth owns `auth_identities`.
- No `MembershipController` — plate-auth ships `/api/memberships/me`.
- No JWT claim mapping — plate-auth's `JwtAuthFilter` populates `SecurityContext.getAuthentication()` with the membership list.
The next workstream (W3) consumes that authenticated user.
---
### W3 — API
**Goal:** Sparkboard exposes `GET /api/ideas` (list ideas in the user's org) and `POST /api/ideas` (create idea owned by the user). Both require an authenticated session; plate-auth's `JwtAuthFilter` does the heavy lifting.
**Pre-requisite:** W2 complete. The `IdeaService` and `Idea` entity exist. plate-auth's `JwtAuthFilter` is registered (this happens automatically via `plate-auth-starter`'s auto-config — Sparkboard writes zero code for it).
**Deliverables:**
1. [`backend/src/main/java/de/plate/sparkboard/idea/IdeaController.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaController.java).
2. [`backend/src/main/java/de/plate/sparkboard/idea/IdeaDto.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaDto.java).
3. [`backend/src/main/java/de/plate/sparkboard/idea/CreateIdeaRequest.java`](../backend/src/main/java/de/plate/sparkboard/idea/CreateIdeaRequest.java).
4. _Optional:_ [`backend/src/main/java/de/plate/sparkboard/config/SparkboardSecurityCustomizations.java`](../backend/src/main/java/de/plate/sparkboard/config/SparkboardSecurityCustomizations.java) if plate-auth's default security chain does not protect `/api/ideas/**` automatically (TBD during W3 — see [Open Question Q06](Open-Questions.md#q06-security-customisation-needed-or-not)).
**Acceptance gate:**
- Unauthenticated `POST /api/ideas``401 Unauthorized`.
- Unauthenticated `GET /api/ideas``401 Unauthorized`.
- Authenticated `POST /api/ideas` with valid body → `201 Created` + JSON `IdeaDto`.
- Authenticated `POST /api/ideas` with empty/null `title``400 Bad Request` with field error.
- Authenticated `GET /api/ideas``200 OK` + JSON array, newest first, only ideas in the user's `SPARK_ORG`.
- Satisfies the API half of **A4**.
**Code sketch — [`CreateIdeaRequest.java`](../backend/src/main/java/de/plate/sparkboard/idea/CreateIdeaRequest.java):**
```java
package de.plate.sparkboard.idea;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateIdeaRequest(
@NotBlank @Size(max = 200) String title,
@Size(max = 10_000) String description
) {}
```
**Code sketch — [`IdeaDto.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaDto.java):**
```java
package de.plate.sparkboard.idea;
import java.time.Instant;
import java.util.UUID;
public record IdeaDto(
UUID id,
UUID authorId,
String title,
String description,
IdeaStatus status,
Instant createdAt,
Instant updatedAt
) {
public static IdeaDto from(Idea i) {
return new IdeaDto(
i.getId(), i.getAuthorId(), i.getTitle(), i.getDescription(),
i.getStatus(), i.getCreatedAt(), i.getUpdatedAt()
);
}
}
```
**Code sketch — [`IdeaController.java`](../backend/src/main/java/de/plate/sparkboard/idea/IdeaController.java):**
```java
package de.plate.sparkboard.idea;
import de.platesoft.auth.security.AuthenticatedUser;
import de.platesoft.auth.security.CurrentUser;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
import static de.plate.sparkboard.onboarding.SparkboardOnboardingHook.FAMILY_SPARK_ID;
@RestController
@RequestMapping("/api/ideas")
public class IdeaController {
private final IdeaService service;
public IdeaController(IdeaService service) {
this.service = service;
}
@GetMapping
public List<IdeaDto> list(@CurrentUser AuthenticatedUser user) {
// user.memberships() guarantees this user is a SPARK_ORG member
// (W2's OnboardingHook is what put them there)
return service.listForOrg(FAMILY_SPARK_ID)
.stream()
.map(IdeaDto::from)
.toList();
}
@PostMapping
public ResponseEntity<IdeaDto> create(@CurrentUser AuthenticatedUser user,
@Valid @RequestBody CreateIdeaRequest req) {
UUID authorId = user.userId();
Idea created = service.create(FAMILY_SPARK_ID, authorId, req.title(), req.description());
return ResponseEntity.status(HttpStatus.CREATED).body(IdeaDto.from(created));
}
}
```
`@CurrentUser` and `AuthenticatedUser` come from plate-auth's `de.platesoft.auth.security` package and are populated by plate-auth's `JwtAuthFilter`. Sparkboard does not implement either. See [plate-auth Architecture §3.2](https://git.plate-software.de/pplate/plate-auth/wiki/Architecture#32-spring-beans-the-consumer-can-inject).
**Why there is no error-mapping advice in W3:**
Spring Boot's default `@RestControllerAdvice` for `MethodArgumentNotValidException` (the `@Valid` failure) already returns a clean `400` with field errors. plate-auth's `JwtAuthFilter` already returns `401` for missing/invalid tokens. Sparkboard does not need its own `@RestControllerAdvice` in v1.
**Why `FAMILY_SPARK_ID` is a constant in the controller and not looked up from the user's memberships:**
In v1 there is exactly one org. Looking it up from `user.memberships()` would be defensive code that has no production value yet. In Sprint 2 (Kindling) or whenever a second org becomes possible, change this line to `user.memberships().stream().filter(m -> "SPARK_ORG".equals(m.orgType())).findFirst().get().orgId()`. Today, the constant is fine and self-documenting.
---
_Continued in [Sprint-1-Plan-Part-3](Sprint-1-Plan-Part-3.md): W4 (frontend), W5 (seed data), W6 (deploy + CI/CD)._