Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public enum MobileAppPath {
DINING("dining"),
KEYWORD("keyword"),
CHAT("chat"),
CALLVAN("callvan"),
CALLVAN_CHAT("callvan-chat"),
CLUB("club"),
TIMETABLE("timetable"),
;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap;

public interface BenefitCategoryMapRepository extends Repository<BenefitCategoryMap, Integer> {

List<BenefitCategoryMap> findByBenefitCategoryId(Integer benefitCategoryId);
@Query("""
SELECT bcm FROM BenefitCategoryMap bcm
JOIN FETCH bcm.shop s
WHERE bcm.benefitCategory.id = :benefitCategoryId
AND s.isDeleted = false
""")
List<BenefitCategoryMap> findActiveShopMapsByBenefitCategoryId(@Param("benefitCategoryId") Integer benefitCategoryId);

@Query("""
SELECT bcm FROM BenefitCategoryMap bcm
JOIN FETCH bcm.shop s
JOIN FETCH bcm.benefitCategory bc
WHERE s.isDeleted = false
""")
List<BenefitCategoryMap> findAllWithFetchJoin();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public BenefitCategoryResponse getBenefitCategories() {
}

public BenefitShopsResponse getBenefitShops(Integer benefitId) {
List<BenefitCategoryMap> benefitCategoryMaps = benefitCategoryMapRepository.findByBenefitCategoryId(benefitId);
List<BenefitCategoryMap> benefitCategoryMaps =
benefitCategoryMapRepository.findActiveShopMapsByBenefitCategoryId(benefitId);
LocalDateTime now = LocalDateTime.now(clock);

List<InnerShopResponse> innerShopResponses = benefitCategoryMaps.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest;
import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse;
Expand All @@ -19,6 +20,7 @@
import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanRestrictionResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest;
import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation;
import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter;
Expand All @@ -29,9 +31,6 @@

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import in.koreatech.koin.global.code.ApiResponseCodes;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down Expand Up @@ -124,6 +123,29 @@ ResponseEntity<CallvanPostSearchResponse> getCallvanPosts(
@UserId Integer userId
);

@ApiResponseCodes({
OK,
NOT_FOUND_USER
})
@Operation(summary = "내 콜밴 이용 제한 상태 조회", description = """
### 내 콜밴 이용 제한 상태 조회 API
로그인한 사용자의 현재 활성화된 콜밴 이용 제한 상태를 조회합니다.

#### 인증 조건
- **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다.

#### 비즈니스 로직
1. 현재 활성화된 정지 상태가 있으면 `is_restricted=true`를 반환합니다.
2. 14일 이용 정지인 경우 `restriction_type=TEMPORARY_RESTRICTION_14_DAYS`와 `restricted_until`을 반환합니다.
3. 영구 이용 정지인 경우 `restriction_type=PERMANENT_RESTRICTION`을 반환하고 `restricted_until`은 null입니다.
4. 경고(`WARNING`)와 만료된 14일 정지는 이 API에서 반환하지 않습니다.
5. 활성화된 정지가 없으면 `is_restricted=false`와 null 필드들을 반환합니다.
""")
@GetMapping("/restriction")
ResponseEntity<CallvanRestrictionResponse> getCallvanRestriction(
@Auth(permit = {STUDENT}) Integer userId
);

@ApiResponseCodes({
OK,
NOT_FOUND_ARTICLE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,28 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest;
import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest;
import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse.CallvanPostResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanRestrictionResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest;
import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation;
import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter;
import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria;
import in.koreatech.koin.domain.callvan.model.filter.CallvanPostStatusFilter;
import in.koreatech.koin.domain.callvan.service.CallvanPostQueryService;
import in.koreatech.koin.domain.callvan.service.CallvanChatService;
import in.koreatech.koin.domain.callvan.service.CallvanNotificationService;
import in.koreatech.koin.domain.callvan.service.CallvanPostCreateService;
import in.koreatech.koin.domain.callvan.service.CallvanPostJoinService;
import in.koreatech.koin.domain.callvan.service.CallvanPostQueryService;
import in.koreatech.koin.domain.callvan.service.CallvanPostStatusService;
import in.koreatech.koin.domain.callvan.service.CallvanChatService;
import in.koreatech.koin.domain.callvan.service.CallvanNotificationService;
import in.koreatech.koin.domain.callvan.service.CallvanRestrictionQueryService;
import in.koreatech.koin.domain.callvan.service.CallvanUserReportService;
import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest;
import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse;
import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse;
import in.koreatech.koin.global.auth.Auth;
import in.koreatech.koin.global.auth.UserId;
import jakarta.validation.Valid;
Expand All @@ -52,6 +54,7 @@ public class CallvanController implements CallvanApi {
private final CallvanPostStatusService callvanPostStatusService;
private final CallvanChatService callvanChatService;
private final CallvanNotificationService callvanNotificationService;
private final CallvanRestrictionQueryService callvanRestrictionQueryService;
private final CallvanUserReportService callvanUserReportService;

@PostMapping
Expand Down Expand Up @@ -84,6 +87,14 @@ public ResponseEntity<CallvanPostSearchResponse> getCallvanPosts(
return ResponseEntity.ok().body(response);
}

@GetMapping("/restriction")
public ResponseEntity<CallvanRestrictionResponse> getCallvanRestriction(
@Auth(permit = {STUDENT}) Integer userId
) {
CallvanRestrictionResponse response = callvanRestrictionQueryService.getRestriction(userId);
return ResponseEntity.ok(response);
}

@GetMapping("/posts/{postId}/summary")
public ResponseEntity<CallvanPostResponse> getCallvanPostSummary(
@PathVariable Integer postId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package in.koreatech.koin.domain.callvan.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import java.time.LocalDateTime;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import in.koreatech.koin.domain.callvan.model.CallvanReportProcess;
import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType;
import io.swagger.v3.oas.annotations.media.Schema;

@JsonNaming(SnakeCaseStrategy.class)
public record CallvanRestrictionResponse(
@Schema(description = "콜벤 기능 이용 제한 여부", example = "true", requiredMode = REQUIRED)
boolean isRestricted,

@Schema(
description = "현재 활성화된 이용 제한 유형. 제한이 없으면 null이며 WARNING(1차 경고)는 반환되지 않습니다.",
example = "TEMPORARY_RESTRICTION_14_DAYS"
)
CallvanReportProcessType restrictionType,

@Schema(
description = "임시 이용 제한 종료 시각. 영구 제한 또는 미제한이면 null입니다.",
example = "2026-04-21T12:00:00"
)
LocalDateTime restrictedUntil
) {

public static CallvanRestrictionResponse from(CallvanReportProcess process) {
return new CallvanRestrictionResponse(
true,
process.getProcessType(),
process.getRestrictedUntil()
);
}

public static CallvanRestrictionResponse unrestricted() {
return new CallvanRestrictionResponse(false, null, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package in.koreatech.koin.domain.callvan.event;

import java.util.List;

public record CallvanPushNotificationEvent(
List<Integer> notificationIds
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package in.koreatech.koin.domain.callvan.model;

import in.koreatech.koin.common.model.MobileAppPath;
import in.koreatech.koin.domain.user.model.User;

public record CallvanPushNotification(
MobileAppPath mobileAppPath,
String schemeUri,
String title,
String message,
String imageUrl,
String type,
User recipient
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package in.koreatech.koin.domain.callvan.model;

import static in.koreatech.koin.common.model.MobileAppPath.CALLVAN;
import static in.koreatech.koin.common.model.MobileAppPath.CALLVAN_CHAT;
import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.DEPARTURE_UPCOMING;
import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.NEW_MESSAGE;
import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.PARTICIPANT_JOINED;
import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.RECRUITMENT_COMPLETE;

import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import in.koreatech.koin.common.model.MobileAppPath;

@Component
public class CallvanPushNotificationFactory {

public CallvanPushNotification from(CallvanNotification notification) {
MobileAppPath path = notification.getNotificationType() == NEW_MESSAGE ? CALLVAN_CHAT : CALLVAN;
String schemeUri = generateSchemeUri(notification);
String title = generateTitle(notification);
String message = generateMessage(notification);

return new CallvanPushNotification(
path,
schemeUri,
title,
message,
null,
notification.getNotificationType().name().toLowerCase(),
notification.getRecipient()
);
}

private String generateTitle(CallvanNotification notification) {
return switch (notification.getNotificationType()) {
case NEW_MESSAGE -> "콜벤팟 %s님의 메시지".formatted(notification.getSenderNickname());
case PARTICIPANT_JOINED -> "콜벤팟 새 참여자";
case RECRUITMENT_COMPLETE -> "콜벤팟 인원 모집 완료";
case DEPARTURE_UPCOMING -> "콜벤팟 출발 30분 전";
case REPORT_WARNING, REPORT_RESTRICTION_14_DAYS, REPORT_PERMANENT_RESTRICTION -> "콜벤팟 이용 안내";
};
}

private String generateMessage(CallvanNotification notification) {
return switch (notification.getNotificationType()) {
case PARTICIPANT_JOINED -> "%s님이 콜벤팟에 참여했어요".formatted(notification.getJoinedMemberNickname());
case DEPARTURE_UPCOMING -> "%s -> %s 콜벤팟이 30분 뒤 출발해요".formatted(
getLocationName(notification.getDepartureCustomName(), notification.getDepartureType().getName()),
getLocationName(notification.getArrivalCustomName(), notification.getArrivalType().getName())
);
case NEW_MESSAGE, RECRUITMENT_COMPLETE, REPORT_WARNING, REPORT_RESTRICTION_14_DAYS,
REPORT_PERMANENT_RESTRICTION -> notification.getMessagePreview();
};
}

private String generateSchemeUri(CallvanNotification notification) {
Integer postId = notification.getPost() != null ? notification.getPost().getId() : null;
if (notification.getNotificationType() == NEW_MESSAGE) {
return "callvan-chat?postId=%d&chatRoomId=%d".formatted(postId, notification.getChatRoom().getId());
}
return "callvan?id=%d".formatted(postId);
}

private String getLocationName(String customName, String defaultName) {
if (StringUtils.hasText(customName)) {
return customName;
}
return defaultName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ public interface CallvanNotificationRepository extends JpaRepository<CallvanNoti

List<CallvanNotification> findAllByRecipientIdOrderByCreatedAtDesc(Integer recipientId);

@Query("""
SELECT DISTINCT n
FROM CallvanNotification n
JOIN FETCH n.recipient
LEFT JOIN FETCH n.post
LEFT JOIN FETCH n.chatRoom
WHERE n.id IN :notificationIds
AND n.isDeleted = false
""")
List<CallvanNotification> findAllByIdInWithRelations(@Param("notificationIds") List<Integer> notificationIds);

@Modifying(clearAutomatically = true)
@Query("UPDATE CallvanNotification n SET n.isRead = true WHERE n.recipient.id = :recipientId AND n.isDeleted = false")
void updateIsReadByRecipientId(@Param("recipientId") Integer recipientId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
Expand All @@ -18,7 +19,7 @@ public interface CallvanReportProcessRepository extends Repository<CallvanReport
boolean existsByReportIdAndIsDeletedFalse(Integer reportId);

@Query("""
SELECT CASE WHEN COUNT(process) > 0 THEN true ELSE false END
SELECT process
FROM CallvanReportProcess process
WHERE process.report.reported.id = :userId
AND process.isDeleted = false
Expand All @@ -30,9 +31,24 @@ SELECT CASE WHEN COUNT(process) > 0 THEN true ELSE false END
AND process.restrictedUntil >= :now
)
)
ORDER BY CASE
WHEN process.processType = in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType.PERMANENT_RESTRICTION
THEN 0
ELSE 1
END ASC,
process.createdAt DESC,
process.id DESC
""")
boolean existsActiveRestrictionByReportedUserId(
List<CallvanReportProcess> findAllActiveRestrictionsByReportedUserId(
@Param("userId") Integer userId,
@Param("now") LocalDateTime now
);

default Optional<CallvanReportProcess> findActiveRestrictionByReportedUserId(Integer userId, LocalDateTime now) {
return findAllActiveRestrictionsByReportedUserId(userId, now).stream().findFirst();
}

default boolean existsActiveRestrictionByReportedUserId(Integer userId, LocalDateTime now) {
return findActiveRestrictionByReportedUserId(userId, now).isPresent();
}
}
Loading
Loading