Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.bytefight.webserver.social.application;

import lombok.RequiredArgsConstructor;
import org.bytefight.webserver.player.domain.Player;
import org.bytefight.webserver.social.domain.Profile;
import org.bytefight.webserver.social.infra.ProfileSpecification;
import org.bytefight.webserver.social.domain.dto.PublicProfileDto;
import org.bytefight.webserver.social.infra.ProfileRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ProfileService {
private final ProfileRepository profileRepository;

@Transactional(readOnly = true)
public List<PublicProfileDto> getProfiles(String username, String major, Integer year, String keyword) {
return profileRepository.findAll(ProfileSpecification.fromFilter(username, major, year, keyword))
.stream()
.map(PublicProfileDto::from)
.toList();
}

@Transactional(readOnly = true)
public Page<PublicProfileDto> getProfiles(String username, String major, Integer year, String keyword, Pageable pageable) {
return profileRepository.findAll(ProfileSpecification.fromFilter(username, major, year, keyword), pageable)
.map(PublicProfileDto::from);
}

@Transactional
public PublicProfileDto createProfile(Player player, String description, String major, Integer year) {
if (profileRepository.existsByPlayerAndIsDeletedFalse(player)) {
throw new IllegalArgumentException("Player already has a profile");
}

if (major == null || major.isBlank()) throw new IllegalArgumentException("Major is required");
if (year == null) throw new IllegalArgumentException("Year is required");
if (year < 0) throw new IllegalArgumentException("Year cannot be negative");

Profile profile = new Profile();
profile.setPlayer(player);
profile.setDescription(description);
profile.setMajor(major.trim());
profile.setYear(year);

return PublicProfileDto.from(profileRepository.save(profile));
}

@Transactional
public PublicProfileDto updateProfile(Player player, String description, String major, Integer year) {
Profile profile = profileRepository.findByPlayerAndIsDeletedFalse(player)
.orElseThrow(() -> new IllegalArgumentException("Profile not found"));

if (major != null && !major.isBlank()) profile.setMajor(major.trim());
if (description != null) profile.setDescription(description);
if (year != null) {
if (year < 0) throw new IllegalArgumentException("Year cannot be negative");
profile.setYear(year);
}

return PublicProfileDto.from(profileRepository.save(profile));
}

@Transactional
public void deleteProfile(Player player) {
Profile profile = profileRepository.findByPlayerAndIsDeletedFalse(player)
.orElseThrow(() -> new IllegalArgumentException("Profile not found"));

profile.softDelete();
profileRepository.save(profile);
}


@Transactional(readOnly = true)
public PublicProfileDto getProfile(
Player player
) {
return profileRepository.findByPlayerAndIsDeletedFalse(player)
.map(PublicProfileDto::from)
.orElseThrow(() -> new IllegalArgumentException("Profile not found"));
}
}
30 changes: 30 additions & 0 deletions src/main/java/org/bytefight/webserver/social/domain/Profile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.bytefight.webserver.social.domain;

import org.bytefight.webserver.common.domain.SoftDeletableEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.bytefight.webserver.player.domain.Player;

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "Profile")
public class Profile extends SoftDeletableEntity {

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "player_id", nullable = false)
private Player player;

@Column(name = "description", length = 512)
private String description;

@Column(name = "major", length=256, nullable = false)
private String major;

@Column(name = "year", length = 50, nullable = false)
private Integer year;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.bytefight.webserver.social.domain.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import org.bytefight.webserver.social.domain.Profile;

