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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ NAVER_CLIENT_SECRET=
AWS_S3_BUCKET=
AWS_REGION=
TOUR_SERVICE_KEY=
FIREBASE_ENABLED=false
FIREBASE_PROJECT_ID=
GOOGLE_APPLICATION_CREDENTIALS=
NOTIFICATION_ZONE=Asia/Seoul
COURSE_REMINDER_CRON=0 0 9 * * *

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Quote the cron expression.

Line 13 contains spaces. If deployment or local tooling loads this file as a shell-compatible dotenv file, the unquoted assignment is parsed as multiple words. Quote the value to preserve the six-field cron expression.

Proposed fix
-COURSE_REMINDER_CRON=0 0 9 * * *
+COURSE_REMINDER_CRON="0 0 9 * * *"

The supplied dotenv-linter warning also identifies this value.

📝 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
COURSE_REMINDER_CRON=0 0 9 * * *
COURSE_REMINDER_CRON="0 0 9 * * *"
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 13-13: [UnorderedKey] The COURSE_REMINDER_CRON key should go before the DB_PW key

(UnorderedKey)


[warning] 13-13: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example at line 13, Quote the COURSE_REMINDER_CRON value in the dotenv
example so the six-field expression, including its spaces, is preserved when
parsed by shell-compatible dotenv tooling.

Source: Linters/SAST tools

NOTIFICATION_DISPATCH_DELAY_MS=60000
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ Elastic Beanstalk EC2 Instance Profile을 포함한 AWS SDK 기본 자격 증명
반환됩니다. 버킷 정책상 반환된 객체 URL을 클라이언트가 읽을 수 있도록 별도 접근 정책도
배포 전에 준비해야 합니다.

## Firebase Cloud Messaging 설정

푸시 알림을 실제 발송하려면 `FIREBASE_ENABLED=true`와 `FIREBASE_PROJECT_ID`를 설정하고,
Firebase 서비스 계정 파일 경로를 `GOOGLE_APPLICATION_CREDENTIALS`로 제공해야 합니다.
서비스 계정 JSON은 저장소에 커밋하지 않습니다. 설정하지 않은 환경에서도 서버와 테스트는
시작되지만 푸시 발송은 실패 상태로 기록되고 재시도됩니다.

코스 D-1/D-0 알림은 기본적으로 `Asia/Seoul` 기준 매일 오전 9시에 생성됩니다.
`NOTIFICATION_ZONE`, `COURSE_REMINDER_CRON`, `NOTIFICATION_DISPATCH_DELAY_MS` 환경변수로
시간대, 생성 시각, 실패 알림 재시도 주기를 변경할 수 있습니다.

## 정책 문서

