diff --git a/src/main/java/org/bytefight/webserver/social/application/ProfileService.java b/src/main/java/org/bytefight/webserver/social/application/ProfileService.java new file mode 100644 index 00000000..cebfbb94 --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/application/ProfileService.java @@ -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 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 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")); + } +} \ No newline at end of file diff --git a/src/main/java/org/bytefight/webserver/social/domain/Profile.java b/src/main/java/org/bytefight/webserver/social/domain/Profile.java new file mode 100644 index 00000000..77dac44f --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/domain/Profile.java @@ -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; + +} diff --git a/src/main/java/org/bytefight/webserver/social/domain/dto/PublicProfileDto.java b/src/main/java/org/bytefight/webserver/social/domain/dto/PublicProfileDto.java new file mode 100644 index 00000000..06121a8e --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/domain/dto/PublicProfileDto.java @@ -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(); + } + +} diff --git a/src/main/java/org/bytefight/webserver/social/infra/PrivateProfileController.java b/src/main/java/org/bytefight/webserver/social/infra/PrivateProfileController.java new file mode 100644 index 00000000..d7f5d33a --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/infra/PrivateProfileController.java @@ -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 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 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 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 getUserProfile( + @AuthenticationPrincipal User user + ) { + Player player = playerService.getPlayer(user) + .orElseThrow(() -> new IllegalArgumentException("Player not found")); + return ResponseEntity.ok(profileService.getProfile(player)); + } +} \ No newline at end of file diff --git a/src/main/java/org/bytefight/webserver/social/infra/ProfileRepository.java b/src/main/java/org/bytefight/webserver/social/infra/ProfileRepository.java new file mode 100644 index 00000000..b9edd185 --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/infra/ProfileRepository.java @@ -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, JpaSpecificationExecutor { + Optional findByPlayerAndIsDeletedFalse(Player player); + + boolean existsByPlayerAndIsDeletedFalse(Player player); +} \ No newline at end of file diff --git a/src/main/java/org/bytefight/webserver/social/infra/ProfileSpecification.java b/src/main/java/org/bytefight/webserver/social/infra/ProfileSpecification.java new file mode 100644 index 00000000..9effe87c --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/infra/ProfileSpecification.java @@ -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 fromFilter(String username, String major, Integer year, String keyword) { + return (root, query, cb) -> { + List 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)); + }; + } +} \ No newline at end of file diff --git a/src/main/java/org/bytefight/webserver/social/infra/PublicProfileController.java b/src/main/java/org/bytefight/webserver/social/infra/PublicProfileController.java new file mode 100644 index 00000000..4936a35c --- /dev/null +++ b/src/main/java/org/bytefight/webserver/social/infra/PublicProfileController.java @@ -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 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 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 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 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; + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V14__add_profile_table.sql b/src/main/resources/db/migration/V14__add_profile_table.sql new file mode 100644 index 00000000..56bef4d7 --- /dev/null +++ b/src/main/resources/db/migration/V14__add_profile_table.sql @@ -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"); \ No newline at end of file