@Getter
@Builder
public class PublicProfileDto {
@NotNull String uuid;
@NotNull String username;
String description;
@NotNull String major;
@NotNull @Min(0) Integer year;


public static PublicProfileDto from(Profile profile) {
return PublicProfileDto.builder()
.uuid(profile.getPlayer().getUser().getUuid().toString())
.username(profile.getPlayer().getUsername())
.description(profile.getDescription())
.major(profile.getMajor())
.year(profile.getYear())
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.bytefight.webserver.social.infra;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.bytefight.webserver.user.domain.User;
import org.bytefight.webserver.player.application.PlayerService;
import org.bytefight.webserver.player.domain.Player;
import org.bytefight.webserver.social.application.ProfileService;
import org.bytefight.webserver.social.domain.dto.PublicProfileDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Profile (Private)")
@RestController
@RequestMapping("api/v1/private/profiles")
@RequiredArgsConstructor
public class PrivateProfileController {

private final ProfileService profileService;
private final PlayerService playerService;

@PostMapping
@Operation(
operationId = "createProfile",
summary = "REST endpoint to create a profile"
)
public ResponseEntity<PublicProfileDto> createProfile(
@AuthenticationPrincipal User user,
@RequestParam(required = false) String description,
@RequestParam String major,
@RequestParam Integer year
) {
Player player = playerService.getPlayer(user)
.orElseThrow(() -> new IllegalArgumentException("Player not found"));
return ResponseEntity.status(HttpStatus.CREATED)
.body(profileService.createProfile(player, description, major, year));
}

@PatchMapping
@Operation(
operationId = "updateProfile",
summary = "REST endpoint to update a profile"
)
public ResponseEntity<PublicProfileDto> updateProfile(
@AuthenticationPrincipal User user,
@RequestParam(required = false) String description,
@RequestParam(required = false) String major,
@RequestParam(required = false) Integer year
) {
Player player = playerService.getPlayer(user)
.orElseThrow(() -> new IllegalArgumentException("Player not found"));
return ResponseEntity.ok(profileService.updateProfile(player, description, major, year));
}

@DeleteMapping
@Operation(
operationId = "deleteProfile",
summary = "REST endpoint to delete a profile"
)
public ResponseEntity<Void> deleteProfile(
@AuthenticationPrincipal User user
) {
Player player = playerService.getPlayer(user)
.orElseThrow(() -> new IllegalArgumentException("Player not found"));
profileService.deleteProfile(player);
return ResponseEntity.noContent().build();
}

@GetMapping
@Operation(
operationId = "getUserProfile",
summary = "Get the authenticated user's profile"
)
public ResponseEntity<PublicProfileDto> getUserProfile(
@AuthenticationPrincipal User user
) {
Player player = playerService.getPlayer(user)
.orElseThrow(() -> new IllegalArgumentException("Player not found"));
return ResponseEntity.ok(profileService.getProfile(player));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.bytefight.webserver.social.infra;

import java.util.Optional;

import org.bytefight.webserver.player.domain.Player;
import org.bytefight.webserver.social.domain.Profile;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface ProfileRepository extends JpaRepository<Profile, Long>, JpaSpecificationExecutor<Profile> {
Optional<Profile> findByPlayerAndIsDeletedFalse(Player player);

boolean existsByPlayerAndIsDeletedFalse(Player player);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.bytefight.webserver.social.infra;

import jakarta.persistence.criteria.Predicate;
import org.bytefight.webserver.social.domain.Profile;
import org.springframework.data.jpa.domain.Specification;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class ProfileSpecification {
public static Specification<Profile> fromFilter(String username, String major, Integer year, String keyword) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();

predicates.add(cb.equal(root.get("isDeleted"), false));
if (username != null) predicates.add(cb.equal(root.get("player").get("username"), username));
if (major != null) predicates.add(cb.equal(root.get("major"), major));
if (year != null) predicates.add(cb.equal(root.get("year"), year));
if (keyword != null) predicates.add(cb.like(root.get("description"), "%" + keyword + "%"));

return cb.and(predicates.toArray(Predicate[]::new));
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.bytefight.webserver.social.infra;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.bytefight.webserver.common.web.RestPageRequest;
import org.bytefight.webserver.social.application.ProfileService;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.Set;

@Tag(name = "Profile (Public)")
@RestController
@RequestMapping("api/v1/public/profiles")
@RequiredArgsConstructor

public class PublicProfileController {

private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final String DEFAULT_SORT_FIELD = "major";
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("major", "year", "createdAt");

private final ProfileService profileService;

@GetMapping
@Operation(
operationId = "listProfiles",
summary = "REST endpoint to list profiles"
)
public ResponseEntity<?> getProfiles(
@ModelAttribute RestPageRequest pageRequest,
@RequestParam(defaultValue = "false") boolean paginated
) {
Map<String, Object> filter = pageRequest.getFilter();

String username = parseString(filter, "username");
String major = parseString(filter, "major");
Integer year = parseInteger(filter, "year");
String keyword = parseString(filter, "keyword");

if (paginated) {
Pageable pageable = pageRequest.toPageable(
DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE,
DEFAULT_SORT_FIELD,
ALLOWED_SORT_FIELDS
);
return ResponseEntity.ok(profileService.getProfiles(username, major, year, keyword, pageable));
}

return ResponseEntity.ok(profileService.getProfiles(username, major, year, keyword));
}

private static String parseString(Map<String, Object> filter, String key) {
if (filter == null) return null;
Object value = filter.get(key);
if (value instanceof String text && !text.isBlank()) return text;
return null;
}

private static Integer parseInteger(Map<String, Object> filter, String key) {
if (filter == null) return null;
Object value = filter.get(key);
if (value instanceof Number number) return number.intValue();
if (value instanceof String text && !text.isBlank()) {
try { return Integer.parseInt(text); }
catch (NumberFormatException ex) { return null; }
}
return null;
}
}
18 changes: 18 additions & 0 deletions src/main/resources/db/migration/V14__add_profile_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE "profile" (
"id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"created_at" timestamp NOT NULL DEFAULT (now()),
"updated_at" timestamp NOT NULL DEFAULT (now()),
"player_id" bigint NOT NULL,
"description" varchar(512),
"major" varchar(256) NOT NULL,
"year" integer NOT NULL,
"is_deleted" boolean NOT NULL DEFAULT false,
"deleted_at" timestamp
);

CREATE UNIQUE INDEX ON "profile" ("player_id")
WHERE "is_deleted" = false;

ALTER TABLE "profile"
ADD CONSTRAINT profile_player_id_fkey
FOREIGN KEY ("player_id") REFERENCES "players" ("id");