`src/main/resources/policies`의 두 문서는 현재 출시 전 자리표시자입니다. 운영 배포 전에
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ dependencies {
// AWS S3
implementation platform('software.amazon.awssdk:bom:2.41.24')
implementation 'software.amazon.awssdk:s3'

// Firebase Cloud Messaging
implementation 'com.google.firebase:firebase-admin:9.10.0'
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file="src/main/java/com/daytodo/domain/user/push/FirebasePushNotificationGateway.java"

if rg -n -C 4 'setToken\(|addToken\(|addAllTokens\(' "$file"; then
  echo "Deprecated token-builder usage found. Verify the gateway implementation."
else
  echo "No deprecated token-builder usage found."
fi

Repository: DayTodo/DayTodo_BE

Length of output: 644


🌐 Web query:

Firebase Admin Java 9.10.0 release notes message token MulticastMessage token deprecations

💡 Result:

The Firebase Admin Java SDK version 9.10.0, released on July 2, 2026, introduces a deprecation of the token-based messaging approach in favor of Firebase Installation IDs (FIDs) [1][2][3]. Key changes in version 9.10.0 include: Deprecation of Token Fields and Methods: - The token field in the Message class is deprecated [1]. - The tokens field in the MulticastMessage class is deprecated [1]. - The setToken method in Message.Builder is deprecated [1]. - The addToken and addAllTokens methods in MulticastMessage.Builder are deprecated [1]. Introduction of FID Support: - The SDK now supports sending messages using Firebase Installation IDs (FIDs) [1]. - New fields have been added: the fid field in the Message class and the fids field in the MulticastMessage class [1]. - New methods have been introduced: setFid in Message.Builder, and addFid and addAllFids in MulticastMessage.Builder [1]. While the MulticastMessage class continues to support both tokens and FIDs, developers are advised to migrate to the new FID-based fields, as the token-based fields are now officially deprecated [1]. [1][2]

Citations:


Replace the Message.setToken() call with the supported FID API.

FirebasePushNotificationGateway still uses Message.builder().setToken(...), which is deprecated in Firebase Admin Java 9.10.0. Use Message.builder().setFid(...) unless the client contract already requires server-provided FCM tokens.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build.gradle` around lines 60 - 61, Update FirebasePushNotificationGateway’s
message construction to replace Message.builder().setToken(...) with the
supported setFid(...) API, preserving the existing recipient value and payload
configuration unless the client contract explicitly requires server-provided FCM
tokens.

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
import com.daytodo.domain.auth.repository.RefreshTokenRepository;
import com.daytodo.domain.auth.repository.SocialAccountRepository;
import com.daytodo.domain.user.entity.User;
import com.daytodo.domain.user.entity.UserNotificationSetting;
import com.daytodo.domain.user.enums.LoginType;
import com.daytodo.domain.user.enums.UserStatus;
import com.daytodo.domain.user.repository.UserRepository;
import com.daytodo.domain.user.repository.UserNotificationSettingRepository;
import com.daytodo.global.apiPayload.exception.ProjectException;
import com.daytodo.global.security.JwtProperties;
import com.daytodo.global.security.JwtTokenProvider;
Expand Down Expand Up @@ -46,6 +48,7 @@ public class AuthService {
private static final long PASSWORD_RESET_EXPIRY_MINUTES = 10;

private final UserRepository userRepository;
private final UserNotificationSettingRepository userNotificationSettingRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final SocialAccountRepository socialAccountRepository;
private final EmailVerificationTokenRepository emailVerificationTokenRepository;
Expand Down Expand Up @@ -88,6 +91,7 @@ public AuthResponse.SignUp signUp(AuthRequest.SignUp request) {

// 이메일 인증 전까지는 INACTIVE 상태로 두고, 인증 완료 시 ACTIVE로 전환한다.
user.changeStatus(UserStatus.INACTIVE);
userNotificationSettingRepository.save(new UserNotificationSetting(user));
issueEmailVerificationToken(user);
return new AuthResponse.SignUp(user.getId(), user.getEmail(), user.getNickname(), user.getCreatedAt());
}
Expand Down Expand Up @@ -298,6 +302,7 @@ private User createUserFromNaverProfile(NaverApiClient.NaverProfileResponse prof
null,
LoginType.NAVER
));
userNotificationSettingRepository.save(new UserNotificationSetting(user));
socialAccountRepository.save(new SocialAccount(user, SocialProvider.NAVER, profile.response().id()));
return user;
}
Expand Down Expand Up @@ -334,4 +339,4 @@ private String generateDefaultNickname() {
long count = userRepository.count() + 1;
return String.format("투두%02d", count);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import com.daytodo.domain.course.entity.CourseMember;
import com.daytodo.domain.course.enums.MemberStatus;
import com.daytodo.domain.course.enums.CourseStatus;
import com.daytodo.domain.user.enums.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.time.LocalDate;


public interface CourseMemberRepository extends JpaRepository<CourseMember, Long> {
Expand All @@ -18,6 +21,23 @@ public interface CourseMemberRepository extends JpaRepository<CourseMember, Long

List<CourseMember> findByCourseCourseIdAndMemberStatus(Long courseId, MemberStatus memberStatus);

@Query("""
select cm from CourseMember cm
join fetch cm.course course
join fetch cm.user user
where course.courseDate = :courseDate
and course.courseStatus not in :excludedStatuses
and cm.memberStatus = :memberStatus
and user.userStatus = :userStatus
order by course.courseId asc, user.id asc
""")
List<CourseMember> findReminderCandidates(
@Param("courseDate") LocalDate courseDate,
@Param("excludedStatuses") Collection<CourseStatus> excludedStatuses,
@Param("memberStatus") MemberStatus memberStatus,
@Param("userStatus") UserStatus userStatus
);

Optional<CourseMember> findByCourseCourseIdAndUserIdAndMemberStatus(Long courseId, Long targetUserId, MemberStatus memberStatus);

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.daytodo.domain.user.dto.UserRequest;
import com.daytodo.domain.user.dto.UserResponse;
import com.daytodo.domain.user.service.FeedbackService;
import com.daytodo.domain.user.service.FcmTokenService;
import com.daytodo.domain.user.service.PolicyService;
import com.daytodo.domain.user.service.ProfileService;
import com.daytodo.domain.user.service.UserNotificationService;
Expand Down Expand Up @@ -34,6 +35,7 @@ public class UserController {
private final UserNotificationService notificationService;
private final FeedbackService feedbackService;
private final PolicyService policyService;
private final FcmTokenService fcmTokenService;

@Operation(summary = "프로필 조회")
@GetMapping("/profile")
Expand Down Expand Up @@ -96,6 +98,26 @@ public UserResponse.NotificationSettings updateNotificationSettings(
return notificationService.updateSettings(userId, request);
}

@Operation(summary = "FCM 토큰 등록 또는 갱신")
@PostMapping("/fcm-token")
public ResponseEntity<Void> registerFcmToken(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody UserRequest.RegisterFcmToken request
) {
fcmTokenService.register(userId, request);
return ResponseEntity.noContent().build();
}

@Operation(summary = "FCM 토큰 삭제")
@DeleteMapping("/fcm-token")
public ResponseEntity<Void> deleteFcmToken(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody UserRequest.DeleteFcmToken request
) {
fcmTokenService.delete(userId, request);
return ResponseEntity.noContent().build();
}

@Operation(summary = "의견 보내기")
@PostMapping("/feedback")
public ResponseEntity<Void> submitFeedback(
Expand Down
26 changes: 21 additions & 5 deletions src/main/java/com/daytodo/domain/user/dto/UserRequest.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.daytodo.domain.user.dto;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import com.daytodo.domain.user.enums.DevicePlatform;

import java.util.List;

Expand All @@ -17,11 +21,23 @@ public record ReplaceInterestRegions(

public record UpdateNotificationSettings(
@NotNull(message = "앱 푸시 알림 설정은 필수입니다.")
Boolean pushEnabled,
@NotNull(message = "D-1 코스 알림 설정은 필수입니다.")
Boolean courseD1Enabled,
@NotNull(message = "D-0 코스 알림 설정은 필수입니다.")
Boolean courseD0Enabled
Boolean pushEnabled
) {
}

public record RegisterFcmToken(
@NotBlank(message = "FCM 토큰은 필수입니다.")
@Size(max = 512, message = "FCM 토큰은 512자 이하여야 합니다.")
String token,
@NotNull(message = "기기 플랫폼은 필수입니다.")
DevicePlatform platform
) {
}

public record DeleteFcmToken(
@NotBlank(message = "FCM 토큰은 필수입니다.")
@Size(max = 512, message = "FCM 토큰은 512자 이하여야 합니다.")
String token
) {
}

Expand Down
4 changes: 1 addition & 3 deletions src/main/java/com/daytodo/domain/user/dto/UserResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ public record InterestRegions(List<InterestRegion> regions) {
}

public record NotificationSettings(
boolean pushEnabled,
boolean courseD1Enabled,
boolean courseD0Enabled
boolean pushEnabled
) {
}

Expand Down
47 changes: 47 additions & 0 deletions src/main/java/com/daytodo/domain/user/entity/FcmToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.daytodo.domain.user.entity;

import com.daytodo.domain.common.BaseEntity;
import com.daytodo.domain.user.enums.DevicePlatform;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "fcm_token")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FcmToken extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "fcm_token_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(name = "token", nullable = false, unique = true, length = 512)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make token registration atomic across users.

Line 35 enforces global token uniqueness. FcmTokenService.register first reads by token and then inserts. Its user-row lock does not serialize requests for different users. Two concurrent registrations of the same new token can both see no row, and one request will fail with a unique-constraint error.

Use a database upsert, or handle the constraint conflict by reloading and reassigning the token in a new transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/daytodo/domain/user/entity/FcmToken.java` at line 35, The
FcmTokenService.register flow must atomically handle a globally unique token
across different users. Replace the separate token lookup-and-insert sequence
with a database upsert, or catch the unique-constraint conflict, reload the
existing token, and reassign it in a new transaction while preserving the
intended token ownership.

private String token;

@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private DevicePlatform platform;

public FcmToken(User user, String token, DevicePlatform platform) {
this.user = user;
this.token = token;
this.platform = platform;
}
}
43 changes: 42 additions & 1 deletion src/main/java/com/daytodo/domain/user/entity/Notification.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.daytodo.domain.user.entity;
import com.daytodo.domain.user.enums.NotificationType;
import com.daytodo.domain.user.enums.NotificationDeliveryStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
Expand All @@ -12,6 +13,7 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Getter;
Expand All @@ -21,7 +23,13 @@

