ListController.java

package com.oliwier.listmebackend.api;

import com.oliwier.listmebackend.api.dto.CreateListRequest;
import com.oliwier.listmebackend.api.dto.ListResponse;
import com.oliwier.listmebackend.api.dto.ParticipantResponse;
import com.oliwier.listmebackend.api.dto.UpdateListRequest;
import com.oliwier.listmebackend.domain.model.*;
import com.oliwier.listmebackend.domain.repository.*;
import com.oliwier.listmebackend.identity.CurrentDevice;
import com.oliwier.listmebackend.identity.CurrentUser;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.util.*;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/lists")
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ListController {

    private final ShoppingListRepository listRepository;
    private final ListDeviceRepository listDeviceRepository;
    private final ItemRepository itemRepository;
    private final PresetItemRepository presetItemRepository;
    private final DeviceSiblingRepository deviceSiblingRepository;
    private final SimpMessagingTemplate messaging;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @Transactional
    public ListResponse create(@CurrentUser User user,
                               @CurrentDevice Device device,
                               @Valid @RequestBody CreateListRequest req) {
        ShoppingList list = new ShoppingList();
        list.setId(UUID.randomUUID());
        list.setName(req.name());
        list.setEmoji(req.emoji() != null ? req.emoji() : "\uD83D\uDED2");
        list.setCreatedByDevice(device);
        list.setUser(user);

        ListDevice ld = new ListDevice(list, device, "owner");
        list.getListDevices().add(ld);

        final ShoppingList savedList = listRepository.save(list);

        // Notify this user's other devices (same userId) via WebSocket
        messaging.convertAndSend("/topic/user/" + user.getId(), (Object) Map.of("type", "LIST_ADDED"));

        // If a preset was specified, copy its items into the new list
        int itemCount = 0;
        if (req.presetId() != null) {
            List<PresetItem> presetItems = presetItemRepository.findByPresetIdOrderByPosition(req.presetId());
            List<Item> items = new ArrayList<>();
            for (PresetItem pi : presetItems) {
                Item item = new Item();
                item.setList(savedList);
                item.setName(pi.getName());
                item.setChecked(false);
                item.setPosition(pi.getPosition());
                item.setQuantity(pi.getQuantity());
                item.setQuantityUnit(pi.getQuantityUnit());
                item.setPrice(pi.getPrice());
                item.setImageUrl(pi.getImageUrl());
                item.setCreatedByDevice(device);
                items.add(item);
            }
            itemRepository.saveAll(items);
            itemCount = items.size();
        }

        return ListResponse.fromWithCount(savedList, itemCount);
    }

    @GetMapping
    public List<ListResponse> getMyLists(@CurrentUser User user, @CurrentDevice Device device) {
        // Owned lists (primary identity via userId)
        List<ShoppingList> owned = listRepository.findByUserOrderByUpdatedAtDesc(user);
        Set<UUID> ownedIds = owned.stream().map(ShoppingList::getId).collect(Collectors.toSet());

        // Shared lists: device was added as editor via a share token (user_id belongs to someone else)
        List<ShoppingList> shared = listRepository.findSharedWithDevice(device.getId(), user.getId());

        List<ShoppingList> all = new ArrayList<>(owned);
        all.addAll(shared.stream().filter(l -> !ownedIds.contains(l.getId())).toList());
        all.sort(Comparator.comparing(ShoppingList::getUpdatedAt).reversed());

        return all.stream().map(ListResponse::from).toList();
    }

    @GetMapping("/{listId}")
    public ListResponse getList(@PathVariable UUID listId,
                                @CurrentUser User user,
                                @CurrentDevice Device device) {
        return ListResponse.from(requireAccess(listId, user, device));
    }

    @PutMapping("/{listId}")
    @Transactional
    public ListResponse update(@PathVariable UUID listId,
                               @CurrentUser User user,
                               @CurrentDevice Device device,
                               @Valid @RequestBody UpdateListRequest req) {
        ShoppingList list = requireAccess(listId, user, device);
        list.setName(req.name());
        if (req.emoji() != null) list.setEmoji(req.emoji());
        return ListResponse.from(listRepository.save(list));
    }

    @GetMapping("/{listId}/participants")
    public List<ParticipantResponse> getParticipants(@PathVariable UUID listId,
                                                     @CurrentUser User user,
                                                     @CurrentDevice Device device) {
        requireAccess(listId, user, device);
        List<ListDevice> all = listDeviceRepository.findByListId(listId);

        Set<UUID> allIds = all.stream()
                .map(l -> l.getDevice().getId())
                .collect(Collectors.toSet());

        Set<UUID> excluded = new HashSet<>();
        all.stream()
                .sorted(Comparator.comparing((ListDevice l) -> "owner".equals(l.getRole()) ? 0 : 1)
                        .thenComparing(ListDevice::getJoinedAt))
                .forEach(l -> {
                    UUID id = l.getDevice().getId();
                    if (excluded.contains(id)) return;
                    deviceSiblingRepository.findSiblingIds(id).stream()
                            .filter(allIds::contains)
                            .forEach(excluded::add);
                });

        return all.stream()
                .filter(l -> !excluded.contains(l.getDevice().getId()))
                .map(l -> new ParticipantResponse(
                        l.getDevice().getId(), l.getRole(), l.getJoinedAt(),
                        l.getDevice().getDisplayName(), l.getDevice().getProfilePicture()))
                .toList();
    }

    @PostMapping("/{listId}/duplicate")
    @ResponseStatus(HttpStatus.CREATED)
    @Transactional
    public ListResponse duplicate(@PathVariable UUID listId,
                                  @CurrentUser User user,
                                  @CurrentDevice Device device) {
        ShoppingList orig = requireAccess(listId, user, device);

        ShoppingList copy = new ShoppingList();
        copy.setId(UUID.randomUUID());
        copy.setName(orig.getName() + " (Kopie)");
        copy.setEmoji(orig.getEmoji());
        copy.setCreatedByDevice(device);
        copy.setUser(user);
        copy.getListDevices().add(new ListDevice(copy, device, "owner"));

        orig.getItems().forEach(origItem -> {
            Item item = new Item();
            item.setList(copy);
            item.setName(origItem.getName());
            item.setPosition(origItem.getPosition());
            item.setChecked(false);
            item.setCreatedByDevice(device);
            copy.getItems().add(item);
        });

        return ListResponse.from(listRepository.save(copy));
    }

    @DeleteMapping("/{listId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @Transactional
    public void delete(@PathVariable UUID listId,
                       @CurrentUser User user,
                       @CurrentDevice Device device) {
        ShoppingList list = requireAccess(listId, user, device);

        // If this user owns the list, delete it entirely
        if (list.getUser() != null && list.getUser().getId().equals(user.getId())) {
            listRepository.delete(list);
            return;
        }

        // Otherwise just remove this device's access (shared list)
        listDeviceRepository.findByListId(listId).stream()
                .filter(ld -> ld.getDevice().getId().equals(device.getId()))
                .findFirst()
                .ifPresent(listDeviceRepository::delete);
    }

    private ShoppingList requireAccess(UUID listId, User user, Device device) {
        ShoppingList list = listRepository.findById(listId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found"));

        // Owner: list belongs to this user
        if (list.getUser() != null && list.getUser().getId().equals(user.getId())) {
            return list;
        }
        // Editor: device was added via share token
        if (listDeviceRepository.existsByListIdAndDeviceId(listId, device.getId())) {
            return list;
        }

        throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not a participant of this list");
    }
}