diff --git a/README.md b/README.md
index df66ea03..ece28560 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/backend/src/main/java/disscount/config/OptionalBearerAuthenticationFilter.java b/backend/src/main/java/disscount/config/OptionalBearerAuthenticationFilter.java
new file mode 100644
index 00000000..636640a6
--- /dev/null
+++ b/backend/src/main/java/disscount/config/OptionalBearerAuthenticationFilter.java
@@ -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.
+ *
+ *
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.
+ *
+ *
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);
+ }
+}
diff --git a/backend/src/main/java/disscount/config/SecurityConfig.java b/backend/src/main/java/disscount/config/SecurityConfig.java
index 73858259..aeee374a 100644
--- a/backend/src/main/java/disscount/config/SecurityConfig.java
+++ b/backend/src/main/java/disscount/config/SecurityConfig.java
@@ -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;
@@ -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
@@ -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 userProvisioningFilterRegistration(
+ UserProvisioningFilter filter
+ ) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>(filter);
+ registration.setEnabled(false);
+ return registration;
+ }
+
+ @Bean
+ public FilterRegistrationBean optionalBearerFilterRegistration(
+ OptionalBearerAuthenticationFilter filter
+ ) {
+ FilterRegistrationBean 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);
+
+ return http.build();
+ }
+
+ @Bean
+ @Order(2)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
diff --git a/backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java b/backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java
index f2c223ec..c2b0fa37 100644
--- a/backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java
+++ b/backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java
@@ -36,4 +36,7 @@ public interface ShoppingListRepository extends JpaRepository findActiveByIdAndOwner(UUID id, User owner);
+
+ @Query("SELECT sl FROM ShoppingList sl WHERE sl.shareToken = :shareToken AND sl.deletedAt IS NULL")
+ Optional findActiveByShareToken(UUID shareToken);
}
diff --git a/backend/src/main/java/disscount/shoppingList/domain/LinkAccess.java b/backend/src/main/java/disscount/shoppingList/domain/LinkAccess.java
new file mode 100644
index 00000000..b722947f
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/domain/LinkAccess.java
@@ -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.
+ *
+ * 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());
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/domain/ListAccess.java b/backend/src/main/java/disscount/shoppingList/domain/ListAccess.java
new file mode 100644
index 00000000..91095b9a
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/domain/ListAccess.java
@@ -0,0 +1,35 @@
+package disscount.shoppingList.domain;
+
+/**
+ * What a caller may do with a shopping list.
+ *
+ *
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;
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java b/backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java
index 9eb5c89c..0fb943bb 100644
--- a/backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java
+++ b/backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java
@@ -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;
@@ -51,6 +58,11 @@ public class ShoppingList {
@Builder.Default
private List 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();
diff --git a/backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java b/backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java
index 8619c329..61da5940 100644
--- a/backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java
+++ b/backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java
@@ -7,6 +7,7 @@
import java.util.List;
import java.util.UUID;
+import disscount.shoppingList.domain.ListAccess;
import disscount.shoppingListItem.dto.ShoppingListItemDto;
@Data
@@ -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 items;
diff --git a/backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java b/backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java
index 97207b04..eff3bbbf 100644
--- a/backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java
+++ b/backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java
@@ -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;
}
diff --git a/backend/src/main/java/disscount/shoppingList/rest/SharedShoppingListController.java b/backend/src/main/java/disscount/shoppingList/rest/SharedShoppingListController.java
new file mode 100644
index 00000000..b5d683db
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/rest/SharedShoppingListController.java
@@ -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}.
+ *
+ * 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 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 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 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 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);
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/service/SharedShoppingListService.java b/backend/src/main/java/disscount/shoppingList/service/SharedShoppingListService.java
new file mode 100644
index 00000000..e55001b9
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/service/SharedShoppingListService.java
@@ -0,0 +1,148 @@
+package disscount.shoppingList.service;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import disscount.exceptions.ForbiddenException;
+import disscount.exceptions.UnauthorizedException;
+import disscount.shoppingList.dao.ShoppingListRepository;
+import disscount.shoppingList.domain.ListAccess;
+import disscount.shoppingList.domain.ShoppingList;
+import disscount.shoppingList.dto.ShoppingListDto;
+import disscount.shoppingList.dto.ShoppingListRequest;
+import disscount.shoppingListItem.dao.ShoppingListItemRepository;
+import disscount.shoppingListItem.domain.ShoppingListItem;
+import disscount.shoppingListItem.dto.ShoppingListItemDto;
+import disscount.shoppingListItem.dto.ShoppingListItemRequest;
+import disscount.user.dao.UserRepository;
+import disscount.user.domain.User;
+import disscount.util.Timestamps;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Predicate;
+
+/**
+ * Everything reachable through a share token. The token is the capability, so it travels on
+ * every call here and knowing a list's id is never enough on its own.
+ *
+ * An unresolvable token yields an empty Optional, which the controller turns into a 404
+ * rather than a 403, so the endpoint never confirms that a token once existed.
+ */
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class SharedShoppingListService {
+
+ private final ShoppingListRepository shoppingListRepository;
+ private final ShoppingListItemRepository shoppingListItemRepository;
+ private final UserRepository userRepository;
+ private final ShoppingListAccessService accessService;
+ private final ShoppingListMapper shoppingListMapper;
+
+ @Transactional(readOnly = true)
+ public Optional getByToken(String token, UUID userId) {
+ return findShared(token)
+ .map(list -> shoppingListMapper.toDto(list, accessService.resolve(list, userId)));
+ }
+
+ public Optional updateTitle(String token, UUID userId, ShoppingListRequest request) {
+ return findShared(token).map(list -> {
+ ListAccess access = requireAccess(list, userId, ListAccess::canEditItems);
+
+ list.setTitle(request.getTitle());
+ return shoppingListMapper.toDto(shoppingListRepository.save(list), access);
+ });
+ }
+
+ public Optional updateItem(
+ String token, UUID itemId, UUID userId, ShoppingListItemRequest request) {
+ return findShared(token).flatMap(list -> {
+ ListAccess access = requireAccess(list, userId, ListAccess::canCheck);
+ User actor = requireUser(userId);
+
+ return shoppingListItemRepository.findActiveByIdAndShoppingList(itemId, list).map(item -> {
+ applyItemUpdate(item, request, access);
+ item.setUpdatedAt(Timestamps.nowUtc());
+ item.setUpdatedByUser(actor);
+
+ ShoppingListItem saved = shoppingListItemRepository.save(item);
+ touchList(list);
+ return shoppingListMapper.toItemDto(saved, access);
+ });
+ });
+ }
+
+ public boolean deleteItem(String token, UUID itemId, UUID userId) {
+ return findShared(token).map(list -> {
+ requireAccess(list, userId, ListAccess::canEditItems);
+
+ return shoppingListItemRepository.findActiveByIdAndShoppingList(itemId, list).map(item -> {
+ item.setDeletedAt(Timestamps.nowUtc());
+ shoppingListItemRepository.save(item);
+ touchList(list);
+ return true;
+ }).orElse(false);
+ }).orElse(false);
+ }
+
+ /**
+ * A list is reachable by token only while it is actually shared. The token is nulled
+ * whenever link access goes back to NONE, so this is belt and braces.
+ */
+ private Optional findShared(String token) {
+ UUID parsed;
+ try {
+ parsed = UUID.fromString(token);
+ } catch (IllegalArgumentException ex) {
+ // A malformed token is indistinguishable from an unknown one, by design.
+ return Optional.empty();
+ }
+
+ return shoppingListRepository.findActiveByShareToken(parsed)
+ .filter(list -> list.resolvedLinkAccess() != ListAccess.NONE);
+ }
+
+ private ListAccess requireAccess(ShoppingList list, UUID userId, Predicate allowed) {
+ ListAccess access = accessService.resolve(list, userId);
+ if (!allowed.test(access)) {
+ throw new ForbiddenException("Insufficient access to this shopping list");
+ }
+ return access;
+ }
+
+ private User requireUser(UUID userId) {
+ return userRepository.findById(userId)
+ .orElseThrow(() -> new UnauthorizedException("User not found"));
+ }
+
+ /**
+ * SHOP is the in-the-shop level: what got ticked, which store it is coming from, and the
+ * prices captured at that moment. Everything structural is left as the server has it, so a
+ * SHOP-level caller cannot rename or resize an item by sending a fuller payload.
+ */
+ private void applyItemUpdate(ShoppingListItem item, ShoppingListItemRequest request, ListAccess access) {
+ item.setIsChecked(request.getIsChecked() != null ? request.getIsChecked() : false);
+ item.setChainCode(request.getChainCode());
+ item.setAvgPrice(request.getAvgPrice());
+ item.setStorePrice(request.getStorePrice());
+
+ if (!access.canEditItems()) {
+ return;
+ }
+
+ item.setEan(request.getEan());
+ item.setBrand(request.getBrand());
+ item.setName(request.getName());
+ item.setQuantity(request.getQuantity());
+ item.setUnit(request.getUnit());
+ item.setAmount(request.getAmount() != null ? request.getAmount() : 1);
+ }
+
+ /** Item activity reorders the owner's list index, which sorts by updatedAt. */
+ private void touchList(ShoppingList list) {
+ list.setUpdatedAt(Timestamps.nowUtc());
+ shoppingListRepository.save(list);
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/service/ShoppingListAccessService.java b/backend/src/main/java/disscount/shoppingList/service/ShoppingListAccessService.java
new file mode 100644
index 00000000..48616699
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/service/ShoppingListAccessService.java
@@ -0,0 +1,34 @@
+package disscount.shoppingList.service;
+
+import org.springframework.stereotype.Service;
+
+import disscount.shoppingList.domain.ListAccess;
+import disscount.shoppingList.domain.ShoppingList;
+
+import java.util.UUID;
+
+/**
+ * The single authorization rule for shopping lists. Membership adds a
+ * max(member permission, link access) branch here and nowhere else.
+ */
+@Service
+public class ShoppingListAccessService {
+
+ /**
+ * @param userId the caller, or null when the request carries no token at all
+ */
+ public ListAccess resolve(ShoppingList list, UUID userId) {
+ if (userId != null && userId.equals(list.getOwner().getId())) {
+ return ListAccess.OWNER;
+ }
+
+ ListAccess link = list.resolvedLinkAccess();
+ if (link == ListAccess.NONE) {
+ return ListAccess.NONE;
+ }
+
+ // Anonymous callers are capped at VIEW however generous the link is, which is what
+ // keeps every write attributable to an account.
+ return userId == null ? ListAccess.VIEW : link;
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/service/ShoppingListMapper.java b/backend/src/main/java/disscount/shoppingList/service/ShoppingListMapper.java
new file mode 100644
index 00000000..656ef09a
--- /dev/null
+++ b/backend/src/main/java/disscount/shoppingList/service/ShoppingListMapper.java
@@ -0,0 +1,76 @@
+package disscount.shoppingList.service;
+
+import org.springframework.stereotype.Component;
+
+import disscount.shoppingList.domain.ListAccess;
+import disscount.shoppingList.domain.ShoppingList;
+import disscount.shoppingList.dto.ShoppingListDto;
+import disscount.shoppingListItem.domain.ShoppingListItem;
+import disscount.shoppingListItem.dto.ShoppingListItemDto;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * One place items and lists become DTOs, so the owner view and the shared view cannot
+ * drift apart in what they expose.
+ */
+@Component
+public class ShoppingListMapper {
+
+ public ShoppingListDto toDto(ShoppingList list, ListAccess access) {
+ boolean isOwner = access == ListAccess.OWNER;
+
+ List items = list.getItems().stream()
+ .filter(item -> item.getDeletedAt() == null)
+ .map(item -> toItemDto(item, access))
+ .collect(Collectors.toList());
+
+ return ShoppingListDto.builder()
+ .id(list.getId())
+ // Account ids are for the owner only. They are stable cross-request
+ // identifiers, and a share link can travel anywhere, so a recipient
+ // would otherwise be able to correlate two links as the same person.
+ .ownerId(isOwner ? list.getOwner().getId() : null)
+ .title(list.getTitle())
+ .linkAccess(isOwner ? list.resolvedLinkAccess() : null)
+ .shareToken(isOwner ? list.getShareToken() : null)
+ .myAccess(access)
+ .updatedAt(list.getUpdatedAt())
+ .createdAt(list.getCreatedAt())
+ .items(items)
+ .build();
+ }
+
+ public ShoppingListItemDto toItemDto(ShoppingListItem item, ListAccess access) {
+ return ShoppingListItemDto.builder()
+ .id(item.getId())
+ .shoppingListId(item.getShoppingList().getId())
+ .ean(item.getEan())
+ .brand(item.getBrand())
+ .name(item.getName())
+ .quantity(item.getQuantity())
+ .unit(item.getUnit())
+ .amount(item.getAmount())
+ .isChecked(item.getIsChecked())
+ .chainCode(item.getChainCode())
+ .avgPrice(item.getAvgPrice())
+ .storePrice(item.getStorePrice())
+ .createdAt(item.getCreatedAt())
+ .updatedAt(item.getUpdatedAt())
+ // Same reason as ownerId. On a SHOP or EDIT list several accounts touch
+ // items, so this would hand a link visitor the account id of everyone
+ // shopping it. Nothing renders attribution yet; version 2 adds it as a
+ // name, not an id.
+ .updatedByUserId(updatedByUserId(item, access))
+ .build();
+ }
+
+ private UUID updatedByUserId(ShoppingListItem item, ListAccess access) {
+ if (access != ListAccess.OWNER || item.getUpdatedByUser() == null) {
+ return null;
+ }
+ return item.getUpdatedByUser().getId();
+ }
+}
diff --git a/backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java b/backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java
index 348ccb46..d73c8685 100644
--- a/backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java
+++ b/backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java
@@ -7,20 +7,24 @@
import disscount.exceptions.BadRequestException;
import disscount.exceptions.UnauthorizedException;
import disscount.shoppingList.dao.ShoppingListRepository;
+import disscount.shoppingList.domain.LinkAccess;
+import disscount.shoppingList.domain.ListAccess;
import disscount.shoppingList.domain.ShoppingList;
import disscount.shoppingList.dto.ShoppingListDto;
import disscount.shoppingList.dto.ShoppingListRequest;
-import disscount.shoppingListItem.dto.ShoppingListItemDto;
import disscount.user.dao.UserRepository;
import disscount.user.domain.User;
import disscount.util.Timestamps;
-import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
+/**
+ * The owner's view of their own lists. Everything here is owner-only; access granted by a
+ * share link runs through {@link SharedShoppingListService} instead.
+ */
@Service
@RequiredArgsConstructor
@Transactional
@@ -28,19 +32,21 @@ public class ShoppingListService {
private final ShoppingListRepository shoppingListRepository;
private final UserRepository userRepository;
+ private final ShoppingListMapper shoppingListMapper;
public ShoppingListDto createShoppingList(UUID ownerId, ShoppingListRequest request) {
User owner = userRepository.findById(ownerId)
.orElseThrow(() -> new UnauthorizedException("User not found"));
+ // New lists are always private, which also makes "copy list" private by construction.
+ // Sharing needs a persisted id to bind a token to, so it is turned on afterwards.
ShoppingList shoppingList = ShoppingList.builder()
.owner(owner)
.title(request.getTitle())
- .isPublic(request.getIsPublic() != null ? request.getIsPublic() : false)
.build();
shoppingList = shoppingListRepository.save(shoppingList);
- return convertToDto(shoppingList);
+ return shoppingListMapper.toDto(shoppingList, ListAccess.OWNER);
}
// Read-only: the class-level @Transactional would otherwise keep a dirty-checking
@@ -52,7 +58,7 @@ public List getUserShoppingLists(UUID ownerId) {
return shoppingListRepository.findActiveByOwner(owner)
.stream()
- .map(this::convertToDto)
+ .map(list -> shoppingListMapper.toDto(list, ListAccess.OWNER))
.collect(Collectors.toList());
}
@@ -61,16 +67,8 @@ public Optional getShoppingListById(UUID listId, UUID ownerId)
User owner = userRepository.findById(ownerId)
.orElseThrow(() -> new UnauthorizedException("User not found"));
- // First try to get as owner
- Optional shoppingListOpt = shoppingListRepository.findActiveByIdAndOwner(listId, owner);
-
- // If not found and not owner, try to get public list
- if (shoppingListOpt.isEmpty()) {
- shoppingListOpt = shoppingListRepository.findActiveById(listId)
- .filter(list -> list.getIsPublic());
- }
-
- return shoppingListOpt.map(this::convertToDto);
+ return shoppingListRepository.findActiveByIdAndOwner(listId, owner)
+ .map(list -> shoppingListMapper.toDto(list, ListAccess.OWNER));
}
public ShoppingListDto updateShoppingList(UUID listId, UUID ownerId, ShoppingListRequest request) {
@@ -80,12 +78,11 @@ public ShoppingListDto updateShoppingList(UUID listId, UUID ownerId, ShoppingLis
ShoppingList shoppingList = shoppingListRepository.findActiveByIdAndOwner(listId, owner)
.orElseThrow(() -> new BadRequestException("Shopping list not found"));
- // Update fields
shoppingList.setTitle(request.getTitle());
- shoppingList.setIsPublic(request.getIsPublic() != null ? request.getIsPublic() : false);
+ applyLinkAccess(shoppingList, request.getLinkAccess());
shoppingList = shoppingListRepository.save(shoppingList);
- return convertToDto(shoppingList);
+ return shoppingListMapper.toDto(shoppingList, ListAccess.OWNER);
}
public void deleteShoppingList(UUID listId, UUID ownerId) {
@@ -99,36 +96,30 @@ public void deleteShoppingList(UUID listId, UUID ownerId) {
shoppingListRepository.save(shoppingList);
}
- private ShoppingListDto convertToDto(ShoppingList shoppingList) {
- List itemDtos = shoppingList.getItems().stream()
- .filter(item -> item.getDeletedAt() == null)
- .map(item -> ShoppingListItemDto.builder()
- .id(item.getId())
- .shoppingListId(item.getShoppingList().getId())
- .ean(item.getEan())
- .brand(item.getBrand())
- .name(item.getName())
- .quantity(item.getQuantity())
- .unit(item.getUnit())
- .amount(item.getAmount())
- .isChecked(item.getIsChecked())
- .chainCode(item.getChainCode())
- .avgPrice(item.getAvgPrice())
- .storePrice(item.getStorePrice())
- .createdAt(item.getCreatedAt())
- .updatedAt(item.getUpdatedAt())
- .updatedByUserId(item.getUpdatedByUser() != null ? item.getUpdatedByUser().getId() : null)
- .build())
- .collect(Collectors.toList());
+ /**
+ * Re-enabling a link mints a fresh token, so turning sharing off and on again is a real
+ * revoke. Merely changing the level leaves the token alone, since the people already
+ * holding the link are meant to keep working at the new level.
+ */
+ private void applyLinkAccess(ShoppingList list, LinkAccess requested) {
+ if (requested == null) {
+ return;
+ }
- return ShoppingListDto.builder()
- .id(shoppingList.getId())
- .ownerId(shoppingList.getOwner().getId())
- .title(shoppingList.getTitle())
- .isPublic(shoppingList.getIsPublic())
- .updatedAt(shoppingList.getUpdatedAt())
- .createdAt(shoppingList.getCreatedAt())
- .items(itemDtos)
- .build();
+ ListAccess next = requested.toListAccess();
+ if (next == list.resolvedLinkAccess()) {
+ return;
+ }
+
+ if (next == ListAccess.NONE) {
+ list.setLinkAccess(null);
+ list.setShareToken(null);
+ return;
+ }
+
+ list.setLinkAccess(next);
+ if (list.getShareToken() == null) {
+ list.setShareToken(UUID.randomUUID());
+ }
}
}
diff --git a/backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java b/backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java
index a68ef2e4..dd940788 100644
--- a/backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java
+++ b/backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java
@@ -35,19 +35,21 @@ public ResponseEntity addItemToShoppingList(
@Operation(summary = "Update shopping list item")
@PutMapping("/{itemId}")
public ResponseEntity updateShoppingListItem(
+ @PathVariable UUID listId,
@PathVariable UUID itemId,
@Valid @RequestBody ShoppingListItemRequest request) {
UUID ownerId = SecurityUtils.getCurrentUserId();
- ShoppingListItemDto updated = shoppingListItemService.updateShoppingListItem(itemId, ownerId, request);
+ ShoppingListItemDto updated = shoppingListItemService.updateShoppingListItem(listId, itemId, ownerId, request);
return ResponseEntity.ok(updated);
}
@Operation(summary = "Delete shopping list item")
@DeleteMapping("/{itemId}")
public ResponseEntity deleteShoppingListItem(
+ @PathVariable UUID listId,
@PathVariable UUID itemId) {
UUID ownerId = SecurityUtils.getCurrentUserId();
- shoppingListItemService.deleteShoppingListItem(itemId, ownerId);
+ shoppingListItemService.deleteShoppingListItem(listId, itemId, ownerId);
return ResponseEntity.noContent().build();
}
}
diff --git a/backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java b/backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java
index 1c5be4e0..e5051e19 100644
--- a/backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java
+++ b/backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java
@@ -7,7 +7,9 @@
import disscount.exceptions.BadRequestException;
import disscount.exceptions.UnauthorizedException;
import disscount.shoppingList.dao.ShoppingListRepository;
+import disscount.shoppingList.domain.ListAccess;
import disscount.shoppingList.domain.ShoppingList;
+import disscount.shoppingList.service.ShoppingListMapper;
import disscount.shoppingListItem.dao.ShoppingListItemRepository;
import disscount.shoppingListItem.domain.ShoppingListItem;
import disscount.shoppingListItem.dto.ShoppingListItemDto;
@@ -16,12 +18,16 @@
import disscount.user.domain.User;
import disscount.util.Timestamps;
-import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
+/**
+ * The owner's own items. Writes granted by a share link go through
+ * {@link disscount.shoppingList.service.SharedShoppingListService} instead, so that the token
+ * has to travel with the request.
+ */
@Service
@RequiredArgsConstructor
@Transactional
@@ -30,16 +36,14 @@ public class ShoppingListItemService {
private final ShoppingListItemRepository shoppingListItemRepository;
private final ShoppingListRepository shoppingListRepository;
private final UserRepository userRepository;
+ private final ShoppingListMapper shoppingListMapper;
public ShoppingListItemDto addItemToShoppingList(UUID shoppingListId, UUID ownerId, ShoppingListItemRequest request) {
User owner = userRepository.findById(ownerId)
.orElseThrow(() -> new UnauthorizedException("User not found"));
- // First try to get as owner, then as public list
ShoppingList shoppingList = shoppingListRepository.findActiveByIdAndOwner(shoppingListId, owner)
- .orElseGet(() -> shoppingListRepository.findActiveById(shoppingListId)
- .filter(list -> list.getIsPublic())
- .orElseThrow(() -> new BadRequestException("Shopping list not found or access denied")));
+ .orElseThrow(() -> new BadRequestException("Shopping list not found or access denied"));
// Check if item with same name already exists in the shopping list
Optional existingItem = shoppingListItemRepository
@@ -53,7 +57,7 @@ public ShoppingListItemDto addItemToShoppingList(UUID shoppingListId, UUID owner
// Cap the merged total at the same limit the request DTO enforces (@Max)
int newAmount = Math.min(item.getAmount() + requestedAmount, 999);
item.setAmount(newAmount);
-
+
// Update other fields with new values if provided
if (request.getEan() != null) item.setEan(request.getEan());
if (request.getBrand() != null) item.setBrand(request.getBrand());
@@ -62,7 +66,7 @@ public ShoppingListItemDto addItemToShoppingList(UUID shoppingListId, UUID owner
if (request.getChainCode() != null) item.setChainCode(request.getChainCode());
if (request.getAvgPrice() != null) item.setAvgPrice(request.getAvgPrice());
if (request.getStorePrice() != null) item.setStorePrice(request.getStorePrice());
-
+
// Update tracking fields
item.setUpdatedAt(Timestamps.nowUtc());
item.setUpdatedByUser(owner);
@@ -90,23 +94,14 @@ public ShoppingListItemDto addItemToShoppingList(UUID shoppingListId, UUID owner
shoppingList.setUpdatedAt(Timestamps.nowUtc());
shoppingListRepository.save(shoppingList);
- return convertToDto(item);
+ return shoppingListMapper.toItemDto(item, ListAccess.OWNER);
}
- public ShoppingListItemDto updateShoppingListItem(UUID itemId, UUID ownerId, ShoppingListItemRequest request) {
- userRepository.findById(ownerId)
+ public ShoppingListItemDto updateShoppingListItem(UUID listId, UUID itemId, UUID ownerId, ShoppingListItemRequest request) {
+ User owner = userRepository.findById(ownerId)
.orElseThrow(() -> new UnauthorizedException("User not found"));
- ShoppingListItem item = shoppingListItemRepository.findActiveById(itemId)
- .filter(i -> {
- // Allow if user is owner OR if list is public
- return i.getShoppingList().getOwner().getId().equals(ownerId) ||
- i.getShoppingList().getIsPublic();
- })
- .orElseThrow(() -> new BadRequestException("Shopping list item not found or access denied"));
-
- User currentUser = userRepository.findById(ownerId)
- .orElseThrow(() -> new UnauthorizedException("User not found"));
+ ShoppingListItem item = findOwnedItem(listId, itemId, owner);
// Update fields
item.setEan(request.getEan());
@@ -119,10 +114,10 @@ public ShoppingListItemDto updateShoppingListItem(UUID itemId, UUID ownerId, Sho
item.setChainCode(request.getChainCode());
item.setAvgPrice(request.getAvgPrice());
item.setStorePrice(request.getStorePrice());
-
+
// Update tracking fields
item.setUpdatedAt(Timestamps.nowUtc());
- item.setUpdatedByUser(currentUser);
+ item.setUpdatedByUser(owner);
item = shoppingListItemRepository.save(item);
@@ -130,20 +125,14 @@ public ShoppingListItemDto updateShoppingListItem(UUID itemId, UUID ownerId, Sho
item.getShoppingList().setUpdatedAt(Timestamps.nowUtc());
shoppingListRepository.save(item.getShoppingList());
- return convertToDto(item);
+ return shoppingListMapper.toItemDto(item, ListAccess.OWNER);
}
- public void deleteShoppingListItem(UUID itemId, UUID ownerId) {
- userRepository.findById(ownerId)
+ public void deleteShoppingListItem(UUID listId, UUID itemId, UUID ownerId) {
+ User owner = userRepository.findById(ownerId)
.orElseThrow(() -> new UnauthorizedException("User not found"));
- ShoppingListItem item = shoppingListItemRepository.findActiveById(itemId)
- .filter(i -> {
- // Allow if user is owner OR if list is public
- return i.getShoppingList().getOwner().getId().equals(ownerId) ||
- i.getShoppingList().getIsPublic();
- })
- .orElseThrow(() -> new BadRequestException("Shopping list item not found or access denied"));
+ ShoppingListItem item = findOwnedItem(listId, itemId, owner);
item.setDeletedAt(Timestamps.nowUtc());
shoppingListItemRepository.save(item);
@@ -159,27 +148,16 @@ public List getUserShoppingListItems(UUID ownerId) {
return shoppingListItemRepository.findAllActiveItemsByUser(owner)
.stream()
- .map(this::convertToDto)
+ .map(item -> shoppingListMapper.toItemDto(item, ListAccess.OWNER))
.collect(Collectors.toList());
}
- private ShoppingListItemDto convertToDto(ShoppingListItem item) {
- return ShoppingListItemDto.builder()
- .id(item.getId())
- .shoppingListId(item.getShoppingList().getId())
- .ean(item.getEan())
- .brand(item.getBrand())
- .name(item.getName())
- .quantity(item.getQuantity())
- .unit(item.getUnit())
- .amount(item.getAmount())
- .isChecked(item.getIsChecked())
- .chainCode(item.getChainCode())
- .avgPrice(item.getAvgPrice())
- .storePrice(item.getStorePrice())
- .createdAt(item.getCreatedAt())
- .updatedAt(item.getUpdatedAt())
- .updatedByUserId(item.getUpdatedByUser() != null ? item.getUpdatedByUser().getId() : null)
- .build();
+ /** The item has to belong both to the list in the path and to the caller. */
+ private ShoppingListItem findOwnedItem(UUID listId, UUID itemId, User owner) {
+ ShoppingList shoppingList = shoppingListRepository.findActiveByIdAndOwner(listId, owner)
+ .orElseThrow(() -> new BadRequestException("Shopping list not found or access denied"));
+
+ return shoppingListItemRepository.findActiveByIdAndShoppingList(itemId, shoppingList)
+ .orElseThrow(() -> new BadRequestException("Shopping list item not found or access denied"));
}
}
diff --git a/docs/AUTH.md b/docs/AUTH.md
index a4cb877f..f83a6fb7 100644
--- a/docs/AUTH.md
+++ b/docs/AUTH.md
@@ -124,7 +124,8 @@ Mapped codes: `email_not_found`, `email_doesn't_match`, `account_already_linked_
- A `NimbusJwtDecoder` is built with the JWKS URI and pinned to `ES256`, and it validates the token issuer (`better.auth.issuer`).
- The session policy is `STATELESS` (no server session; the JWT is the whole story) and CSRF is disabled (there is no cookie-based auth to protect).
-- Public endpoints: `/actuator/health`, Swagger, and the OpenAPI docs. Everything else requires a valid token.
+- Public endpoints: `/actuator/health`, Swagger, the OpenAPI docs, `POST /api/contact`, and `/api/shared/**`. Everything else requires a valid token.
+- `/api/shared/**` (shopping list sharing) has its own `@Order(1)` filter chain. It is the one place where a bearer token is **optional**: the caller may legitimately be anonymous, and authorization is done in application code against the list's `share_token` and `link_access` via `SharedShoppingListService` / `ShoppingListAccessService`. `permitAll` on the main chain would not be enough, because `BearerTokenAuthenticationFilter` answers 401 for an expired or malformed token before authorization is consulted, so a stale cached token would lock a visitor out of a link that works. An `OptionalBearerAuthenticationFilter` decodes the token when it can and falls through to anonymous when it cannot.
- A `UserProvisioningFilter` runs after the bearer-token filter. On the first authenticated request it lazily upserts the `app_user` profile row (same UUID as the Better Auth user) via `UserService.ensureActiveProfile`, seeding the username from the provider name (falling back to the email local-part). `app_user.username` is deliberately **not unique**; only email is.
## 8. Password reset, set password, and change email
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 00e6bc47..d249efd7 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -285,26 +285,62 @@ Set in **Dokploy → service → Environment**, per environment. Both DSNs live
## 10. What's automatic vs manual
-| Task | Automatic? | Notes |
-| ---------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------ |
-| Build & deploy on `git push` | ✅ auto | Dokploy autodeploy (per branch) |
-| CI checks (typecheck, lint, format, build, backend verify) | ✅ auto | `.github/workflows/ci.yml` on every push + PR; required to merge to `main` |
-| PR previews (head branch is not `main` or `dev`) | ✅ auto | Netlify, gated by `frontend/netlify.toml` (see [§4](#netlify-pr-previews)) |
-| HTTPS certificate issuance + renewal | ✅ auto | Traefik + Let's Encrypt |
-| HTTP to HTTPS redirect | ✅ auto | Cloudflare |
-| DB migrations (auth tables + app tables) | ✅ auto | `migrate` service (drizzle) + Hibernate `ddl-auto=update` on each deploy |
-| Nightly DB backups (R2 + local) + rotation | ✅ auto | Dokploy Backups + Schedule |
-| OS security updates | ✅ auto | unattended-upgrades |
-| Uptime checks | ✅ auto | UptimeRobot, published as a [public status page](https://stats.uptimerobot.com/ej4ROz2eMo) |
-| **Adding/Changing a `NEXT_PUBLIC_*` var** | ❌ manual | edit in Dokploy env **+ redeploy** |
-| **Adding a new domain/subdomain** | ❌ manual | Cloudflare DNS + Dokploy Domains (+ redeploy for Compose) |
-| **New OAuth provider redirect URIs** | ❌ manual | add in Google/Meta consoles |
-| **Hard-refresh after deploy** | ❌ manual | avoids stale-bundle errors |
-| **Restoring a backup** | ❌ manual | see [§8](#8-backups--restore) |
-| **Rotating secrets / tokens** | ❌ manual | as needed |
+| Task | Automatic? | Notes |
+| ---------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| Build & deploy on `git push` | ✅ auto | Dokploy autodeploy (per branch) |
+| CI checks (typecheck, lint, format, build, backend verify) | ✅ auto | `.github/workflows/ci.yml` on every push + PR; required to merge to `main` |
+| PR previews (head branch is not `main` or `dev`) | ✅ auto | Netlify, gated by `frontend/netlify.toml` (see [§4](#netlify-pr-previews)) |
+| HTTPS certificate issuance + renewal | ✅ auto | Traefik + Let's Encrypt |
+| HTTP to HTTPS redirect | ✅ auto | Cloudflare |
+| DB migrations (auth tables + app tables) | ✅ auto | `migrate` service (drizzle) + Hibernate `ddl-auto=update` on each deploy. **Additive only**: see the dropped-column row below |
+| Nightly DB backups (R2 + local) + rotation | ✅ auto | Dokploy Backups + Schedule |
+| OS security updates | ✅ auto | unattended-upgrades |
+| Uptime checks | ✅ auto | UptimeRobot, published as a [public status page](https://stats.uptimerobot.com/ej4ROz2eMo) |
+| **Adding/Changing a `NEXT_PUBLIC_*` var** | ❌ manual | edit in Dokploy env **+ redeploy** |
+| **Adding a new domain/subdomain** | ❌ manual | Cloudflare DNS + Dokploy Domains (+ redeploy for Compose) |
+| **New OAuth provider redirect URIs** | ❌ manual | add in Google/Meta consoles |
+| **Hard-refresh after deploy** | ❌ manual | avoids stale-bundle errors |
+| **Restoring a backup** | ❌ manual | see [§8](#8-backups--restore) |
+| **Rotating secrets / tokens** | ❌ manual | as needed |
+| **Dropping or narrowing a column** | ❌ manual | `ddl-auto=update` never drops, so the column outlives the code. See [§10.1](#101-dropping-a-column) |
---
+### 10.1 Dropping a column
+
+`ddl-auto=update` is additive. It adds tables and columns, and never removes or narrows
+one. So when an entity stops mapping a column, the column stays in the database with
+whatever constraints it had, and a `NOT NULL` column with no default then rejects every
+insert the new code makes, because Hibernate has stopped supplying a value for it.
+
+Deploys fire automatically on push, so there is no window in which you control which
+image is running. That makes the order matter, and a single `DROP COLUMN` cannot be
+ordered safely: run it before the push and the still-running old image breaks, run it
+after and every insert fails until you do.
+
+Three steps, in this order:
+
+```sql
+-- 1. Before pushing. The old image still writes the column, the new one omits it.
+ALTER TABLE ALTER COLUMN DROP NOT NULL;
+```
+
+```bash
+# 2. Push. Both images can now write, so the rollout window is safe either way.
+git push
+```
+
+```sql
+-- 3. After the deploy has settled.
+ALTER TABLE DROP COLUMN ;
+```
+
+**Pending for the shopping list sharing release:** `shopping_list.is_public`, replaced by
+`link_access` and `share_token`. It is `NOT NULL` with no default, so it needs exactly the
+sequence above.
+
+Open a DB shell the same way as for a restore, see [§8](#8-backups--restore).
+
## 11. Common operations (how-to)
| I want to | Do this |
diff --git a/docs/LANDING.md b/docs/LANDING.md
index f5ddd623..671b35cb 100644
--- a/docs/LANDING.md
+++ b/docs/LANDING.md
@@ -141,14 +141,15 @@ Because `faqItems` feeds both the visible accordion and the structured data, the
The landing is the app's most SEO-sensitive surface, so several layers work together.
-| Layer | Where | Notes |
-| ------------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------- |
-| Page title + description | `page.tsx` `metadata` | Title fills the `Disscount - %s` template from the layout; Croatian description |
-| Site-wide metadata | `app/layout.tsx` | `openGraph` (`hr_HR`), `twitter` (`summary_large_image`), keywords, robots index/follow |
-| Structured data | `components/json-ld.tsx` | One `