@Getter
@Entity
@Table(name = "notification")
@Table(
name = "notification",
uniqueConstraints = @UniqueConstraint(
name = "uk_notification_user_course_type",
columnNames = {"user_id", "course_id", "notification_type"}
)
)
Comment on lines +26 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate schema-management configuration and migrations.
fd -t f -i '(flyway|liquibase|migration)|^V[0-9].*__.*\.(sql|xml|yaml|yml)$' .
rg -n -i -C 3 \
  'notification|delivery_status|attempt_count|uk_notification_user_course_type|flyway|liquibase|ddl-auto' \
  src

Repository: DayTodo/DayTodo_BE

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant files:\n'
fd -t f 'Notification.java|application.yml|pom.xml|build.gradle|settings.gradle|flyway|liquibase|migration.sql|M[0-9]|db|schema' .

printf '\nNotification entity:\n'
cat -n src/main/java/com/daytodo/domain/user/entity/Notification.java

printf '\nApplication schema config:\n'
cat -n src/main/resources/application.yml | sed -n '1,80p'

printf '\nBuild schema-management deps:\n'
if [ -f pom.xml ]; then
  rg -n -C 2 '(flyway|liquibase|maven-fmp|spring-boot-maven-plugin)' pom.xml
fi

printf '\nFind migration scripts only:\n'
fd -t f 'migration|\.sql$|flyway|liquibase' . | sed -n '1,120p'

