2
Sprint 1 Plan Part 2
Patrick Plate edited this page 2026-06-24 15:28:48 +02:00

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:

  1. 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 — seed the single org row.
  3. backend/src/main/java/de/plate/sparkboard/idea/Idea.java — JPA entity.
  4. 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.javaJpaRepository.
  6. backend/src/main/java/de/plate/sparkboard/idea/IdeaService.java.
  7. 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@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:

-- 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 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.
  2. backend/src/main/java/de/plate/sparkboard/idea/IdeaDto.java.
  3. backend/src/main/java/de/plate/sparkboard/idea/CreateIdeaRequest.java.
  4. Optional: 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).

Acceptance gate:

  • Unauthenticated POST /api/ideas401 Unauthorized.
  • Unauthenticated GET /api/ideas401 Unauthorized.
  • Authenticated POST /api/ideas with valid body → 201 Created + JSON IdeaDto.
  • Authenticated POST /api/ideas with empty/null title400 Bad Request with field error.
  • Authenticated GET /api/ideas200 OK + JSON array, newest first, only ideas in the user's SPARK_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).