Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2c6d9e4
feat(shopping-lists): Replace isPublic with a revocable share token
OffCrazyFreak Jul 30, 2026
55175c6
feat(shopping-lists): Wire the frontend to link access and share tokens
OffCrazyFreak Jul 31, 2026
b28f729
feat(shopping-lists): Gate item controls on the caller's access level
OffCrazyFreak Jul 31, 2026
e750356
feat(shopping-lists): Add the share settings modal
OffCrazyFreak Jul 31, 2026
2626e1b
feat(shopping-lists): Add the public /s/[token] shared list page
OffCrazyFreak Jul 31, 2026
9593f1a
fix(shopping-lists): Drop dead-end controls for logged-out link visitors
OffCrazyFreak Jul 31, 2026
67483f1
Merge remote-tracking branch 'origin/dev' into feat/shopping-list-sha…
OffCrazyFreak Aug 2, 2026
203b8b0
Merge branch 'fix/dev-vs-main-review-2026-07' into feat/shopping-list…
OffCrazyFreak Aug 2, 2026
e7e09b0
Merge branch 'dev' into feat/shopping-list-sharing
OffCrazyFreak Aug 2, 2026
4d5866f
chore(agents): Hand back clickable paths and system-themed review HTML
OffCrazyFreak Aug 5, 2026
0dbfc69
Merge branch 'dev' into feat/shopping-list-sharing
OffCrazyFreak Aug 5, 2026
f4adef1
fix(shopping-lists): Stop sending account ids to link visitors
OffCrazyFreak Aug 5, 2026
1e4c42e
fix(shopping-lists): Let a stale token fall back to anonymous on shar…
OffCrazyFreak Aug 5, 2026
14eec4a
fix(shopping-lists): Redact the share token from telemetry and referrers
OffCrazyFreak Aug 5, 2026
80313dd
fix(shopping-lists): Key the offline cache by identity and stop purgi…
OffCrazyFreak Aug 5, 2026
7f4ef14
feat(shopping-lists): Make shared lists work offline
OffCrazyFreak Aug 5, 2026
e2186dd
fix(shopping-lists): Roll back one item, and own the optimism in onMu…
OffCrazyFreak Aug 5, 2026
1cbdd65
fix(shopping-lists): Spell query keys once and drop a revoked list fr…
OffCrazyFreak Aug 5, 2026
f154141
fix(shopping-lists): Let a recipient forward the link they are lookin…
OffCrazyFreak Aug 5, 2026
ff5f256
feat(shopping-lists): Rework the share modal and make the level legible
OffCrazyFreak Aug 5, 2026
3ffdbdf
fix(a11y): Restore focus when a modal closes
OffCrazyFreak Aug 5, 2026
22cc862
docs(shopping-lists): Record sharing, and the ordered column drop
OffCrazyFreak Aug 5, 2026
72129a6
fix(shopping-lists): Land the review findings on the sharing fixes
OffCrazyFreak Aug 6, 2026
be5509f
Merge remote-tracking branch 'origin/dev' into feat/shopping-list-sha…
OffCrazyFreak Aug 6, 2026
a1fae2e
fix(shopping-lists): Adopt the dev share and route helpers in the sha…
OffCrazyFreak Aug 6, 2026
bc410b2
docs: Correct the sharing and offline cache details that went stale
OffCrazyFreak Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Under the hood it is a full production stack: a Next.js frontend that also acts
- Product search across 29 Croatian retail chains (with barcode scanning)
- Price comparison per store and price history charts ("is the discount real?")
- Smart shopping lists with per-store basket totals
- Shared shopping lists via a private link, with view, shop or edit access and a revocable token
- Product watchlist
- Installable PWA that works offline (IndexedDB reads, background-sync writes)
- Google + email/password auth with account linking
Expand All @@ -31,7 +32,6 @@ Under the hood it is a full production stack: a Next.js frontend that also acts
- Digital loyalty cards
- Store map with working hours
- Spending analysis and market statistics
- Shopping list sharing