printf '\nSearch db-related tables/corresponding scripts:\n'
rg -n -i -C 2 'create table.*notification|alter table.*notification|notification_table|delivery_status|attempt_count|uk_notification_user_course_type|unique constraint|constraint' . \
  -g '*.sql' -g '*db*' -g 'src/main/resources/**' | sed -n '1,220p'

Repository: DayTodo/DayTodo_BE

Length of output: 7173


Add a migration for the notification schema changes.

The live profile uses hibernate.ddl-auto: update, but there are no checked-in Flyway/Liquibase migrations for delivery_status, attempt_count, or uk_notification_user_course_type. Existing rows need backfill values for non-null columns and deterministic duplicate-resolution before the unique constraint is added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/daytodo/domain/user/entity/Notification.java` around lines
26 - 32, Add a checked-in Flyway/Liquibase migration for the notification schema
matching the entity fields, including delivery_status and attempt_count with
appropriate non-null defaults, backfill existing rows deterministically, resolve
duplicate user/course/type records before creating
uk_notification_user_course_type, and add the unique constraint after cleanup.

@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification {
Expand Down Expand Up @@ -50,6 +58,22 @@ public class Notification {
@Column(name = "is_read", nullable = false)
private boolean read;

@Enumerated(EnumType.STRING)
@Column(name = "delivery_status", length = 20, nullable = false)
private NotificationDeliveryStatus deliveryStatus;

@Column(name = "attempt_count", nullable = false)
private int attemptCount;

@Column(name = "sent_at")
private LocalDateTime sentAt;

@Column(name = "next_retry_at")
private LocalDateTime nextRetryAt;

@Column(name = "last_error", length = 1000)
private String lastError;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
Expand All @@ -67,9 +91,26 @@ public Notification(
this.title = title;
this.body = body;
this.read = false;
this.deliveryStatus = NotificationDeliveryStatus.PENDING;
this.attemptCount = 0;
}

public void markAsRead() {
this.read = true;
}

public void markSent(LocalDateTime sentAt) {
this.deliveryStatus = NotificationDeliveryStatus.SENT;
this.attemptCount++;
this.sentAt = sentAt;
this.nextRetryAt = null;
this.lastError = null;
}

public void markFailed(String error, LocalDateTime nextRetryAt) {
this.deliveryStatus = NotificationDeliveryStatus.FAILED;
this.attemptCount++;
this.nextRetryAt = nextRetryAt;
this.lastError = error == null ? null : error.substring(0, Math.min(error.length(), 1000));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,23 @@ public class UserNotificationSetting {
name = "course_d1_enabled",
nullable = false
)
private boolean courseD1Enabled;
private boolean legacyCourseD1Enabled;

@Column(
name = "course_d0_enabled",
nullable = false
)
private boolean courseD0Enabled;
private boolean legacyCourseD0Enabled;

public UserNotificationSetting(User user) {
this.user = user;
this.pushEnabled = true;
this.courseD1Enabled = true;
this.courseD0Enabled = true;
// 기존 DB의 NOT NULL 컬럼과 호환하기 위한 값이다. D-1/D-0는 개별 설정하지 않는다.
this.legacyCourseD1Enabled = true;
this.legacyCourseD0Enabled = true;
}

public void updateSettings(
boolean pushEnabled,
boolean courseD1Enabled,
boolean courseD0Enabled
) {
public void updatePushEnabled(boolean pushEnabled) {
this.pushEnabled = pushEnabled;
this.courseD1Enabled = courseD1Enabled;
this.courseD0Enabled = courseD0Enabled;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daytodo.domain.user.enums;

public enum DevicePlatform {
ANDROID,
IOS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daytodo.domain.user.enums;

public enum NotificationDeliveryStatus {
PENDING,
SENT,
FAILED
}
Loading
Loading