Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8bfa996
refactor: 불필요한 모듈 세팅 삭제
rootTiket Sep 1, 2025
cfd7626
refactor: 현재 시간을 외부에서 입력받도록 변경
rootTiket Sep 1, 2025
aa7e28f
refactor: 현재 시간을 외부에서 입력받도록 변경
rootTiket Sep 1, 2025
acf0b4e
feat: 카테고리에 색상 필드 추가
rootTiket Sep 1, 2025
e9b339f
refactor: 필드 추가 반영
rootTiket Sep 1, 2025
020ef7a
feat: 카테고리 생성시 색이 추가되는 로직 구현
rootTiket Sep 1, 2025
f856c7c
refactor: 필드추가 반영
rootTiket Sep 1, 2025
5d52389
refactor: 생성시 색상도 함께 응답하도록 변경
rootTiket Sep 1, 2025
99b4332
refactor: user와 color를 복합 유니크 키로 변경
rootTiket Sep 1, 2025
1bc095d
refactor: 글자 수 예외처리를 엔티티에서 하도록 변경
rootTiket Sep 2, 2025
8eb5de1
fix: 카테고리 개수 예외 누락 수정
rootTiket Sep 2, 2025
9606f9c
test: 카테고리 usecase 테스트 추가
rootTiket Sep 2, 2025
9a5a26d
feat: 아티클 전체 조회시 카테고리 정보도 함께 전달하도록 변경
rootTiket Sep 2, 2025
3fdba82
fix: 기존 구현과 다른 부분 수정
rootTiket Sep 2, 2025
2aae766
fix: JPA Auditing 누락 수정
rootTiket Sep 2, 2025
45d1121
feat: 병렬 테스트, 빌드 추가
rootTiket Sep 2, 2025
9943984
feat: 테스트 서머리 추가
rootTiket Sep 2, 2025
699bcd7
refactor: 리마인드 아티클 조회시, 읽음 상태를 요청받도록 변경
rootTiket Sep 3, 2025
d78e807
feat: 리마인드 아티클 조회시 읽은 아티클 수와 읽지 않은 아티클 수를 함께 조회하도록 변경
rootTiket Sep 3, 2025
06d8bfd
test: 리마인드 아티클 테스트코드 작성
rootTiket Sep 3, 2025
db74d5e
feat: 리마인드 아티클 컨트롤러 생성
rootTiket Sep 3, 2025
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
6 changes: 5 additions & 1 deletion .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: ['21']
fail-fast: false
services:
redis:
image: redis:7
Expand Down Expand Up @@ -39,7 +43,7 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Build with Gradle Wrapper
run: ./gradlew clean build --no-daemon --build-cache
run: ./gradlew clean build --no-daemon --build-cache --parallel --max-workers=4

- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
Expand Down
2 changes: 2 additions & 0 deletions api/src/main/java/com/pinback/api/PinbackApiApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication(scanBasePackages = {
Expand All @@ -13,6 +14,7 @@
})
@EntityScan("com.pinback.domain")
@EnableJpaRepositories("com.pinback.infrastructure")
@EnableJpaAuditing
public class PinbackApiApplication {
public static void main(String[] args) {
SpringApplication.run(PinbackApiApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.pinback.api.article.controller;

import java.time.LocalDateTime;

import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -16,6 +18,7 @@
import com.pinback.application.article.dto.response.ArticleDetailResponse;
import com.pinback.application.article.dto.response.ArticlesPageResponse;
import com.pinback.application.article.dto.response.ReadArticleResponse;
import com.pinback.application.article.dto.response.TodayRemindResponse;
import com.pinback.application.article.port.in.CreateArticlePort;
import com.pinback.application.article.port.in.DeleteArticlePort;
import com.pinback.application.article.port.in.GetArticlePort;
Expand Down Expand Up @@ -130,6 +133,20 @@ public ResponseDto<ReadArticleResponse> updateArticleStatus(
return ResponseDto.ok(response);
}

@Operation(summary = "리마인드 아티클 조회", description = "오늘 리마인드할 아티클을 읽음/안읽음 상태별로 조회합니다")
@GetMapping("/remind")
public ResponseDto<TodayRemindResponse> getRemindArticles(
@Parameter(hidden = true) @CurrentUser User user,
@Parameter(description = "현재 시간", example = "2025-09-03T10:00:00") @RequestParam LocalDateTime now,
@Parameter(description = "읽음 상태 (true: 읽음, false: 안읽음)", example = "true") @RequestParam(name = "read-status") boolean readStatus,
@Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "8") int size
Comment on lines +142 to +143
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Guard page/size bounds (min/max) at the edge.

If PageQuery doesn’t validate, add @Min/@max to prevent heavy queries and negative indexes.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Inspect PageQuery for bounds/validation
fd -a PageQuery.java
rg -n -C3 -g '!**/build/**' $'class\\s+PageQuery\\b|@Min|@Max|@Positive|@PositiveOrZero|Math\\.(min|max)|requireNonNull|clamp' $(fd -a PageQuery.java)

Length of output: 395


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file=$(fd -a PageQuery.java)
echo "Inspecting $file"
sed -n '1,200p' "$file"

Length of output: 728


Constrain page/size inputs with bean validation. Add @PositiveOrZero to pageNumber and @Min(1)/@Max(100) to pageSize on the PageQuery record, and enable validation in the controller (e.g. @Validated on the class and @Valid on the PageQuery parameter).

🤖 Prompt for AI Agents
In api/src/main/java/com/pinback/api/article/controller/ArticleController.java
around lines 142-143, the request page and size parameters are not validated;
update the PageQuery record to annotate the page number with @PositiveOrZero and
the page size with @Min(1) and @Max(100), then enable validation in the
controller by adding @Validated on the controller class and annotating the
PageQuery parameter with @Valid so incoming page/size values are constrained and
validated automatically.

) {
PageQuery query = new PageQuery(page, size);
TodayRemindResponse response = getArticlePort.getRemindArticles(user, now, readStatus, query);
return ResponseDto.ok(response);
}

@Operation(summary = "아티클 삭제", description = "아티클을 삭제합니다")
@DeleteMapping("/{articleId}")
public ResponseDto<Void> deleteArticle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@ public record ArticleUpdateRequest(
@NotNull(message = "카테고리 ID는 필수입니다")
@Positive(message = "카테고리 ID는 양수여야 합니다")
Long categoryId,

@Schema(description = "메모", example = "수정된 메모입니다")
@Size(max = 500, message = "메모는 500자 이하로 입력해주세요")
String memo,

@Schema(description = "리마인더 시간", example = "2025-12-31T23:59:00")

@Schema(description = "현재 시간", example = "2025-12-31T23:59:00")
@NotNull(message = "현재시간은 필수입니다")
LocalDateTime now,

Comment on lines +23 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Client-supplied “now” is a trust boundary risk

Allowing the client to dictate “now” enables back/forward-dating and inconsistent reminder behavior. Prefer server-generated time (via Clock) in the application layer; if you must accept it, strictly bound acceptable drift.

Option A — remove now from the API and compute server-side:

-	@Schema(description = "현재 시간", example = "2025-12-31T23:59:00")
-	@NotNull(message = "현재시간은 필수입니다")
-	LocalDateTime now,

And adjust toCommand below.
Option B — keep now but validate drift (example 5 minutes) in controller/usecase and ignore if out-of-bounds. I can propose a concrete implementation if you choose this path.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Schema(description = "현재 시간", example = "2025-12-31T23:59:00")
@NotNull(message = "현재시간은 필수입니다")
LocalDateTime now,
🤖 Prompt for AI Agents
In
api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java
around lines 23-26, the DTO exposes a client-supplied "now" which is a
trust-boundary risk; remove the now field from the API and compute current time
server-side (inject java.time.Clock into controller/usecase), then update the
toCommand mapping to use Instant/LocalDateTime from Clock; if you must keep the
field instead, add strict drift validation in the controller/usecase (e.g.,
parse client now, compare to server Clock.now(), reject or ignore the request if
drift > 5 minutes, or clamp to server time) and document the behavior.

@Schema(description = "리마인드 시간", example = "2025-12-31T23:59:00")
LocalDateTime remindTime

) {
public ArticleUpdateCommand toCommand() {
return new ArticleUpdateCommand(categoryId, memo, remindTime);
return new ArticleUpdateCommand(categoryId, memo, now, remindTime);
}
Comment on lines +32 to 33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Adjust toCommand if “now” is removed

If you adopt Option A, update the command mapping accordingly.

-		return new ArticleUpdateCommand(categoryId, memo, now, remindTime);
+		return new ArticleUpdateCommand(categoryId, memo, remindTime);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return new ArticleUpdateCommand(categoryId, memo, now, remindTime);
}
return new ArticleUpdateCommand(categoryId, memo, remindTime);
}
🤖 Prompt for AI Agents
In
api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java
around lines 32-33, the toCommand() call still passes the "now" parameter but
the ArticleUpdateCommand no longer expects it (Option A); update the mapping to
match the new constructor by removing the now argument and either (a) call the
constructor without now, or (b) if the command requires a timestamp, instantiate
the command using Instant.now() inside the command constructor or the service
layer—ensure the toCommand signature and the ArticleUpdateCommand constructor
parameters align.

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.pinback.api.auth.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.pinback.api.auth.dto.request.SignUpRequest;
Expand Down Expand Up @@ -33,12 +35,12 @@ public ResponseDto<SignUpResponse> signUp(
return ResponseDto.created(response);
}

@Operation(summary = "로그인", description = "이메일로 로그인합니다")
@PostMapping("/signin")
@Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.")
@GetMapping("/token")
public ResponseDto<TokenResponse> signIn(
@Valid @RequestBody SignUpRequest request
@Valid @RequestParam String email
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

@Valid on String is a no-op; add concrete constraints.

Add @Email and @notblank to enforce format and presence. Keep normalization server-side.

Add imports outside this hunk:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

Diff within this line is included in the main change above.

🤖 Prompt for AI Agents
In api/src/main/java/com/pinback/api/auth/controller/AuthController.java around
line 41, the @Valid on a String is ineffective—replace it with concrete
validation annotations by adding @Email and @NotBlank to the email parameter and
ensure you add the corresponding imports (jakarta.validation.constraints.Email
and jakarta.validation.constraints.NotBlank) at the top of the file; keep any
server-side normalization logic unchanged.

) {
TokenResponse response = authUsecase.getToken(request.email());
TokenResponse response = authUsecase.getToken(email);
return ResponseDto.ok(response);
}
Comment on lines +38 to 45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Blocker: GET + email-only token issuance is insecure (account takeover, user enumeration, cacheable).

Issuing tokens with just an email via GET enables:

  • User enumeration by response differences.
  • Token delivery without proof-of-identity (no password/refresh-token/OTP).
  • Query-string PII leakage in logs/proxies and potential caching of responses.

Action:

  • Use POST.
  • Require a credential: valid refresh token, password, or a one-time code delivered out-of-band. If this is a “magic link” flow, don’t return the token here—send the link and return 202.
  • Add input constraints and normalize email.
  • Add rate limiting and ensure Cache-Control: no-store on responses.

Apply within this hunk:

-  @Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.")
-  @GetMapping("/token")
-  public ResponseDto<TokenResponse> signIn(
-    @Valid @RequestParam String email
-  ) {
-    TokenResponse response = authUsecase.getToken(email);
+  @Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.")
+  @PostMapping("/token")
+  public ResponseDto<TokenResponse> issueToken(
+    @RequestParam @NotBlank @Email String email
+  ) {
+    TokenResponse response = authUsecase.getToken(email.trim().toLowerCase());
     return ResponseDto.ok(response);
   }

Also ensure global/security layer sets Cache-Control: no-store and adds rate limiting (e.g., IP+email). If this endpoint replaces a prior signin flow, consider a deprecation window or a compatibility shim.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.")
@GetMapping("/token")
public ResponseDto<TokenResponse> signIn(
@Valid @RequestBody SignUpRequest request
@Valid @RequestParam String email
) {
TokenResponse response = authUsecase.getToken(request.email());
TokenResponse response = authUsecase.getToken(email);
return ResponseDto.ok(response);
}
@Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.")
- @GetMapping("/token")
- public ResponseDto<TokenResponse> signIn(
- @Valid @RequestParam String email
- ) {
@PostMapping("/token")
public ResponseDto<TokenResponse> issueToken(
@RequestParam @NotBlank @Email String email
) {
// Normalize email to prevent case‐sensitive duplicates
String normalizedEmail = email.trim().toLowerCase();
TokenResponse response = authUsecase.getToken(normalizedEmail);
return ResponseDto.ok(response);
}
🤖 Prompt for AI Agents
In api/src/main/java/com/pinback/api/auth/controller/AuthController.java around
lines 38-45, the current GET /token endpoint accepts only an email and returns a
token which is insecure; change this to a POST endpoint, validate and normalize
the email (trim, lowercase, length and pattern checks), and require an
authentication credential (either a valid refresh token in Authorization/cookie,
a password field, or a one-time code) — if implementing a magic-link flow, do
not return a token: send the link and return 202 Accepted. Ensure the controller
sets Cache-Control: no-store on responses, add input constraints (DTO validation
annotations) and server-side rate limiting (IP+email) for this route, and
coordinate with the global/security layer to enforce no-store and rate limiting
policy; if compatibility is needed, implement a documented deprecation shim.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.pinback.application.article.dto;

import org.springframework.data.domain.Page;

import com.pinback.domain.article.entity.Article;

public record RemindArticlesWithCountDto(
long readCount,
long unreadCount,
Page<Article> articles
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
public record ArticleUpdateCommand(
Long categoryId,
String memo,
LocalDateTime now,
LocalDateTime remindTime
) {
Comment on lines 5 to 10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid client-supplied “now”; compute on the server (inject Clock).

Passing now through the API/command lets clients spoof time and suppress/force reminder scheduling. Prefer deriving “now” on the server (e.g., Clock injection in the use case) and remove this field from the command/request.

I can propose a minimal refactor in the use case to switch to Clock while keeping tests deterministic.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@

import java.time.LocalDateTime;

import com.pinback.application.category.dto.response.CategoryResponse;
import com.pinback.domain.article.entity.Article;

public record ArticleResponse(
long articleId,
String url,
String memo,
LocalDateTime createdAt,
boolean isRead
boolean isRead,
CategoryResponse category
) {
public static ArticleResponse from(Article article) {
return new ArticleResponse(
article.getId(),
article.getUrl(),
article.getMemo(),
article.getCreatedAt(),
article.isRead()
article.isRead(),
CategoryResponse.from(article.getCategory())
);
Comment on lines +14 to 24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Null-safe category mapping to avoid NPEs

article.getCategory() may be null; CategoryResponse.from(...) would NPE. Map defensively.

Apply:

-            article.isRead(),
-            CategoryResponse.from(article.getCategory())
+            article.isRead(),
+            (article.getCategory() != null ? CategoryResponse.from(article.getCategory()) : null)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CategoryResponse category
) {
public static ArticleResponse from(Article article) {
return new ArticleResponse(
article.getId(),
article.getUrl(),
article.getMemo(),
article.getCreatedAt(),
article.isRead()
article.isRead(),
CategoryResponse.from(article.getCategory())
);
public static ArticleResponse from(Article article) {
return new ArticleResponse(
article.getId(),
article.getUrl(),
article.getMemo(),
article.getCreatedAt(),
article.isRead(),
(article.getCategory() != null
? CategoryResponse.from(article.getCategory())
: null)
);
}
🤖 Prompt for AI Agents
In
application/src/main/java/com/pinback/application/article/dto/response/ArticleResponse.java
around lines 14 to 24, the mapping calls
CategoryResponse.from(article.getCategory()) which will NPE if
article.getCategory() is null; change this to map defensively by passing null
through: use a conditional such as article.getCategory() == null ? null :
CategoryResponse.from(article.getCategory()) (or
Optional.ofNullable(article.getCategory()).map(CategoryResponse::from).orElse(null))
so the constructor receives null safely when no category is present.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.pinback.application.article.dto.response;

import java.time.LocalDateTime;

import com.pinback.application.category.dto.response.CategoryResponse;
import com.pinback.domain.article.entity.Article;

public record RemindArticleResponse(
long articleId,
String url,
String memo,
LocalDateTime createdAt,
boolean isRead,
LocalDateTime remindAt,
CategoryResponse category
) {
public static RemindArticleResponse from(Article article) {
return new RemindArticleResponse(
article.getId(),
article.getUrl(),
article.getMemo(),
article.getCreatedAt(),
article.isRead(),
article.getRemindAt(),
CategoryResponse.from(article.getCategory())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public record RemindArticlesResponse(
LocalDateTime nextRemindDate,
List<ArticleResponse> articles
) {
public static RemindArticlesResponse of(long totalElements, LocalDateTime nextRemindDate, List<ArticleResponse> articles) {
public static RemindArticlesResponse of(long totalElements, LocalDateTime nextRemindDate,
List<ArticleResponse> articles) {
return new RemindArticlesResponse(totalElements, nextRemindDate, articles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.pinback.application.article.dto.response;

import java.util.List;

public record TodayRemindResponse(
long readArticleCount,
long unreadArticleCount,
List<RemindArticleResponse> articles
) {
public static TodayRemindResponse of(long readArticleCount, long unreadArticleCount, List<RemindArticleResponse> articles) {
return new TodayRemindResponse(readArticleCount, unreadArticleCount, articles);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.pinback.application.article.dto.response.ArticleDetailResponse;
import com.pinback.application.article.dto.response.ArticlesPageResponse;
import com.pinback.application.article.dto.response.RemindArticlesResponse;
import com.pinback.application.article.dto.response.TodayRemindResponse;
import com.pinback.domain.user.entity.User;

public interface GetArticlePort {
Expand All @@ -19,5 +20,5 @@ public interface GetArticlePort {

ArticlesPageResponse getUnreadArticles(User user, PageQuery query);

RemindArticlesResponse getRemindArticles(User user, LocalDateTime now, PageQuery query);
TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boolean readStatus, PageQuery query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.data.domain.Pageable;

import com.pinback.application.article.dto.ArticlesWithUnreadCountDto;
import com.pinback.application.article.dto.RemindArticlesWithCountDto;
import com.pinback.domain.article.entity.Article;
import com.pinback.domain.category.entity.Category;
import com.pinback.domain.user.entity.User;
Expand All @@ -29,5 +30,7 @@ public interface ArticleGetServicePort {

ArticlesWithUnreadCountDto findUnreadArticles(User user, PageRequest pageRequest);

Page<Article> findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable);
Page<Article> findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead);

RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead);
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public void updateArticle(User user, long articleId, ArticleUpdateCommand comman
Category category = getCategoryPort.getCategoryAndUser(command.categoryId(), user);
article.update(command.memo(), category, command.remindTime());

handleReminderUpdate(article, user, command.remindTime(), remindTimeChanged, articleId);
handleReminderUpdate(article, user, command.now(), command.remindTime(), remindTimeChanged, articleId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Time spoofing risk: using command.now() to gate scheduling.

A malicious or skewed client can set now arbitrarily, preventing scheduling or forcing it. Compute “now” on the server.

Apply this diff to stop threading client time into scheduling:

- handleReminderUpdate(article, user, command.now(), command.remindTime(), remindTimeChanged, articleId);
+ handleReminderUpdate(article, user, command.remindTime(), remindTimeChanged, articleId);

And adjust the method signature per the next comment.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
handleReminderUpdate(article, user, command.now(), command.remindTime(), remindTimeChanged, articleId);
handleReminderUpdate(article, user, command.remindTime(), remindTimeChanged, articleId);
🤖 Prompt for AI Agents
In
application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java
around line 47, remove use of command.now() when calling handleReminderUpdate to
avoid client-side time spoofing; instead compute serverNow = Instant.now() (or
equivalent server-side time) once in the usecase and pass serverNow into
handleReminderUpdate, and update handleReminderUpdate's method signature to
accept a server-sourced Instant rather than a client-supplied time.

}

private void validateMemoLength(String memo) {
Expand All @@ -53,12 +53,12 @@ private void validateMemoLength(String memo) {
}
}

private void handleReminderUpdate(Article article, User user, LocalDateTime remindTime,
private void handleReminderUpdate(Article article, User user, LocalDateTime now, LocalDateTime remindTime,
boolean remindTimeChanged, long articleId) {
Comment on lines +56 to 57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Derive “now” on server (Clock) inside handler; keep tests deterministic.

Inject a Clock and compare against LocalDateTime.now(clock). This removes the trust boundary issue and keeps tests stable.

-private void handleReminderUpdate(Article article, User user, LocalDateTime now, LocalDateTime remindTime,
-  boolean remindTimeChanged, long articleId) {
+private void handleReminderUpdate(Article article, User user, LocalDateTime remindTime,
+  boolean remindTimeChanged, long articleId) {
   if (remindTimeChanged) {
     manageArticleReminderPort.cancelArticleReminder(articleId, user.getId());
-    if (remindTime != null && !remindTime.isBefore(now)) {
+    var now = LocalDateTime.now(clock);
+    if (remindTime != null && !remindTime.isBefore(now)) {
       PushSubscription subscriptionInfo = getPushSubscription.findPushSubscription(user);
       manageArticleReminderPort.scheduleArticleReminder(article, user, subscriptionInfo.getToken());
     }
   }
 }

Additional changes (outside selected lines):

// imports
import java.time.Clock;
// field
private final Clock clock;

I can push a patch that adds Clock to the constructor and updates the API/controller to stop accepting “now”.

Also applies to: 61-64

🤖 Prompt for AI Agents
In
application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java
around lines 56-57 (and similarly 61-64), the handler should derive "now" from
an injected Clock instead of accepting it as a parameter; add a private final
Clock clock field, accept Clock in the constructor (update wiring/DI and
controller/API to stop passing now), remove the LocalDateTime now parameter from
handleReminderUpdate and any related methods/call sites, and inside the handler
compute LocalDateTime now = LocalDateTime.now(clock) to perform comparisons;
update unit tests to inject a fixed Clock instance so timing remains
deterministic.

if (remindTimeChanged) {
manageArticleReminderPort.cancelArticleReminder(articleId, user.getId());

if (remindTime != null && !remindTime.isBefore(LocalDateTime.now())) {
if (remindTime != null && !remindTime.isBefore(now)) {
PushSubscription subscriptionInfo = getPushSubscription.findPushSubscription(user);
manageArticleReminderPort.scheduleArticleReminder(article, user, subscriptionInfo.getToken());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.pinback.application.article.dto.ArticlesWithUnreadCountDto;
import com.pinback.application.article.dto.RemindArticlesWithCountDto;
import com.pinback.application.article.dto.query.PageQuery;
import com.pinback.application.article.dto.response.ArticleDetailResponse;
import com.pinback.application.article.dto.response.ArticleResponse;
import com.pinback.application.article.dto.response.ArticlesPageResponse;
import com.pinback.application.article.dto.response.RemindArticlesResponse;
import com.pinback.application.article.dto.response.RemindArticleResponse;
import com.pinback.application.article.dto.response.TodayRemindResponse;
import com.pinback.application.article.port.in.GetArticlePort;
import com.pinback.application.article.port.out.ArticleGetServicePort;
import com.pinback.application.category.port.in.GetCategoryPort;
Expand Down Expand Up @@ -96,19 +97,19 @@ public ArticlesPageResponse getUnreadArticles(User user, PageQuery query) {
}

@Override
public RemindArticlesResponse getRemindArticles(User user, LocalDateTime now, PageQuery query) {
public TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boolean readStatus, PageQuery query) {
LocalDateTime remindDateTime = getRemindDateTime(now, user.getRemindDefault());

Page<Article> articles = articleGetServicePort.findTodayRemind(
user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize()));
RemindArticlesWithCountDto result = articleGetServicePort.findTodayRemindWithCount(
user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize()), readStatus);

List<ArticleResponse> articleResponses = articles.stream()
.map(ArticleResponse::from)
List<RemindArticleResponse> articleResponses = result.articles().stream()
.map(RemindArticleResponse::from)
.toList();

return RemindArticlesResponse.of(
articles.getTotalElements(),
remindDateTime.plusDays(1),
return TodayRemindResponse.of(
result.readCount(),
result.unreadCount(),
articleResponses
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

public record CategoryResponse(
long categoryId,
String categoryName
String categoryName,
String categoryColor
) {
public static CategoryResponse from(Category category) {
return new CategoryResponse(category.getId(), category.getName());
return new CategoryResponse(category.getId(), category.getName(), category.getColor().toString());
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.pinback.application.category.dto.response;

import com.pinback.domain.category.enums.CategoryColor;

public record CreateCategoryResponse(
Long id,
String name
Long categoryId,
String categoryName,
String categoryColor
) {
public static CreateCategoryResponse of(Long id, String name) {
return new CreateCategoryResponse(id, name);
public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) {
return new CreateCategoryResponse(categoryId, categoryName, categoryColor.toString());
}
Comment on lines +3 to 12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use name() (not toString()) and guard against nulls for stable API

Enum.toString() can be overridden; name() is stable for wire formats. Also prevent accidental nulls.

Apply:

 package com.pinback.application.category.dto.response;

-import com.pinback.domain.category.enums.CategoryColor;
+import com.pinback.domain.category.enums.CategoryColor;
+import java.util.Objects;

 public record CreateCategoryResponse(
 	Long categoryId,
 	String categoryName,
 	String categoryColor
 ) {
 	public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) {
-		return new CreateCategoryResponse(categoryId, categoryName, categoryColor.toString());
+		Objects.requireNonNull(categoryColor, "categoryColor");
+		return new CreateCategoryResponse(categoryId, categoryName, categoryColor.name());
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import com.pinback.domain.category.enums.CategoryColor;
public record CreateCategoryResponse(
Long id,
String name
Long categoryId,
String categoryName,
String categoryColor
) {
public static CreateCategoryResponse of(Long id, String name) {
return new CreateCategoryResponse(id, name);
public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) {
return new CreateCategoryResponse(categoryId, categoryName, categoryColor.toString());
}
package com.pinback.application.category.dto.response;
import com.pinback.domain.category.enums.CategoryColor;
import java.util.Objects;
public record CreateCategoryResponse(
Long categoryId,
String categoryName,
String categoryColor
) {
public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) {
Objects.requireNonNull(categoryColor, "categoryColor");
return new CreateCategoryResponse(categoryId, categoryName, categoryColor.name());
}
}
🤖 Prompt for AI Agents
In
application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java
around lines 3 to 12, the factory uses CategoryColor.toString() which can be
overridden and may produce unstable wire values and it doesn't guard against
null; change the mapping to use CategoryColor.name() for a stable enum
identifier and guard against nulls (e.g., if categoryColor is null return null
or a safe default string) so the produced CreateCategoryResponse always has a
predictable categoryColor value.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pinback.application.category.port.out;

import java.util.Set;

import com.pinback.domain.category.enums.CategoryColor;
import com.pinback.domain.user.entity.User;

public interface CategoryColorServicePort {

Set<CategoryColor> getUsedColorsByUser(User user);
}
Loading