## Tech stack

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package disscount.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* Authenticates a bearer token when one is present and usable, and lets the request through
* anonymously when it is not.
*
* <p>This exists because a share link has to work for both. The standard
* {@code BearerTokenAuthenticationFilter} rejects an expired or malformed token with 401
* before authorization rules are consulted, so a permitAll endpoint is not actually reachable
* by a caller whose cached token has gone stale. Dropping the resource server from the shared
* chain instead would fix that but break the other half: a signed-in recipient would be seen
* as anonymous and capped at VIEW however generous the link is.
*
* <p>Validation itself is unchanged and is not hand-rolled. This injects the same
* {@code JwtDecoder} bean the resource server uses, so a token is still checked against the
* better-auth JWKS, pinned to ES256, and validated for issuer and expiry. Only the response to
* a failed check differs: continue anonymously instead of committing a 401. Spring's OAuth2
* resource-server DSL has no supported way to express that, because its entry point commits
* the response rather than continuing the chain.
*/
@Component
@RequiredArgsConstructor
public class OptionalBearerAuthenticationFilter extends OncePerRequestFilter {

private static final String BEARER_PREFIX = "Bearer ";

private final JwtDecoder jwtDecoder;
private final JwtAuthenticationConverter converter = new JwtAuthenticationConverter();

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain chain
) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);

// RFC 7235 makes the scheme name case-insensitive. Our own client always sends
// "Bearer", but a recipient arriving from anything else should not be silently
// demoted to anonymous over capitalisation.
if (header != null
&& header.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
try {
Jwt jwt = jwtDecoder.decode(header.substring(BEARER_PREFIX.length()));
SecurityContextHolder.getContext().setAuthentication(converter.convert(jwt));
} catch (JwtException ignored) {
// Expired, malformed or issued elsewhere. The caller keeps whatever the link
// grants an anonymous visitor, which the access resolver caps at VIEW.
}
}

chain.doFilter(request, response);
}
}
61 changes: 61 additions & 0 deletions backend/src/main/java/disscount/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
Expand All @@ -16,6 +18,7 @@
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;

@Configuration
@EnableWebSecurity
Expand All @@ -39,7 +42,65 @@ public JwtDecoder jwtDecoder(
return decoder;
}

/**
* Spring Boot auto-registers every {@code Filter} bean into the servlet chain, so a
* {@code @Component} filter meant only for a security chain runs twice: once where it was
* placed and once for every request that never reaches that chain. Both of these extend
* {@code OncePerRequestFilter}, whose already-filtered attribute makes the second run a
* no-op only when the first one happened, so on any path outside {@code /api/shared/**}
* the optional bearer filter would decode the token again after the real chain had
* finished with it. These beans turn the servlet registration off and leave the security
* chains as the only place either filter runs.
*/
@Bean
public FilterRegistrationBean<UserProvisioningFilter> userProvisioningFilterRegistration(
UserProvisioningFilter filter
) {
FilterRegistrationBean<UserProvisioningFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}

@Bean
public FilterRegistrationBean<OptionalBearerAuthenticationFilter> optionalBearerFilterRegistration(
OptionalBearerAuthenticationFilter filter
) {
FilterRegistrationBean<OptionalBearerAuthenticationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}

