Conversation
There was a problem hiding this comment.
Pull request overview
Implements the “Packages” epic end-to-end: backend persistence + import/list APIs, and a new dashboard/packages UI flow to import dependencies (lockfile upload or paste) and browse the package list.
Changes:
- Add backend domain/model + DB migration for
packages, with import (lockfile/list) and list endpoints. - Add frontend packages page with search + infinite scroll, plus upload/paste import components and client API bindings.
- Update dashboard “Get Started” steps and dashboard stats plumbing to reflect budget/packages progress.
Reviewed changes
Copilot reviewed 32 out of 39 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/pages/PackagesPage.tsx | New packages page (search, infinite scroll, import actions). |
| apps/web/src/pages/CompanyDashboardPage.tsx | Updates “Get Started” steps UI/state logic and links to packages. |
| apps/web/src/features/packages/index.ts | Barrel exports for packages feature components/API/types. |
| apps/web/src/features/packages/api.ts | Frontend API client for packages list/import endpoints. |
| apps/web/src/features/packages/PastePackagesDialog.tsx | UI dialog to import packages via pasted list. |
| apps/web/src/features/packages/PackageCard.tsx | UI component for rendering a package row/card. |
| apps/web/src/features/packages/FileDropzone.tsx | UI component for lockfile drag/drop + file picker. |
| apps/web/src/features/budget/BudgetSetupForm.tsx | Refreshes dashboard state after budget is set. |
| apps/web/src/App.tsx | Adds /dashboard/packages route. |
| apps/audit.md | Removes audit document from the repo. |
| apps/api/src/main/resources/db/migration/V9__create_packages_table.sql | Creates packages table + indexes. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageEntity.java | JPA entity for packages. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageMapper.java | Maps domain Package ↔ persistence entity. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/persistence/pkg/PackageJpaRepository.java | Panache-backed repository implementing PackageRepository. |
| apps/api/src/main/java/com/upkeep/application/port/out/pkg/PackageRepository.java | Outbound port for package persistence operations. |
| apps/api/src/main/java/com/upkeep/application/port/out/pkg/LockfileParser.java | Outbound port for lockfile parsing. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapter.java | Parser adapter for package-lock.json and yarn.lock. |
| apps/api/src/main/java/com/upkeep/domain/model/pkg/PackageId.java | Domain identifier for Package. |
| apps/api/src/main/java/com/upkeep/domain/model/pkg/Package.java | Domain model + npm name validation. |
| apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromLockfileUseCase.java | Use-case port for lockfile import. |
| apps/api/src/main/java/com/upkeep/application/port/in/pkg/ImportPackagesFromListUseCase.java | Use-case port for list import. |
| apps/api/src/main/java/com/upkeep/application/port/in/pkg/ListCompanyPackagesUseCase.java | Use-case port for listing packages with pagination/search. |
| apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImpl.java | Implements lockfile import flow. |
| apps/api/src/main/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImpl.java | Implements list import flow. |
| apps/api/src/main/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImpl.java | Implements package listing + membership check. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageResource.java | REST endpoints for list + imports. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/PackageListResponse.java | DTO for list response. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportLockfileRequest.java | DTO for lockfile import request. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportResultResponse.java | DTO for lockfile import response. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListRequest.java | DTO for list import request. |
| apps/api/src/main/java/com/upkeep/infrastructure/adapter/in/rest/pkg/ImportListResultResponse.java | DTO for list import response. |
| apps/api/src/main/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImpl.java | Adds hasBudget/hasPackages computation to dashboard stats. |
| apps/api/src/test/java/com/upkeep/infrastructure/adapter/out/parser/LockfileParserAdapterTest.java | Unit tests for lockfile parsing. |
| apps/api/src/test/java/com/upkeep/domain/model/pkg/PackageTest.java | Unit tests for package name validation + creation. |
| apps/api/src/test/java/com/upkeep/application/usecase/ListCompanyPackagesUseCaseImplTest.java | Unit tests for listing use case. |
| apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromLockfileUseCaseImplTest.java | Unit tests for lockfile import use case. |
| apps/api/src/test/java/com/upkeep/application/usecase/ImportPackagesFromListUseCaseImplTest.java | Unit tests for list import use case. |
| apps/api/src/test/java/com/upkeep/application/usecase/GetCompanyDashboardUseCaseImplTest.java | Updates tests for new dashboard dependencies + hasBudget case. |
| Copilot-Processing.md | Tracks implementation plan/checklist for the epic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return tokenService.validateAccessToken(accessToken); | ||
| } | ||
|
|
||
| private Response unauthorizedResponse() { | ||
| return Response.status(401) | ||
| .entity(ApiResponse.error(ApiError.of("UNAUTHORIZED", "Authentication required", null))) |
There was a problem hiding this comment.
validateToken forwards to tokenService.validateAccessToken, which throws IllegalArgumentException for invalid/expired tokens (see JwtTokenService). Since there is no mapper for IllegalArgumentException, an invalid cookie will currently bubble up as a 500 instead of a 401. Catch the token parse exception here (or introduce a dedicated access-token exception/mapper) and return unauthorizedResponse() / INVALID_TOKEN consistently.
| return tokenService.validateAccessToken(accessToken); | |
| } | |
| private Response unauthorizedResponse() { | |
| return Response.status(401) | |
| .entity(ApiResponse.error(ApiError.of("UNAUTHORIZED", "Authentication required", null))) | |
| try { | |
| return tokenService.validateAccessToken(accessToken); | |
| } catch (IllegalArgumentException ex) { | |
| // Invalid or expired token; treat as unauthorized | |
| return null; | |
| } | |
| } | |
| private Response unauthorizedResponse() { | |
| return Response.status(401) | |
| .entity(ApiResponse.error(ApiError.of("INVALID_TOKEN", "Authentication required", null))) |
| <Button variant="outline" size="sm" disabled={!dashboard.stats.hasBudget || !dashboard.stats.hasPackages}> | ||
| Allocate | ||
| <ArrowRight className="ml-2 h-4 w-4" /> |
There was a problem hiding this comment.
The step 3 "Allocate" button can now become enabled when hasBudget and hasPackages are true (because the Get Started card now renders until hasAllocations is true), but the button has no onClick handler—so it becomes a dead-end CTA. Wire it to navigate to the allocations flow (e.g. /dashboard/allocations) or keep it disabled until the route/feature exists.
| @GET | ||
| public Response listPackages(@CookieParam(ACCESS_TOKEN_COOKIE) String accessToken, | ||
| @PathParam("companyId") String companyId, | ||
| @QueryParam("search") String search, | ||
| @QueryParam("page") @DefaultValue("0") int page, | ||
| @QueryParam("size") @DefaultValue("50") int size) { |
There was a problem hiding this comment.
page/size query params are passed through without validation. Values like size=0 or negative numbers can lead to invalid offsets and can break pagination downstream (e.g., Panache page size 0). Add basic bounds checks (page >= 0, 1 <= size <= max) and return a 400 with a clear error when out of range.
| const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const files = e.target.files; | ||
| if (files && files.length > 0) { | ||
| onFileAccepted(files[0]); | ||
| } | ||
| if (inputRef.current) { | ||
| inputRef.current.value = ''; | ||
| } |
There was a problem hiding this comment.
handleFileChange calls onFileAccepted for any selected file, but drag/drop path filters via isAcceptedFile. This means browsing can submit unsupported filenames (anything matching accept=.json,.lock) and will likely fail server-side. Apply the same isAcceptedFile check in handleFileChange (and ideally surface a user-facing error when rejected).
| validateName(name); | ||
| return new Package(PackageId.generate(), companyId, name, "npm", Instant.now()); | ||
| } | ||
|
|
||
| public static Package reconstitute(PackageId id, CompanyId companyId, String name, | ||
| String registry, Instant importedAt) { | ||
| return new Package(id, companyId, name, registry, importedAt); |
There was a problem hiding this comment.
Package.create validates name using name.trim() (via isValidNpmPackageName) but then persists the original, untrimmed name. This allows values like "lodash " to pass validation while violating the intended invariant and can lead to confusing duplicates/search behavior. Normalize the value (at least trim()) before storing it in the domain object.
| validateName(name); | |
| return new Package(PackageId.generate(), companyId, name, "npm", Instant.now()); | |
| } | |
| public static Package reconstitute(PackageId id, CompanyId companyId, String name, | |
| String registry, Instant importedAt) { | |
| return new Package(id, companyId, name, registry, importedAt); | |
| String normalizedName = name == null ? null : name.trim(); | |
| validateName(normalizedName); | |
| return new Package(PackageId.generate(), companyId, normalizedName, "npm", Instant.now()); | |
| } | |
| public static Package reconstitute(PackageId id, CompanyId companyId, String name, | |
| String registry, Instant importedAt) { | |
| String normalizedName = name == null ? null : name.trim(); | |
| return new Package(id, companyId, normalizedName, registry, importedAt); |
No description provided.