Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,76 @@ paths:
schema:
$ref: '#/components/schemas/Error'

/user-progress/likes:
get:
tags: [likes]
operationId: listLikes
summary: List the series the current user has liked ("My Shows"), newest first
security:
- bearerAuth: []
responses:
'200':
description: Series IDs the current user has liked, most recently liked first
content:
application/json:
schema:
type: array
items:
type: string
format: uuid
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/user-progress/likes/{seriesId}:
put:
tags: [likes]
operationId: likeSeries
summary: Like a series (idempotent — liking an already-liked series is a no-op)
security:
- bearerAuth: []
parameters:
- name: seriesId
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: The series is liked
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
tags: [likes]
operationId: unlikeSeries
summary: Remove a like (idempotent — unliking a series that isn't liked is a no-op)
security:
- bearerAuth: []
parameters:
- name: seriesId
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: The series is not liked
'401':
description: Missing or invalid token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/catalog/health:
get:
tags: [catalog]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.tso.userprogress.controller;

import com.tso.userprogress.api.LikesApi;
import com.tso.userprogress.service.SeriesLikeService;
import java.util.List;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RestController;

/**
* "My Shows" — the set of series a user has liked. Implements the generated {@link LikesApi} so the
* contract stays single-sourced in api/openapi.yaml.
*/
@RestController
public class SeriesLikeController implements LikesApi {

private final SeriesLikeService likeService;

public SeriesLikeController(SeriesLikeService likeService) {
this.likeService = likeService;
}

@Override
public ResponseEntity<List<UUID>> listLikes() {
return ResponseEntity.ok(likeService.likedSeriesIds(currentUserId()));
}

@Override
public ResponseEntity<Void> likeSeries(UUID seriesId) {
likeService.like(currentUserId(), seriesId);
return ResponseEntity.noContent().build();
}

@Override
public ResponseEntity<Void> unlikeSeries(UUID seriesId) {
likeService.unlike(currentUserId(), seriesId);
return ResponseEntity.noContent().build();
}

private UUID currentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return UUID.fromString((String) authentication.getPrincipal());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.tso.userprogress.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;

/** A user "liking" a series — surfaced as "My Shows" on the client. */
@Entity
@Table(name = "likes")
public class SeriesLike {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Column(name = "user_id", nullable = false)
private UUID userId;

@Column(name = "series_id", nullable = false)
private UUID seriesId;

@CreationTimestamp
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;

protected SeriesLike() {}

public SeriesLike(UUID userId, UUID seriesId) {
this.userId = userId;
this.seriesId = seriesId;
}

public UUID getSeriesId() {
return seriesId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.tso.userprogress.repository;

import com.tso.userprogress.entity.SeriesLike;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface SeriesLikeRepository extends JpaRepository<SeriesLike, UUID> {

/** Most-recently-liked first, so "My Shows" lists newest at the top. */
List<SeriesLike> findByUserIdOrderByCreatedAtDesc(UUID userId);

/**
* Atomic idempotent insert: relies on the UNIQUE(user_id, series_id) constraint so concurrent
* likes of the same series can't race into a constraint violation. {@code id} and {@code
* created_at} come from their DB defaults (see V4 migration).
*/
@Modifying
@Query(
value =
"INSERT INTO likes (user_id, series_id) VALUES (:userId, :seriesId)"
+ " ON CONFLICT (user_id, series_id) DO NOTHING",
nativeQuery = true)
void insertIgnore(@Param("userId") UUID userId, @Param("seriesId") UUID seriesId);

void deleteByUserIdAndSeriesId(UUID userId, UUID seriesId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.tso.userprogress.service;

import com.tso.userprogress.entity.SeriesLike;
import com.tso.userprogress.repository.SeriesLikeRepository;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SeriesLikeService {

private final SeriesLikeRepository repository;

public SeriesLikeService(SeriesLikeRepository repository) {
this.repository = repository;
}

/** The caller's liked series ids, most recently liked first. */
@Transactional(readOnly = true)
public List<UUID> likedSeriesIds(UUID userId) {
return repository.findByUserIdOrderByCreatedAtDesc(userId).stream()
.map(SeriesLike::getSeriesId)
.toList();
}

/** Idempotent: liking an already-liked series is a no-op, safe under concurrency. */
@Transactional
public void like(UUID userId, UUID seriesId) {
repository.insertIgnore(userId, seriesId);
}

/** Idempotent: unliking a series that isn't liked is a no-op. */
@Transactional
public void unlike(UUID userId, UUID seriesId) {
repository.deleteByUserIdAndSeriesId(userId, seriesId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Flyway migration: per-user "liked" series ("My Shows").
-- One row per (user, series). Like watch progress, series_id references the
-- catalog's series by value only (the catalog lives in a separate service/DB),
-- so there is no cross-service foreign key.

CREATE TABLE likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
series_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, series_id)
);

CREATE INDEX idx_likes_user ON likes(user_id);
Loading
Loading