/**
* Shared lists get their own chain because they are the one place where a bearer token is
* optional. Authorization happens on the share token plus ShoppingListAccessService, and
* the caller may legitimately be anonymous, so a token that fails to decode must degrade
* to anonymous rather than 401. permitAll on the main chain cannot express that: its
* bearer filter rejects a stale token before authorization is ever consulted.
*/
@Bean
@Order(1)
public SecurityFilterChain sharedShoppingListChain(
HttpSecurity http,
OptionalBearerAuthenticationFilter optionalBearerAuthenticationFilter
) throws Exception {
http
.securityMatcher("/api/shared/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz.anyRequest().permitAll())
// Provisioning is anchored on the bearer filter rather than sharing the
// anonymous anchor with it. Two addFilterBefore calls against one anchor get the
// same order and only stay in sequence because the sort happens to be stable,
// which is not something to depend on: provisioning has to see the
// authentication the bearer filter produced.
.addFilterBefore(optionalBearerAuthenticationFilter, AnonymousAuthenticationFilter.class)
.addFilterAfter(userProvisioningFilter, OptionalBearerAuthenticationFilter.class);
Comment on lines +91 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'Security-chain registration:'
rg -n -C 4 'addFilterBefore|addFilterAfter|AnonymousAuthenticationFilter|SecurityContextHolderFilter' \
  backend/src/main/java/disscount/config/SecurityConfig.java

printf '%s\n' 'Spring Security ordering implementation, if dependency sources are cached:'
fd -a 'spring-security-config-*-sources.jar' "${HOME}/.m2" 2>/dev/null |
while IFS= read -r jar; do
  unzip -p "$jar" '*FilterOrderRegistration.java' 2>/dev/null |
    rg -n -C 3 'addFilterBefore|addFilterAfter|order - 1|order \+ 1' || true
done

Repository: OffCrazyFreak/Disscount

Length of output: 2044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'SecurityConfig relevant sections:'
sed -n '1,150p' backend/src/main/java/disscount/config/SecurityConfig.java | nl -ba

printf '%s\n' 'Filter bean ordering if present:'
rg -n -C 3 'UserProvisioningFilter|OptionalBearerAuthenticationFilter|SecurityFilterChain|FilterRegistrationBean|Ordered|`@Order`|after|before' \
  backend/src/main/java/disscount/config backend/src/main/java/disscount || true

printf '%s\n' 'Repository dependency versions:'
for f in pom.xml backend/pom.xml build/prod/pom.xml gradle.properties; do
  [ -f "$f" ] && { printf '\n--- %s ---\n'; sed -n '1,220p' "$f"; }
done

printf '%s\n' 'Lockfiles for Spring Security packages:'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn.lock|gradle.lockfile|.*gradle\.lockfile|pom\.xml)$' | while read -r f; do
  if rg -q 'spring-security|spring-boot-starter-security' "$f" 2>/dev/null; then
    printf '\n--- %s spring-security references ---\n' "$f"
    rg -n 'spring-security|spring-boot-starter-security|spring-boot-starter-web' "$f" || true
  fi
done

Repository: OffCrazyFreak/Disscount

Length of output: 235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'SecurityConfig relevant sections:'
sed -n '1,150p' backend/src/main/java/disscount/config/SecurityConfig.java | awk '{ printf "%6d\t%s\n", NR, $0 }'

printf '%s\n' 'Filter bean ordering if present:'
rg -n -C 3 'UserProvisioningFilter|OptionalBearerAuthenticationFilter|SecurityFilterChain|FilterRegistrationBean|Ordered|`@Order`|after|before' \
  backend/src/main/java/disscount/config backend/src/main/java/disscount || true

printf '%s\n' 'Repository dependency versions:'
for f in pom.xml backend/pom.xml build/prod/pom.xml gradle.properties; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    sed -n '1,220p' "$f"
  fi
done

printf '%s\n' 'Lockfiles for Spring Security packages:'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn.lock|gradle.lockfile|.*gradle\.lockfile|pom\.xml)$' | while read -r f; do
  if rg -q 'spring-security|spring-boot-starter-security' "$f" 2>/dev/null; then
    printf '\n--- %s spring-security references ---\n' "$f"
    rg -n 'spring-security|spring-boot-starter-security|spring-boot-starter-web' "$f" || true
  fi
done

Repository: OffCrazyFreak/Disscount

Length of output: 38851


🌐 Web query:

Spring Security SecurityFilterChainBuilder addFilterBefore addFilterAfter equal order same position

💡 Result:

