diff --git a/scripts/backfill-public-id.sh b/scripts/backfill-public-id.sh new file mode 100644 index 0000000..eeee470 --- /dev/null +++ b/scripts/backfill-public-id.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILES="${SPRING_PROFILES_ACTIVE:-backfill-public-id}" + +./gradlew bootRun --args="--spring.profiles.active=${PROFILES} --spring.main.web-application-type=none" diff --git a/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java b/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java index c445d7d..b4faece 100644 --- a/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java +++ b/src/main/java/me/pinitnotification/application/notification/ScheduleNotificationService.java @@ -7,6 +7,7 @@ import me.pinitnotification.application.notification.query.ScheduleQueryPort; import me.pinitnotification.domain.notification.UpcomingScheduleNotification; import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; +import me.pinitnotification.domain.shared.IdGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -18,10 +19,14 @@ public class ScheduleNotificationService { private final UpcomingScheduleNotificationRepository notificationRepository; private final ScheduleQueryPort scheduleQueryPort; + private final IdGenerator idGenerator; - public ScheduleNotificationService(UpcomingScheduleNotificationRepository notificationRepository, ScheduleQueryPort scheduleQueryPort) { + public ScheduleNotificationService(UpcomingScheduleNotificationRepository notificationRepository, + ScheduleQueryPort scheduleQueryPort, + IdGenerator idGenerator) { this.notificationRepository = notificationRepository; this.scheduleQueryPort = scheduleQueryPort; + this.idGenerator = idGenerator; } @Transactional @@ -74,6 +79,7 @@ private UpcomingScheduleNotification buildNotification(Long ownerId, Long schedu String scheduleStartTime = scheduleStartTimeOverride != null ? scheduleStartTimeOverride : basics.designatedStartTime(); return new UpcomingScheduleNotification( + idGenerator.generate(), basics.ownerId(), basics.scheduleId(), basics.scheduleTitle(), diff --git a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java index a06db74..bc41477 100644 --- a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java +++ b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotification.java @@ -1,39 +1,43 @@ package me.pinitnotification.domain.notification; - -import jakarta.persistence.*; import lombok.Getter; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.Map; +import java.util.UUID; @Getter -@Entity -@Table( - name = "upcoming_schedule_notification", - uniqueConstraints = { - @UniqueConstraint(name = "uk_schedule_owner", columnNames = {"schedule_id", "owner_id"}), - @UniqueConstraint(name = "uk_idempotent_key", columnNames = {"idempotent_key"}) - } -) public class UpcomingScheduleNotification implements Notification { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(name = "owner_id", nullable = false) + private Long legacyId; + private UUID id; private Long ownerId; - @Column(name = "schedule_id", nullable = false) private Long scheduleId; - @Column(name = "schedule_title", nullable = false) private String scheduleTitle; - @Column(name = "schedule_start_time", nullable = false) private String scheduleStartTime; - @Column(name = "idempotent_key", nullable = false) private String idempotentKey; protected UpcomingScheduleNotification() {} + public UpcomingScheduleNotification(Long legacyId, UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) { + this.legacyId = legacyId; + this.id = id; + this.ownerId = ownerId; + this.scheduleId = scheduleId; + this.scheduleTitle = scheduleTitle; + this.scheduleStartTime = scheduleStartTime; + this.idempotentKey = idempotentKey; + } + + public UpcomingScheduleNotification(UUID id, Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) { + this.id = id; + this.ownerId = ownerId; + this.scheduleId = scheduleId; + this.scheduleTitle = scheduleTitle; + this.scheduleStartTime = scheduleStartTime; + this.idempotentKey = idempotentKey; + } + public UpcomingScheduleNotification(Long ownerId, Long scheduleId, String scheduleTitle, String scheduleStartTime, String idempotentKey) { this.ownerId = ownerId; this.scheduleId = scheduleId; diff --git a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java index 6c96b48..f262433 100644 --- a/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java +++ b/src/main/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationRepository.java @@ -1,11 +1,13 @@ package me.pinitnotification.domain.notification; -import org.springframework.data.jpa.repository.JpaRepository; - +import java.util.List; import java.util.Optional; -public interface UpcomingScheduleNotificationRepository extends JpaRepository { +public interface UpcomingScheduleNotificationRepository { + List findAll(); Optional findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); + UpcomingScheduleNotification save(UpcomingScheduleNotification notification); void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); + void deleteAllInBatch(List notifications); } diff --git a/src/main/java/me/pinitnotification/domain/push/PushSubscription.java b/src/main/java/me/pinitnotification/domain/push/PushSubscription.java index 3d0353b..426e0ed 100644 --- a/src/main/java/me/pinitnotification/domain/push/PushSubscription.java +++ b/src/main/java/me/pinitnotification/domain/push/PushSubscription.java @@ -1,39 +1,36 @@ package me.pinitnotification.domain.push; -import jakarta.persistence.*; import lombok.Getter; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.Instant; +import java.util.UUID; -@Entity -@Table( - uniqueConstraints = { - @UniqueConstraint( - name = "uk_deviceId_memberId", - columnNames = {"member_id", "device_id"} - ) - } -) -@EntityListeners(AuditingEntityListener.class) @Getter public class PushSubscription { - @Id - @GeneratedValue - private Long id; - - @Column(name = "member_id", nullable = false) + private Long legacyId; + private UUID id; private Long memberId; - @Column(name = "device_id", nullable = false) private String deviceId; - @Column(name = "token", nullable = false) private String token; - @LastModifiedDate - @Column(name = "modified_at", nullable = false) private Instant modifiedAt; protected PushSubscription() {} + public PushSubscription(Long legacyId, UUID id, Long memberId, String deviceId, String token, Instant modifiedAt) { + this.legacyId = legacyId; + this.id = id; + this.memberId = memberId; + this.deviceId = deviceId; + this.token = token; + this.modifiedAt = modifiedAt; + } + + public PushSubscription(UUID id, Long memberId, String deviceId, String token) { + this.id = id; + this.memberId = memberId; + this.deviceId = deviceId; + this.token = token; + } + public PushSubscription(Long memberId, String deviceId, String token) { this.memberId = memberId; this.deviceId = deviceId; @@ -41,7 +38,7 @@ public PushSubscription(Long memberId, String deviceId, String token) { } public void updateToken(String token) { - if (this.modifiedAt.isAfter(Instant.now())) { + if (this.modifiedAt != null && this.modifiedAt.isAfter(Instant.now())) { return; } this.token = token; diff --git a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java index 8ff593c..be393d4 100644 --- a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java +++ b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java @@ -1,15 +1,15 @@ package me.pinitnotification.domain.push; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; -public interface PushSubscriptionRepository extends JpaRepository { +public interface PushSubscriptionRepository { Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); List findAllByMemberId(Long memberId); + PushSubscription save(PushSubscription subscription); + void deleteByToken(String token); void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); diff --git a/src/main/java/me/pinitnotification/domain/shared/IdGenerator.java b/src/main/java/me/pinitnotification/domain/shared/IdGenerator.java new file mode 100644 index 0000000..b0716d3 --- /dev/null +++ b/src/main/java/me/pinitnotification/domain/shared/IdGenerator.java @@ -0,0 +1,7 @@ +package me.pinitnotification.domain.shared; + +import java.util.UUID; + +public interface IdGenerator { + UUID generate(); +} diff --git a/src/main/java/me/pinitnotification/infrastructure/batch/PublicIdBackfillRunner.java b/src/main/java/me/pinitnotification/infrastructure/batch/PublicIdBackfillRunner.java new file mode 100644 index 0000000..25cd20b --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/batch/PublicIdBackfillRunner.java @@ -0,0 +1,62 @@ +package me.pinitnotification.infrastructure.batch; + +import me.pinitnotification.domain.shared.IdGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Profile("backfill-public-id") +@Component +public class PublicIdBackfillRunner implements CommandLineRunner { + private static final Logger log = LoggerFactory.getLogger(PublicIdBackfillRunner.class); + + private final JdbcTemplate jdbcTemplate; + private final IdGenerator idGenerator; + private final int batchSize; + + public PublicIdBackfillRunner(JdbcTemplate jdbcTemplate, + IdGenerator idGenerator, + @Value("${backfill.public-id.batch-size:500}") int batchSize) { + this.jdbcTemplate = jdbcTemplate; + this.idGenerator = idGenerator; + this.batchSize = batchSize; + } + + @Override + public void run(String... args) { + backfillTable("upcoming_schedule_notification"); + backfillTable("push_subscription"); + } + + private void backfillTable(String table) { + int total = 0; + while (true) { + List ids = jdbcTemplate.query( + "select id from " + table + " where public_id is null limit ?", + ps -> ps.setInt(1, batchSize), + (rs, rowNum) -> rs.getLong(1) + ); + + if (ids.isEmpty()) { + break; + } + + for (Long id : ids) { + int updated = jdbcTemplate.update( + "update " + table + " set public_id = ? where id = ? and public_id is null", + idGenerator.generate().toString(), + id + ); + total += updated; + } + } + + log.info("Backfill completed for table={}, updatedRows={}", table, total); + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java index fe39097..d4dc08b 100644 --- a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java +++ b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java @@ -9,6 +9,7 @@ import me.pinitnotification.domain.notification.Notification; import me.pinitnotification.domain.push.PushSubscription; import me.pinitnotification.domain.push.PushSubscriptionRepository; +import me.pinitnotification.domain.shared.IdGenerator; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,10 +23,14 @@ public class FcmService implements PushService { private String vapidPublicKey; private final FirebaseMessaging firebaseMessaging; private final PushSubscriptionRepository pushSubscriptionRepository; + private final IdGenerator idGenerator; - public FcmService(FirebaseMessaging firebaseMessaging, PushSubscriptionRepository pushSubscriptionRepository) { + public FcmService(FirebaseMessaging firebaseMessaging, + PushSubscriptionRepository pushSubscriptionRepository, + IdGenerator idGenerator) { this.firebaseMessaging = firebaseMessaging; this.pushSubscriptionRepository = pushSubscriptionRepository; + this.idGenerator = idGenerator; } @Override @@ -64,7 +69,7 @@ public void subscribe(Long memberId, String deviceId, String token) { PushSubscription existingSubscription = byMemberIdAndDeviceId.get(); existingSubscription.updateToken(token); } else { - pushSubscriptionRepository.save(new PushSubscription(memberId, deviceId, token)); + pushSubscriptionRepository.save(new PushSubscription(idGenerator.generate(), memberId, deviceId, token)); } } diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/IdV7GeneratorAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/IdV7GeneratorAdapter.java new file mode 100644 index 0000000..493a77b --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/IdV7GeneratorAdapter.java @@ -0,0 +1,14 @@ +package me.pinitnotification.infrastructure.persistence; + +import me.pinitnotification.domain.shared.IdGenerator; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class IdV7GeneratorAdapter implements IdGenerator { + @Override + public UUID generate() { + return UuidV7Generator.generate(); + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/UuidV7Generator.java b/src/main/java/me/pinitnotification/infrastructure/persistence/UuidV7Generator.java new file mode 100644 index 0000000..979d876 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/UuidV7Generator.java @@ -0,0 +1,47 @@ +package me.pinitnotification.infrastructure.persistence; + +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +public final class UuidV7Generator { + private UuidV7Generator() { + } + + public static UUID generate() { + byte[] bytes = new byte[16]; + long millis = System.currentTimeMillis(); + + bytes[0] = (byte) (millis >>> 40); + bytes[1] = (byte) (millis >>> 32); + bytes[2] = (byte) (millis >>> 24); + bytes[3] = (byte) (millis >>> 16); + bytes[4] = (byte) (millis >>> 8); + bytes[5] = (byte) millis; + + int randA = ThreadLocalRandom.current().nextInt(1 << 12); + bytes[6] = (byte) (0x70 | ((randA >>> 8) & 0x0F)); + bytes[7] = (byte) randA; + + long randB = ThreadLocalRandom.current().nextLong(); + bytes[8] = (byte) (randB >>> 56); + bytes[9] = (byte) (randB >>> 48); + bytes[10] = (byte) (randB >>> 40); + bytes[11] = (byte) (randB >>> 32); + bytes[12] = (byte) (randB >>> 24); + bytes[13] = (byte) (randB >>> 16); + bytes[14] = (byte) (randB >>> 8); + bytes[15] = (byte) randB; + + bytes[8] = (byte) ((bytes[8] & 0x3F) | 0x80); + + long msb = 0; + long lsb = 0; + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (bytes[i] & 0xFF); + } + for (int i = 8; i < 16; i++) { + lsb = (lsb << 8) | (bytes[i] & 0xFF); + } + return new UUID(msb, lsb); + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java new file mode 100644 index 0000000..b6d4821 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationEntity.java @@ -0,0 +1,50 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import me.pinitnotification.infrastructure.persistence.UuidV7Generator; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.UUID; + +@Entity +@Table( + name = "upcoming_schedule_notification", + uniqueConstraints = { + @UniqueConstraint(name = "uk_schedule_owner", columnNames = {"schedule_id", "owner_id"}), + @UniqueConstraint(name = "uk_idempotent_key", columnNames = {"idempotent_key"}) + } +) +@Getter +@Setter +public class UpcomingScheduleNotificationEntity { + private Long id; + + @Id + @JdbcTypeCode(SqlTypes.CHAR) + @Column(name = "public_id", length = 36) + private UUID publicId; + + @Column(name = "owner_id", nullable = false) + private Long ownerId; + @Column(name = "schedule_id", nullable = false) + private Long scheduleId; + @Column(name = "schedule_title", nullable = false) + private String scheduleTitle; + @Column(name = "schedule_start_time", nullable = false) + private String scheduleStartTime; + @Column(name = "idempotent_key", nullable = false) + private String idempotentKey; + + protected UpcomingScheduleNotificationEntity() { + } + + @PrePersist + protected void assignPublicId() { + if (publicId == null) { + publicId = UuidV7Generator.generate(); + } + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java new file mode 100644 index 0000000..9140ee7 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepository.java @@ -0,0 +1,11 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UpcomingScheduleNotificationJpaRepository extends JpaRepository { + Optional findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); + boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); + void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId); +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java new file mode 100644 index 0000000..b7a576f --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapter.java @@ -0,0 +1,78 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import me.pinitnotification.domain.notification.UpcomingScheduleNotification; +import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class UpcomingScheduleNotificationRepositoryAdapter implements UpcomingScheduleNotificationRepository { + private final UpcomingScheduleNotificationJpaRepository jpaRepository; + + public UpcomingScheduleNotificationRepositoryAdapter(UpcomingScheduleNotificationJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public List findAll() { + return jpaRepository.findAll().stream() + .map(this::toDomain) + .toList(); + } + + @Override + public Optional findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId) { + return jpaRepository.findByScheduleIdAndOwnerId(scheduleId, ownerId) + .map(this::toDomain); + } + + @Override + public boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId) { + return jpaRepository.existsByScheduleIdAndOwnerId(scheduleId, ownerId); + } + + @Override + public UpcomingScheduleNotification save(UpcomingScheduleNotification notification) { + UpcomingScheduleNotificationEntity saved = jpaRepository.save(toEntity(notification)); + return toDomain(saved); + } + + @Override + public void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId) { + jpaRepository.deleteByScheduleIdAndOwnerId(scheduleId, ownerId); + } + + @Override + public void deleteAllInBatch(List notifications) { + List entities = notifications.stream() + .map(this::toEntity) + .toList(); + jpaRepository.deleteAllInBatch(entities); + } + + private UpcomingScheduleNotification toDomain(UpcomingScheduleNotificationEntity entity) { + return new UpcomingScheduleNotification( + entity.getId(), + entity.getPublicId(), + entity.getOwnerId(), + entity.getScheduleId(), + entity.getScheduleTitle(), + entity.getScheduleStartTime(), + entity.getIdempotentKey() + ); + } + + private UpcomingScheduleNotificationEntity toEntity(UpcomingScheduleNotification domain) { + UpcomingScheduleNotificationEntity entity = new UpcomingScheduleNotificationEntity(); + entity.setId(domain.getLegacyId()); + entity.setPublicId(domain.getId()); + entity.setOwnerId(domain.getOwnerId()); + entity.setScheduleId(domain.getScheduleId()); + entity.setScheduleTitle(domain.getScheduleTitle()); + entity.setScheduleStartTime(domain.getScheduleStartTime()); + entity.setIdempotentKey(domain.getIdempotentKey()); + return entity; + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionEntity.java b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionEntity.java new file mode 100644 index 0000000..9135659 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionEntity.java @@ -0,0 +1,55 @@ +package me.pinitnotification.infrastructure.persistence.push; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import me.pinitnotification.infrastructure.persistence.UuidV7Generator; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "push_subscription", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_deviceId_memberId", + columnNames = {"member_id", "device_id"} + ) + } +) +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +public class PushSubscriptionEntity { + private Long id; + + @Id + @JdbcTypeCode(SqlTypes.CHAR) + @Column(name = "public_id", length = 36) + private UUID publicId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + @Column(name = "device_id", nullable = false) + private String deviceId; + @Column(name = "token", nullable = false) + private String token; + @LastModifiedDate + @Column(name = "modified_at", nullable = false) + private Instant modifiedAt; + + protected PushSubscriptionEntity() { + } + + @PrePersist + protected void assignPublicId() { + if (publicId == null) { + publicId = UuidV7Generator.generate(); + } + } +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java new file mode 100644 index 0000000..493d40b --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepository.java @@ -0,0 +1,16 @@ +package me.pinitnotification.infrastructure.persistence.push; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PushSubscriptionJpaRepository extends JpaRepository { + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + + List findAllByMemberId(Long memberId); + + void deleteByToken(String token); + + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); +} diff --git a/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java new file mode 100644 index 0000000..aa08242 --- /dev/null +++ b/src/main/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapter.java @@ -0,0 +1,68 @@ +package me.pinitnotification.infrastructure.persistence.push; + +import me.pinitnotification.domain.push.PushSubscription; +import me.pinitnotification.domain.push.PushSubscriptionRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class PushSubscriptionRepositoryAdapter implements PushSubscriptionRepository { + private final PushSubscriptionJpaRepository jpaRepository; + + public PushSubscriptionRepositoryAdapter(PushSubscriptionJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Optional findByMemberIdAndDeviceId(Long memberId, String deviceId) { + return jpaRepository.findByMemberIdAndDeviceId(memberId, deviceId) + .map(this::toDomain); + } + + @Override + public List findAllByMemberId(Long memberId) { + return jpaRepository.findAllByMemberId(memberId).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public PushSubscription save(PushSubscription subscription) { + PushSubscriptionEntity saved = jpaRepository.save(toEntity(subscription)); + return toDomain(saved); + } + + @Override + public void deleteByToken(String token) { + jpaRepository.deleteByToken(token); + } + + @Override + public void deleteByMemberIdAndDeviceId(Long memberId, String deviceId) { + jpaRepository.deleteByMemberIdAndDeviceId(memberId, deviceId); + } + + private PushSubscription toDomain(PushSubscriptionEntity entity) { + return new PushSubscription( + entity.getId(), + entity.getPublicId(), + entity.getMemberId(), + entity.getDeviceId(), + entity.getToken(), + entity.getModifiedAt() + ); + } + + private PushSubscriptionEntity toEntity(PushSubscription domain) { + PushSubscriptionEntity entity = new PushSubscriptionEntity(); + entity.setId(domain.getLegacyId()); + entity.setPublicId(domain.getId()); + entity.setMemberId(domain.getMemberId()); + entity.setDeviceId(domain.getDeviceId()); + entity.setToken(domain.getToken()); + entity.setModifiedAt(domain.getModifiedAt()); + return entity; + } +} diff --git a/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java b/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java index f1599bb..bbd53a4 100644 --- a/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java +++ b/src/test/java/me/pinitnotification/application/notification/ScheduleNotificationServiceTest.java @@ -7,6 +7,7 @@ import me.pinitnotification.application.notification.query.ScheduleQueryPort; import me.pinitnotification.domain.notification.UpcomingScheduleNotification; import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; +import me.pinitnotification.domain.shared.IdGenerator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +31,9 @@ class ScheduleNotificationServiceTest { @Mock private ScheduleQueryPort scheduleQueryPort; + @Mock + private IdGenerator idGenerator; + @InjectMocks private ScheduleNotificationService scheduleNotificationService; @@ -41,7 +45,7 @@ class ScheduleNotificationServiceTest { @BeforeEach void resetMocks() { - reset(notificationRepository, scheduleQueryPort); + reset(notificationRepository, scheduleQueryPort, idGenerator); } @Test @@ -66,6 +70,7 @@ void handleUpcomingUpdated_createsWhenMissing() { when(notificationRepository.findByScheduleIdAndOwnerId(scheduleId, ownerId)).thenReturn(Optional.empty()); when(scheduleQueryPort.getScheduleBasics(scheduleId, ownerId)) .thenReturn(new ScheduleBasics(scheduleId, ownerId, "title", "2024-03-01T00:00:00Z")); + when(idGenerator.generate()).thenReturn(java.util.UUID.randomUUID()); UpcomingUpdatedCommand command = new UpcomingUpdatedCommand( ownerId, scheduleId, OffsetDateTime.parse("2024-04-01T09:30:00Z"), "key-123" @@ -97,6 +102,7 @@ void handleScheduleCanceled_createsWhenMissing() { when(notificationRepository.existsByScheduleIdAndOwnerId(scheduleId, ownerId)).thenReturn(false); when(scheduleQueryPort.getScheduleBasics(scheduleId, ownerId)) .thenReturn(new ScheduleBasics(scheduleId, ownerId, "canceled title", "2024-05-10T10:00:00Z")); + when(idGenerator.generate()).thenReturn(java.util.UUID.randomUUID()); ScheduleStateChangedCommand command = new ScheduleStateChangedCommand(ownerId, scheduleId, "BEFORE", "cancel-key"); scheduleNotificationService.handleScheduleCanceled(command); diff --git a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt index f1c2ebb..3ae6d0f 100644 --- a/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt +++ b/src/test/java/me/pinitnotification/domain/notification/UpcomingScheduleNotificationUtils.kt @@ -1,5 +1,6 @@ package me.pinitnotification.domain.notification +import me.pinitnotification.infrastructure.persistence.UuidV7Generator import org.junit.platform.commons.util.ReflectionUtils fun getSampleUpcomingScheduleNotification( @@ -11,6 +12,7 @@ fun getSampleUpcomingScheduleNotification( idempotencyKey: String = "", ): UpcomingScheduleNotification { val sample = UpcomingScheduleNotification( + UuidV7Generator.generate(), ownerId, scheduleId, scheduleTitle, @@ -19,7 +21,7 @@ fun getSampleUpcomingScheduleNotification( ) ReflectionUtils.findFields( UpcomingScheduleNotification::class.java, - { field -> field.name == "id" }, + { field -> field.name == "legacyId" }, ReflectionUtils.HierarchyTraversalMode.TOP_DOWN ).forEach { field -> field.isAccessible = true diff --git a/src/test/java/me/pinitnotification/domain/push/PushSubscriptionUtils.kt b/src/test/java/me/pinitnotification/domain/push/PushSubscriptionUtils.kt index 905cc8f..78310e3 100644 --- a/src/test/java/me/pinitnotification/domain/push/PushSubscriptionUtils.kt +++ b/src/test/java/me/pinitnotification/domain/push/PushSubscriptionUtils.kt @@ -1,5 +1,6 @@ package me.pinitnotification.domain.push +import me.pinitnotification.infrastructure.persistence.UuidV7Generator import org.junit.platform.commons.util.ReflectionUtils import java.time.Instant @@ -10,10 +11,10 @@ fun getSamplePushSubscription( token: String = "sample-token", modifiedAt: Instant = Instant.EPOCH, ): PushSubscription { - val sample = PushSubscription(memberId, deviceId, token) + val sample = PushSubscription(UuidV7Generator.generate(), memberId, deviceId, token) ReflectionUtils.findFields( PushSubscription::class.java, - { field -> field.name == "id" }, + { field -> field.name == "legacyId" }, ReflectionUtils.HierarchyTraversalMode.TOP_DOWN ).forEach { field -> field.isAccessible = true diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java new file mode 100644 index 0000000..2a32158 --- /dev/null +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationJpaRepositoryTest.java @@ -0,0 +1,35 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class UpcomingScheduleNotificationJpaRepositoryTest { + @Autowired + private UpcomingScheduleNotificationJpaRepository repository; + + @Test + void savesAndFindsByScheduleAndOwner() { + UpcomingScheduleNotificationEntity entity = new UpcomingScheduleNotificationEntity(); + entity.setOwnerId(10L); + entity.setScheduleId(20L); + entity.setScheduleTitle("title"); + entity.setScheduleStartTime("2025-01-01T00:00:00Z"); + entity.setIdempotentKey("key-1"); + + UpcomingScheduleNotificationEntity saved = repository.save(entity); + + assertThat(saved.getPublicId()).isNotNull(); + + Optional loaded = + repository.findByScheduleIdAndOwnerId(20L, 10L); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().getPublicId()).isNotNull(); + } +} diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java new file mode 100644 index 0000000..38c8f73 --- /dev/null +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/notification/UpcomingScheduleNotificationRepositoryAdapterTest.java @@ -0,0 +1,43 @@ +package me.pinitnotification.infrastructure.persistence.notification; + +import me.pinitnotification.domain.notification.UpcomingScheduleNotification; +import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(UpcomingScheduleNotificationRepositoryAdapter.class) +class UpcomingScheduleNotificationRepositoryAdapterTest { + @Autowired + private UpcomingScheduleNotificationRepository repository; + + @Test + void savesAndLoadsDomainWithPublicId() { + UUID publicId = UUID.randomUUID(); + UpcomingScheduleNotification created = new UpcomingScheduleNotification( + publicId, + 1L, + 2L, + "title", + "2025-01-01T00:00:00Z", + "key-1" + ); + + UpcomingScheduleNotification saved = repository.save(created); + + assertThat(saved.getId()).isEqualTo(publicId); + + Optional loaded = + repository.findByScheduleIdAndOwnerId(2L, 1L); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().getId()).isEqualTo(publicId); + } +} diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepositoryTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepositoryTest.java new file mode 100644 index 0000000..56aaa0c --- /dev/null +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionJpaRepositoryTest.java @@ -0,0 +1,35 @@ +package me.pinitnotification.infrastructure.persistence.push; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class PushSubscriptionJpaRepositoryTest { + @Autowired + private PushSubscriptionJpaRepository repository; + + @Test + void savesAndFindsByMemberAndDevice() { + PushSubscriptionEntity entity = new PushSubscriptionEntity(); + entity.setMemberId(101L); + entity.setDeviceId("device-1"); + entity.setToken("token-1"); + + PushSubscriptionEntity saved = repository.save(entity); + + assertThat(saved.getPublicId()).isNotNull(); + assertThat(saved.getModifiedAt()).isNotNull(); + + Optional loaded = + repository.findByMemberIdAndDeviceId(101L, "device-1"); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().getModifiedAt()).isNotNull(); + } + +} diff --git a/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java new file mode 100644 index 0000000..57990bc --- /dev/null +++ b/src/test/java/me/pinitnotification/infrastructure/persistence/push/PushSubscriptionRepositoryAdapterTest.java @@ -0,0 +1,41 @@ +package me.pinitnotification.infrastructure.persistence.push; + +import me.pinitnotification.domain.push.PushSubscription; +import me.pinitnotification.domain.push.PushSubscriptionRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(PushSubscriptionRepositoryAdapter.class) +class PushSubscriptionRepositoryAdapterTest { + @Autowired + private PushSubscriptionRepository repository; + + @Test + void savesAndLoadsDomainWithPublicId() { + UUID publicId = UUID.randomUUID(); + PushSubscription created = new PushSubscription( + publicId, + 101L, + "device-1", + "token-1" + ); + + PushSubscription saved = repository.save(created); + + assertThat(saved.getId()).isEqualTo(publicId); + + Optional loaded = + repository.findByMemberIdAndDeviceId(101L, "device-1"); + + assertThat(loaded).isPresent(); + assertThat(loaded.get().getId()).isEqualTo(publicId); + } +}