User Story
As a logged-in SprintStart user,
I want to retrieve a paginated list of ingested artifacts scoped to a project I have access to,
so that I can browse and select knowledge-base artifacts without pulling their full content upfront.
Context & Motivation
The frontend knowledge-base feature (sprintstart-frontend/src/features/knowledge-base/) currently runs entirely on mocks (knowledgeService.ts:16-23 toggled by VITE_USE_MOCK_API). The first step to unmocking it is exposing a list endpoint the frontend already expects: GET /api/v1/projects/{projectId}/artifacts.
The backend's ArtifactController.kt:44 only exposes single-artifact content retrieval (getArtifactContent); there is no "list artifacts by project" route. The Artifact entity (ingestion/model/entity/Artifact.kt:17-53) stores the data, but ArtifactRepository only offers findBySourceId and findAllByIngestionRunId — neither is project-scoped.
Scope is intentionally minimal: add the list endpoint over the existing ingestion corpus. The frontend will be adapted to the backend's honest response shape (no invented freshness/canonical/tags/owner/aiSummary fields). No new module, no Flyway migration, no schema change, no new events.
Acceptance Criteria
Given an authenticated user with USER role, when they call GET /api/v1/projects/{projectId}/artifacts with a valid project UUID, then the endpoint returns 200 OK with a List JSON body ordered newest-first by ingestedAt.
Given an authenticated user with no access to the requested project, when they call the endpoint, then it returns 403 Forbidden.
Given a project with zero artifacts, when the endpoint is called, then it returns 200 OK with an empty list ([]).
Given the response, then each item exposes the entity's real fields (id, title, artifactType, sourceSystem, sourceId, sourceUrl, mime, language, ingestedAt, createdAtSource, updatedAtSource, contentHash, ingestionRunId) and excludes bodyText (heavy; fetched separately via the content endpoint).
Given an unauthenticated request, when the endpoint is called, then it returns 401 Unauthorized.
Given the endpoint is wired into the existing ArtifactController (@RequestMapping("/api/v1")), then it is annotated @PreAuthorize("hasRole('USER')") and reuses the existing UserApi.userHasAccessToProject auth check used by getArtifactContent.
Given ./gradlew build is run, then ktlint, detekt, and the new unit tests pass.
Given the artifact_projects join table, then the repository query correctly resolves the @ElementCollection (Artifact.kt:37-43) via JPQL :projectId member of a.projectIdsInternal.
Technical Notes
Affected components: sprintstart-backend/ (RESTRICTED — requires approval per AGENTS.md). No frontend changes in this issue; a follow-up issue will adapt the frontend Artifact type and knowledgeService.ts to the new response shape.
Files to add/edit:
- NEW src/main/kotlin/.../ingestion/model/dto/response/ArtifactResponse.kt — immutable data class:
data class ArtifactResponse(
val id: UUID,
val title: String?,
val artifactType: ArtifactType,
val sourceSystem: SourceSystem,
val sourceId: String,
val sourceUrl: String?,
val mime: String?,
val language: String?,
val ingestedAt: Instant,
val createdAtSource: Instant?,
val updatedAtSource: Instant?,
val contentHash: String?,
val ingestionRunId: UUID,
)
(bodyText deliberately excluded from the list response — content is fetched per-artifact via GET .../artifacts/{artifactId}/content.)
- EDIT src/main/kotlin/.../ingestion/repository/ArtifactRepository.kt — add:
@query("select a from Artifact a where :projectId member of a.projectIdsInternal order by a.ingestedAt desc")
fun findAllByProjectId(@Param("projectId") projectId: UUID): List
- EDIT src/main/kotlin/.../ingestion/service/ArtifactService.kt — add listArtifacts(projectId: UUID, authId: String): List mirroring the auth flow of getArtifactContent (ArtifactService.kt:38-51): userApi.userHasAccessToProject(authId, projectId) → 403 if false, else map repository results to ArtifactResponse. Annotate @transactional(readOnly = true).
- EDIT src/main/kotlin/.../ingestion/controller/ArtifactController.kt — add:
@GetMapping("/projects/{projectId}/artifacts")
@PreAuthorize("hasRole('USER')")
fun listArtifacts(
@PathVariable projectId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<List> =
ResponseEntity.ok(artifactService.listArtifacts(projectId, jwt.subject))
- NEW/EDIT src/test/kotlin/.../ingestion/ — unit tests for ArtifactService.listArtifacts (mock UserApi + ArtifactRepository) and controller layer test, mirroring existing test conventions (H2 + mockk/springmockk).
User Story
As a logged-in SprintStart user,
I want to retrieve a paginated list of ingested artifacts scoped to a project I have access to,
so that I can browse and select knowledge-base artifacts without pulling their full content upfront.
Context & Motivation
The frontend knowledge-base feature (sprintstart-frontend/src/features/knowledge-base/) currently runs entirely on mocks (knowledgeService.ts:16-23 toggled by VITE_USE_MOCK_API). The first step to unmocking it is exposing a list endpoint the frontend already expects: GET /api/v1/projects/{projectId}/artifacts.
The backend's ArtifactController.kt:44 only exposes single-artifact content retrieval (getArtifactContent); there is no "list artifacts by project" route. The Artifact entity (ingestion/model/entity/Artifact.kt:17-53) stores the data, but ArtifactRepository only offers findBySourceId and findAllByIngestionRunId — neither is project-scoped.
Scope is intentionally minimal: add the list endpoint over the existing ingestion corpus. The frontend will be adapted to the backend's honest response shape (no invented freshness/canonical/tags/owner/aiSummary fields). No new module, no Flyway migration, no schema change, no new events.
Acceptance Criteria
Given an authenticated user with USER role, when they call GET /api/v1/projects/{projectId}/artifacts with a valid project UUID, then the endpoint returns 200 OK with a List JSON body ordered newest-first by ingestedAt.
Given an authenticated user with no access to the requested project, when they call the endpoint, then it returns 403 Forbidden.
Given a project with zero artifacts, when the endpoint is called, then it returns 200 OK with an empty list ([]).
Given the response, then each item exposes the entity's real fields (id, title, artifactType, sourceSystem, sourceId, sourceUrl, mime, language, ingestedAt, createdAtSource, updatedAtSource, contentHash, ingestionRunId) and excludes bodyText (heavy; fetched separately via the content endpoint).
Given an unauthenticated request, when the endpoint is called, then it returns 401 Unauthorized.
Given the endpoint is wired into the existing ArtifactController (@RequestMapping("/api/v1")), then it is annotated @PreAuthorize("hasRole('USER')") and reuses the existing UserApi.userHasAccessToProject auth check used by getArtifactContent.
Given ./gradlew build is run, then ktlint, detekt, and the new unit tests pass.
Given the artifact_projects join table, then the repository query correctly resolves the @ElementCollection (Artifact.kt:37-43) via JPQL :projectId member of a.projectIdsInternal.
Technical Notes
Affected components: sprintstart-backend/ (RESTRICTED — requires approval per AGENTS.md). No frontend changes in this issue; a follow-up issue will adapt the frontend Artifact type and knowledgeService.ts to the new response shape.
Files to add/edit:
data class ArtifactResponse(
val id: UUID,
val title: String?,
val artifactType: ArtifactType,
val sourceSystem: SourceSystem,
val sourceId: String,
val sourceUrl: String?,
val mime: String?,
val language: String?,
val ingestedAt: Instant,
val createdAtSource: Instant?,
val updatedAtSource: Instant?,
val contentHash: String?,
val ingestionRunId: UUID,
)
(bodyText deliberately excluded from the list response — content is fetched per-artifact via GET .../artifacts/{artifactId}/content.)
@query("select a from Artifact a where :projectId member of a.projectIdsInternal order by a.ingestedAt desc")
fun findAllByProjectId(@Param("projectId") projectId: UUID): List
@GetMapping("/projects/{projectId}/artifacts")
@PreAuthorize("hasRole('USER')")
fun listArtifacts(
@PathVariable projectId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<List> =
ResponseEntity.ok(artifactService.listArtifacts(projectId, jwt.subject))