In Spring Security, the HttpSecurity methods addFilterBefore, addFilterAfter, and addFilterAt are used to place custom filters relative to existing filters within the SecurityFilterChain [1][2][3]. These methods calculate a specific numerical "order" value to determine the execution sequence [4]. Key behaviors regarding ordering and positioning: 1. How Positions are Calculated: The framework uses a registered list of filter classes and their associated order values (e.g., in FilterOrderRegistration.java) [5][4]. When you call addFilterBefore or addFilterAfter, the framework calculates the new filter's order based on the offset relative to the target filter's order [4]. For example, addFilterAfter generally adds a filter at (targetFilterOrder + 1) [4]. 2. Same Position (Numerical Order) Behavior: It is possible for multiple filters to end up with the same numerical order value [6]. Spring Security does not necessarily prevent this at the API level during configuration [6]. When filters have the same order, their relative execution order within the chain is typically determined by the order in which they were added or by the underlying collection implementation, though this can lead to unpredictable or difficult-to-debug behavior [6][7]. 3. Issues with Same Class Instances: A known complication occurs when the same filter instance or filters of the same class are added multiple times or at different relative positions [8][7]. Because the framework sometimes maps order values by class type internally, subsequent calls for the same filter class can override previous registrations, potentially causing a filter to appear in an unexpected location [8]. 4. Recommendations: To ensure predictable ordering: - Avoid relying on implicit ordering or overlapping positions. - If you need multiple filters to run in a specific sequence, explicitly chain them or use separate classes for each filter [6]. - If you intend to replace a standard filter, use addFilterAt to ensure the custom filter takes the exact position of the original, rather than just adding it before or after [1][3]. - If you encounter order conflicts, verify the generated filter order by enabling debug logging, which helps identify the final placement of filters in the chain [9]. For authoritative details on the default ordering of standard filters, you can inspect the FilterOrderRegistration class in the Spring Security source code [1][5].

Citations:


🌐 Web query:

github spring-security FilterOrderRegistration addFilterBefore addFilterAfter compare insertionOrder sameOrderStableSort

💡 Result:

In Spring Security, the FilterOrderRegistration class acts as a central registry that assigns default integer order values to built-in security filters [1][2]. This ensures that when multiple filters are involved, the framework can maintain a predictable sequence (e.g., ensuring authentication filters run before authorization filters) [3]. When you use HttpSecurity methods like addFilterBefore(Filter, Class) or addFilterAfter(Filter, Class), Spring Security uses the order values stored in FilterOrderRegistration to determine where to place your custom filter relative to a known (registered) filter class [4][5]. Key points regarding the registration and ordering mechanics include: 1. Known Filters: The methods addFilterBefore and addFilterAfter operate relative to "known" filter classes [4][5]. A filter is considered "known" if it is a built-in filter already tracked in FilterOrderRegistration or if it has been previously registered via these same HttpSecurity methods [4][5]. 2. Internal Mechanism: While early implementations used a simple map that could lead to overwrites if the same class was registered in multiple positions [6], current versions manage these positions more robustly. However, attempting to register multiple filters in the exact same position (e.g., using addFilterAt) results in non-deterministic ordering among those specific filters, as the framework does not provide an explicit tie-breaking mechanism for same-position registration [4]. 3. Stable Sort: The overall filter chain is typically constructed by sorting these assigned order values [3]. Because the registry uses a step-based increment (e.g., ORDER_STEP = 100) [1], there is generally "room" between standard filters to inject custom filters without collisions. In summary, you should rely on addFilterBefore or addFilterAfter when you need to ensure a specific relative order [3]. If you are concerned about identical positions, avoid multiple registrations at the same target class [4], as the framework is designed to handle relative positioning rather than absolute index-based ordering. [4][3]

Citations:


🌐 Web query:

org.springframework.security.config.annotation.web.SecurityBuilder#doBuild sameOrderStableSort SpringSecurityFilterChainFilterFilter

💡 Result:

In Spring Security, the terms "doBuild" and "sameOrderStableSort" relate to the internal mechanism for constructing and ordering the security filter chain [1][2][3]. The doBuild method is part of the AbstractConfiguredSecurityBuilder class, which is a base class for builders like HttpSecurity [2][4][3]. This method is responsible for orchestrating the build process, which includes invoking configuration methods on applied SecurityConfigurers and finally calling performBuild to construct the target object (e.g., DefaultSecurityFilterChain) [2][5]. While "sameOrderStableSort" is not a public API method, it refers to the internal sorting logic used during the construction of the filter chain to ensure that filters are executed in the correct, predictable order [6]. Spring Security uses the FilterOrderRegistration class to maintain a registry of filter classes and their associated order values [1][6]. This ensures that essential security filters (like those for authentication and authorization) are executed in a specific sequence regardless of how they are added to the configuration [6]. If you are investigating the filter order: 1. FilterOrderRegistration: This is the definitive source of truth for the default order of standard Spring Security filters [1][6]. You can check this class in the Spring Security source code to see exactly how filters are ordered [1]. 2. HttpSecurity: When configuring your application using HttpSecurity, you can influence this order using methods like addFilterBefore and addFilterAfter, which allow you to place your custom filters relative to the standard ones [7]. 3. Architecture: The FilterChainProxy is the entry point that uses the ordered SecurityFilterChain to delegate requests to the appropriate filters [6][8]. If you find discrepancies in documentation or need to understand why a filter is placed in a specific position, the FilterOrderRegistration class remains the primary reference [1][9][6].

