docs(sprint-1-plan, chunk 2/4): W2 domain (Idea entity + OnboardingHook) and W3 API
@@ -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)._
|
||||
Reference in New Issue
Block a user