feat(notifications): Phase B-2 — per-event-type granular preferences
This commit is contained in:
+33
-9
@@ -10,47 +10,70 @@ import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Per-user channel opt-in/out (см. migration 0021 + design doc D3).
|
||||
* Per-user notification preferences row (см. migration 0021 + 0025 +
|
||||
* design doc D3).
|
||||
*
|
||||
* <p>Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для
|
||||
* новых rows; bootstrap loadData может включить email для existing reviewer/maker
|
||||
* ролей при первом deploy.
|
||||
* <p>Composite PK {@code (user_id, event_type, channel)}. Phase B-2 added
|
||||
* {@code event_type} dimension чтобы reviewer мог отключить
|
||||
* 'RecordDraftSubmitted' emails (queue noise) и оставить
|
||||
* 'RecordDraftApproved/Rejected' (action confirmations).
|
||||
*
|
||||
* <p>Special value {@code event_type='*'} — wildcard: matches любое event type
|
||||
* для (user, channel) pair. Используется для bulk per-channel opt-out (Phase B
|
||||
* default) и backward compatibility — pre-0025 rows получили '*' через DEFAULT.
|
||||
*
|
||||
* <p>Channel — lowercase wire format ('email' / 'express' / 'telegram').
|
||||
*
|
||||
* <p>Lookup precedence в {@code UserPreferencesGate}:
|
||||
* <ol>
|
||||
* <li>Specific {@code (user, eventType, channel)} → use если найдено</li>
|
||||
* <li>Wildcard {@code (user, '*', channel)} → fallback (Phase B behavior)</li>
|
||||
* <li>Default policy — email ON, express+telegram OFF</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_notification_prefs")
|
||||
public class UserNotificationPref {
|
||||
|
||||
/** Wildcard event_type — matches любое event для (user, channel). */
|
||||
public static final String ANY_EVENT_TYPE = "*";
|
||||
|
||||
@Embeddable
|
||||
public static class Pk implements Serializable {
|
||||
|
||||
@Column(name = "user_id", length = 128, nullable = false)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "event_type", length = 64, nullable = false)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "channel", length = 16, nullable = false)
|
||||
private String channel;
|
||||
|
||||
protected Pk() {}
|
||||
|
||||
public Pk(String userId, String channel) {
|
||||
public Pk(String userId, String eventType, String channel) {
|
||||
this.userId = userId;
|
||||
this.eventType = eventType;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public String getEventType() { return eventType; }
|
||||
public String getChannel() { return channel; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Pk pk)) return false;
|
||||
return Objects.equals(userId, pk.userId) && Objects.equals(channel, pk.channel);
|
||||
return Objects.equals(userId, pk.userId)
|
||||
&& Objects.equals(eventType, pk.eventType)
|
||||
&& Objects.equals(channel, pk.channel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(userId, channel);
|
||||
return Objects.hash(userId, eventType, channel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +85,14 @@ public class UserNotificationPref {
|
||||
|
||||
protected UserNotificationPref() {}
|
||||
|
||||
public UserNotificationPref(String userId, String channel, boolean enabled) {
|
||||
this.id = new Pk(userId, channel);
|
||||
public UserNotificationPref(String userId, String eventType, String channel, boolean enabled) {
|
||||
this.id = new Pk(userId, eventType, channel);
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Pk getId() { return id; }
|
||||
public String getUserId() { return id.getUserId(); }
|
||||
public String getEventType() { return id.getEventType(); }
|
||||
public String getChannel() { return id.getChannel(); }
|
||||
public boolean isEnabled() { return enabled; }
|
||||
|
||||
|
||||
+10
-3
@@ -8,9 +8,16 @@ import java.util.Optional;
|
||||
public interface UserNotificationPrefRepository
|
||||
extends JpaRepository<UserNotificationPref, UserNotificationPref.Pk> {
|
||||
|
||||
/** Все каналы пользователя — для GET /api/v1/me/notifications page. */
|
||||
/**
|
||||
* Все prefs пользователя — для GET /api/v1/me/notifications/preferences
|
||||
* matrix view. Includes wildcard ('*') rows + per-event rows.
|
||||
*/
|
||||
List<UserNotificationPref> findByIdUserId(String userId);
|
||||
|
||||
/** Single channel lookup для dispatcher (skip if disabled). */
|
||||
Optional<UserNotificationPref> findByIdUserIdAndIdChannel(String userId, String channel);
|
||||
/**
|
||||
* Specific (user, eventType, channel) lookup для dispatcher fast path.
|
||||
* Если empty → caller fallback на wildcard ('*' eventType).
|
||||
*/
|
||||
Optional<UserNotificationPref> findByIdUserIdAndIdEventTypeAndIdChannel(
|
||||
String userId, String eventType, String channel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user