diff --git a/Sprint-1-Plan-Part-2.md b/Sprint-1-Plan-Part-2.md new file mode 100644 index 0000000..be8efeb --- /dev/null +++ b/Sprint-1-Plan-Part-2.md @@ -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 { + List 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 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 admins = List.of(); + + public List getAdmins() { return admins; } + public void setAdmins(List 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 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 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)._