Citations:


Make the custom filter order explicit.

addFilterAfter(userProvisioningFilter, OptionalBearerAuthenticationFilter.class) assigns the same order as the anchor filter, so the provisioning-before-anonymous sequence is an implementation-detail dependency on stable same-order sorting. Anchor both custom filters with a strict relative order, such as a single chain using an explicit earlier standard anchor or an @Order-backed bean registration, instead of relying on insertion-dependent tie-breaking.

Preserve the OAuth2 resource-server path, which still needs SecurityFilterChain filterChain(...) after this change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/main/java/disscount/config/SecurityConfig.java` around lines 91 -
97, Make the custom filter ordering explicit in SecurityConfig by assigning
userProvisioningFilter a strictly later order than
optionalBearerAuthenticationFilter, using a shared earlier standard anchor or
explicit `@Order-backed` registration rather than insertion-dependent same-order
sorting. Preserve the OAuth2 resource-server configuration and keep
SecurityFilterChain filterChain(...) intact.

Source: Coding guidelines


return http.build();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Bean
@Order(2)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID

@Query("SELECT sl FROM ShoppingList sl WHERE sl.id = :id AND sl.owner = :owner AND sl.deletedAt IS NULL")
Optional<ShoppingList> findActiveByIdAndOwner(UUID id, User owner);

@Query("SELECT sl FROM ShoppingList sl WHERE sl.shareToken = :shareToken AND sl.deletedAt IS NULL")
Optional<ShoppingList> findActiveByShareToken(UUID shareToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package disscount.shoppingList.domain;

/**
* The levels a share link can actually be set to, which is every {@link ListAccess} value
* except OWNER.
*
* <p>This is a separate enum rather than a validated {@code ListAccess} so that OWNER is not
* expressible on the wire at all. Jackson rejects it during deserialization, before any
* service code runs, which means the guard in {@code ShoppingListService.applyLinkAccess} is
* no longer the only thing standing between a typo and a caller granting themselves the
* ability to manage sharing.
*/
public enum LinkAccess {

NONE,
VIEW,
SHOP,
EDIT;

public ListAccess toListAccess() {
return ListAccess.valueOf(name());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package disscount.shoppingList.domain;

/**
* What a caller may do with a shopping list.
*
* <p>Only NONE, VIEW, SHOP and EDIT are ever stored in {@code shopping_list.link_access}.
* OWNER is produced by {@link disscount.shoppingList.service.ShoppingListAccessService}
* and never persisted.
*/
public enum ListAccess {

NONE,
VIEW,
SHOP,
EDIT,
OWNER;

public boolean canView() {
return this != NONE;
}

/** The in-the-shop level: tick an item off, switch its store, snapshot its price. */
public boolean canCheck() {
return this == SHOP || this == EDIT || this == OWNER;
}

/** Amount, removal and the list title. Adding items arrives with membership. */
public boolean canEditItems() {
return this == EDIT || this == OWNER;
}

public boolean canManageShare() {
return this == OWNER;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,16 @@ public class ShoppingList {
@Column(nullable = false)
private String title;

@Column(name = "is_public", nullable = false)
@Builder.Default
private Boolean isPublic = false;
// Nullable because ddl-auto=update cannot add a NOT NULL column to a populated table.
// Read it through resolvedLinkAccess(), never directly.
@Enumerated(EnumType.STRING)
@Column(name = "link_access", length = 16)
private ListAccess linkAccess;

// Deliberately not the list id: a token can be rotated, so turning sharing off and on
// again actually revokes instead of handing the same URL back to everyone who kept it.
@Column(name = "share_token", unique = true)
private UUID shareToken;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
Expand All @@ -51,6 +58,11 @@ public class ShoppingList {
@Builder.Default
private List<ShoppingListItem> items = new ArrayList<>();

/** Null link access means the list is not shared at all. */
public ListAccess resolvedLinkAccess() {
return linkAccess != null ? linkAccess : ListAccess.NONE;
}

@PrePersist
protected void onCreate() {
LocalDateTime now = Timestamps.nowUtc();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.List;
import java.util.UUID;

import disscount.shoppingList.domain.ListAccess;
import disscount.shoppingListItem.dto.ShoppingListItemDto;

@Data
Expand All @@ -16,7 +17,15 @@ public class ShoppingListDto {
private UUID id;
private UUID ownerId;
private String title;
private Boolean isPublic;

// Both owner-only: a link visitor handed the token could reshare the list at a level
// its owner never granted.
private ListAccess linkAccess;
private UUID shareToken;

/** The caller's resolved access, echoed back so the frontend never re-derives the rule. */
private ListAccess myAccess;

private LocalDateTime updatedAt;
private LocalDateTime createdAt;
private List<ShoppingListItemDto> items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

import disscount.shoppingList.domain.LinkAccess;

@Data
public class ShoppingListRequest {

@NotBlank(message = "Title is required")
private String title;

private Boolean isPublic = false;
// Owner-only, and ignored on create: sharing is turned on from an existing list,
// because there is no id to bind a token to until the list has been saved.
// LinkAccess rather than ListAccess, so OWNER cannot be sent at all.
private LinkAccess linkAccess;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package disscount.shoppingList.rest;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import disscount.shoppingList.dto.ShoppingListDto;
import disscount.shoppingList.dto.ShoppingListRequest;
import disscount.shoppingList.service.SharedShoppingListService;
import disscount.shoppingListItem.dto.ShoppingListItemDto;
import disscount.shoppingListItem.dto.ShoppingListItemRequest;
import disscount.util.SecurityUtils;

import java.util.UUID;

/**
* Public entry point for shared lists. Permit-all at the filter chain; the real check is the
* token plus {@link disscount.shoppingList.service.ShoppingListAccessService}.
*
* <p>Callers may be anonymous, so every method reads the user through
* {@code getCurrentUserIdOptional()} and never {@code getCurrentUserId()}, which throws.
*/
@RestController
@RequestMapping("/api/shared")
@RequiredArgsConstructor
@Tag(name = "Shared Shopping Lists", description = "Shopping lists reachable by share token")
public class SharedShoppingListController {

private final SharedShoppingListService sharedShoppingListService;

@Operation(summary = "Get a shared shopping list by its share token")
@GetMapping("/{token}")
public ResponseEntity<ShoppingListDto> getSharedShoppingList(@PathVariable String token) {
UUID userId = currentUserId();
return sharedShoppingListService.getByToken(token, userId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@Operation(summary = "Rename a shared shopping list")
@PutMapping("/{token}")
public ResponseEntity<ShoppingListDto> updateSharedShoppingList(
@PathVariable String token,
@Valid @RequestBody ShoppingListRequest request) {
UUID userId = currentUserId();
return sharedShoppingListService.updateTitle(token, userId, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@Operation(summary = "Update an item on a shared shopping list")
@PutMapping("/{token}/items/{itemId}")
public ResponseEntity<ShoppingListItemDto> updateSharedItem(
@PathVariable String token,
@PathVariable UUID itemId,
@Valid @RequestBody ShoppingListItemRequest request) {
UUID userId = currentUserId();
return sharedShoppingListService.updateItem(token, itemId, userId, request)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@Operation(summary = "Delete an item from a shared shopping list")
@DeleteMapping("/{token}/items/{itemId}")
public ResponseEntity<Void> deleteSharedItem(
@PathVariable String token,
@PathVariable UUID itemId) {
UUID userId = currentUserId();
return sharedShoppingListService.deleteItem(token, itemId, userId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}

private UUID currentUserId() {
return SecurityUtils.getCurrentUserIdOptional().orElse(null);
}
}
Loading