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 `