Table of Contents
Sprint 1 — Plan, Part 2 ("Spark")
Continued from Sprint-1-Plan. 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:
backend/src/main/resources/db/migration/V1__init.sql— Sparkboard's first migration (spark_org+ideas).backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql— seed the single org row.backend/src/main/java/de/plate/sparkboard/idea/Idea.java— JPA entity.backend/src/main/java/de/plate/sparkboard/idea/IdeaStatus.java— enum (RAW,EXPLORING,BUILDING,SHIPPED,DEAD).backend/src/main/java/de/plate/sparkboard/idea/IdeaRepository.java—JpaRepository.backend/src/main/java/de/plate/sparkboard/idea/IdeaService.java.backend/src/main/java/de/plate/sparkboard/onboarding/SparkboardOnboardingHook.java— implements plate-auth'sOnboardingHook.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
/loginflow → query DB → exactly one row exists inmembershipsfor that user withorg_type='SPARK_ORG',org_id='00000000-0000-0000-0000-000000000001', androlematching thesparkboard.admins[]list. - Second login by the same user does not create a duplicate
membershipsrow. - Satisfies the membership half of A3.
Code sketch — V1__init.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:
INSERT INTO spark_org (id, name) VALUES
('00000000-0000-0000-0000-000000000001', 'Family Spark')
ON CONFLICT (id) DO NOTHING;
Code sketch — Idea.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:
package de.plate.sparkboard.idea;
public enum IdeaStatus {
RAW, EXPLORING, BUILDING, SHIPPED, DEAD;
}
Code sketch — IdeaRepository.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:
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:
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:
package de.plate.sparkboard.onboarding;
import de.platesoft.auth.spi.OnboardingHook;
import de.platesoft.auth.model.AuthenticatedUser;
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 sign-in, before NextAuth's signIn
* callback returns to the user.
*
* Idempotent: runs every login (plate-auth promises first-sign-in-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 onFirstSignIn(AuthenticatedUser user) {
Role role = admins.isAdminEmail(user.email()) ? Role.ADMIN : Role.MEMBER;
memberships.upsert(user.id(), ORG_TYPE, FAMILY_SPARK_ID, role);
}
}
Why this is all the code Sparkboard writes for membership management:
- No
Userentity — plate-auth ownsauth_identities. - No
MembershipController— plate-auth ships/api/memberships/me. - No JWT claim mapping — plate-auth's
JwtAuthFilterpopulatesSecurityContext.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:
backend/src/main/java/de/plate/sparkboard/idea/IdeaController.java.backend/src/main/java/de/plate/sparkboard/idea/IdeaDto.java.backend/src/main/java/de/plate/sparkboard/idea/CreateIdeaRequest.java.- Optional:
backend/src/main/java/de/plate/sparkboard/config/SparkboardSecurityCustomizations.javaif plate-auth's default security chain does not protect/api/ideas/**automatically (TBD during W3 — see Open Question Q06).
Acceptance gate:
- Unauthenticated
POST /api/ideas→401 Unauthorized. - Unauthenticated
GET /api/ideas→401 Unauthorized. - Authenticated
POST /api/ideaswith valid body →201 Created+ JSONIdeaDto. - Authenticated
POST /api/ideaswith empty/nulltitle→400 Bad Requestwith field error. - Authenticated
GET /api/ideas→200 OK+ JSON array, newest first, only ideas in the user'sSPARK_ORG. - Satisfies the API half of A4.
Code sketch — CreateIdeaRequest.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:
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:
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.
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: W4 (frontend), W5 (seed data), W6 (deploy + CI/CD).