SyncTokenService.java

package com.oliwier.listmebackend.domain.service;

import com.oliwier.listmebackend.api.dto.SyncApplyResponse;
import com.oliwier.listmebackend.api.dto.ListResponse;
import com.oliwier.listmebackend.domain.model.*;
import com.oliwier.listmebackend.domain.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;

@Service
@RequiredArgsConstructor
public class SyncTokenService {

    private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    private static final int TOKEN_LENGTH = 24;
    private static final SecureRandom RANDOM = new SecureRandom();

    private final SyncTokenRepository syncTokenRepository;
    private final ShoppingListRepository listRepository;
    private final ListDeviceRepository listDeviceRepository;
    private final PresetRepository presetRepository;
    private final DeviceRepository deviceRepository;
    private final DeviceSiblingRepository deviceSiblingRepository;

    @Transactional
    public SyncToken create(Device device, String theme) {
        List<ShoppingList> deviceLists = listRepository.findAllByDeviceId(device.getId());

        SyncToken syncToken = new SyncToken();
        syncToken.setToken(randomToken(TOKEN_LENGTH));
        syncToken.setCreatedByDevice(device);
        syncToken.setLists(new HashSet<>(deviceLists));
        syncToken.setExpiresAt(Instant.now().plus(30, ChronoUnit.DAYS));
        syncToken.setDisplayNameSnapshot(device.getDisplayName());
        syncToken.setProfilePictureSnapshot(device.getProfilePicture());
        syncToken.setThemeSnapshot(theme != null && !theme.isBlank() ? theme : "dark");

        return syncTokenRepository.save(syncToken);
    }

    @Transactional(readOnly = true)
    public SyncToken resolve(String token) {
        SyncToken syncToken = syncTokenRepository.findById(token)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Invalid sync token"));

        if (syncToken.getExpiresAt() != null && syncToken.getExpiresAt().isBefore(Instant.now())) {
            throw new ResponseStatusException(HttpStatus.GONE, "Sync token has expired");
        }
        return syncToken;
    }

    @Transactional
    public SyncApplyResponse apply(String token, Device device) {
        SyncToken syncToken = resolve(token);
        Device source = syncToken.getCreatedByDevice();

        // Add device to all lists
        for (ShoppingList list : syncToken.getLists()) {
            if (!listDeviceRepository.existsByListIdAndDeviceId(list.getId(), device.getId())) {
                listDeviceRepository.save(new ListDevice(list, device, "editor"));
            }
        }

        // Apply source profile to the new device.
        // The device argument is detached (resolved before this transaction started),
        // so we must explicitly merge it via save() to persist the changes.
        if (syncToken.getDisplayNameSnapshot() != null || syncToken.getProfilePictureSnapshot() != null) {
            device.setDisplayName(syncToken.getDisplayNameSnapshot());
            device.setProfilePicture(syncToken.getProfilePictureSnapshot());
            deviceRepository.save(device);
        }

        // Record bidirectional sibling relationship (skip if same device or already recorded)
        if (!source.getId().equals(device.getId())) {
            DeviceSiblingId abId = new DeviceSiblingId(source.getId(), device.getId());
            if (!deviceSiblingRepository.existsById(abId)) {
                deviceSiblingRepository.save(new DeviceSibling(source.getId(), device.getId()));
                deviceSiblingRepository.save(new DeviceSibling(device.getId(), source.getId()));
            }
        }

        // Clone user presets from source to destination (skip if same device)
        int presetsImported = 0;
        if (!source.getId().equals(device.getId())) {
            presetsImported = clonePresets(source, device);
        }

        List<ListResponse> listResponses = syncToken.getLists().stream().map(ListResponse::from).toList();
        return new SyncApplyResponse(
                listResponses,
                syncToken.getDisplayNameSnapshot(),
                syncToken.getProfilePictureSnapshot(),
                syncToken.getThemeSnapshot(),
                presetsImported
        );
    }

    private int clonePresets(Device source, Device destination) {
        List<Preset> sourcePresets = presetRepository.findByCreatedByDeviceIdOrderByCreatedAtDesc(source.getId());
        for (Preset src : sourcePresets) {
            Preset copy = new Preset();
            copy.setName(src.getName());
            copy.setEmoji(src.getEmoji());
            copy.setCreatedByDevice(destination);
            for (PresetItem srcItem : src.getItems()) {
                PresetItem item = new PresetItem();
                item.setPreset(copy);
                item.setName(srcItem.getName());
                item.setQuantity(srcItem.getQuantity());
                item.setQuantityUnit(srcItem.getQuantityUnit());
                item.setPrice(srcItem.getPrice());
                item.setImageUrl(srcItem.getImageUrl());
                item.setPosition(srcItem.getPosition());
                copy.getItems().add(item);
            }
            presetRepository.save(copy);
        }
        return sourcePresets.size();
    }

    private String randomToken(int length) {
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
        }
        return sb.toString();
    }
}