Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -295,12 +295,13 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# What ships to browsers: `dependencies` and the runtime they pull in
|
# The whole tree, build and test tooling included. Until #5021 this
|
||||||
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
# audited only what ships to browsers (`--omit=dev`), because vite,
|
||||||
# kit's dev server) is left out because none of it reaches a user, and
|
# vitest, tailwind and kit carried advisories that needed major
|
||||||
# its open advisories need major-version upgrades tracked separately.
|
# upgrades. Those upgrades landed and the full tree audits clean, so
|
||||||
- name: npm audit (shipped dependencies)
|
# the tooling that builds the shipped bundle is held to the same bar.
|
||||||
run: npm audit --omit=dev --audit-level=moderate
|
- name: npm audit (all dependencies)
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
|
||||||
- name: Type-check + svelte-check
|
- name: Type-check + svelte-check
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- Notifications when the app is closed (M489 #5347): the delivery
|
||||||
|
service, and starting it again after a reboot or an update. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||||
is what lets that install happen with NO confirm dialog. The platform
|
is what lets that install happen with NO confirm dialog. The platform
|
||||||
@@ -57,6 +61,29 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<!-- Keeps the process alive so notifications arrive with the app
|
||||||
|
closed. specialUse: dataSync is stopped after six hours on
|
||||||
|
Android 15, and shortService after three minutes. -->
|
||||||
|
<service
|
||||||
|
android:name=".notifications.delivery.DeliveryService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Starts delivery after a reboot or an update; both broadcasts may
|
||||||
|
start a foreground service from the background. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".notifications.delivery.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- The FileProvider that used to live here existed solely to expose the
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
install intent. A PackageInstaller session takes a stream instead,
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.fabledsword.minstrel
|
package com.fabledsword.minstrel
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||||
@@ -27,7 +34,10 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
import com.fabledsword.minstrel.nav.NowPlaying
|
import com.fabledsword.minstrel.nav.NowPlaying
|
||||||
|
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
|
||||||
|
import com.fabledsword.minstrel.notifications.ui.routeForLink
|
||||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||||
@Inject lateinit var serverHealth: NetworkStatusController
|
@Inject lateinit var serverHealth: NetworkStatusController
|
||||||
|
@Inject lateinit var authStore: AuthStore
|
||||||
|
|
||||||
// Flipped to true when the user taps the media notification (or
|
// Set when the user taps a notification: the media one asks for the full
|
||||||
// any other entry point that asks for the full player). The App
|
// player, a Minstrel notice for what it is about. The App composable
|
||||||
// composable observes this, navigates to NowPlaying once the
|
// navigates there once the NavHost is ready, then calls back to clear it
|
||||||
// NavHost is ready, then calls back to reset the flag so the
|
// so the navigation doesn't re-fire on the next recomposition.
|
||||||
// navigation doesn't re-fire on the next recomposition.
|
private val pendingRoute = MutableStateFlow<Any?>(null)
|
||||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
|
||||||
|
// The answer needs no handling: the system remembers it, and the
|
||||||
|
// notification settings screen reads it on every resume.
|
||||||
|
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
consumeOpenNowPlayingIntent(intent)
|
consumeRouteIntent(intent)
|
||||||
|
askToNotifyOnceWanted()
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
seedCache = seedCache,
|
seedCache = seedCache,
|
||||||
cachedTrackIds = cachedTrackIds,
|
cachedTrackIds = cachedTrackIds,
|
||||||
serverHealth = serverHealth,
|
serverHealth = serverHealth,
|
||||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
pendingRoute = pendingRoute.asStateFlow(),
|
||||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
onOpenedRoute = { pendingRoute.value = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeOpenNowPlayingIntent(intent)
|
consumeRouteIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
private fun consumeRouteIntent(intent: Intent?) {
|
||||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
if (intent == null) return
|
||||||
pendingOpenNowPlaying.value = true
|
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
|
||||||
|
pendingRoute.value = NowPlaying
|
||||||
// Strip the extra so a subsequent config-change recreation
|
// Strip the extra so a subsequent config-change recreation
|
||||||
// doesn't re-trigger the navigation.
|
// doesn't re-trigger the navigation.
|
||||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||||
}
|
}
|
||||||
|
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
|
||||||
|
// A notice the app has no screen for, or a pile of them, opens
|
||||||
|
// the inbox.
|
||||||
|
pendingRoute.value = routeForLink(link) ?: Notifications
|
||||||
|
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android 13+ asks before an app may post notifications (M489 #5347).
|
||||||
|
* Asked once delivery is wanted (signed in, background delivery on),
|
||||||
|
* each launch until answered: after a second "no" the system stops
|
||||||
|
* showing the prompt by itself, and Settings → Notifications links to
|
||||||
|
* the system page.
|
||||||
|
*/
|
||||||
|
private fun askToNotifyOnceWanted() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
|
||||||
|
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
|
||||||
|
val permission = Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!granted) askToNotify.launch(permission)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
* so a media-notification tap lands on the full NowPlaying screen
|
* so a media-notification tap lands on the full NowPlaying screen
|
||||||
* instead of whatever shell route MainActivity last rendered. */
|
* instead of whatever shell route MainActivity last rendered. */
|
||||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||||
|
|
||||||
|
/** PendingIntent extra on a Minstrel notice: the web path it links to,
|
||||||
|
* or empty for a pile, which opens the inbox. */
|
||||||
|
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,15 +141,15 @@ private fun App(
|
|||||||
seedCache: DetailSeedCache,
|
seedCache: DetailSeedCache,
|
||||||
cachedTrackIds: CachedTrackIds,
|
cachedTrackIds: CachedTrackIds,
|
||||||
serverHealth: NetworkStatusController,
|
serverHealth: NetworkStatusController,
|
||||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
pendingRoute: StateFlow<Any?>,
|
||||||
onOpenedNowPlaying: () -> Unit,
|
onOpenedRoute: () -> Unit,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
gate: AuthGateViewModel = hiltViewModel(),
|
gate: AuthGateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
val pending by pendingRoute.collectAsStateWithLifecycle()
|
||||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDetailSeedCache provides seedCache,
|
LocalDetailSeedCache provides seedCache,
|
||||||
@@ -119,16 +168,14 @@ private fun App(
|
|||||||
// Queue / unauthenticated) bypass the shell entirely.
|
// Queue / unauthenticated) bypass the shell entirely.
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
// Honour a pending notification-tap once the NavHost is
|
// Honour a pending notification-tap once the NavHost is
|
||||||
// mounted. launchSingleTop avoids stacking copies of
|
// mounted. launchSingleTop avoids stacking copies of a
|
||||||
// NowPlaying if the user taps the notification while
|
// screen if the user taps the notification while already
|
||||||
// already on it; the callback clears the flag so a later
|
// on it; the callback clears it so a later recomposition
|
||||||
// recomposition (config change, theme switch) doesn't
|
// (config change, theme switch) doesn't re-navigate.
|
||||||
// re-navigate.
|
|
||||||
LaunchedEffect(pending, navController) {
|
LaunchedEffect(pending, navController) {
|
||||||
if (pending) {
|
val route = pending ?: return@LaunchedEffect
|
||||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
navController.navigate(route) { launchSingleTop = true }
|
||||||
onOpenedNowPlaying()
|
onOpenedRoute()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MinstrelNavGraph(
|
MinstrelNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
|||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||||
|
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||||
@@ -75,6 +76,14 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
|
||||||
|
* the background-delivery service and runs the notification catch-up on
|
||||||
|
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
|
||||||
|
* notification would ever be posted.
|
||||||
|
*/
|
||||||
|
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — PlayEventsReporter's init
|
* Same construct-the-singleton trick — PlayEventsReporter's init
|
||||||
* block subscribes to PlayerController.uiState and reports the
|
* block subscribes to PlayerController.uiState and reports the
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox and its per-user settings (M489). The server
|
||||||
|
* renders each notice's title, body and link, so the app shows them as given.
|
||||||
|
*/
|
||||||
|
interface NotificationsApi {
|
||||||
|
@GET("api/me/notifications")
|
||||||
|
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
|
||||||
|
|
||||||
|
/** 204; 404 when the notice is gone, which a replay treats as done. */
|
||||||
|
@POST("api/me/notifications/{id}/read")
|
||||||
|
suspend fun markRead(@Path("id") id: String)
|
||||||
|
|
||||||
|
@POST("api/me/notifications/read-all")
|
||||||
|
suspend fun readAll(@Body body: ReadAllBody)
|
||||||
|
|
||||||
|
@GET("api/me/notification-settings")
|
||||||
|
suspend fun getSettings(): NotificationSettingsWire
|
||||||
|
|
||||||
|
@PUT("api/me/notification-settings")
|
||||||
|
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationWire(
|
||||||
|
val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val link: String,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("read_at") val readAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationsPageWire(
|
||||||
|
val items: List<NotificationWire>,
|
||||||
|
@SerialName("unread_count") val unreadCount: Long,
|
||||||
|
@SerialName("next_before") val nextBefore: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `upTo` limits "mark all read" to what existed when the user asked, so a
|
||||||
|
* replay landing later leaves newer notices unread. No default on purpose:
|
||||||
|
* the app's Json drops default-valued fields.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationKindSettingWire(
|
||||||
|
val kind: String,
|
||||||
|
@SerialName("admin_only") val adminOnly: Boolean,
|
||||||
|
val inbox: Boolean,
|
||||||
|
val phone: Boolean,
|
||||||
|
val email: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingsWire(
|
||||||
|
val kinds: List<NotificationKindSettingWire>,
|
||||||
|
@SerialName("email_available") val emailAvailable: Boolean,
|
||||||
|
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind's change. Untouched channels stay null and, being equal to their
|
||||||
|
* default, are left out of the JSON, so the server changes only the channel
|
||||||
|
* the user touched.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingChangeWire(
|
||||||
|
val kind: String,
|
||||||
|
val inbox: Boolean? = null,
|
||||||
|
val phone: Boolean? = null,
|
||||||
|
val email: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
|
||||||
@@ -81,6 +81,12 @@ class AuthStore @Inject constructor(
|
|||||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||||
|
|
||||||
|
// Background delivery (M489 #5347): the device's choice, on by default.
|
||||||
|
// The shade's high-water mark lives in the same row but is read and
|
||||||
|
// written through the DAO by NotificationSync, awaited, never cached here.
|
||||||
|
private val backgroundDeliveryState = MutableStateFlow(true)
|
||||||
|
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// Serialises every cookie persist with the one-time hydration, so a
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
@@ -104,6 +110,7 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
|
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +212,17 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistNormalization(encoded) }
|
scope.launch { persistNormalization(encoded) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBackgroundDelivery(value: Boolean) {
|
||||||
|
backgroundDeliveryState.value = value
|
||||||
|
scope.launch {
|
||||||
|
if (dao.get() == null) {
|
||||||
|
dao.upsert(currentEntity().copy(backgroundDelivery = value))
|
||||||
|
} else {
|
||||||
|
dao.setBackgroundDelivery(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun persistLegacyCookie(value: String?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
@@ -287,6 +305,7 @@ class AuthStore @Inject constructor(
|
|||||||
NormalizationPrefs.serializer(),
|
NormalizationPrefs.serializer(),
|
||||||
normalizationState.value,
|
normalizationState.value,
|
||||||
),
|
),
|
||||||
|
backgroundDelivery = backgroundDeliveryState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+43
-1
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -28,6 +29,8 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
@@ -66,7 +69,14 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
|
CachedNotificationEntity::class,
|
||||||
|
CachedNotificationSettingsEntity::class,
|
||||||
],
|
],
|
||||||
|
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
|
||||||
|
// background-delivery choice and its shade high-water mark (M489 #5347).
|
||||||
|
// v11: + cached_notifications and cached_notification_settings, the
|
||||||
|
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
||||||
|
// both; nothing to backfill, the first refresh fills them.
|
||||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||||
@@ -82,7 +92,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 10,
|
version = 12,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -102,6 +112,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
|
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||||
@@ -125,3 +136,34 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
|||||||
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
|
||||||
|
* matches what Room generates for the two entities; Room checks it on open.
|
||||||
|
*/
|
||||||
|
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
|
||||||
|
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
|
||||||
|
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
|
||||||
|
"`readAt` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
|
||||||
|
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
|
||||||
|
* as the entity's column default says; the high-water mark starts empty, so
|
||||||
|
* the first catch-up sets it without announcing anything.
|
||||||
|
*/
|
||||||
|
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
|
||||||
|
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -37,7 +38,7 @@ object DatabaseModule {
|
|||||||
// launch, so users lose only the unsynced mutation queue
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// Migration entries before the first tagged release.
|
||||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -112,5 +113,10 @@ object DatabaseModule {
|
|||||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
db.diagnosticEventDao()
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
||||||
|
db.cachedNotificationDao()
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface AuthSessionDao {
|
interface AuthSessionDao {
|
||||||
@@ -50,4 +51,12 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the serialized normalization preference. */
|
/** Partial update: change only the serialized normalization preference. */
|
||||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||||
suspend fun setNormalizationJson(json: String?)
|
suspend fun setNormalizationJson(json: String?)
|
||||||
|
|
||||||
|
/** Partial update: change only the background-delivery choice. */
|
||||||
|
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
|
||||||
|
suspend fun setBackgroundDelivery(enabled: Boolean)
|
||||||
|
|
||||||
|
/** Partial update: change only the shade's high-water mark. */
|
||||||
|
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
|
||||||
|
suspend fun setNotifiedUpTo(upTo: Instant?)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CachedNotificationDao {
|
||||||
|
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
|
||||||
|
fun observeAll(): Flow<List<CachedNotificationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
|
||||||
|
fun observeUnreadCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notifications")
|
||||||
|
suspend fun getAll(): List<CachedNotificationEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM cached_notifications")
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(rows: List<CachedNotificationEntity>)
|
||||||
|
|
||||||
|
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
|
||||||
|
clear()
|
||||||
|
insertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
|
||||||
|
suspend fun markRead(id: String, at: Instant)
|
||||||
|
|
||||||
|
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
|
||||||
|
suspend fun markAllRead(at: Instant)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||||
|
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||||
|
suspend fun getSettings(): CachedNotificationSettingsEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
|
||||||
|
}
|
||||||
+15
@@ -1,7 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.cache.db.entities
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the user's session cookie, configured
|
* Single-row table holding the user's session cookie, configured
|
||||||
@@ -49,4 +51,17 @@ data class AuthSessionEntity(
|
|||||||
* defaults apply. Kept so offline playback still levels.
|
* defaults apply. Kept so offline playback still levels.
|
||||||
*/
|
*/
|
||||||
val normalizationJson: String? = null,
|
val normalizationJson: String? = null,
|
||||||
|
/**
|
||||||
|
* "Notifications when the app is closed" (M489 #5347): keep the
|
||||||
|
* delivery foreground service running. A device choice, on by default.
|
||||||
|
* The column default matches MIGRATION_11_12's, which Room checks.
|
||||||
|
*/
|
||||||
|
@ColumnInfo(defaultValue = "1")
|
||||||
|
val backgroundDelivery: Boolean = true,
|
||||||
|
/**
|
||||||
|
* The newest notice this device has announced in the shade. A catch-up
|
||||||
|
* announces only what is newer, so a reboot or a reconnect never
|
||||||
|
* re-announces a backlog. Cleared on sign-out.
|
||||||
|
*/
|
||||||
|
val notifiedUpTo: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One notice from the user's inbox (M489), kept so the Notifications screen
|
||||||
|
* and the bell's badge work offline, and so a read made offline shows at once.
|
||||||
|
* The newest page is cached; older notices are the server's to keep.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cached_notifications")
|
||||||
|
data class CachedNotificationEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val link: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val readAt: Instant?,
|
||||||
|
)
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row copy of the user's notification settings (M489), stored as the
|
||||||
|
* wire JSON, so the settings screen opens offline and a toggle shows at once.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cached_notification_settings")
|
||||||
|
data class CachedNotificationSettingsEntity(
|
||||||
|
@PrimaryKey val id: Int = SINGLETON_ID,
|
||||||
|
val json: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val SINGLETON_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
@@ -47,6 +48,17 @@ object MutationKind {
|
|||||||
// preference, a target state like the toggles above, so queued changes
|
// preference, a target state like the toggles above, so queued changes
|
||||||
// collapse to the last one and an older one can never be replayed last.
|
// collapse to the last one and an older one can never be replayed last.
|
||||||
const val NORMALIZATION_SET: String = "normalization_set"
|
const val NORMALIZATION_SET: String = "normalization_set"
|
||||||
|
|
||||||
|
// M489 notifications inbox. A read is a one-way action (read never goes
|
||||||
|
// back to unread), so neither read kind needs collapsing. Read-all
|
||||||
|
// carries the moment the user asked, so a late replay leaves newer
|
||||||
|
// notices unread.
|
||||||
|
const val NOTIFICATION_READ: String = "notification_read"
|
||||||
|
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
|
||||||
|
|
||||||
|
// M489 per-kind channel setting. One row per (kind, channel) target
|
||||||
|
// state, collapsed on that pair, so the newest choice is the one sent.
|
||||||
|
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +202,25 @@ class MutationQueue @Inject constructor(
|
|||||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATION_READ,
|
||||||
|
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATIONS_READ_ALL,
|
||||||
|
json.encodeToString(
|
||||||
|
NotificationsReadAllPayload.serializer(),
|
||||||
|
NotificationsReadAllPayload(upToIso),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
|
||||||
|
insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET,
|
||||||
|
json.encodeToString(NotificationSettingPayload.serializer(), payload),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload(
|
|||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
val clientId: String,
|
val clientId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationReadPayload(val id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
|
||||||
|
* `upToIso` is the newest notice the user could see when they asked; null
|
||||||
|
* when the inbox was empty on the device, which marks everything.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationsReadAllPayload(val upToIso: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
|
||||||
|
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
|
||||||
|
* "email". No defaults, so every field is always written.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingPayload(
|
||||||
|
val kind: String,
|
||||||
|
val channel: String,
|
||||||
|
val value: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The wire change for one queued channel setting, or null for an unknown channel. */
|
||||||
|
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
|
||||||
|
when (p.channel) {
|
||||||
|
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
|
||||||
|
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
|
||||||
|
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
+41
@@ -8,6 +8,9 @@ import com.fabledsword.minstrel.api.endpoints.EventsApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
@@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
private val meApi: MeApi = retrofit.create()
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
private val notificationsApi: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -170,10 +174,22 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||||
|
MutationKind.NOTIFICATION_READ,
|
||||||
|
MutationKind.NOTIFICATIONS_READ_ALL,
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET,
|
||||||
|
-> dispatchNotification(row)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */
|
||||||
|
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||||
|
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
|
||||||
|
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
|
||||||
|
else -> Outcome.DROP
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||||
val kindPath = when (decoded.entityType) {
|
val kindPath = when (decoded.entityType) {
|
||||||
@@ -293,6 +309,27 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
|
||||||
|
private suspend fun dispatchNotificationRead(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
|
||||||
|
notificationsApi.markRead(decoded.id)
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
|
||||||
|
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unknown channel can only come from a corrupt row: DROP it. */
|
||||||
|
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
|
||||||
|
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
|
||||||
|
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -355,6 +392,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
|||||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
|
||||||
|
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
|
||||||
|
|
||||||
// One preference per user, so every normalization row shares one key.
|
// One preference per user, so every normalization row shares one key.
|
||||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package com.fabledsword.minstrel.events
|
package com.fabledsword.minstrel.events
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -28,9 +32,6 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
private const val SSE_PATH = "/api/events/stream"
|
private const val SSE_PATH = "/api/events/stream"
|
||||||
private const val EVENTS_BUFFER_CAPACITY = 64
|
private const val EVENTS_BUFFER_CAPACITY = 64
|
||||||
private const val BASE_BACKOFF_MS = 1_000L
|
|
||||||
private const val MAX_BACKOFF_MS = 30_000L
|
|
||||||
private const val BACKOFF_FACTOR = 2
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
|
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
|
||||||
@@ -45,11 +46,16 @@ private const val BACKOFF_FACTOR = 2
|
|||||||
* - No client-side timeout — the server emits 15s heartbeats which
|
* - No client-side timeout — the server emits 15s heartbeats which
|
||||||
* okhttp-sse handles transparently.
|
* okhttp-sse handles transparently.
|
||||||
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
||||||
* restart, network blip) it reconnects with exponential backoff
|
* restart, network blip) it reconnects after [ReconnectBackoff]'s
|
||||||
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
|
* jittered wait (2s doubling to 5 min), reset on a successful open.
|
||||||
|
* A network coming up reconnects at once: the callback is a hint, and
|
||||||
|
* the only test of whether the server is reachable is trying it. Only
|
||||||
* reconnects while still signed in; a sign-out cancels the pending
|
* reconnects while still signed in; a sign-out cancels the pending
|
||||||
* retry. Without this a single blip silently kills cross-device
|
* retry. Without this a single blip silently kills cross-device
|
||||||
* reactivity until the next app launch.
|
* reactivity until the next app launch.
|
||||||
|
* - [connected] says whether a stream is open. Background delivery
|
||||||
|
* (M489 #5347) catches up on every rising edge: nothing replays a
|
||||||
|
* frame sent while the stream was down.
|
||||||
*
|
*
|
||||||
* The URL passes through the placeholder host that
|
* The URL passes through the placeholder host that
|
||||||
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
||||||
@@ -62,6 +68,7 @@ class EventsStream @Inject constructor(
|
|||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
|
private val connectivity: ConnectivityObserver,
|
||||||
) {
|
) {
|
||||||
private val factory = EventSources.createFactory(okHttpClient)
|
private val factory = EventSources.createFactory(okHttpClient)
|
||||||
|
|
||||||
@@ -72,10 +79,13 @@ class EventsStream @Inject constructor(
|
|||||||
)
|
)
|
||||||
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
||||||
|
|
||||||
|
private val connectedState = MutableStateFlow(false)
|
||||||
|
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
|
||||||
|
|
||||||
private var currentSource: EventSource? = null
|
private var currentSource: EventSource? = null
|
||||||
@Volatile private var signedIn = false
|
@Volatile private var signedIn = false
|
||||||
private var reconnectJob: Job? = null
|
private var reconnectJob: Job? = null
|
||||||
private var backoffMs = BASE_BACKOFF_MS
|
private var backoffMs = ReconnectBackoff.BASE_MS
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -85,13 +95,30 @@ class EventsStream @Inject constructor(
|
|||||||
.collect { isSignedIn ->
|
.collect { isSignedIn ->
|
||||||
signedIn = isSignedIn
|
signedIn = isSignedIn
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
backoffMs = BASE_BACKOFF_MS
|
backoffMs = ReconnectBackoff.BASE_MS
|
||||||
connect()
|
connect()
|
||||||
} else {
|
} else {
|
||||||
disconnect()
|
disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
connectivity.online.collect { up -> if (up) reconnectNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts a pending backoff short: reconnects at once and starts the ladder
|
||||||
|
* over. For a network that has just come up, or the app coming to the
|
||||||
|
* foreground, where waiting out a five-minute backoff would leave the
|
||||||
|
* server unheard from for nothing. Does nothing while a stream is open or
|
||||||
|
* opening.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun reconnectNow() {
|
||||||
|
if (!signedIn || reconnectJob?.isActive != true) return
|
||||||
|
backoffMs = ReconnectBackoff.BASE_MS
|
||||||
|
connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -106,20 +133,22 @@ class EventsStream @Inject constructor(
|
|||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
currentSource?.cancel()
|
currentSource?.cancel()
|
||||||
currentSource = null
|
currentSource = null
|
||||||
|
connectedState.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a reconnect after the current backoff, then double it
|
* Schedule a reconnect after the current backoff, jittered, then
|
||||||
* (capped). No-op when signed out — sign-out's [disconnect]
|
* double it (capped). No-op when signed out — sign-out's [disconnect]
|
||||||
* cancels the pending job. A successful [Listener.onOpen] resets
|
* cancels the pending job. A successful [Listener.onOpen] resets
|
||||||
* the backoff to the floor.
|
* the backoff to the floor.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun scheduleReconnect() {
|
private fun scheduleReconnect() {
|
||||||
|
connectedState.value = false
|
||||||
if (!signedIn) return
|
if (!signedIn) return
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
val waitMs = backoffMs
|
val waitMs = ReconnectBackoff.jittered(backoffMs)
|
||||||
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
|
backoffMs = ReconnectBackoff.next(backoffMs)
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(waitMs)
|
delay(waitMs)
|
||||||
if (signedIn) connect()
|
if (signedIn) connect()
|
||||||
@@ -136,7 +165,8 @@ class EventsStream @Inject constructor(
|
|||||||
|
|
||||||
private inner class Listener : EventSourceListener() {
|
private inner class Listener : EventSourceListener() {
|
||||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||||
backoffMs = BASE_BACKOFF_MS
|
backoffMs = ReconnectBackoff.BASE_MS
|
||||||
|
connectedState.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvent(
|
override fun onEvent(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -30,6 +31,7 @@ import javax.inject.Singleton
|
|||||||
class LiveEventsDispatcher @Inject constructor(
|
class LiveEventsDispatcher @Inject constructor(
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) : DefaultLifecycleObserver {
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
"artist.liked",
|
"artist.liked",
|
||||||
"artist.unliked",
|
"artist.unliked",
|
||||||
-> refreshLikes()
|
-> refreshLikes()
|
||||||
|
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||||
|
"notification.created" -> refreshNotifications()
|
||||||
}
|
}
|
||||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||||
@@ -58,9 +62,18 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
// App returned to the foreground. SSE will catch up but might
|
// App returned to the foreground. SSE will catch up but might
|
||||||
// not have reconnected yet; flush the cross-screen refreshes
|
// not have reconnected yet (it may be deep in its backoff, so it
|
||||||
|
// is told to try now); flush the cross-screen refreshes
|
||||||
// defensively.
|
// defensively.
|
||||||
|
eventsStream.reconnectNow()
|
||||||
refreshLikes()
|
refreshLikes()
|
||||||
|
refreshNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNotifications() {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { notifications.refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshLikes() {
|
private fun refreshLikes() {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.fabledsword.minstrel.events
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long [EventsStream] waits before reconnecting (M489 #5347).
|
||||||
|
*
|
||||||
|
* The stream is wanted around the clock once notifications arrive with the app
|
||||||
|
* closed, so a server that is down overnight must not cost a radio wakeup
|
||||||
|
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
|
||||||
|
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
|
||||||
|
* phone on the server reconnects, which is when the server has just come back
|
||||||
|
* and can least take a spike.
|
||||||
|
*
|
||||||
|
* A network coming up (a hint, never a gate) or the app coming to the
|
||||||
|
* foreground reconnects at once and starts the ladder over.
|
||||||
|
*/
|
||||||
|
internal object ReconnectBackoff {
|
||||||
|
const val BASE_MS = 2_000L
|
||||||
|
const val MAX_MS = 300_000L
|
||||||
|
private const val JITTER = 0.25
|
||||||
|
|
||||||
|
/** The wait after [currentMs], before jitter. */
|
||||||
|
fun next(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MAX_MS)
|
||||||
|
|
||||||
|
/** [ms] moved by up to a quarter either way. */
|
||||||
|
fun jittered(ms: Long, random: Random = Random.Default): Long {
|
||||||
|
val spread = ms * JITTER
|
||||||
|
return (ms + random.nextDouble(-spread, spread)).toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ import com.fabledsword.minstrel.home.ui.HomeScreen
|
|||||||
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
||||||
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
|
import com.fabledsword.minstrel.notifications.ui.NotificationSettingsScreen
|
||||||
|
import com.fabledsword.minstrel.notifications.ui.NotificationsScreen
|
||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||||
import com.fabledsword.minstrel.player.ui.QueueScreen
|
import com.fabledsword.minstrel.player.ui.QueueScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||||
@@ -59,6 +61,7 @@ fun MinstrelNavGraph(
|
|||||||
) {
|
) {
|
||||||
inShellTopLevel(navController, expandPlayer)
|
inShellTopLevel(navController, expandPlayer)
|
||||||
inShellDetail(navController, expandPlayer)
|
inShellDetail(navController, expandPlayer)
|
||||||
|
inShellNotifications(navController, expandPlayer)
|
||||||
outsideShell(navController)
|
outsideShell(navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +203,27 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notifications inbox and its settings (M489). */
|
||||||
|
private fun NavGraphBuilder.inShellNotifications(
|
||||||
|
navController: NavHostController,
|
||||||
|
expandPlayer: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable<Notifications> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
NotificationsScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable<NotificationSettings> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
NotificationSettingsScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
composable<NowPlaying>(
|
composable<NowPlaying>(
|
||||||
// Slide up from the bottom on enter; back down on dismiss.
|
// Slide up from the bottom on enter; back down on dismiss.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object Settings
|
@Serializable data object Settings
|
||||||
@Serializable data object Admin
|
@Serializable data object Admin
|
||||||
@Serializable data object Requests
|
@Serializable data object Requests
|
||||||
|
@Serializable data object Notifications
|
||||||
|
@Serializable data object NotificationSettings
|
||||||
|
|
||||||
// ── In-shell detail / push-on-top destinations ────────────────────────
|
// ── In-shell detail / push-on-top destinations ────────────────────────
|
||||||
|
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.notificationSettingChange
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** A notification channel, as the server names it. */
|
||||||
|
enum class NotificationChannel(val wire: String) { INBOX("inbox"), PHONE("phone"), EMAIL("email") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user notification settings (M489), following snippet #5107: the device
|
||||||
|
* copy (a cached JSON row) shows a change at once, the PUT is best effort,
|
||||||
|
* and a failed or out-of-order one is queued for the MutationReplayer.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationSettingsRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
private val dao: CachedNotificationDao,
|
||||||
|
private val mutationDao: CachedMutationDao,
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
private val api: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
|
val settings: Flow<NotificationSettingsWire?> = dao.observeSettings().map { it?.let(::decode) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the server's settings unless a change made here is still queued:
|
||||||
|
* the server has not seen it, and its older value would undo the change
|
||||||
|
* on screen until the replay lands. Throws on a network failure.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() {
|
||||||
|
val server = api.getSettings()
|
||||||
|
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) return
|
||||||
|
save(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||||
|
current()?.let { save(it.withChannel(kind, channel, value)) }
|
||||||
|
val payload = NotificationSettingPayload(kind = kind, channel = channel.wire, value = value)
|
||||||
|
// An earlier change still queued would replay after this PUT; queue
|
||||||
|
// behind it instead, and the replayer sends the newest per channel.
|
||||||
|
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) {
|
||||||
|
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val change = notificationSettingChange(payload) ?: return
|
||||||
|
try {
|
||||||
|
save(api.putSettings(PutNotificationSettingsBody(listOf(change))))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notification settings: PUT failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun current(): NotificationSettingsWire? = dao.getSettings()?.let(::decode)
|
||||||
|
|
||||||
|
/** Null for an unreadable row: the next refresh writes a good one. */
|
||||||
|
private fun decode(row: CachedNotificationSettingsEntity): NotificationSettingsWire? =
|
||||||
|
runCatching { json.decodeFromString(NotificationSettingsWire.serializer(), row.json) }.getOrNull()
|
||||||
|
|
||||||
|
private suspend fun save(s: NotificationSettingsWire) {
|
||||||
|
val encoded = json.encodeToString(NotificationSettingsWire.serializer(), s)
|
||||||
|
dao.upsertSettings(CachedNotificationSettingsEntity(json = encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun NotificationSettingsWire.withChannel(
|
||||||
|
kind: String,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
value: Boolean,
|
||||||
|
): NotificationSettingsWire = copy(
|
||||||
|
kinds = kinds.map { k ->
|
||||||
|
if (k.kind != kind) {
|
||||||
|
k
|
||||||
|
} else {
|
||||||
|
when (channel) {
|
||||||
|
NotificationChannel.INBOX -> k.copy(inbox = value)
|
||||||
|
NotificationChannel.PHONE -> k.copy(phone = value)
|
||||||
|
NotificationChannel.EMAIL -> k.copy(email = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox (M489). The newest page lives in Room so the bell's
|
||||||
|
* badge and the Notifications screen work offline. Reads follow rule 100: the
|
||||||
|
* device marks the row at once, the call is best effort, and a failed call is
|
||||||
|
* queued for the MutationReplayer.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationsRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
private val dao: CachedNotificationDao,
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
) {
|
||||||
|
private val api: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
|
val notifications: Flow<List<CachedNotificationEntity>> = dao.observeAll().distinctUntilChanged()
|
||||||
|
|
||||||
|
/** Unread notices in the cached page, which is what the badge shows. */
|
||||||
|
val unreadCount: Flow<Int> = dao.observeUnreadCount().distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the cache with the server's newest page. A read made on this
|
||||||
|
* device and not yet replayed stays read: a read never goes back to
|
||||||
|
* unread, so the device's mark wins over the server's older view.
|
||||||
|
* Throws on a network failure; the cache then stands.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() {
|
||||||
|
val page = api.list(limit = PAGE_SIZE)
|
||||||
|
val localReads = dao.getAll().associate { it.id to it.readAt }
|
||||||
|
dao.replaceAll(page.items.mapNotNull { it.toEntity(localReads[it.id]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markRead(id: String) {
|
||||||
|
dao.markRead(id, Clock.System.now())
|
||||||
|
try {
|
||||||
|
api.markRead(id)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// A 4xx (404: trimmed or gone server-side) leaves nothing to mark;
|
||||||
|
// a 5xx is the server's trouble, so the read waits in the queue.
|
||||||
|
if (e.code() >= HTTP_SERVER_ERROR) {
|
||||||
|
mutationQueue.enqueueNotificationRead(id)
|
||||||
|
} else {
|
||||||
|
Timber.i(e, "notifications: mark read refused (%d)", e.code())
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notifications: mark read failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationRead(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks everything the device holds read. The server is asked to mark
|
||||||
|
* only what existed up to the newest notice shown here, so a replay that
|
||||||
|
* lands later leaves anything newer unread.
|
||||||
|
*/
|
||||||
|
suspend fun markAllRead() {
|
||||||
|
val upTo = readAllCutoff(dao.getAll().map { it.createdAt })?.toString()
|
||||||
|
dao.markAllRead(Clock.System.now())
|
||||||
|
try {
|
||||||
|
api.readAll(ReadAllBody(upTo = upTo))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notifications: mark all read failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationsReadAll(upTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page the device holds, newest first. */
|
||||||
|
suspend fun cachedPage(): List<CachedNotificationEntity> = dao.getAll()
|
||||||
|
|
||||||
|
/** On sign-out: the next account on this device must not see these. */
|
||||||
|
suspend fun clearLocal() {
|
||||||
|
dao.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The newest page the device keeps; older notices stay on the server. */
|
||||||
|
const val PAGE_SIZE = 50
|
||||||
|
|
||||||
|
private const val HTTP_SERVER_ERROR = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest notice shown, rounded up a millisecond. Room keeps milliseconds
|
||||||
|
* and the server microseconds, so the stored time can sit just before the
|
||||||
|
* server's; without rounding up, the newest notice would stay unread.
|
||||||
|
*/
|
||||||
|
internal fun readAllCutoff(createdAts: List<Instant>): Instant? =
|
||||||
|
createdAts.maxOrNull()?.plus(1.milliseconds)
|
||||||
|
|
||||||
|
/** Null for a row whose timestamp will not parse, rather than failing the page. */
|
||||||
|
internal fun NotificationWire.toEntity(localReadAt: Instant?): CachedNotificationEntity? {
|
||||||
|
val created = runCatching { Instant.parse(createdAt) }.getOrNull() ?: return null
|
||||||
|
val serverRead = readAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
|
return CachedNotificationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = kind,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
link = link,
|
||||||
|
createdAt = created,
|
||||||
|
readAt = serverRead ?: localReadAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts background delivery after a reboot, and after the app updates itself
|
||||||
|
* (M489 #5347). Without it, notifications stop until the user next opens the
|
||||||
|
* app, which is exactly when they least need telling.
|
||||||
|
*
|
||||||
|
* Both broadcasts are among the few exemptions allowed to start a foreground
|
||||||
|
* service from the background, so the start happens here, inside the
|
||||||
|
* receiver's window, rather than whenever [DeliveryLauncher] next observes it.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var authStore: AuthStore
|
||||||
|
|
||||||
|
@Inject @ApplicationScope lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||||
|
val pending = goAsync()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Bounded (rule 156): the session load gives up after its
|
||||||
|
// own deadline and answers from what it has.
|
||||||
|
authStore.awaitSessionHydrated()
|
||||||
|
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||||
|
if (deliveryWanted(signedIn, authStore.backgroundDelivery.value)) {
|
||||||
|
DeliveryService.start(context)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone notifications (M489 #5347). Activated by force-@Inject in
|
||||||
|
* MinstrelApplication, and the single owner of [DeliveryService]'s lifetime:
|
||||||
|
* it runs exactly while signed in with "Notifications when the app is closed"
|
||||||
|
* on. Signing out stops it, so a logged-out device holds no connection.
|
||||||
|
*
|
||||||
|
* The catch-up runs here, in the process, whether or not the service does:
|
||||||
|
* with background delivery off, notifications still reach the shade while the
|
||||||
|
* process is alive (the app open, or music playing).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DeliveryLauncher @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
authStore: AuthStore,
|
||||||
|
eventsStream: EventsStream,
|
||||||
|
sync: NotificationSync,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }.distinctUntilChanged()
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { wanted -> if (wanted) DeliveryService.start(context) else DeliveryService.stop(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nudge and a reconnect mean the same thing: go and look. The
|
||||||
|
// stream only runs while signed in, so neither fires signed out.
|
||||||
|
scope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind == NOTIFICATION_CREATED }
|
||||||
|
.collect { sync.catchUp() }
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
eventsStream.connected.filter { it }.collect { sync.catchUp() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sign-out, not the signed-out start a cold launch begins with
|
||||||
|
// before the session loads: that would wipe the mark on every start.
|
||||||
|
scope.launch {
|
||||||
|
var wasSignedIn = false
|
||||||
|
signedIn.collect { now ->
|
||||||
|
if (wasSignedIn && !now) sync.forget()
|
||||||
|
wasSignedIn = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NOTIFICATION_CREATED = "notification.created"
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decisions behind phone notifications (M489 #5347), kept free of Android
|
||||||
|
* so they can be tested as plain functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A handful each get a line; more than this become one line with a count. */
|
||||||
|
internal const val INDIVIDUAL_LIMIT = 3
|
||||||
|
|
||||||
|
/** What a catch-up announces, and the high-water mark to keep afterwards. */
|
||||||
|
internal data class CatchUp(
|
||||||
|
val announce: List<CachedNotificationEntity>,
|
||||||
|
val upTo: Instant?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks what to announce from the newest page.
|
||||||
|
*
|
||||||
|
* - With no mark yet (a fresh sign-in on this device), the mark is set to the
|
||||||
|
* newest notice and nothing is announced: the user is looking at the app,
|
||||||
|
* and a backlog in the shade is noise. An empty inbox sets the mark to the
|
||||||
|
* start of time, so the very first notice is announced.
|
||||||
|
* - Otherwise: unread notices newer than the mark, whose kind has the phone
|
||||||
|
* on, oldest first so the newest lands on top of the shade.
|
||||||
|
* - The mark moves to the newest notice in the page, announced or not, so a
|
||||||
|
* kind with the phone off is never announced later.
|
||||||
|
*
|
||||||
|
* A coalesced notice (tracks missing, now 5) moves its time forward when its
|
||||||
|
* count grows, so it is announced again with the new count.
|
||||||
|
*/
|
||||||
|
internal fun planCatchUp(
|
||||||
|
page: List<CachedNotificationEntity>,
|
||||||
|
mark: Instant?,
|
||||||
|
phoneOn: (String) -> Boolean,
|
||||||
|
): CatchUp {
|
||||||
|
val newest = page.maxOfOrNull { it.createdAt }
|
||||||
|
if (mark == null) return CatchUp(emptyList(), newest ?: Instant.DISTANT_PAST)
|
||||||
|
val fresh = page
|
||||||
|
.filter { it.readAt == null && it.createdAt > mark && phoneOn(it.kind) }
|
||||||
|
.sortedBy { it.createdAt }
|
||||||
|
val upTo = if (newest != null && newest > mark) newest else mark
|
||||||
|
return CatchUp(fresh, upTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a catch-up reaches the shade. */
|
||||||
|
internal sealed interface Announcement {
|
||||||
|
data class Each(val items: List<CachedNotificationEntity>) : Announcement
|
||||||
|
|
||||||
|
/** Coming back from a day offline is one line, not forty buzzes. */
|
||||||
|
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
|
||||||
|
fresh.isEmpty() -> null
|
||||||
|
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
|
||||||
|
// A pile goes where its notices would: library health only when every
|
||||||
|
// one of them is, so a listener's own news is never filed under it.
|
||||||
|
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
|
||||||
|
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
|
||||||
|
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
|
||||||
|
internal fun phoneOnFor(settings: NotificationSettingsWire?): (String) -> Boolean {
|
||||||
|
val byKind = settings?.kinds?.associateBy { it.kind }.orEmpty()
|
||||||
|
return { kind -> byKind[kind]?.let { it.inbox && it.phone } ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android channels, so either audience can be silenced in system settings as
|
||||||
|
* well as in Minstrel's. The ids are permanent: renaming one orphans the
|
||||||
|
* user's system-level choice for it.
|
||||||
|
*/
|
||||||
|
internal enum class ShadeChannel(val id: String, val title: String, val description: String) {
|
||||||
|
YOUR_REQUESTS("your_requests", "Your requests", "Approved, declined, and new music arriving"),
|
||||||
|
LIBRARY_HEALTH("library_health", "Library health", "For admins: requests to review and library problems"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val requesterKinds = setOf("request_approved", "request_rejected", "request_completed")
|
||||||
|
|
||||||
|
/** Everything a listener can receive is theirs; the rest is admin work. */
|
||||||
|
internal fun shadeChannelFor(kind: String): ShadeChannel =
|
||||||
|
if (kind in requesterKinds) ShadeChannel.YOUR_REQUESTS else ShadeChannel.LIBRARY_HEALTH
|
||||||
|
|
||||||
|
/** The delivery service runs exactly while signed in with background delivery on. */
|
||||||
|
internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = signedIn && enabled
|
||||||
|
|
||||||
|
/** One line for a pile. */
|
||||||
|
internal fun pileText(count: Int): String = "$count new notifications"
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import com.fabledsword.minstrel.MainActivity
|
||||||
|
import com.fabledsword.minstrel.R
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps Minstrel's process alive so notifications arrive with the app closed
|
||||||
|
* (M489 #5347, Roundtable's DeliveryService).
|
||||||
|
*
|
||||||
|
* **Why a held connection instead of push.** The APK installs from the
|
||||||
|
* Minstrel server itself, not the Play Store, so nothing pushes the app onto
|
||||||
|
* Firebase; the alternatives cost more (UnifiedPush's only embedded
|
||||||
|
* distributor is Firebase, and ntfy is a second app to install and set up).
|
||||||
|
*
|
||||||
|
* **This service holds nothing itself.** [com.fabledsword.minstrel.events.EventsStream]
|
||||||
|
* stays connected for as long as the process lives and someone is signed in,
|
||||||
|
* and [DeliveryLauncher] runs the catch-up on every nudge and reconnect. All
|
||||||
|
* this does is keep the process from being reclaimed. While music plays the
|
||||||
|
* player's own foreground service does that too; this one still runs, so
|
||||||
|
* delivery does not depend on playback.
|
||||||
|
*
|
||||||
|
* **Typed `specialUse`, and that is not arbitrary.** Android 15 stops a
|
||||||
|
* `dataSync` service after six hours, and `shortService` is capped at three
|
||||||
|
* minutes. `specialUse` is the only type that may run as long as the user
|
||||||
|
* wants it to.
|
||||||
|
*
|
||||||
|
* The quiet notice in the shade is the honest price of not using Google's
|
||||||
|
* push, and the settings screen says so.
|
||||||
|
*/
|
||||||
|
class DeliveryService : Service() {
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ensureChannel()
|
||||||
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, ongoing(), type)
|
||||||
|
// If the system reclaims the process, bring it back: the point is to
|
||||||
|
// be running when nothing else is.
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ongoing(): Notification {
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("Minstrel")
|
||||||
|
.setContentText("Listening for notifications")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(open)
|
||||||
|
// MIN: at the bottom of the shade, no sound, no heads-up. It has
|
||||||
|
// to exist; it does not have to be loud.
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "Background connection", NotificationManager.IMPORTANCE_MIN)
|
||||||
|
.apply { description = "The quiet notice shown while Minstrel listens for notifications" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_STOP = "com.fabledsword.minstrel.DELIVERY_STOP"
|
||||||
|
private const val CHANNEL_ID = "background_connection"
|
||||||
|
private const val NOTIFICATION_ID = 4801
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starting a foreground service from the background is refused on
|
||||||
|
* Android 12+ outside the exemptions (boot, an update, the app on
|
||||||
|
* screen). A refusal is logged, not thrown: the app's next start, or
|
||||||
|
* the next boot, tries again.
|
||||||
|
*/
|
||||||
|
fun start(context: Context) {
|
||||||
|
try {
|
||||||
|
context.startForegroundService(Intent(context, DeliveryService::class.java))
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "delivery service: start refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
try {
|
||||||
|
context.startService(Intent(context, DeliveryService::class.java).setAction(ACTION_STOP))
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
|
||||||
|
) {
|
||||||
|
// Not running and not startable from here: nothing to stop.
|
||||||
|
Timber.i(e, "delivery service: stop not delivered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.fabledsword.minstrel.MainActivity
|
||||||
|
import com.fabledsword.minstrel.R
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts an [Announcement] in the shade (M489 #5347). The words are the
|
||||||
|
* server's: the same title and body the inbox shows. Tapping one opens what it
|
||||||
|
* is about; tapping a pile opens the inbox.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationPoster @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
// Checked just below: posting is skipped when the user has said no.
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
internal fun post(announcement: Announcement) {
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
// Denied POST_NOTIFICATIONS, or every channel silenced: say nothing.
|
||||||
|
// The inbox still has it all.
|
||||||
|
if (!manager.areNotificationsEnabled()) return
|
||||||
|
ensureChannels()
|
||||||
|
when (announcement) {
|
||||||
|
is Announcement.Each -> announcement.items.forEach { manager.notify(it.id.hashCode(), single(it)) }
|
||||||
|
is Announcement.Pile -> manager.notify(PILE_ID, pile(announcement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun single(item: CachedNotificationEntity) =
|
||||||
|
NotificationCompat.Builder(context, shadeChannelFor(item.kind).id)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(item.title)
|
||||||
|
.setContentText(item.body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(item.body))
|
||||||
|
.setWhen(item.createdAt.toEpochMilliseconds())
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openIntent(item.link, item.id.hashCode()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun pile(p: Announcement.Pile) =
|
||||||
|
NotificationCompat.Builder(context, p.channel.id)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("Minstrel")
|
||||||
|
.setContentText(pileText(p.count))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Each notice gets its own request code, so their links stay apart. */
|
||||||
|
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
.putExtra(MainActivity.EXTRA_NOTIFICATION_LINK, link.orEmpty()),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ensureChannels() {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
ShadeChannel.entries.forEach { c ->
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(c.id, c.title, NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
.apply { description = c.description },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PILE_ID = 4802
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what the shade says by asking the server, never by trusting a frame
|
||||||
|
* (M489 #5347, Roundtable's NotificationSync).
|
||||||
|
*
|
||||||
|
* A `notification.created` frame and a reconnect both mean "go and look", so
|
||||||
|
* both run [catchUp]. The frame carries nothing, so nothing can be announced
|
||||||
|
* twice: the answer is the server's unread state and this device's
|
||||||
|
* high-water mark, which survive a reboot. A notice raised while the phone was
|
||||||
|
* unreachable is not lost either: the event bus replays nothing, but the row
|
||||||
|
* is still there, unread.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationSync @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
private val settings: NotificationSettingsRepository,
|
||||||
|
private val prefs: AuthSessionDao,
|
||||||
|
private val poster: NotificationPoster,
|
||||||
|
) {
|
||||||
|
// One catch-up at a time: a nudge arriving with a reconnect must not
|
||||||
|
// read the mark before the other has moved it.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announces what is new since the mark, then moves it. Failure is quiet on
|
||||||
|
* purpose: this runs on a reconnect, exactly when a request is most likely
|
||||||
|
* to lose a race with a network still settling, and the next trigger tries
|
||||||
|
* again. While the app is on screen nothing is posted (the bell shows it),
|
||||||
|
* but the mark still moves, so it is not announced later either.
|
||||||
|
*/
|
||||||
|
suspend fun catchUp() {
|
||||||
|
mutex.withLock { catchUpLocked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun catchUpLocked() {
|
||||||
|
try {
|
||||||
|
notifications.refresh()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notification catch-up: refresh failed; next trigger retries")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val plan = planCatchUp(
|
||||||
|
page = notifications.cachedPage(),
|
||||||
|
mark = prefs.get()?.notifiedUpTo,
|
||||||
|
phoneOn = phoneOnFor(settings.settings.first()),
|
||||||
|
)
|
||||||
|
if (!appOnScreen()) announcementFor(plan.announce)?.let(poster::post)
|
||||||
|
plan.upTo?.let { prefs.setNotifiedUpTo(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On sign-out: the next account starts with no mark, so it announces no backlog. */
|
||||||
|
suspend fun forget() {
|
||||||
|
mutex.withLock { prefs.setNotifiedUpTo(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun appOnScreen(): Boolean = withContext(Dispatchers.Main) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app screen for a notice's link. The server writes web paths, the same
|
||||||
|
* for every client; this maps them onto the app's routes. Admin pages the app
|
||||||
|
* has no screen for (missing files, duplicates, playback errors) open the
|
||||||
|
* Admin landing; anything unknown opens nothing.
|
||||||
|
*/
|
||||||
|
internal fun routeForLink(link: String): Any? {
|
||||||
|
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
|
||||||
|
return when {
|
||||||
|
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
|
||||||
|
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
|
||||||
|
parts == listOf("requests") -> Requests
|
||||||
|
parts == listOf("admin", "requests") -> AdminRequests
|
||||||
|
parts == listOf("admin", "quarantine") -> AdminQuarantine
|
||||||
|
parts.firstOrNull() == "admin" -> Admin
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private row composables
|
||||||
|
|
||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ChevronRight
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
|
||||||
|
private val CELL_WIDTH = 56.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user notification settings (M489): a row per kind with a box for each
|
||||||
|
* channel. Rows read as a menu (rule 188); anything that needs explaining
|
||||||
|
* is one short line.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationSettingsScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
|
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||||
|
val backgroundDelivery by viewModel.backgroundDelivery.collectAsStateWithLifecycle()
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Notifications",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = NotificationSettings::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
val s = settings
|
||||||
|
if (s == null) {
|
||||||
|
LoadingCentered(modifier = Modifier.padding(inner))
|
||||||
|
} else {
|
||||||
|
SettingsBody(
|
||||||
|
settings = s,
|
||||||
|
isAdmin = isAdmin,
|
||||||
|
deviceRows = { DeviceRows(backgroundDelivery, viewModel::setBackgroundDelivery) },
|
||||||
|
onSet = viewModel::set,
|
||||||
|
modifier = Modifier.padding(inner),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsBody(
|
||||||
|
settings: NotificationSettingsWire,
|
||||||
|
isAdmin: Boolean,
|
||||||
|
deviceRows: @Composable () -> Unit,
|
||||||
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
deviceRows()
|
||||||
|
HeaderRow()
|
||||||
|
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
|
val adminKinds = settings.kinds.filter { it.adminOnly }
|
||||||
|
if (adminKinds.isNotEmpty()) {
|
||||||
|
SectionLabel("Library health")
|
||||||
|
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
|
}
|
||||||
|
if (!settings.emailAvailable) {
|
||||||
|
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderRow() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
NotificationChannel.entries.forEach { c ->
|
||||||
|
Text(
|
||||||
|
text = channelLabel(c),
|
||||||
|
modifier = Modifier.width(CELL_WIDTH),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KindRow(
|
||||||
|
row: NotificationKindSettingWire,
|
||||||
|
emailAvailable: Boolean,
|
||||||
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val label = kindLabel(row.kind)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
NotificationChannel.entries.forEach { c ->
|
||||||
|
val enabled = channelEnabled(row, c, emailAvailable)
|
||||||
|
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
|
||||||
|
Checkbox(
|
||||||
|
checked = channelValue(row, c) && enabled,
|
||||||
|
onCheckedChange = { onSet(row.kind, c, it) },
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.semantics { contentDescription = "$label: ${channelLabel(c)}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this device does, above the per-kind grid that follows the account:
|
||||||
|
* whether notifications arrive with the app closed (M489 #5347), and whether
|
||||||
|
* the system lets Minstrel post them at all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boolean) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Re-read on every resume: the user may come back from system settings.
|
||||||
|
var phoneAllowed by remember { mutableStateOf(true) }
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
val askToNotify = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
phoneAllowed = granted
|
||||||
|
}
|
||||||
|
BackgroundDeliveryRow(
|
||||||
|
checked = backgroundDelivery,
|
||||||
|
onChange = { on ->
|
||||||
|
onBackgroundDelivery(on)
|
||||||
|
// Android 13+: turning it on is the moment to ask, if not yet allowed.
|
||||||
|
if (on && !phoneAllowed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
askToNotify.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!phoneAllowed) {
|
||||||
|
PhoneBlockedRow(onClick = { openAppNotificationSettings(context) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
// The ongoing notice is the price of not using Google's push;
|
||||||
|
// said plainly rather than left to be discovered.
|
||||||
|
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Muted("Allow notifications for Minstrel in system settings", padded = false)
|
||||||
|
}
|
||||||
|
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Muted(text: String, padded: Boolean = true) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppNotificationSettings(context: Context) {
|
||||||
|
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phone and email ride on the inbox; email also needs an address and SMTP. */
|
||||||
|
internal fun channelEnabled(
|
||||||
|
row: NotificationKindSettingWire,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
emailAvailable: Boolean,
|
||||||
|
): Boolean = when (channel) {
|
||||||
|
NotificationChannel.INBOX -> true
|
||||||
|
NotificationChannel.PHONE -> row.inbox
|
||||||
|
NotificationChannel.EMAIL -> row.inbox && emailAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun channelValue(row: NotificationKindSettingWire, channel: NotificationChannel): Boolean =
|
||||||
|
when (channel) {
|
||||||
|
NotificationChannel.INBOX -> row.inbox
|
||||||
|
NotificationChannel.PHONE -> row.phone
|
||||||
|
NotificationChannel.EMAIL -> row.email
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun channelLabel(c: NotificationChannel): String = when (c) {
|
||||||
|
NotificationChannel.INBOX -> "Inbox"
|
||||||
|
NotificationChannel.PHONE -> "Phone"
|
||||||
|
NotificationChannel.EMAIL -> "Email"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short row labels, the same as the web's. Unknown kinds show their key. */
|
||||||
|
internal fun kindLabel(kind: String): String = when (kind) {
|
||||||
|
"request_approved" -> "Request approved"
|
||||||
|
"request_rejected" -> "Request declined"
|
||||||
|
"request_completed" -> "New music arrived"
|
||||||
|
"request_pending" -> "Requests to review"
|
||||||
|
"quarantine_flagged" -> "Tracks flagged"
|
||||||
|
"scan_failed" -> "Library scan failed"
|
||||||
|
"tracks_missing" -> "Tracks gone missing"
|
||||||
|
"duplicates_found" -> "Duplicates to review"
|
||||||
|
"playback_errors" -> "Playback errors"
|
||||||
|
else -> kind
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun emailUnavailableLine(reason: String?, isAdmin: Boolean): String = when {
|
||||||
|
reason == "no_address" -> "Email is off: add an email address in your profile"
|
||||||
|
isAdmin -> "Email is off: SMTP isn't set up on the server"
|
||||||
|
else -> "Email is off: this server doesn't send email"
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
|
private val repository: NotificationSettingsRepository,
|
||||||
|
authController: AuthController,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
/** "Notifications when the app is closed": this device's choice (M489 #5347). */
|
||||||
|
val backgroundDelivery: StateFlow<Boolean> = authStore.backgroundDelivery
|
||||||
|
|
||||||
|
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||||
|
|
||||||
|
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||||
|
.map { it?.isAdmin == true }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Offline, the device's copy stands.
|
||||||
|
viewModelScope.launch { runCatching { repository.refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||||
|
viewModelScope.launch { repository.set(kind, channel, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBackgroundDelivery(on: Boolean) {
|
||||||
|
authStore.setBackgroundDelivery(on)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox (M489): one row per notice, its title on one line
|
||||||
|
* and how long ago. Tapping a row marks it read and opens what it is about.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: NotificationsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||||
|
val anyUnread = items?.any { it.readAt == null } == true
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Notifications",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = Notifications::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
actions = {
|
||||||
|
if (anyUnread) {
|
||||||
|
TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
|
val list = items
|
||||||
|
when {
|
||||||
|
list == null -> LoadingCentered()
|
||||||
|
list.isEmpty() -> EmptyState(
|
||||||
|
title = "Nothing waiting for you",
|
||||||
|
body = "Requests, new music and anything needing a look will gather here.",
|
||||||
|
icon = Lucide.Bell,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(list, key = { it.id }) { item ->
|
||||||
|
NotificationRow(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
viewModel.open(item)
|
||||||
|
routeForLink(item.link)?.let { navController.navigate(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) {
|
||||||
|
val unread = item.readAt == null
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
if (unread) contentDescription = "Unread: ${item.title}"
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.then(
|
||||||
|
if (unread) {
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (unread) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = ago(item.createdAt),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "just now", "12m", "5h", "3d": the same coarse steps as the web inbox. */
|
||||||
|
internal fun ago(at: Instant, now: Instant = Clock.System.now()): String {
|
||||||
|
val d = now - at
|
||||||
|
return when {
|
||||||
|
d >= 1.days -> "${d.inWholeDays}d"
|
||||||
|
d >= 1.hours -> "${d.inWholeHours}h"
|
||||||
|
d >= 1.minutes -> "${d.inWholeMinutes}m"
|
||||||
|
else -> "just now"
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationsViewModel @Inject constructor(
|
||||||
|
private val repository: NotificationsRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
) : ViewModel() {
|
||||||
|
val items: StateFlow<List<CachedNotificationEntity>?> = repository.notifications
|
||||||
|
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { it }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
// Back online: the cached page may be stale (#1245's recovery idiom).
|
||||||
|
viewModelScope.launch { networkStatus.recoveries().collect { refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline, the cached page stands. */
|
||||||
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
runCatching { repository.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(item: CachedNotificationEntity) {
|
||||||
|
if (item.readAt != null) return
|
||||||
|
viewModelScope.launch { repository.markRead(item.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAllRead() {
|
||||||
|
viewModelScope.launch { repository.markAllRead() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
import com.composables.icons.lucide.ChevronRight
|
import com.composables.icons.lucide.ChevronRight
|
||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.LogOut
|
import com.composables.icons.lucide.LogOut
|
||||||
@@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Shield
|
import com.composables.icons.lucide.Shield
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||||
import com.fabledsword.minstrel.nav.ServerUrl
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
@@ -98,6 +100,7 @@ fun SettingsScreen(
|
|||||||
themeMode = themeMode,
|
themeMode = themeMode,
|
||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
|
onNavToNotifications = { navController.navigate(NotificationSettings) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
@@ -121,6 +124,7 @@ private fun SettingsList(
|
|||||||
themeMode: ThemeMode,
|
themeMode: ThemeMode,
|
||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
|
onNavToNotifications: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
onToggleDiagnostics: (Boolean) -> Unit,
|
onToggleDiagnostics: (Boolean) -> Unit,
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
@@ -144,6 +148,12 @@ private fun SettingsList(
|
|||||||
subtitle = "Track what you've asked Minstrel to add",
|
subtitle = "Track what you've asked Minstrel to add",
|
||||||
onClick = onNavToRequests,
|
onClick = onNavToRequests,
|
||||||
)
|
)
|
||||||
|
NavTile(
|
||||||
|
icon = Lucide.Bell,
|
||||||
|
title = "Notifications",
|
||||||
|
subtitle = "What reaches you, and where",
|
||||||
|
onClick = onNavToNotifications,
|
||||||
|
)
|
||||||
if (state.isAdmin) {
|
if (state.isAdmin) {
|
||||||
NavTile(
|
NavTile(
|
||||||
icon = Lucide.Shield,
|
icon = Lucide.Shield,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -28,6 +29,7 @@ data class SettingsState(
|
|||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
transient.update { it.copy(isSigningOut = true) }
|
transient.update { it.copy(isSigningOut = true) }
|
||||||
authController.signOut()
|
authController.signOut()
|
||||||
|
// The inbox is this account's; the next one on the device must not see it.
|
||||||
|
runCatching { notifications.clearLocal() }
|
||||||
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AppBarActionsViewModel @Inject constructor(
|
class AppBarActionsViewModel @Inject constructor(
|
||||||
authController: AuthController,
|
authController: AuthController,
|
||||||
|
notifications: NotificationsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||||
.map { it?.isAdmin == true }
|
.map { it?.isAdmin == true }
|
||||||
@@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Unread notices for the bell's badge (M489). */
|
||||||
|
val unreadCount: StateFlow<Int> = notifications.unreadCount
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -1,10 +1,13 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
import com.composables.icons.lucide.House
|
import com.composables.icons.lucide.House
|
||||||
import com.composables.icons.lucide.LibraryBig
|
import com.composables.icons.lucide.LibraryBig
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
@@ -23,6 +27,7 @@ import com.fabledsword.minstrel.nav.Admin
|
|||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.nav.Home
|
import com.fabledsword.minstrel.nav.Home
|
||||||
import com.fabledsword.minstrel.nav.Library
|
import com.fabledsword.minstrel.nav.Library
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
import com.fabledsword.minstrel.nav.Playlists
|
import com.fabledsword.minstrel.nav.Playlists
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||||
@@ -50,6 +55,7 @@ fun MainAppBarActions(
|
|||||||
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||||
|
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||||
Row {
|
Row {
|
||||||
if (currentRouteName != Home::class.qualifiedName) {
|
if (currentRouteName != Home::class.qualifiedName) {
|
||||||
IconButton(onClick = { navController.navigate(Home) }) {
|
IconButton(onClick = { navController.navigate(Home) }) {
|
||||||
@@ -66,10 +72,47 @@ fun MainAppBarActions(
|
|||||||
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (currentRouteName != Notifications::class.qualifiedName) {
|
||||||
|
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
|
||||||
|
}
|
||||||
OverflowMenu(navController = navController, isAdmin = isAdmin)
|
OverflowMenu(navController = navController, isAdmin = isAdmin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The inbox bell with its unread badge (M489): the count to 9, then "9+". */
|
||||||
|
@Composable
|
||||||
|
private fun NotificationsBell(unread: Int, onClick: () -> Unit) {
|
||||||
|
val label = badgeLabel(unread)
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
// Inverse of the bar, not error red: an unread count is news, not a
|
||||||
|
// fault, and the house style keeps the accent off general chrome.
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (label.isNotEmpty()) {
|
||||||
|
Badge(
|
||||||
|
containerColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.surface,
|
||||||
|
) { Text(label) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Bell,
|
||||||
|
contentDescription = if (unread > 0) "Notifications, $unread unread" else "Notifications",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge text: nothing at zero, the count up to nine, then "9+". */
|
||||||
|
internal fun badgeLabel(count: Int): String = when {
|
||||||
|
count <= 0 -> ""
|
||||||
|
count > MAX_BADGE -> "$MAX_BADGE+"
|
||||||
|
else -> count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_BADGE = 9
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Status-bar icon for Minstrel's own notifications (M489 #5347): Lucide's
|
||||||
|
bell, stroked in white. The system tints it; only the alpha is used. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M6,8a6,6 0,0 1,12 0c0,7 3,9 3,9H3s3,-2 3,-9"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M10.3,21a1.94,1.94 0,0 0,3.4 0"
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
Vendored
+31
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
@@ -153,4 +154,34 @@ class SupersededToggleIdsTest {
|
|||||||
)
|
)
|
||||||
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun settingRow(id: Long, kind: String, channel: String, value: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.NOTIFICATION_SETTING_SET,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
NotificationSettingPayload.serializer(),
|
||||||
|
NotificationSettingPayload(kind, channel, value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notification settings collapse per kind and channel, never across them`() {
|
||||||
|
val rows = listOf(
|
||||||
|
settingRow(1, "request_completed", "email", false),
|
||||||
|
settingRow(2, "request_completed", "phone", false),
|
||||||
|
settingRow(3, "request_completed", "email", true),
|
||||||
|
settingRow(4, "request_approved", "email", false),
|
||||||
|
)
|
||||||
|
// Only the older email toggle for request_completed is superseded.
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a queued setting becomes a one-channel change, and an unknown channel none`() {
|
||||||
|
assertEquals(
|
||||||
|
NotificationSettingChangeWire(kind = "k", phone = true),
|
||||||
|
notificationSettingChange(NotificationSettingPayload("k", "phone", true)),
|
||||||
|
)
|
||||||
|
assertEquals(null, notificationSettingChange(NotificationSettingPayload("k", "pager", true)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.fabledsword.minstrel.events
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ReconnectBackoffTest {
|
||||||
|
@Test
|
||||||
|
fun `doubles from two seconds to a five minute cap`() {
|
||||||
|
val ladder = generateSequence(ReconnectBackoff.BASE_MS) { ReconnectBackoff.next(it) }.take(10).toList()
|
||||||
|
assertEquals(listOf(2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 64_000L, 128_000L, 256_000L), ladder.take(8))
|
||||||
|
assertEquals(300_000L, ladder[8])
|
||||||
|
assertEquals(300_000L, ladder[9])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `jitter stays within a quarter either way and does spread`() {
|
||||||
|
val random = Random(42)
|
||||||
|
val waits = List(1_000) { ReconnectBackoff.jittered(100_000L, random) }
|
||||||
|
assertTrue(waits.all { it in 75_000L..125_000L }, "out of bounds: ${waits.minOrNull()}..${waits.maxOrNull()}")
|
||||||
|
assertTrue(waits.toSet().size > 100, "jitter should spread reconnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
/** Settings follow snippet #5107: shown at once, never overwritten or reordered by an older change. */
|
||||||
|
class NotificationSettingsRepositoryTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val api: NotificationsApi = mockk(relaxed = true)
|
||||||
|
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||||
|
private val mutationDao: CachedMutationDao = mockk()
|
||||||
|
private val queue: MutationQueue = mockk(relaxed = true)
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(NotificationsApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val repo = NotificationSettingsRepository(retrofit, dao, mutationDao, queue, json)
|
||||||
|
|
||||||
|
private val settings = NotificationSettingsWire(
|
||||||
|
kinds = listOf(
|
||||||
|
NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true),
|
||||||
|
),
|
||||||
|
emailAvailable = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cached() {
|
||||||
|
coEvery { dao.getSettings() } returns CachedNotificationSettingsEntity(
|
||||||
|
json = json.encodeToString(NotificationSettingsWire.serializer(), settings),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle is shown at once and sends only that channel`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||||
|
val saved = mutableListOf<CachedNotificationSettingsEntity>()
|
||||||
|
coEvery { dao.upsertSettings(capture(saved)) } returns Unit
|
||||||
|
val body = slot<PutNotificationSettingsBody>()
|
||||||
|
coEvery { api.putSettings(capture(body)) } returns settings
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.EMAIL, false)
|
||||||
|
|
||||||
|
val optimistic = json.decodeFromString(NotificationSettingsWire.serializer(), saved.first().json)
|
||||||
|
assertFalse(optimistic.kinds.single().email)
|
||||||
|
assertEquals(
|
||||||
|
listOf(NotificationSettingChangeWire(kind = "request_completed", email = false)),
|
||||||
|
body.captured.kinds,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationSettingSet(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle that cannot reach the server is queued`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||||
|
coEvery { api.putSettings(any()) } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.PHONE, false)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "phone", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle behind a queued one queues too`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.INBOX, false)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { api.putSettings(any()) }
|
||||||
|
coVerify {
|
||||||
|
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "inbox", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh leaves a queued change on screen`() = runTest {
|
||||||
|
coEvery { api.getSettings() } returns settings
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { dao.upsertSettings(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsPageWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads are offline-first (rule 100): the device marks the row at once, and a
|
||||||
|
* read the server has not taken is queued, except when the server says the
|
||||||
|
* notice is gone.
|
||||||
|
*/
|
||||||
|
class NotificationsRepositoryTest {
|
||||||
|
private val api: NotificationsApi = mockk(relaxed = true)
|
||||||
|
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||||
|
private val queue: MutationQueue = mockk(relaxed = true)
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(NotificationsApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val repo = NotificationsRepository(retrofit, dao, queue)
|
||||||
|
|
||||||
|
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
|
||||||
|
|
||||||
|
private fun row(id: String, created: String, readAt: Instant? = null) = CachedNotificationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = "request_completed",
|
||||||
|
title = "t",
|
||||||
|
body = "b",
|
||||||
|
link = "/requests",
|
||||||
|
createdAt = Instant.parse(created),
|
||||||
|
readAt = readAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read is shown at once and sent`() = runTest {
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { dao.markRead("n1", any()) }
|
||||||
|
coVerify { api.markRead("n1") }
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read that cannot reach the server is queued`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { dao.markRead("n1", any()) }
|
||||||
|
coVerify { queue.enqueueNotificationRead("n1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a notice the server no longer has is not queued`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws httpError(404)
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a server error queues the read for later`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws httpError(503)
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { queue.enqueueNotificationRead("n1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mark all read sends the newest notice shown, rounded up, and queues it offline`() = runTest {
|
||||||
|
coEvery { dao.getAll() } returns listOf(
|
||||||
|
row("a", "2026-10-08T10:00:00.123Z"),
|
||||||
|
row("b", "2026-10-08T11:00:00.456Z"),
|
||||||
|
)
|
||||||
|
val body = slot<ReadAllBody>()
|
||||||
|
coEvery { api.readAll(capture(body)) } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.markAllRead()
|
||||||
|
|
||||||
|
coVerify { dao.markAllRead(any()) }
|
||||||
|
assertEquals("2026-10-08T11:00:00.457Z", body.captured.upTo)
|
||||||
|
coVerify { queue.enqueueNotificationsReadAll("2026-10-08T11:00:00.457Z") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh keeps a read made here that the server has not seen yet`() = runTest {
|
||||||
|
val readHere = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
coEvery { dao.getAll() } returns listOf(row("a", "2026-10-08T10:00:00Z", readAt = readHere))
|
||||||
|
coEvery { api.list(any()) } returns NotificationsPageWire(
|
||||||
|
items = listOf(
|
||||||
|
NotificationWire("a", "request_completed", "t", "b", "/requests", "2026-10-08T10:00:00Z", null),
|
||||||
|
NotificationWire("b", "request_completed", "t", "b", "/requests", "2026-10-08T11:00:00Z", null),
|
||||||
|
),
|
||||||
|
unreadCount = 2,
|
||||||
|
)
|
||||||
|
val saved = slot<List<CachedNotificationEntity>>()
|
||||||
|
coEvery { dao.replaceAll(capture(saved)) } returns Unit
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
val byId = saved.captured.associateBy { it.id }
|
||||||
|
assertEquals(readHere, byId.getValue("a").readAt)
|
||||||
|
assertNull(byId.getValue("b").readAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a notice with an unreadable timestamp is skipped, not the whole page`() {
|
||||||
|
val bad = NotificationWire("x", "k", "t", "b", "/", "yesterday", null)
|
||||||
|
assertNull(bad.toEntity(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no cutoff when nothing is shown`() {
|
||||||
|
assertNull(readAllCutoff(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DeliveryPlanTest {
|
||||||
|
private val t0 = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
|
||||||
|
private fun notice(
|
||||||
|
id: String,
|
||||||
|
minutes: Long,
|
||||||
|
kind: String = "request_approved",
|
||||||
|
read: Boolean = false,
|
||||||
|
) = CachedNotificationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = kind,
|
||||||
|
title = "t-$id",
|
||||||
|
body = "b-$id",
|
||||||
|
link = "/requests",
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(t0.toEpochMilliseconds() + minutes * 60_000),
|
||||||
|
readAt = if (read) t0 else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val allOn: (String) -> Boolean = { true }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a first look sets the mark to the newest and announces nothing`() {
|
||||||
|
val page = listOf(notice("b", 5), notice("a", 1))
|
||||||
|
val plan = planCatchUp(page, mark = null, phoneOn = allOn)
|
||||||
|
assertTrue(plan.announce.isEmpty())
|
||||||
|
assertEquals(page[0].createdAt, plan.upTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a first look at an empty inbox still lets the first notice through`() {
|
||||||
|
val first = planCatchUp(emptyList(), mark = null, phoneOn = allOn)
|
||||||
|
assertEquals(Instant.DISTANT_PAST, first.upTo)
|
||||||
|
val next = planCatchUp(listOf(notice("a", 1)), mark = first.upTo, phoneOn = allOn)
|
||||||
|
assertEquals(listOf("a"), next.announce.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `announces unread notices newer than the mark, oldest first, and moves the mark`() {
|
||||||
|
val page = listOf(notice("c", 9), notice("b", 6), notice("old", 2), notice("read", 7, read = true))
|
||||||
|
val plan = planCatchUp(page, mark = notice("m", 3).createdAt, phoneOn = allOn)
|
||||||
|
assertEquals(listOf("b", "c"), plan.announce.map { it.id })
|
||||||
|
assertEquals(page[0].createdAt, plan.upTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing new leaves the mark where it was`() {
|
||||||
|
val mark = notice("m", 10).createdAt
|
||||||
|
val plan = planCatchUp(listOf(notice("a", 1)), mark = mark, phoneOn = allOn)
|
||||||
|
assertTrue(plan.announce.isEmpty())
|
||||||
|
assertEquals(mark, plan.upTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a kind with the phone off is passed over, and not announced later either`() {
|
||||||
|
val page = listOf(notice("h", 5, kind = "tracks_missing"), notice("r", 4))
|
||||||
|
val plan = planCatchUp(page, mark = t0, phoneOn = { it != "tracks_missing" })
|
||||||
|
assertEquals(listOf("r"), plan.announce.map { it.id })
|
||||||
|
assertEquals(page[0].createdAt, plan.upTo, "the mark passes it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phone prefs come from the cached settings, with every kind on when none are cached`() {
|
||||||
|
val settings = NotificationSettingsWire(
|
||||||
|
kinds = listOf(
|
||||||
|
NotificationKindSettingWire("request_approved", false, inbox = true, phone = false, email = true),
|
||||||
|
NotificationKindSettingWire("request_completed", false, inbox = false, phone = true, email = true),
|
||||||
|
NotificationKindSettingWire("request_rejected", false, inbox = true, phone = true, email = true),
|
||||||
|
),
|
||||||
|
emailAvailable = true,
|
||||||
|
)
|
||||||
|
val phoneOn = phoneOnFor(settings)
|
||||||
|
assertFalse(phoneOn("request_approved"))
|
||||||
|
assertFalse(phoneOn("request_completed"), "phone rides on the inbox")
|
||||||
|
assertTrue(phoneOn("request_rejected"))
|
||||||
|
assertTrue(phoneOnFor(null)("tracks_missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three get a line each, more become one line with the count`() {
|
||||||
|
assertNull(announcementFor(emptyList()))
|
||||||
|
val three = (1..3).map { notice("n$it", it.toLong()) }
|
||||||
|
assertEquals(Announcement.Each(three), announcementFor(three))
|
||||||
|
|
||||||
|
val five = (1..5).map { notice("n$it", it.toLong()) }
|
||||||
|
val pile = assertIs<Announcement.Pile>(announcementFor(five))
|
||||||
|
assertEquals(5, pile.count)
|
||||||
|
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
|
||||||
|
assertEquals("5 new notifications", pileText(pile.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pile of admin notices goes to library health, a mixed one does not`() {
|
||||||
|
val admin = (1..4).map { notice("a$it", it.toLong(), kind = "tracks_missing") }
|
||||||
|
assertEquals(ShadeChannel.LIBRARY_HEALTH, assertIs<Announcement.Pile>(announcementFor(admin)).channel)
|
||||||
|
val mixed = admin + notice("r", 9)
|
||||||
|
assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs<Announcement.Pile>(announcementFor(mixed)).channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `listener kinds are their requests, the rest is library health`() {
|
||||||
|
listOf("request_approved", "request_rejected", "request_completed").forEach {
|
||||||
|
assertEquals(ShadeChannel.YOUR_REQUESTS, shadeChannelFor(it))
|
||||||
|
}
|
||||||
|
listOf("request_pending", "quarantine_flagged", "scan_failed", "tracks_missing").forEach {
|
||||||
|
assertEquals(ShadeChannel.LIBRARY_HEALTH, shadeChannelFor(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the service runs only signed in with background delivery on`() {
|
||||||
|
assertTrue(deliveryWanted(signedIn = true, enabled = true))
|
||||||
|
assertFalse(deliveryWanted(signedIn = true, enabled = false))
|
||||||
|
assertFalse(deliveryWanted(signedIn = false, enabled = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.badgeLabel
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class NotificationUiTest {
|
||||||
|
@Test
|
||||||
|
fun `server links open the matching screens`() {
|
||||||
|
assertEquals(AlbumDetail("al-1"), routeForLink("/albums/al-1"))
|
||||||
|
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
|
||||||
|
assertEquals(Requests, routeForLink("/requests"))
|
||||||
|
assertEquals(AdminRequests, routeForLink("/admin/requests"))
|
||||||
|
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
|
||||||
|
// Admin pages the app has no screen for open the Admin landing.
|
||||||
|
assertEquals(Admin, routeForLink("/admin/duplicates"))
|
||||||
|
assertEquals(Admin, routeForLink("/admin"))
|
||||||
|
assertNull(routeForLink("/somewhere-new"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the badge counts to nine, then 9+`() {
|
||||||
|
assertEquals("", badgeLabel(0))
|
||||||
|
assertEquals("1", badgeLabel(1))
|
||||||
|
assertEquals("9", badgeLabel(9))
|
||||||
|
assertEquals("9+", badgeLabel(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ages read in coarse steps`() {
|
||||||
|
val now = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
assertEquals("just now", ago(Instant.parse("2026-10-08T11:59:30Z"), now))
|
||||||
|
assertEquals("12m", ago(Instant.parse("2026-10-08T11:48:00Z"), now))
|
||||||
|
assertEquals("5h", ago(Instant.parse("2026-10-08T07:00:00Z"), now))
|
||||||
|
assertEquals("3d", ago(Instant.parse("2026-10-05T12:00:00Z"), now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phone and email ride on the inbox, and email needs to be available`() {
|
||||||
|
val on = NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true)
|
||||||
|
val off = on.copy(inbox = false)
|
||||||
|
assertTrue(channelEnabled(on, NotificationChannel.PHONE, emailAvailable = true))
|
||||||
|
assertFalse(channelEnabled(off, NotificationChannel.PHONE, emailAvailable = true))
|
||||||
|
assertFalse(channelEnabled(on, NotificationChannel.EMAIL, emailAvailable = false))
|
||||||
|
assertTrue(channelEnabled(off, NotificationChannel.INBOX, emailAvailable = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the email line says why it is off`() {
|
||||||
|
assertEquals("Email is off: add an email address in your profile", emailUnavailableLine("no_address", false))
|
||||||
|
assertEquals("Email is off: SMTP isn't set up on the server", emailUnavailableLine("smtp_not_configured", true))
|
||||||
|
assertEquals("Email is off: this server doesn't send email", emailUnavailableLine("smtp_not_configured", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-2
@@ -26,6 +26,8 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
@@ -313,7 +315,14 @@ func run() error {
|
|||||||
}
|
}
|
||||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||||
}
|
}
|
||||||
|
// The notifications inbox's one writer (M489), shared by every
|
||||||
|
// background producer started here. The API builds its own over the
|
||||||
|
// same pool and bus.
|
||||||
|
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
|
||||||
|
|
||||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
|
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
|
||||||
|
lidarrReconciler.SetNotifier(notifier)
|
||||||
|
library.SetNotifier(notifier)
|
||||||
go lidarrReconciler.Run(ctx)
|
go lidarrReconciler.Run(ctx)
|
||||||
|
|
||||||
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
||||||
@@ -331,12 +340,14 @@ func run() error {
|
|||||||
if reacqErr != nil {
|
if reacqErr != nil {
|
||||||
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
||||||
}
|
}
|
||||||
go reacquisition.NewSweeper(
|
reacqSweeper := reacquisition.NewSweeper(
|
||||||
pool,
|
pool,
|
||||||
reacqSettings,
|
reacqSettings,
|
||||||
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
|
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
|
||||||
logger.With("component", "reacquisition"),
|
logger.With("component", "reacquisition"),
|
||||||
).Run(ctx)
|
)
|
||||||
|
reacqSweeper.SetNotifier(notifier)
|
||||||
|
go reacqSweeper.Run(ctx)
|
||||||
|
|
||||||
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||||
// rows older than the configured retention so the change-log table
|
// rows older than the configured retention so the change-log table
|
||||||
@@ -345,6 +356,19 @@ func run() error {
|
|||||||
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
|
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
|
||||||
go libraryChangesCompactor.Run(ctx)
|
go libraryChangesCompactor.Run(ctx)
|
||||||
|
|
||||||
|
// Notifications inbox retention (M489). Daily: read rows go after 90
|
||||||
|
// days, anything at all after a year.
|
||||||
|
go notifications.NewRetention(pool, logger.With("component", "notifications_retention")).Run(ctx)
|
||||||
|
|
||||||
|
// Notifications by email (M489 #5346), grouped, never one per event: new
|
||||||
|
// music as a daily summary at a local hour, everything else batched an
|
||||||
|
// hour (admin-configurable) after the first item.
|
||||||
|
go notifications.NewDigest(
|
||||||
|
pool,
|
||||||
|
mailer.NewSMTPSender(pool, logger.With("component", "mailer")),
|
||||||
|
logger.With("component", "notification_digest"),
|
||||||
|
).Run(ctx)
|
||||||
|
|
||||||
// Per-user system-playlist scheduler (#392 Half B). Fires each
|
// Per-user system-playlist scheduler (#392 Half B). Fires each
|
||||||
// active user's daily build at 03:00 in their stored timezone.
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
|
|||||||
|
|
||||||
Nothing is published unless every check passes: the Go, integration, web and
|
Nothing is published unless every check passes: the Go, integration, web and
|
||||||
Android test suites, `govulncheck` against the toolchain that builds the
|
Android test suites, `govulncheck` against the toolchain that builds the
|
||||||
image, and `npm audit` on the packages that ship to the browser. See
|
image, and `npm audit` on every web dependency, build tooling included. See
|
||||||
`.gitea/workflows/release.yml`.
|
`.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
## Reporting a problem
|
## Reporting a problem
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notificationEmailBody is the wire shape for GET and PUT
|
||||||
|
// /api/admin/notification-email (M489 #5346): when the grouped emails go out.
|
||||||
|
type notificationEmailBody struct {
|
||||||
|
SummaryHour int32 `json:"summary_hour"`
|
||||||
|
BatchWindowMinutes int32 `json:"batch_window_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationEmailBodyOf(s notifications.EmailSettings) notificationEmailBody {
|
||||||
|
return notificationEmailBody{SummaryHour: s.SummaryHour, BatchWindowMinutes: s.BatchWindowMinutes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetNotificationEmail implements GET /api/admin/notification-email.
|
||||||
|
func (h *handlers) handleGetNotificationEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s, err := notifications.LoadEmailSettings(r.Context(), dbq.New(h.pool))
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin notification email: load failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, notificationEmailBodyOf(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateNotificationEmail implements PUT /api/admin/notification-email.
|
||||||
|
// A whole-row write; the digest reads it on its next tick.
|
||||||
|
func (h *handlers) handleUpdateNotificationEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req notificationEmailBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := notifications.SaveEmailSettings(r.Context(), dbq.New(h.pool), notifications.EmailSettings{
|
||||||
|
SummaryHour: req.SummaryHour,
|
||||||
|
BatchWindowMinutes: req.BatchWindowMinutes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, notifications.ErrEmailSettingOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin notification email: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, notificationEmailBodyOf(saved))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateNotificationEmail_Rejects(t *testing.T) {
|
||||||
|
// No pool: every case is refused before the database is reached.
|
||||||
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
body string
|
||||||
|
code string
|
||||||
|
mentions string
|
||||||
|
}{
|
||||||
|
"an hour past 23": {body: `{"summary_hour":24,"batch_window_minutes":60}`, code: "invalid_setting", mentions: "summary_hour"},
|
||||||
|
"a window under 15m": {body: `{"summary_hour":9,"batch_window_minutes":5}`, code: "invalid_setting", mentions: "batch_window_minutes"},
|
||||||
|
"a body missing both": {body: `{}`, code: "invalid_setting", mentions: "batch_window_minutes"},
|
||||||
|
"malformed JSON": {body: `{"summary_hour":`, code: "invalid_body"},
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleUpdateNotificationEmail(rec, httptest.NewRequest(
|
||||||
|
http.MethodPut, "/api/admin/notification-email", strings.NewReader(tc.body)))
|
||||||
|
body := rec.Body.String()
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(body, `"`+tc.code+`"`) || !strings.Contains(body, tc.mentions) {
|
||||||
|
t.Errorf("%s: status %d body %s; want 400 %s mentioning %q", name, rec.Code, body, tc.code, tc.mentions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validRequestStatuses is the set of allowed values for the ?status= param.
|
// validRequestStatuses is the set of allowed values for the ?status= param.
|
||||||
@@ -125,6 +126,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.notifyRequestDecided(r.Context(), notifications.KindRequestApproved, admin, row, "")
|
||||||
h.publishRequestStatusChanged(row)
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,7 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.notifyRequestDecided(r.Context(), notifications.KindRequestRejected, admin, row, body.Notes)
|
||||||
h.publishRequestStatusChanged(row)
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-14
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||||
@@ -61,6 +62,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
mailer: sender,
|
mailer: sender,
|
||||||
eventbus: bus,
|
eventbus: bus,
|
||||||
|
notifier: notifications.New(pool, bus, logger.With("component", "notifications")),
|
||||||
playlistScheduler: playlistScheduler,
|
playlistScheduler: playlistScheduler,
|
||||||
streamSecret: streamSecret,
|
streamSecret: streamSecret,
|
||||||
netSettings: netSettings,
|
netSettings: netSettings,
|
||||||
@@ -124,6 +126,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/me/sessions", h.handleListMySessions)
|
authed.Get("/me/sessions", h.handleListMySessions)
|
||||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||||
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
||||||
|
authed.Get("/me/notifications", h.handleListMyNotifications)
|
||||||
|
authed.Get("/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
|
||||||
|
authed.Post("/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
|
||||||
|
authed.Post("/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
|
||||||
|
authed.Get("/me/notification-settings", h.handleGetMyNotificationSettings)
|
||||||
|
authed.Put("/me/notification-settings", h.handlePutMyNotificationSettings)
|
||||||
|
|
||||||
authed.Get("/artists", h.handleListArtists)
|
authed.Get("/artists", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||||
@@ -292,6 +300,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
admin.Get("/notification-email", h.handleGetNotificationEmail)
|
||||||
|
admin.Put("/notification-email", h.handleUpdateNotificationEmail)
|
||||||
|
|
||||||
// Recommendation tuning lab (#1250): scoring-weight
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
// profiles + taste-build knobs, DB-backed, live effect.
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
@@ -327,20 +337,23 @@ type handlers struct {
|
|||||||
// librarySize memoises the track count that sizes the candidate pool
|
// librarySize memoises the track count that sizes the candidate pool
|
||||||
// (#3880). Held here rather than counted per request: the count is a
|
// (#3880). Held here rather than counted per request: the count is a
|
||||||
// full table scan, and library size only moves when a scan runs.
|
// full table scan, and library size only moves when a scan runs.
|
||||||
librarySize *recommendation.LibrarySize
|
librarySize *recommendation.LibrarySize
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
lidarrQuarantine *lidarrquarantine.Service
|
lidarrQuarantine *lidarrquarantine.Service
|
||||||
tracks *tracks.Service
|
tracks *tracks.Service
|
||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
tagSettings *tags.SettingsService
|
tagSettings *tags.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
mailer mailer.Sender
|
mailer mailer.Sender
|
||||||
eventbus *eventbus.Bus
|
eventbus *eventbus.Bus
|
||||||
|
// notifier writes the notifications inbox (M489). Nil-safe: a nil
|
||||||
|
// notifier records nothing, which is what most handler tests want.
|
||||||
|
notifier *notifications.Notifier
|
||||||
playlistScheduler *playlists.Scheduler
|
playlistScheduler *playlists.Scheduler
|
||||||
// reacqSettings is the DB-backed policy for auto re-acquisition of
|
// reacqSettings is the DB-backed policy for auto re-acquisition of
|
||||||
// missing files (milestone #290) — grace window, backoff, attempt caps.
|
// missing files (milestone #290) — grace window, backoff, attempt caps.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
@@ -72,7 +73,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The notifications inbox (M489). Rows are written by internal/notifications;
|
||||||
|
// this surface lists them, counts the unread, marks them read, and holds each
|
||||||
|
// user's per-kind settings.
|
||||||
|
|
||||||
|
const (
|
||||||
|
notificationsDefaultLimit = 30
|
||||||
|
notificationsMaxLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// notificationResp is one inbox row, rendered server-side so every client
|
||||||
|
// says the same thing (notifications.Render).
|
||||||
|
type notificationResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ReadAt *time.Time `json:"read_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationsPageResp struct {
|
||||||
|
Items []notificationResp `json:"items"`
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
// NextBefore is the cursor for the next page, absent on the last one.
|
||||||
|
NextBefore string `json:"next_before,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unreadCountResp struct {
|
||||||
|
UnreadCount int64 `json:"unread_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationSettingsResp is every kind the caller can receive, plus whether
|
||||||
|
// email can be delivered at all, so a client can say why before anyone tries.
|
||||||
|
type notificationSettingsResp struct {
|
||||||
|
Kinds []notifications.KindSetting `json:"kinds"`
|
||||||
|
EmailAvailable bool `json:"email_available"`
|
||||||
|
// EmailUnavailableReason is "no_address" or "smtp_not_configured" when
|
||||||
|
// EmailAvailable is false.
|
||||||
|
EmailUnavailableReason string `json:"email_unavailable_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationSettingsReq struct {
|
||||||
|
Kinds []notifications.SettingChange `json:"kinds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMyNotifications implements GET /api/me/notifications?limit&before.
|
||||||
|
func (h *handlers) handleListMyNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := notificationsDefaultLimit
|
||||||
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_limit", "limit must be a positive integer"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = min(n, notificationsMaxLimit)
|
||||||
|
}
|
||||||
|
params := dbq.ListNotificationsParams{UserID: user.ID, PageLimit: int32(limit)}
|
||||||
|
if raw := r.URL.Query().Get("before"); raw != "" {
|
||||||
|
at, id, ok := parseNotificationCursor(raw)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_cursor", "before is not a cursor this server issued"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.BeforeCreatedAt, params.BeforeID = at, id
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.ListNotifications(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unread, err := q.CountUnreadNotifications(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := notificationsPageResp{Items: make([]notificationResp, 0, len(rows)), UnreadCount: unread}
|
||||||
|
for _, row := range rows {
|
||||||
|
rendered := notifications.Render(notifications.Kind(row.Kind), row.Payload)
|
||||||
|
item := notificationResp{
|
||||||
|
ID: uuidToString(row.ID),
|
||||||
|
Kind: row.Kind,
|
||||||
|
Title: rendered.Title,
|
||||||
|
Body: rendered.Body,
|
||||||
|
Link: rendered.Link,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
if row.ReadAt.Valid {
|
||||||
|
t := row.ReadAt.Time
|
||||||
|
item.ReadAt = &t
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, item)
|
||||||
|
}
|
||||||
|
if len(rows) == limit {
|
||||||
|
last := rows[len(rows)-1]
|
||||||
|
out.NextBefore = formatNotificationCursor(last.CreatedAt, last.ID)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMyUnreadNotificationCount implements GET /api/me/notifications/unread-count,
|
||||||
|
// the cheap call behind the badge.
|
||||||
|
func (h *handlers) handleMyUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).CountUnreadNotifications(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, unreadCountResp{UnreadCount: n})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMarkMyNotificationRead implements POST /api/me/notifications/{id}/read.
|
||||||
|
// Repeating it is harmless; another user's id is a 404, the same answer as a
|
||||||
|
// malformed one, so ids can't be probed.
|
||||||
|
func (h *handlers) handleMarkMyNotificationRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, apierror.NotFound("notification"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).MarkNotificationRead(r.Context(), dbq.MarkNotificationReadParams{ID: id, UserID: user.ID})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: mark read", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("notification"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMarkAllMyNotificationsRead implements POST /api/me/notifications/read-all.
|
||||||
|
func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Optional {"up_to": RFC3339}: only what existed then. A client replaying
|
||||||
|
// an offline "mark all read" sends the moment the user asked.
|
||||||
|
var body struct {
|
||||||
|
UpTo *time.Time `json:"up_to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := dbq.MarkAllNotificationsReadParams{UserID: user.ID}
|
||||||
|
if body.UpTo != nil {
|
||||||
|
params.UpTo = pgtype.Timestamptz{Time: *body.UpTo, Valid: true}
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), params); err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetMyNotificationSettings implements GET /api/me/notification-settings.
|
||||||
|
func (h *handlers) handleGetMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
kinds, err := notifications.LoadSettings(r.Context(), q, user.ID, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: load settings", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeNotificationSettings(w, r, q, user, kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutMyNotificationSettings implements PUT /api/me/notification-settings.
|
||||||
|
// A partial update: only the kinds and channels named change.
|
||||||
|
func (h *handlers) handlePutMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body notificationSettingsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
kinds, err := notifications.SaveSettings(r.Context(), q, user.ID, user.IsAdmin, body.Kinds)
|
||||||
|
if errors.Is(err, notifications.ErrSettingInvalid) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_notification_setting", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: save settings", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeNotificationSettings(w, r, q, user, kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) writeNotificationSettings(w http.ResponseWriter, r *http.Request, q *dbq.Queries, user dbq.User, kinds []notifications.KindSetting) {
|
||||||
|
resp := notificationSettingsResp{Kinds: kinds, EmailAvailable: true}
|
||||||
|
if user.Email == nil || strings.TrimSpace(*user.Email) == "" {
|
||||||
|
resp.EmailAvailable, resp.EmailUnavailableReason = false, "no_address"
|
||||||
|
} else {
|
||||||
|
// A failed read is an error, not "not configured": that would tell
|
||||||
|
// the user something about the server the read never established.
|
||||||
|
cfg, err := q.GetSMTPConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "notifications: read smtp config", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !mailer.Configured(cfg) {
|
||||||
|
resp.EmailAvailable, resp.EmailUnavailableReason = false, "smtp_not_configured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor is the last row's (created_at, id), opaque to clients.
|
||||||
|
func formatNotificationCursor(at pgtype.Timestamptz, id pgtype.UUID) string {
|
||||||
|
return at.Time.UTC().Format(time.RFC3339Nano) + "_" + uuidToString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNotificationCursor(raw string) (pgtype.Timestamptz, pgtype.UUID, bool) {
|
||||||
|
ts, idStr, found := strings.Cut(raw, "_")
|
||||||
|
if !found {
|
||||||
|
return pgtype.Timestamptz{}, pgtype.UUID{}, false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339Nano, ts)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.Timestamptz{}, pgtype.UUID{}, false
|
||||||
|
}
|
||||||
|
id, ok := parseUUID(idStr)
|
||||||
|
if !ok {
|
||||||
|
return pgtype.Timestamptz{}, pgtype.UUID{}, false
|
||||||
|
}
|
||||||
|
return pgtype.Timestamptz{Time: at, Valid: true}, id, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
func notificationsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/me/notifications", h.handleListMyNotifications)
|
||||||
|
r.Get("/api/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
|
||||||
|
r.Post("/api/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
|
||||||
|
r.Post("/api/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
|
||||||
|
r.Get("/api/me/notification-settings", h.handleGetMyNotificationSettings)
|
||||||
|
r.Put("/api/me/notification-settings", h.handlePutMyNotificationSettings)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func callAs(t *testing.T, r chi.Router, user dbq.User, method, path, body string, out any) int {
|
||||||
|
t.Helper()
|
||||||
|
req := withUser(httptest.NewRequest(method, path, bytes.NewBufferString(body)), user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if out != nil && rec.Code == http.StatusOK {
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyN(t *testing.T, pool *pgxpool.Pool, user dbq.User, n int) {
|
||||||
|
t.Helper()
|
||||||
|
nt := notifications.New(pool, nil, nil)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if err := nt.Notify(context.Background(), notifications.KindRequestCompleted,
|
||||||
|
notifications.ToUser(user.ID), notifications.Payload{Name: "Album", AlbumID: "al-1"}.Map()); err != nil {
|
||||||
|
t.Fatalf("notify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyNotifications_ListRendersPagesAndCounts(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "notif-alice", "pw", false)
|
||||||
|
bob := seedUser(t, pool, "notif-bob", "pw", false)
|
||||||
|
notifyN(t, pool, alice, 3)
|
||||||
|
notifyN(t, pool, bob, 1)
|
||||||
|
r := notificationsRouter(h)
|
||||||
|
|
||||||
|
var page notificationsPageResp
|
||||||
|
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2", "", &page); code != http.StatusOK {
|
||||||
|
t.Fatalf("list = %d", code)
|
||||||
|
}
|
||||||
|
if len(page.Items) != 2 || page.UnreadCount != 3 || page.NextBefore == "" {
|
||||||
|
t.Fatalf("first page = %d items, unread %d, next %q; want 2, 3, a cursor", len(page.Items), page.UnreadCount, page.NextBefore)
|
||||||
|
}
|
||||||
|
first := page.Items[0]
|
||||||
|
if first.Title != "Now in your library" || first.Body != "Album has arrived." || first.Link != "/albums/al-1" || first.ReadAt != nil {
|
||||||
|
t.Errorf("rendered item = %+v", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rest notificationsPageResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2&before="+page.NextBefore, "", &rest)
|
||||||
|
if len(rest.Items) != 1 || rest.NextBefore != "" {
|
||||||
|
t.Errorf("second page = %d items, next %q; want 1 and no cursor", len(rest.Items), rest.NextBefore)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, it := range append(page.Items, rest.Items...) {
|
||||||
|
if seen[it.ID] {
|
||||||
|
t.Errorf("item %s on two pages", it.ID)
|
||||||
|
}
|
||||||
|
seen[it.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"?limit=0", "?limit=x", "?before=nonsense"} {
|
||||||
|
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications"+bad, "", nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("GET %s = %d, want 400", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "notif-owner", "pw", false)
|
||||||
|
mallory := seedUser(t, pool, "notif-mallory", "pw", false)
|
||||||
|
notifyN(t, pool, alice, 2)
|
||||||
|
r := notificationsRouter(h)
|
||||||
|
|
||||||
|
var page notificationsPageResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
||||||
|
id := page.Items[0].ID
|
||||||
|
|
||||||
|
if code := callAs(t, r, mallory, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNotFound {
|
||||||
|
t.Errorf("another user's mark-read = %d, want 404", code)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNoContent {
|
||||||
|
t.Errorf("mark-read #%d = %d, want 204 (repeat is harmless)", i+1, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count unreadCountResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
||||||
|
if count.UnreadCount != 1 {
|
||||||
|
t.Errorf("unread = %d, want 1", count.UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", "", nil); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("read-all = %d", code)
|
||||||
|
}
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
||||||
|
if count.UnreadCount != 0 {
|
||||||
|
t.Errorf("unread after read-all = %d, want 0", count.UnreadCount)
|
||||||
|
}
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/not-a-uuid/read", "", nil); code != http.StatusNotFound {
|
||||||
|
t.Errorf("malformed id = %d, want 404", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "mark all read" replayed from an offline queue carries the moment the
|
||||||
|
// user asked; what arrived after it stays unread.
|
||||||
|
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "notif-upto", "pw", false)
|
||||||
|
r := notificationsRouter(h)
|
||||||
|
|
||||||
|
notifyN(t, pool, alice, 1)
|
||||||
|
var page notificationsPageResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
||||||
|
seen := page.Items[0].CreatedAt
|
||||||
|
notifyN(t, pool, alice, 1)
|
||||||
|
|
||||||
|
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("read-all up_to = %d", code)
|
||||||
|
}
|
||||||
|
var count unreadCountResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
||||||
|
if count.UnreadCount != 1 {
|
||||||
|
t.Errorf("unread = %d, want 1 (the one that arrived later)", count.UnreadCount)
|
||||||
|
}
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("malformed up_to = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
prev, err := q.GetSMTPConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read smtp: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = q.UpdateSMTPConfig(context.Background(), dbq.UpdateSMTPConfigParams{
|
||||||
|
Enabled: prev.Enabled, Host: prev.Host, Port: prev.Port, Username: prev.Username,
|
||||||
|
Password: prev.Password, FromAddress: prev.FromAddress, FromName: prev.FromName, UseTls: prev.UseTls,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err := q.UpdateSMTPConfig(ctx, dbq.UpdateSMTPConfigParams{
|
||||||
|
Enabled: enabled, Host: "smtp.example.com", Port: 587, FromAddress: "minstrel@example.com", FromName: "Minstrel", UseTls: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set smtp: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyNotificationSettings_DefaultsRoundTripAndAdminKinds(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
setSMTP(t, pool, true)
|
||||||
|
user := seedUser(t, pool, "notif-settings", "pw", false)
|
||||||
|
admin := seedUser(t, pool, "notif-settings-admin", "pw", true)
|
||||||
|
r := notificationsRouter(h)
|
||||||
|
|
||||||
|
var s notificationSettingsResp
|
||||||
|
if code := callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s); code != http.StatusOK {
|
||||||
|
t.Fatalf("get = %d", code)
|
||||||
|
}
|
||||||
|
for _, k := range s.Kinds {
|
||||||
|
if k.AdminOnly {
|
||||||
|
t.Errorf("non-admin offered admin kind %s", k.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.EmailAvailable || s.EmailUnavailableReason != "no_address" {
|
||||||
|
t.Errorf("no address on file: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partial change touches only what it names.
|
||||||
|
body := `{"kinds":[{"kind":"request_completed","email":false}]}`
|
||||||
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", body, &s); code != http.StatusOK {
|
||||||
|
t.Fatalf("put = %d", code)
|
||||||
|
}
|
||||||
|
for _, k := range s.Kinds {
|
||||||
|
if k.Kind == notifications.KindRequestCompleted && (k.Email || !k.Inbox || !k.Phone) {
|
||||||
|
t.Errorf("after PUT request_completed = %+v, want inbox+phone on, email off", k)
|
||||||
|
}
|
||||||
|
if k.Kind == notifications.KindRequestApproved && !k.Email {
|
||||||
|
t.Errorf("an untouched kind changed: %+v", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-admin can't set an admin kind; nothing in the batch is applied.
|
||||||
|
bad := `{"kinds":[{"kind":"request_approved","inbox":false},{"kind":"tracks_missing","inbox":false}]}`
|
||||||
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", bad, nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("non-admin setting an admin kind = %d, want 400", code)
|
||||||
|
}
|
||||||
|
callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s)
|
||||||
|
for _, k := range s.Kinds {
|
||||||
|
if k.Kind == notifications.KindRequestApproved && !k.Inbox {
|
||||||
|
t.Error("a refused batch was partly applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", `{"kinds":[{"kind":"bogus"}]}`, nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("unknown kind = %d, want 400", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An admin with an address and SMTP on sees the admin kinds and can email.
|
||||||
|
if _, err := pool.Exec(context.Background(), `UPDATE users SET email = 'admin@example.com' WHERE id = $1`, admin.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
admin.Email = strPtr("admin@example.com")
|
||||||
|
callAs(t, r, admin, http.MethodGet, "/api/me/notification-settings", "", &s)
|
||||||
|
if !s.EmailAvailable {
|
||||||
|
t.Errorf("admin with address + SMTP: email unavailable (%q)", s.EmailUnavailableReason)
|
||||||
|
}
|
||||||
|
var adminKinds int
|
||||||
|
for _, k := range s.Kinds {
|
||||||
|
if k.AdminOnly {
|
||||||
|
adminKinds++
|
||||||
|
if k.Kind == notifications.KindTracksMissing && k.Email {
|
||||||
|
t.Error("tracks_missing should default to email off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if adminKinds == 0 {
|
||||||
|
t.Error("admin was offered no admin kinds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyNotificationSettings_EmailUnavailableWhenSMTPIsOff(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
setSMTP(t, pool, false)
|
||||||
|
user := seedUser(t, pool, "notif-nosmtp", "pw", false)
|
||||||
|
user.Email = strPtr("someone@example.com")
|
||||||
|
|
||||||
|
var s notificationSettingsResp
|
||||||
|
callAs(t, notificationsRouter(h), user, http.MethodGet, "/api/me/notification-settings", "", &s)
|
||||||
|
if s.EmailAvailable || s.EmailUnavailableReason != "smtp_not_configured" {
|
||||||
|
t.Errorf("SMTP off: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helpers for the handlers that produce notifications (M489). Every call is
|
||||||
|
// NotifyLogged: a notification never fails the action that caused it.
|
||||||
|
|
||||||
|
// userLabel is how a user is named to someone else: their display name, or
|
||||||
|
// their username when they have none.
|
||||||
|
func userLabel(u dbq.User) string {
|
||||||
|
if u.DisplayName != nil && *u.DisplayName != "" {
|
||||||
|
return *u.DisplayName
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
|
||||||
|
return notifications.Payload{
|
||||||
|
RequestID: uuidToString(row.ID),
|
||||||
|
RequestKind: string(row.Kind),
|
||||||
|
Name: lidarrrequests.DisplayName(row),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyRequestDecided tells the requester an admin approved or declined
|
||||||
|
// their request. An admin deciding their own request needs no notice.
|
||||||
|
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
|
||||||
|
if row.UserID == admin.ID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := requestPayload(row)
|
||||||
|
p.Reason = reason
|
||||||
|
h.notifier.NotifyLogged(ctx, kind, notifications.ToUser(row.UserID), p.Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
// quarantineReasonLabel is a flag reason as the flag popover words it, in
|
||||||
|
// lower case to sit inside a sentence ("flagged X: bad rip").
|
||||||
|
func quarantineReasonLabel(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "bad_rip":
|
||||||
|
return "bad rip"
|
||||||
|
case "wrong_file":
|
||||||
|
return "wrong file"
|
||||||
|
case "wrong_tags":
|
||||||
|
return "wrong tags"
|
||||||
|
case "duplicate":
|
||||||
|
return "duplicate"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackLabel names a track "Artist – Title" for a notification, falling back
|
||||||
|
// to the title alone, or to nothing, rather than failing the caller.
|
||||||
|
func (h *handlers) trackLabel(ctx context.Context, trackID pgtype.UUID) string {
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
t, err := q.GetTrackByID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil && a.Name != "" {
|
||||||
|
return a.Name + " – " + t.Title
|
||||||
|
}
|
||||||
|
return t.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyPlaybackErrors tells admins how many playback errors await review.
|
||||||
|
// The notice coalesces, so while it is unread a new report only updates the
|
||||||
|
// count.
|
||||||
|
func (h *handlers) notifyPlaybackErrors(ctx context.Context) {
|
||||||
|
n, err := dbq.New(h.pool).CountUnresolvedPlaybackErrors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("api: playback_error: count unresolved", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}),
|
||||||
|
notifications.Payload{Count: n}.Map())
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inboxItem is one thing the notifier left for one user: kind and decoded payload,
|
||||||
|
// newest first.
|
||||||
|
type inboxItem struct {
|
||||||
|
Kind string
|
||||||
|
Payload notifications.Payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboxOf(t *testing.T, pool *pgxpool.Pool, user dbq.User) []inboxItem {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
|
||||||
|
UserID: user.ID, PageLimit: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list notifications: %v", err)
|
||||||
|
}
|
||||||
|
out := make([]inboxItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var p notifications.Payload
|
||||||
|
if err := json.Unmarshal(r.Payload, &p); err != nil {
|
||||||
|
t.Fatalf("payload %s: %v", r.Payload, err)
|
||||||
|
}
|
||||||
|
out = append(out, inboxItem{Kind: r.Kind, Payload: p})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications.Kind) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(kinds) {
|
||||||
|
t.Fatalf("%s inbox = %+v, want kinds %v", who, got, kinds)
|
||||||
|
}
|
||||||
|
for i, k := range kinds {
|
||||||
|
if got[i].Kind != string(k) {
|
||||||
|
t.Errorf("%s inbox[%d] = %q, want %q", who, i, got[i].Kind, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newApprovingLidarrStub answers every Lidarr call an approval makes.
|
||||||
|
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
||||||
|
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"id":1}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(stub.Close)
|
||||||
|
return stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
resetLidarrState(t, h)
|
||||||
|
|
||||||
|
alice := seedUser(t, pool, "np-alice", "pw", false)
|
||||||
|
admin := seedUser(t, pool, "np-admin", "pw", true)
|
||||||
|
selfAdmin := seedUser(t, pool, "np-self", "pw", true)
|
||||||
|
|
||||||
|
rv := createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
|
||||||
|
// The same request again dedups into the first and is not announced twice.
|
||||||
|
createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
|
||||||
|
|
||||||
|
got := inboxOf(t, pool, admin)
|
||||||
|
wantInbox(t, "admin", got, notifications.KindRequestPending)
|
||||||
|
if got[0].Payload.RequestID != uuidToString(rv.ID) || got[0].Payload.Name != "Pending Band" || got[0].Payload.Actor != alice.Username {
|
||||||
|
t.Errorf("pending payload = %+v", got[0].Payload)
|
||||||
|
}
|
||||||
|
wantInbox(t, "requester", inboxOf(t, pool, alice))
|
||||||
|
|
||||||
|
// An admin's own request goes to the other admins, never back to them.
|
||||||
|
createArtistRequest(t, h, selfAdmin, "np-mbid-2", "Own Band")
|
||||||
|
wantInbox(t, "self-admin", inboxOf(t, pool, selfAdmin), notifications.KindRequestPending)
|
||||||
|
if n := len(inboxOf(t, pool, admin)); n != 2 {
|
||||||
|
t.Errorf("admin inbox = %d, want 2 (alice's and the self-admin's)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
||||||
|
h, _ := testHandlersWithClientFn(t)
|
||||||
|
resetLidarrState(t, h)
|
||||||
|
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
||||||
|
|
||||||
|
alice := seedUser(t, h.pool, "np-auto", "pw", false)
|
||||||
|
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
|
||||||
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
|
"UPDATE users SET auto_approve_requests = true WHERE id = $1", alice.ID); err != nil {
|
||||||
|
t.Fatalf("set auto_approve: %v", err)
|
||||||
|
}
|
||||||
|
alice, err := dbq.New(h.pool).GetUserByID(context.Background(), alice.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := createArtistRequest(t, h, alice, "np-auto-mbid", "Auto Band")
|
||||||
|
if rv.Status != "approved" {
|
||||||
|
t.Fatalf("status = %q, want approved — the stub should accept the add", rv.Status)
|
||||||
|
}
|
||||||
|
wantInbox(t, "admin", inboxOf(t, h.pool, admin))
|
||||||
|
wantInbox(t, "requester", inboxOf(t, h.pool, alice))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
|
||||||
|
h, _ := testHandlersWithClientFn(t)
|
||||||
|
resetLidarrState(t, h)
|
||||||
|
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
||||||
|
|
||||||
|
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
|
||||||
|
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
|
||||||
|
|
||||||
|
approved := seedPendingArtistRequest(t, h, alice, "np-dec-1", "Yes Band")
|
||||||
|
rejected := seedPendingArtistRequest(t, h, alice, "np-dec-2", "No Band")
|
||||||
|
own := seedPendingArtistRequest(t, h, admin, "np-dec-3", "Own Band")
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
id string
|
||||||
|
verb string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{uuidToString(approved.ID), "approve", nil},
|
||||||
|
{uuidToString(rejected.ID), "reject", []byte(`{"notes":"Only a live bootleg exists"}`)},
|
||||||
|
{uuidToString(own.ID), "approve", nil},
|
||||||
|
} {
|
||||||
|
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+c.id+"/"+c.verb, c.body, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s %s: status = %d; body = %s", c.verb, c.id, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inboxOf(t, h.pool, alice)
|
||||||
|
wantInbox(t, "requester", got, notifications.KindRequestRejected, notifications.KindRequestApproved)
|
||||||
|
if got[0].Payload.Name != "No Band" || got[0].Payload.Reason != "Only a live bootleg exists" {
|
||||||
|
t.Errorf("rejected payload = %+v", got[0].Payload)
|
||||||
|
}
|
||||||
|
if got[1].Payload.Name != "Yes Band" || got[1].Payload.Reason != "" {
|
||||||
|
t.Errorf("approved payload = %+v", got[1].Payload)
|
||||||
|
}
|
||||||
|
// The admin decided their own request: no notice. alice's two pending
|
||||||
|
// notices are all the admin holds.
|
||||||
|
for _, it := range inboxOf(t, h.pool, admin) {
|
||||||
|
if it.Kind != string(notifications.KindRequestPending) {
|
||||||
|
t.Errorf("admin inbox holds %q, want only request_pending", it.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_FlagReachesAdminsButNotTheFlagger(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
|
||||||
|
alice := seedUser(t, pool, "np-flag-alice", "pw", false)
|
||||||
|
admin := seedUser(t, pool, "np-flag-admin", "pw", true)
|
||||||
|
other := seedUser(t, pool, "np-flag-other", "pw", true)
|
||||||
|
track := seedQuarantineTrack(t, h, "np")
|
||||||
|
|
||||||
|
w := doFlag(h, alice, fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID)))
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("flag: status = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := inboxOf(t, pool, admin)
|
||||||
|
wantInbox(t, "admin", got, notifications.KindQuarantineFlagged)
|
||||||
|
if p := got[0].Payload; p.Name != "Q Artist np – Q Track np" || p.Actor != alice.Username || p.Reason != "bad rip" {
|
||||||
|
t.Errorf("flag payload = %+v", p)
|
||||||
|
}
|
||||||
|
wantInbox(t, "flagger", inboxOf(t, pool, alice))
|
||||||
|
|
||||||
|
// An admin flagging a track tells the other admins only.
|
||||||
|
track2 := seedQuarantineTrack(t, h, "np2")
|
||||||
|
w = doFlag(h, admin, fmt.Sprintf(`{"track_id":%q,"reason":"other"}`, uuidToString(track2.ID)))
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("admin flag: status = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(inboxOf(t, pool, admin)); n != 1 {
|
||||||
|
t.Errorf("flagging admin inbox = %d, want 1 (alice's flag only)", n)
|
||||||
|
}
|
||||||
|
if n := len(inboxOf(t, pool, other)); n != 2 {
|
||||||
|
t.Errorf("other admin inbox = %d, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuarantineReasonLabel(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"bad_rip": "bad rip", "wrong_file": "wrong file", "wrong_tags": "wrong tags",
|
||||||
|
"duplicate": "duplicate", "other": "", "": "",
|
||||||
|
} {
|
||||||
|
if got := quarantineReasonLabel(in); got != want {
|
||||||
|
t.Errorf("quarantineReasonLabel(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_PlaybackErrorsCoalesceIntoOneCount(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
|
||||||
|
alice := seedUser(t, pool, "np-play-alice", "pw", false)
|
||||||
|
admin := seedUser(t, pool, "np-play-admin", "pw", true)
|
||||||
|
track := seedQuarantineTrack(t, h, "play")
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
body := fmt.Sprintf(`{"track_id":%q,"kind":"load_failed","client_id":"web-%d"}`, uuidToString(track.ID), i)
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodPost, "/api/playback-errors", strings.NewReader(body)), alice)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleReportPlaybackError(w, req)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("report %d: status = %d; body = %s", i, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inboxOf(t, pool, admin)
|
||||||
|
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
|
||||||
|
if got[0].Payload.Count != 2 {
|
||||||
|
t.Errorf("count = %d, want 2 unresolved", got[0].Payload.Count)
|
||||||
|
}
|
||||||
|
wantInbox(t, "reporter", inboxOf(t, pool, alice))
|
||||||
|
}
|
||||||
@@ -130,6 +130,7 @@ func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Requ
|
|||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.notifyPlaybackErrors(r.Context())
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
// quarantineView is the JSON shape returned by the user-facing endpoints
|
// quarantineView is the JSON shape returned by the user-facing endpoints
|
||||||
@@ -70,6 +71,10 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Broadcast: the flagging user's other clients invalidate their
|
// Broadcast: the flagging user's other clients invalidate their
|
||||||
// Hidden tab; admins' clients invalidate their quarantine queue.
|
// Hidden tab; admins' clients invalidate their quarantine queue.
|
||||||
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
|
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
|
||||||
|
// Admins review flags (M489). An admin flagging a track needs no notice
|
||||||
|
// of their own flag.
|
||||||
|
h.notifier.NotifyLogged(r.Context(), notifications.KindQuarantineFlagged, notifications.ToAdmins(user.ID),
|
||||||
|
notifications.Payload{Name: h.trackLabel(r.Context(), trackID), Actor: userLabel(user), Reason: quarantineReasonLabel(body.Reason)}.Map())
|
||||||
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
|
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestView is the JSON shape returned by all /api/requests handlers.
|
// requestView is the JSON shape returned by all /api/requests handlers.
|
||||||
@@ -130,7 +131,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
row, err := h.lidarrRequests.Create(r.Context(), user.ID, lidarrrequests.CreateParams{
|
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
|
||||||
Kind: body.Kind,
|
Kind: body.Kind,
|
||||||
LidarrArtistMBID: body.LidarrArtistMBID,
|
LidarrArtistMBID: body.LidarrArtistMBID,
|
||||||
LidarrAlbumMBID: body.LidarrAlbumMBID,
|
LidarrAlbumMBID: body.LidarrAlbumMBID,
|
||||||
@@ -175,6 +176,16 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new request still pending after any auto-approval waits on an admin
|
||||||
|
// (M489). One that deduped into a request already in flight was
|
||||||
|
// announced when it was first made, and the requester, if they are an
|
||||||
|
// admin themselves, needs no notice of their own request.
|
||||||
|
if created && row.Status == dbq.LidarrRequestStatusPending {
|
||||||
|
p := requestPayload(row)
|
||||||
|
p.Actor = userLabel(user)
|
||||||
|
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
|
||||||
|
}
|
||||||
|
|
||||||
h.publishRequestStatusChanged(row)
|
h.publishRequestStatusChanged(row)
|
||||||
writeJSON(w, http.StatusCreated, requestViewFrom(row))
|
writeJSON(w, http.StatusCreated, requestViewFrom(row))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,35 +216,55 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
WITH scored AS (
|
||||||
FROM tracks t
|
SELECT t.id, t.album_id, t.artist_id,
|
||||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
SUM(nt.weight) AS weight,
|
||||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
md5(t.id::text || $2::text) AS tiebreak
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
FROM tracks t
|
||||||
AND nt.weight > 0
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
AND trim(g_split.g) <> ''
|
JOIN taste_profile_tags nt ON nt.user_id = $3 AND trim(g_split.g) = nt.tag
|
||||||
AND NOT EXISTS (
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
SELECT 1 FROM play_events pe
|
AND nt.weight > 0
|
||||||
WHERE pe.user_id = $1
|
AND trim(g_split.g) <> ''
|
||||||
AND pe.track_id = t.id
|
AND NOT EXISTS (
|
||||||
AND pe.was_skipped = false
|
SELECT 1 FROM play_events pe
|
||||||
)
|
WHERE pe.user_id = $3
|
||||||
AND NOT EXISTS (
|
AND pe.track_id = t.id
|
||||||
SELECT 1 FROM general_likes gl
|
AND pe.was_skipped = false
|
||||||
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
)
|
||||||
)
|
AND NOT EXISTS (
|
||||||
AND NOT EXISTS (
|
SELECT 1 FROM general_likes gl
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
WHERE gl.user_id = $3 AND gl.track_id = t.id
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
)
|
||||||
)
|
AND NOT EXISTS (
|
||||||
GROUP BY t.id, t.album_id, t.artist_id
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
WHERE q.user_id = $3 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
),
|
||||||
|
album_capped AS (
|
||||||
|
SELECT s.id, s.album_id, s.artist_id, s.weight, s.tiebreak,
|
||||||
|
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
|
||||||
|
FROM scored s
|
||||||
|
),
|
||||||
|
artist_capped AS (
|
||||||
|
SELECT a.id, a.album_id, a.artist_id, a.weight, a.tiebreak, a.album_rank,
|
||||||
|
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
|
||||||
|
FROM album_capped a
|
||||||
|
WHERE a.album_rank <= $4::int
|
||||||
|
)
|
||||||
|
SELECT c.id, c.album_id, c.artist_id
|
||||||
|
FROM artist_capped c
|
||||||
|
WHERE c.artist_rank <= $1::int
|
||||||
|
ORDER BY c.weight DESC, c.tiebreak
|
||||||
LIMIT 120
|
LIMIT 120
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListTasteUnheardTracksForDiscoverParams struct {
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
||||||
UserID pgtype.UUID
|
MaxPerArtist int32
|
||||||
Column2 string
|
DateSeed string
|
||||||
|
UserID pgtype.UUID
|
||||||
|
MaxPerAlbum int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTasteUnheardTracksForDiscoverRow struct {
|
type ListTasteUnheardTracksForDiscoverRow struct {
|
||||||
@@ -261,9 +281,21 @@ type ListTasteUnheardTracksForDiscoverRow struct {
|
|||||||
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
// when the user has no taste tags yet (cold start), so the caller
|
// when the user has no taste tags yet (cold start), so the caller
|
||||||
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
//
|
||||||
|
// The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
|
||||||
|
// weight rewards a track for carrying many of the user's tags, so a few
|
||||||
|
// artists whose every track is tagged with the whole profile take every row
|
||||||
|
// of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
|
||||||
|
// the best-performing arm handed its slots to the others. Ranking within
|
||||||
|
// album, then within artist over what the album cap kept, is the same walk
|
||||||
|
// capByAlbumAndArtist makes, so the caller's caps keep everything here.
|
||||||
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover,
|
||||||
|
arg.MaxPerArtist,
|
||||||
|
arg.DateSeed,
|
||||||
|
arg.UserID,
|
||||||
|
arg.MaxPerAlbum,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateG
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countDuplicateGroupsDetectedSince = `-- name: CountDuplicateGroupsDetectedSince :one
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND detected_at >= $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Pending proposals first made at or after `since`: what a sweep that started
|
||||||
|
// then found for the first time. A refreshed proposal keeps its detected_at,
|
||||||
|
// so a sweep that only re-finds known groups counts none (M489: admins are
|
||||||
|
// told about new duplicates, not reminded of the same ones every sweep).
|
||||||
|
func (q *Queries) CountDuplicateGroupsDetectedSince(ctx context.Context, since pgtype.Timestamptz) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countDuplicateGroupsDetectedSince, since)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
|
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
|
||||||
SELECT count(*)::bigint
|
SELECT count(*)::bigint
|
||||||
FROM duplicate_groups g
|
FROM duplicate_groups g
|
||||||
|
|||||||
@@ -472,6 +472,13 @@ type NetworkSetting struct {
|
|||||||
PublicUrl string
|
PublicUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationEmailSetting struct {
|
||||||
|
ID bool
|
||||||
|
SummaryHour int32
|
||||||
|
BatchWindowMinutes int32
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type PasswordReset struct {
|
type PasswordReset struct {
|
||||||
Token string
|
Token string
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -831,6 +838,35 @@ type UserNormalizationPref struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserNotification struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
ReadAt pgtype.Timestamptz
|
||||||
|
CoalesceKey *string
|
||||||
|
EmailedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserNotificationEmailState struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
EmailGroup string
|
||||||
|
BatchOpenedAt pgtype.Timestamptz
|
||||||
|
LastSentAt pgtype.Timestamptz
|
||||||
|
Failures int32
|
||||||
|
RetryAfter pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserNotificationPref struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Inbox bool
|
||||||
|
Phone bool
|
||||||
|
Email bool
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type YouMightLikeAlbum struct {
|
type YouMightLikeAlbum struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: notifications.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countUnreadNotifications = `-- name: CountUnreadNotifications :one
|
||||||
|
SELECT count(*) FROM user_notifications
|
||||||
|
WHERE user_id = $1 AND read_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountUnreadNotifications(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countUnreadNotifications, userID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationEmailSettings = `-- name: GetNotificationEmailSettings :one
|
||||||
|
SELECT id, summary_hour, batch_window_minutes, updated_at FROM notification_email_settings WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetNotificationEmailSettings(ctx context.Context) (NotificationEmailSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getNotificationEmailSettings)
|
||||||
|
var i NotificationEmailSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SummaryHour,
|
||||||
|
&i.BatchWindowMinutes,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertNotification = `-- name: InsertNotification :one
|
||||||
|
|
||||||
|
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
|
||||||
|
VALUES ($1, $2, $3,
|
||||||
|
CASE WHEN $4::boolean THEN NULL ELSE now() END)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertNotificationParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
EmailWanted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// M489 notifications inbox (#726). Rows are written only through
|
||||||
|
// internal/notifications.Notifier, which decides recipients and honours each
|
||||||
|
// recipient's inbox preference before it reaches these.
|
||||||
|
// email_wanted is the recipient's email channel for this kind as of now. A
|
||||||
|
// row nobody wants emailed is stamped at once, so the digest never has to
|
||||||
|
// judge it and a later "email on" doesn't send an old backlog.
|
||||||
|
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (pgtype.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertNotification,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Payload,
|
||||||
|
arg.EmailWanted,
|
||||||
|
)
|
||||||
|
var id pgtype.UUID
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
|
||||||
|
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]pgtype.UUID, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAdminUserIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []pgtype.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id pgtype.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEmailPendingNotifications = `-- name: ListEmailPendingNotifications :many
|
||||||
|
|
||||||
|
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
|
||||||
|
u.email::text AS email, u.username, u.display_name, u.timezone,
|
||||||
|
now()::timestamptz AS read_as_of
|
||||||
|
FROM user_notifications n
|
||||||
|
JOIN users u ON u.id = n.user_id
|
||||||
|
WHERE n.read_at IS NULL
|
||||||
|
AND n.emailed_at IS NULL
|
||||||
|
AND u.email IS NOT NULL AND u.email <> ''
|
||||||
|
ORDER BY n.user_id, n.created_at, n.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEmailPendingNotificationsRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
Email string
|
||||||
|
Username string
|
||||||
|
DisplayName *string
|
||||||
|
Timezone string
|
||||||
|
ReadAsOf pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email digest (#5346) ------------------------------------------------------
|
||||||
|
// Every unread, un-emailed row of a user who has an address, oldest first.
|
||||||
|
// Read rows are never selected: the user has seen them, so they are not news.
|
||||||
|
// read_as_of is the database's clock at the read, handed back to
|
||||||
|
// MarkNotificationsEmailed.
|
||||||
|
func (q *Queries) ListEmailPendingNotifications(ctx context.Context) ([]ListEmailPendingNotificationsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEmailPendingNotifications)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListEmailPendingNotificationsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListEmailPendingNotificationsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Payload,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Email,
|
||||||
|
&i.Username,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.ReadAsOf,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listNotificationEmailState = `-- name: ListNotificationEmailState :many
|
||||||
|
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
|
||||||
|
FROM user_notification_email_state
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListNotificationEmailState(ctx context.Context) ([]UserNotificationEmailState, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listNotificationEmailState)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []UserNotificationEmailState
|
||||||
|
for rows.Next() {
|
||||||
|
var i UserNotificationEmailState
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.EmailGroup,
|
||||||
|
&i.BatchOpenedAt,
|
||||||
|
&i.LastSentAt,
|
||||||
|
&i.Failures,
|
||||||
|
&i.RetryAfter,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listNotificationPrefsForKind = `-- name: ListNotificationPrefsForKind :many
|
||||||
|
SELECT user_id, inbox, phone, email
|
||||||
|
FROM user_notification_prefs
|
||||||
|
WHERE kind = $1 AND user_id = ANY($2::uuid[])
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListNotificationPrefsForKindParams struct {
|
||||||
|
Kind string
|
||||||
|
UserIds []pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListNotificationPrefsForKindRow struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Inbox bool
|
||||||
|
Phone bool
|
||||||
|
Email bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored prefs of one kind for a set of recipients. A recipient with no
|
||||||
|
// row has the kind's defaults.
|
||||||
|
func (q *Queries) ListNotificationPrefsForKind(ctx context.Context, arg ListNotificationPrefsForKindParams) ([]ListNotificationPrefsForKindRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listNotificationPrefsForKind, arg.Kind, arg.UserIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListNotificationPrefsForKindRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListNotificationPrefsForKindRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.UserID,
|
||||||
|
&i.Inbox,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Email,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listNotificationPrefsForUser = `-- name: ListNotificationPrefsForUser :many
|
||||||
|
SELECT kind, inbox, phone, email
|
||||||
|
FROM user_notification_prefs
|
||||||
|
WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListNotificationPrefsForUserRow struct {
|
||||||
|
Kind string
|
||||||
|
Inbox bool
|
||||||
|
Phone bool
|
||||||
|
Email bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListNotificationPrefsForUser(ctx context.Context, userID pgtype.UUID) ([]ListNotificationPrefsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listNotificationPrefsForUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListNotificationPrefsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListNotificationPrefsForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Kind,
|
||||||
|
&i.Inbox,
|
||||||
|
&i.Phone,
|
||||||
|
&i.Email,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listNotifications = `-- name: ListNotifications :many
|
||||||
|
SELECT id, kind, payload, created_at, read_at
|
||||||
|
FROM user_notifications
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND ($2::timestamptz IS NULL
|
||||||
|
OR (created_at, id) < ($2::timestamptz, $3::uuid))
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListNotificationsParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
BeforeCreatedAt pgtype.Timestamptz
|
||||||
|
BeforeID pgtype.UUID
|
||||||
|
PageLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListNotificationsRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
ReadAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first, keyset-paged on (created_at, id). Pass both cursor halves
|
||||||
|
// from the last row of the previous page, or neither for the first page.
|
||||||
|
func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsParams) ([]ListNotificationsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listNotifications,
|
||||||
|
arg.UserID,
|
||||||
|
arg.BeforeCreatedAt,
|
||||||
|
arg.BeforeID,
|
||||||
|
arg.PageLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListNotificationsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListNotificationsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Payload,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ReadAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET read_at = now()
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND read_at IS NULL
|
||||||
|
AND ($2::timestamptz IS NULL OR created_at <= $2)
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkAllNotificationsReadParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
UpTo pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// up_to, when set, limits it to what existed when the user asked: a "mark
|
||||||
|
// all read" queued offline and replayed later must not mark notices that
|
||||||
|
// arrived in between, which the user never saw. A coalesced row updated
|
||||||
|
// since then carries a newer created_at, so it stays unread too.
|
||||||
|
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markNotificationRead = `-- name: MarkNotificationRead :execrows
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET read_at = COALESCE(read_at, now())
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkNotificationReadParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped to the owner: another user's id matches no row. Marking an already
|
||||||
|
// read row keeps its original read_at and still matches, so a repeat is not
|
||||||
|
// mistaken for "not yours".
|
||||||
|
func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markNotificationRead, arg.ID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markNotificationsEmailed = `-- name: MarkNotificationsEmailed :execrows
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET emailed_at = now()
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
AND created_at <= $2::timestamptz
|
||||||
|
AND emailed_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkNotificationsEmailedParams struct {
|
||||||
|
Ids []pgtype.UUID
|
||||||
|
ReadAsOf pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamps the rows an email carried, or that were judged not to need one.
|
||||||
|
// read_as_of is from ListEmailPendingNotifications: a coalesced row updated
|
||||||
|
// since that read carries a later created_at, holds newer news, and stays
|
||||||
|
// pending for the next email.
|
||||||
|
func (q *Queries) MarkNotificationsEmailed(ctx context.Context, arg MarkNotificationsEmailedParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markNotificationsEmailed, arg.Ids, arg.ReadAsOf)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimNotifications = `-- name: TrimNotifications :execrows
|
||||||
|
DELETE FROM user_notifications
|
||||||
|
WHERE (read_at IS NOT NULL AND read_at < $1)
|
||||||
|
OR created_at < $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type TrimNotificationsParams struct {
|
||||||
|
ReadCutoff pgtype.Timestamptz
|
||||||
|
AnyCutoff pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention: read rows go after read_cutoff, and anything at all after
|
||||||
|
// any_cutoff, so an inbox nobody opens doesn't grow without bound either.
|
||||||
|
func (q *Queries) TrimNotifications(ctx context.Context, arg TrimNotificationsParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, trimNotifications, arg.ReadCutoff, arg.AnyCutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNotificationEmailSettings = `-- name: UpdateNotificationEmailSettings :one
|
||||||
|
UPDATE notification_email_settings
|
||||||
|
SET summary_hour = $1,
|
||||||
|
batch_window_minutes = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING id, summary_hour, batch_window_minutes, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateNotificationEmailSettingsParams struct {
|
||||||
|
SummaryHour int32
|
||||||
|
BatchWindowMinutes int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 0074's CHECKs are the backstop behind the service's validation.
|
||||||
|
func (q *Queries) UpdateNotificationEmailSettings(ctx context.Context, arg UpdateNotificationEmailSettingsParams) (NotificationEmailSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateNotificationEmailSettings, arg.SummaryHour, arg.BatchWindowMinutes)
|
||||||
|
var i NotificationEmailSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SummaryHour,
|
||||||
|
&i.BatchWindowMinutes,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertCoalescedNotification = `-- name: UpsertCoalescedNotification :one
|
||||||
|
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
|
||||||
|
VALUES ($1, $2, $3, $4,
|
||||||
|
CASE WHEN $5::boolean THEN NULL ELSE now() END)
|
||||||
|
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
payload = CASE
|
||||||
|
WHEN $6::boolean THEN
|
||||||
|
EXCLUDED.payload || jsonb_build_object('count',
|
||||||
|
COALESCE((user_notifications.payload->>'count')::bigint, 0)
|
||||||
|
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
|
||||||
|
ELSE EXCLUDED.payload
|
||||||
|
END,
|
||||||
|
created_at = now(),
|
||||||
|
emailed_at = EXCLUDED.emailed_at
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertCoalescedNotificationParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
CoalesceKey *string
|
||||||
|
EmailWanted bool
|
||||||
|
SumCount bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// One unread row per (user, coalesce_key). A new event while that row is
|
||||||
|
// unread updates it in place and moves it back to the top of the inbox.
|
||||||
|
//
|
||||||
|
// sum_count: the payload's `count` adds to the unread row's count rather than
|
||||||
|
// replacing it. For kinds whose event is "N more happened" (tracks marked
|
||||||
|
// missing). Kinds whose event states the whole current total (pending
|
||||||
|
// duplicate groups) pass false and the payload simply replaces.
|
||||||
|
//
|
||||||
|
// emailed_at is cleared so the newer state goes out in the next batch; an
|
||||||
|
// emailed-but-unread row that keeps growing is news the user hasn't seen.
|
||||||
|
// Unless email_wanted is false, as in InsertNotification.
|
||||||
|
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Payload,
|
||||||
|
arg.CoalesceKey,
|
||||||
|
arg.EmailWanted,
|
||||||
|
arg.SumCount,
|
||||||
|
)
|
||||||
|
var id pgtype.UUID
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertNotificationEmailState = `-- name: UpsertNotificationEmailState :exec
|
||||||
|
INSERT INTO user_notification_email_state
|
||||||
|
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
|
||||||
|
VALUES ($1, $2, $3,
|
||||||
|
$4, $5, $6)
|
||||||
|
ON CONFLICT (user_id, email_group) DO UPDATE SET
|
||||||
|
batch_opened_at = EXCLUDED.batch_opened_at,
|
||||||
|
last_sent_at = EXCLUDED.last_sent_at,
|
||||||
|
failures = EXCLUDED.failures,
|
||||||
|
retry_after = EXCLUDED.retry_after
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertNotificationEmailStateParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
EmailGroup string
|
||||||
|
BatchOpenedAt pgtype.Timestamptz
|
||||||
|
LastSentAt pgtype.Timestamptz
|
||||||
|
Failures int32
|
||||||
|
RetryAfter pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertNotificationEmailState(ctx context.Context, arg UpsertNotificationEmailStateParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertNotificationEmailState,
|
||||||
|
arg.UserID,
|
||||||
|
arg.EmailGroup,
|
||||||
|
arg.BatchOpenedAt,
|
||||||
|
arg.LastSentAt,
|
||||||
|
arg.Failures,
|
||||||
|
arg.RetryAfter,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertNotificationPref = `-- name: UpsertNotificationPref :exec
|
||||||
|
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, kind) DO UPDATE SET
|
||||||
|
inbox = EXCLUDED.inbox,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertNotificationPrefParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Kind string
|
||||||
|
Inbox bool
|
||||||
|
Phone bool
|
||||||
|
Email bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertNotificationPref(ctx context.Context, arg UpsertNotificationPrefParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertNotificationPref,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Inbox,
|
||||||
|
arg.Phone,
|
||||||
|
arg.Email,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countUnresolvedPlaybackErrors = `-- name: CountUnresolvedPlaybackErrors :one
|
||||||
|
SELECT count(*)::bigint FROM playback_errors WHERE resolved_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountUnresolvedPlaybackErrors(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countUnresolvedPlaybackErrors)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPlaybackError = `-- name: InsertPlaybackError :one
|
const insertPlaybackError = `-- name: InsertPlaybackError :one
|
||||||
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS user_notification_prefs;
|
||||||
|
DROP TABLE IF EXISTS user_notifications;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- M489: a persistent, per-user notifications inbox (#726).
|
||||||
|
--
|
||||||
|
-- The event bus is fire-and-forget: a client that is not connected when a
|
||||||
|
-- request completes, or when the scanner marks tracks missing, never hears of
|
||||||
|
-- it. A row here is the durable record; the bus only nudges open clients to
|
||||||
|
-- come and read it.
|
||||||
|
--
|
||||||
|
-- `kind` is CHECK-gated (rule 36). A new kind adds its value here, in the
|
||||||
|
-- same migration as the code that produces it. The list is mirrored in
|
||||||
|
-- internal/notifications/kinds.go, and both CHECKs below must agree with it.
|
||||||
|
|
||||||
|
CREATE TABLE user_notifications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL CHECK (kind IN (
|
||||||
|
'request_approved',
|
||||||
|
'request_rejected',
|
||||||
|
'request_completed',
|
||||||
|
'request_pending',
|
||||||
|
'quarantine_flagged',
|
||||||
|
'scan_failed',
|
||||||
|
'tracks_missing',
|
||||||
|
'duplicates_found',
|
||||||
|
'playback_errors'
|
||||||
|
)),
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
read_at timestamptz,
|
||||||
|
-- Burst-prone kinds share one key per user. While a row with that key is
|
||||||
|
-- unread, a new event updates it in place rather than adding another, so
|
||||||
|
-- fourteen missing tracks are one notification with a count.
|
||||||
|
coalesce_key text,
|
||||||
|
-- Stamped once the row has gone out in an email (or been judged not to
|
||||||
|
-- need one), so the digest never sends the same item twice.
|
||||||
|
emailed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The inbox list, newest first.
|
||||||
|
CREATE INDEX user_notifications_user_created_idx
|
||||||
|
ON user_notifications (user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- The unread badge, and the digest's selection of unread rows.
|
||||||
|
CREATE INDEX user_notifications_unread_idx
|
||||||
|
ON user_notifications (user_id, created_at DESC)
|
||||||
|
WHERE read_at IS NULL;
|
||||||
|
|
||||||
|
-- At most one unread row per coalesce key per user. The upsert in
|
||||||
|
-- notifications.sql targets this index.
|
||||||
|
CREATE UNIQUE INDEX user_notifications_coalesce_idx
|
||||||
|
ON user_notifications (user_id, coalesce_key)
|
||||||
|
WHERE read_at IS NULL AND coalesce_key IS NOT NULL;
|
||||||
|
|
||||||
|
-- Per user, per kind: which channels a kind reaches. A missing row means the
|
||||||
|
-- kind's defaults (internal/notifications/kinds.go), so nothing is seeded and
|
||||||
|
-- a new kind needs no backfill.
|
||||||
|
CREATE TABLE user_notification_prefs (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL CHECK (kind IN (
|
||||||
|
'request_approved',
|
||||||
|
'request_rejected',
|
||||||
|
'request_completed',
|
||||||
|
'request_pending',
|
||||||
|
'quarantine_flagged',
|
||||||
|
'scan_failed',
|
||||||
|
'tracks_missing',
|
||||||
|
'duplicates_found',
|
||||||
|
'playback_errors'
|
||||||
|
)),
|
||||||
|
inbox boolean NOT NULL,
|
||||||
|
phone boolean NOT NULL,
|
||||||
|
email boolean NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, kind)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS user_notifications_email_pending_idx;
|
||||||
|
DROP TABLE IF EXISTS user_notification_email_state;
|
||||||
|
DROP TABLE IF EXISTS notification_email_settings;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- M489 #5346: notifications by email, grouped. Nothing is emailed per event.
|
||||||
|
--
|
||||||
|
-- New music (request_completed) goes out as at most one summary a day, at a set
|
||||||
|
-- hour in each user's own timezone. Everything else is batched: a batch opens
|
||||||
|
-- at the first un-emailed item and one email goes out a window later, holding
|
||||||
|
-- whatever accumulated. internal/notifications/digest.go is the one sender.
|
||||||
|
|
||||||
|
-- The two knobs, in admin Settings (rule 25). Singleton in the style of
|
||||||
|
-- fingerprint_settings (0061).
|
||||||
|
CREATE TABLE notification_email_settings (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
-- The local hour (0-23, in each user's timezone) the daily new-music
|
||||||
|
-- summary goes out.
|
||||||
|
summary_hour integer NOT NULL DEFAULT 9,
|
||||||
|
-- How long a batch stays open after its first item before it is sent.
|
||||||
|
batch_window_minutes integer NOT NULL DEFAULT 60,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT notification_email_settings_singleton CHECK (id = true),
|
||||||
|
CONSTRAINT notification_email_settings_hour_range
|
||||||
|
CHECK (summary_hour >= 0 AND summary_hour <= 23),
|
||||||
|
CONSTRAINT notification_email_settings_window_range
|
||||||
|
CHECK (batch_window_minutes >= 15 AND batch_window_minutes <= 1440)
|
||||||
|
);
|
||||||
|
INSERT INTO notification_email_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Per user, per email group: where the digest stands. A missing row is a user
|
||||||
|
-- who has never had a batch open or a summary sent.
|
||||||
|
CREATE TABLE user_notification_email_state (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
email_group text NOT NULL CHECK (email_group IN ('batch', 'summary')),
|
||||||
|
-- When the open batch started. Held here rather than read off the items,
|
||||||
|
-- because a coalesced item moves its created_at forward on every update
|
||||||
|
-- and would otherwise keep a batch from ever coming due.
|
||||||
|
batch_opened_at timestamptz,
|
||||||
|
-- The last email of this group the mailer accepted. A summary is due once
|
||||||
|
-- per local day, after the summary hour, if this is before it.
|
||||||
|
last_sent_at timestamptz,
|
||||||
|
-- A failed send backs off: nothing is tried for this group before
|
||||||
|
-- retry_after, and the gap doubles with each failure in a row.
|
||||||
|
failures integer NOT NULL DEFAULT 0,
|
||||||
|
retry_after timestamptz,
|
||||||
|
PRIMARY KEY (user_id, email_group)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The digest's selection: unread rows not yet emailed.
|
||||||
|
CREATE INDEX user_notifications_email_pending_idx
|
||||||
|
ON user_notifications (user_id, created_at)
|
||||||
|
WHERE read_at IS NULL AND emailed_at IS NULL;
|
||||||
|
|
||||||
|
-- Rows written before this migration were never judged for email. Treat them
|
||||||
|
-- as already handled, so the first digest after the upgrade doesn't send a
|
||||||
|
-- backlog nobody asked for.
|
||||||
|
UPDATE user_notifications SET emailed_at = now() WHERE emailed_at IS NULL;
|
||||||
@@ -115,28 +115,53 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
|
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
-- when the user has no taste tags yet (cold start), so the caller
|
-- when the user has no taste tags yet (cold start), so the caller
|
||||||
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
--
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
-- The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
|
||||||
FROM tracks t
|
-- weight rewards a track for carrying many of the user's tags, so a few
|
||||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
-- artists whose every track is tagged with the whole profile take every row
|
||||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
-- of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
-- the best-performing arm handed its slots to the others. Ranking within
|
||||||
AND nt.weight > 0
|
-- album, then within artist over what the album cap kept, is the same walk
|
||||||
AND trim(g_split.g) <> ''
|
-- capByAlbumAndArtist makes, so the caller's caps keep everything here.
|
||||||
AND NOT EXISTS (
|
WITH scored AS (
|
||||||
SELECT 1 FROM play_events pe
|
SELECT t.id, t.album_id, t.artist_id,
|
||||||
WHERE pe.user_id = $1
|
SUM(nt.weight) AS weight,
|
||||||
AND pe.track_id = t.id
|
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
|
||||||
AND pe.was_skipped = false
|
FROM tracks t
|
||||||
)
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
AND NOT EXISTS (
|
JOIN taste_profile_tags nt ON nt.user_id = sqlc.arg(user_id) AND trim(g_split.g) = nt.tag
|
||||||
SELECT 1 FROM general_likes gl
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
AND nt.weight > 0
|
||||||
)
|
AND trim(g_split.g) <> ''
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM play_events pe
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE pe.user_id = sqlc.arg(user_id)
|
||||||
)
|
AND pe.track_id = t.id
|
||||||
GROUP BY t.id, t.album_id, t.artist_id
|
AND pe.was_skipped = false
|
||||||
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
),
|
||||||
|
album_capped AS (
|
||||||
|
SELECT s.*,
|
||||||
|
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
|
||||||
|
FROM scored s
|
||||||
|
),
|
||||||
|
artist_capped AS (
|
||||||
|
SELECT a.*,
|
||||||
|
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
|
||||||
|
FROM album_capped a
|
||||||
|
WHERE a.album_rank <= sqlc.arg(max_per_album)::int
|
||||||
|
)
|
||||||
|
SELECT c.id, c.album_id, c.artist_id
|
||||||
|
FROM artist_capped c
|
||||||
|
WHERE c.artist_rank <= sqlc.arg(max_per_artist)::int
|
||||||
|
ORDER BY c.weight DESC, c.tiebreak
|
||||||
LIMIT 120;
|
LIMIT 120;
|
||||||
|
|||||||
@@ -154,3 +154,13 @@ SELECT p.id AS group_id,
|
|||||||
UPDATE duplicate_groups
|
UPDATE duplicate_groups
|
||||||
SET status = 'dismissed', resolved_at = now()
|
SET status = 'dismissed', resolved_at = now()
|
||||||
WHERE id = sqlc.arg(id) AND status = 'pending';
|
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||||
|
|
||||||
|
-- name: CountDuplicateGroupsDetectedSince :one
|
||||||
|
-- Pending proposals first made at or after `since`: what a sweep that started
|
||||||
|
-- then found for the first time. A refreshed proposal keeps its detected_at,
|
||||||
|
-- so a sweep that only re-finds known groups counts none (M489: admins are
|
||||||
|
-- told about new duplicates, not reminded of the same ones every sweep).
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND detected_at >= sqlc.arg(since);
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
-- M489 notifications inbox (#726). Rows are written only through
|
||||||
|
-- internal/notifications.Notifier, which decides recipients and honours each
|
||||||
|
-- recipient's inbox preference before it reaches these.
|
||||||
|
|
||||||
|
-- name: InsertNotification :one
|
||||||
|
-- email_wanted is the recipient's email channel for this kind as of now. A
|
||||||
|
-- row nobody wants emailed is stamped at once, so the digest never has to
|
||||||
|
-- judge it and a later "email on" doesn't send an old backlog.
|
||||||
|
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
|
||||||
|
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload),
|
||||||
|
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: UpsertCoalescedNotification :one
|
||||||
|
-- One unread row per (user, coalesce_key). A new event while that row is
|
||||||
|
-- unread updates it in place and moves it back to the top of the inbox.
|
||||||
|
--
|
||||||
|
-- sum_count: the payload's `count` adds to the unread row's count rather than
|
||||||
|
-- replacing it. For kinds whose event is "N more happened" (tracks marked
|
||||||
|
-- missing). Kinds whose event states the whole current total (pending
|
||||||
|
-- duplicate groups) pass false and the payload simply replaces.
|
||||||
|
--
|
||||||
|
-- emailed_at is cleared so the newer state goes out in the next batch; an
|
||||||
|
-- emailed-but-unread row that keeps growing is news the user hasn't seen.
|
||||||
|
-- Unless email_wanted is false, as in InsertNotification.
|
||||||
|
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
|
||||||
|
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload), sqlc.arg(coalesce_key),
|
||||||
|
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
|
||||||
|
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
payload = CASE
|
||||||
|
WHEN sqlc.arg(sum_count)::boolean THEN
|
||||||
|
EXCLUDED.payload || jsonb_build_object('count',
|
||||||
|
COALESCE((user_notifications.payload->>'count')::bigint, 0)
|
||||||
|
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
|
||||||
|
ELSE EXCLUDED.payload
|
||||||
|
END,
|
||||||
|
created_at = now(),
|
||||||
|
emailed_at = EXCLUDED.emailed_at
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: ListNotifications :many
|
||||||
|
-- Newest first, keyset-paged on (created_at, id). Pass both cursor halves
|
||||||
|
-- from the last row of the previous page, or neither for the first page.
|
||||||
|
SELECT id, kind, payload, created_at, read_at
|
||||||
|
FROM user_notifications
|
||||||
|
WHERE user_id = sqlc.arg(user_id)
|
||||||
|
AND (sqlc.narg(before_created_at)::timestamptz IS NULL
|
||||||
|
OR (created_at, id) < (sqlc.narg(before_created_at)::timestamptz, sqlc.narg(before_id)::uuid))
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT sqlc.arg(page_limit);
|
||||||
|
|
||||||
|
-- name: CountUnreadNotifications :one
|
||||||
|
SELECT count(*) FROM user_notifications
|
||||||
|
WHERE user_id = $1 AND read_at IS NULL;
|
||||||
|
|
||||||
|
-- name: MarkNotificationRead :execrows
|
||||||
|
-- Scoped to the owner: another user's id matches no row. Marking an already
|
||||||
|
-- read row keeps its original read_at and still matches, so a repeat is not
|
||||||
|
-- mistaken for "not yours".
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET read_at = COALESCE(read_at, now())
|
||||||
|
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
||||||
|
|
||||||
|
-- name: MarkAllNotificationsRead :execrows
|
||||||
|
-- up_to, when set, limits it to what existed when the user asked: a "mark
|
||||||
|
-- all read" queued offline and replayed later must not mark notices that
|
||||||
|
-- arrived in between, which the user never saw. A coalesced row updated
|
||||||
|
-- since then carries a newer created_at, so it stays unread too.
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET read_at = now()
|
||||||
|
WHERE user_id = sqlc.arg(user_id)
|
||||||
|
AND read_at IS NULL
|
||||||
|
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
|
||||||
|
|
||||||
|
-- name: TrimNotifications :execrows
|
||||||
|
-- Retention: read rows go after read_cutoff, and anything at all after
|
||||||
|
-- any_cutoff, so an inbox nobody opens doesn't grow without bound either.
|
||||||
|
DELETE FROM user_notifications
|
||||||
|
WHERE (read_at IS NOT NULL AND read_at < sqlc.arg(read_cutoff))
|
||||||
|
OR created_at < sqlc.arg(any_cutoff);
|
||||||
|
|
||||||
|
-- name: ListAdminUserIDs :many
|
||||||
|
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id;
|
||||||
|
|
||||||
|
-- name: ListNotificationPrefsForUser :many
|
||||||
|
SELECT kind, inbox, phone, email
|
||||||
|
FROM user_notification_prefs
|
||||||
|
WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: ListNotificationPrefsForKind :many
|
||||||
|
-- The stored prefs of one kind for a set of recipients. A recipient with no
|
||||||
|
-- row has the kind's defaults.
|
||||||
|
SELECT user_id, inbox, phone, email
|
||||||
|
FROM user_notification_prefs
|
||||||
|
WHERE kind = sqlc.arg(kind) AND user_id = ANY(sqlc.arg(user_ids)::uuid[]);
|
||||||
|
|
||||||
|
-- name: UpsertNotificationPref :exec
|
||||||
|
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
|
||||||
|
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(inbox), sqlc.arg(phone), sqlc.arg(email))
|
||||||
|
ON CONFLICT (user_id, kind) DO UPDATE SET
|
||||||
|
inbox = EXCLUDED.inbox,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- Email digest (#5346) ------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: ListEmailPendingNotifications :many
|
||||||
|
-- Every unread, un-emailed row of a user who has an address, oldest first.
|
||||||
|
-- Read rows are never selected: the user has seen them, so they are not news.
|
||||||
|
-- read_as_of is the database's clock at the read, handed back to
|
||||||
|
-- MarkNotificationsEmailed.
|
||||||
|
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
|
||||||
|
u.email::text AS email, u.username, u.display_name, u.timezone,
|
||||||
|
now()::timestamptz AS read_as_of
|
||||||
|
FROM user_notifications n
|
||||||
|
JOIN users u ON u.id = n.user_id
|
||||||
|
WHERE n.read_at IS NULL
|
||||||
|
AND n.emailed_at IS NULL
|
||||||
|
AND u.email IS NOT NULL AND u.email <> ''
|
||||||
|
ORDER BY n.user_id, n.created_at, n.id;
|
||||||
|
|
||||||
|
-- name: MarkNotificationsEmailed :execrows
|
||||||
|
-- Stamps the rows an email carried, or that were judged not to need one.
|
||||||
|
-- read_as_of is from ListEmailPendingNotifications: a coalesced row updated
|
||||||
|
-- since that read carries a later created_at, holds newer news, and stays
|
||||||
|
-- pending for the next email.
|
||||||
|
UPDATE user_notifications
|
||||||
|
SET emailed_at = now()
|
||||||
|
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
||||||
|
AND created_at <= sqlc.arg(read_as_of)::timestamptz
|
||||||
|
AND emailed_at IS NULL;
|
||||||
|
|
||||||
|
-- name: ListNotificationEmailState :many
|
||||||
|
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
|
||||||
|
FROM user_notification_email_state;
|
||||||
|
|
||||||
|
-- name: UpsertNotificationEmailState :exec
|
||||||
|
INSERT INTO user_notification_email_state
|
||||||
|
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
|
||||||
|
VALUES (sqlc.arg(user_id), sqlc.arg(email_group), sqlc.narg(batch_opened_at),
|
||||||
|
sqlc.narg(last_sent_at), sqlc.arg(failures), sqlc.narg(retry_after))
|
||||||
|
ON CONFLICT (user_id, email_group) DO UPDATE SET
|
||||||
|
batch_opened_at = EXCLUDED.batch_opened_at,
|
||||||
|
last_sent_at = EXCLUDED.last_sent_at,
|
||||||
|
failures = EXCLUDED.failures,
|
||||||
|
retry_after = EXCLUDED.retry_after;
|
||||||
|
|
||||||
|
-- name: GetNotificationEmailSettings :one
|
||||||
|
SELECT * FROM notification_email_settings WHERE id = true;
|
||||||
|
|
||||||
|
-- name: UpdateNotificationEmailSettings :one
|
||||||
|
-- Migration 0074's CHECKs are the backstop behind the service's validation.
|
||||||
|
UPDATE notification_email_settings
|
||||||
|
SET summary_hour = sqlc.arg(summary_hour),
|
||||||
|
batch_window_minutes = sqlc.arg(batch_window_minutes),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING *;
|
||||||
@@ -61,3 +61,6 @@ UPDATE playback_errors
|
|||||||
resolved_by = $2,
|
resolved_by = $2,
|
||||||
resolution = $3
|
resolution = $3
|
||||||
WHERE track_id = $1 AND resolved_at IS NULL;
|
WHERE track_id = $1 AND resolved_at IS NULL;
|
||||||
|
|
||||||
|
-- name: CountUnresolvedPlaybackErrors :one
|
||||||
|
SELECT count(*)::bigint FROM playback_errors WHERE resolved_at IS NULL;
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ var dataTables = []string{
|
|||||||
"track_loudness", // M464
|
"track_loudness", // M464
|
||||||
"album_loudness", // M464
|
"album_loudness", // M464
|
||||||
"user_normalization_prefs", // M464
|
"user_normalization_prefs", // M464
|
||||||
|
// M489. Both cascade from users, but the operator's own admin row is
|
||||||
|
// never deleted, so rows a test wrote for it would otherwise survive.
|
||||||
|
"user_notifications",
|
||||||
|
"user_notification_prefs",
|
||||||
|
"user_notification_email_state", // #5346
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
@@ -154,4 +159,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
|
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
|
||||||
}
|
}
|
||||||
|
// Notification email settings (M489 #5346), reset the same way.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
UPDATE notification_email_settings
|
||||||
|
SET summary_hour = DEFAULT, batch_window_minutes = DEFAULT, updated_at = DEFAULT`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("dbtest.ResetDB reset notification email settings: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,12 +102,37 @@ func runDuplicateSweep(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if runErr == nil {
|
||||||
|
notifyNewDuplicates(finishCtx, q, sweep.StartedAt, logger)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("duplicate sweep complete",
|
logger.Info("duplicate sweep complete",
|
||||||
"candidates", res.Candidates, "groups", res.Groups, "proposed", res.Proposed,
|
"candidates", res.Candidates, "groups", res.Groups, "proposed", res.Proposed,
|
||||||
"suppressed", res.Suppressed, "retired", res.Retired, "oversize", res.Oversize, "err", runErr)
|
"suppressed", res.Suppressed, "retired", res.Retired, "oversize", res.Oversize, "err", runErr)
|
||||||
return res, runErr
|
return res, runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyNewDuplicates tells admins when a sweep proposed a group it had not
|
||||||
|
// proposed before (M489), counting every proposal awaiting review. A sweep
|
||||||
|
// that only re-finds known groups says nothing, so reading the notice once
|
||||||
|
// is enough until something new turns up.
|
||||||
|
func notifyNewDuplicates(ctx context.Context, q *dbq.Queries, sweepStarted pgtype.Timestamptz, logger *slog.Logger) {
|
||||||
|
fresh, err := q.CountDuplicateGroupsDetectedSince(ctx, sweepStarted)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate sweep: counting new proposals failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fresh == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending, err := q.CountPendingDuplicateGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate sweep: counting pending proposals failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyAdmins(ctx, notifications.KindDuplicatesFound, notifications.Payload{Count: pending})
|
||||||
|
}
|
||||||
|
|
||||||
func sweepDuplicates(
|
func sweepDuplicates(
|
||||||
ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, cfg FingerprintSettings, pageSize int32,
|
ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, cfg FingerprintSettings, pageSize int32,
|
||||||
) (DuplicateSweepResult, error) {
|
) (DuplicateSweepResult, error) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package-level notifier for library-health notices to admins (M489): a
|
||||||
|
// scan that failed, tracks gone missing, new duplicates. Set once at startup
|
||||||
|
// beside SetEventBus, for the same reason the bus is package-level. Nil, as in
|
||||||
|
// tests that never set it, means nobody is told.
|
||||||
|
var (
|
||||||
|
notifierMu sync.RWMutex
|
||||||
|
notifier *notifications.Notifier
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetNotifier wires the notifications inbox into the library package.
|
||||||
|
func SetNotifier(n *notifications.Notifier) {
|
||||||
|
notifierMu.Lock()
|
||||||
|
defer notifierMu.Unlock()
|
||||||
|
notifier = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyAdmins tells every admin about a library-health event. These kinds
|
||||||
|
// coalesce, so a burst is one unread notice with a running count.
|
||||||
|
func notifyAdmins(ctx context.Context, kind notifications.Kind, p notifications.Payload) {
|
||||||
|
notifierMu.RLock()
|
||||||
|
n := notifier
|
||||||
|
notifierMu.RUnlock()
|
||||||
|
n.NotifyLogged(ctx, kind, notifications.ToAdmins(pgtype.UUID{}), p.Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyScanFinished tells admins a scan run ended in error. A scan cut short
|
||||||
|
// by shutdown is not a failure anyone needs telling about.
|
||||||
|
func notifyScanFinished(ctx context.Context, errMsg string) {
|
||||||
|
if errMsg == "" || ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyAdmins(ctx, notifications.KindScanFailed, notifications.Payload{Count: 1, Detail: errMsg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifyingAdmin wires a real notifier for the test and returns an admin whose
|
||||||
|
// inbox the test reads.
|
||||||
|
func notifyingAdmin(t *testing.T, pool *pgxpool.Pool) dbq.User {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + "libnotify", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin: %v", err)
|
||||||
|
}
|
||||||
|
SetNotifier(notifications.New(pool, nil, nil))
|
||||||
|
t.Cleanup(func() { SetNotifier(nil) })
|
||||||
|
return admin
|
||||||
|
}
|
||||||
|
|
||||||
|
type notice struct {
|
||||||
|
kind string
|
||||||
|
count int64
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadNotices(t *testing.T, pool *pgxpool.Pool, user dbq.User) []notice {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
|
||||||
|
UserID: user.ID, PageLimit: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list notifications: %v", err)
|
||||||
|
}
|
||||||
|
var out []notice
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.ReadAt.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var p notifications.Payload
|
||||||
|
if err := json.Unmarshal(r.Payload, &p); err != nil {
|
||||||
|
t.Fatalf("payload: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, notice{kind: r.Kind, count: p.Count, body: p.Detail})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyScanFinished_FailuresCoalesceAndShutdownIsSilent(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
admin := notifyingAdmin(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
notifyScanFinished(ctx, "")
|
||||||
|
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||||||
|
t.Fatalf("a clean scan notified: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelled, cancel := context.WithCancel(ctx)
|
||||||
|
cancel()
|
||||||
|
notifyScanFinished(cancelled, "library: context canceled")
|
||||||
|
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||||||
|
t.Fatalf("a scan cut short by shutdown notified: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyScanFinished(ctx, "library: root /music missing")
|
||||||
|
notifyScanFinished(ctx, "library: root /music still missing")
|
||||||
|
got := unreadNotices(t, pool, admin)
|
||||||
|
if len(got) != 1 || got[0].kind != string(notifications.KindScanFailed) || got[0].count != 2 ||
|
||||||
|
got[0].body != "library: root /music still missing" {
|
||||||
|
t.Fatalf("notices = %+v, want one scan_failed counting 2 with the latest error", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileMissing_NotifiesAdminsWithARunningCount(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
admin := notifyingAdmin(t, pool)
|
||||||
|
s := testScanner(t, populatedRoot(t))
|
||||||
|
|
||||||
|
// Two scans, each losing 2 of 10 tracks (under the mark cap).
|
||||||
|
for pass := 0; pass < 2; pass++ {
|
||||||
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
p := fmt.Sprintf("/music/pass-%d-%02d.mp3", pass, i)
|
||||||
|
rows = append(rows, row(byte(pass*10+i), p, false))
|
||||||
|
if i >= 2 {
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stats Stats
|
||||||
|
if err := s.reconcileMissing(context.Background(), &fakeReconciler{rows: rows}, seen, &stats); err != nil {
|
||||||
|
t.Fatalf("reconcile pass %d: %v", pass, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := unreadNotices(t, pool, admin)
|
||||||
|
if len(got) != 1 || got[0].kind != string(notifications.KindTracksMissing) || got[0].count != 4 {
|
||||||
|
t.Fatalf("notices = %+v, want one tracks_missing counting 4", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
admin := notifyingAdmin(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
dir := t.TempDir()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
|
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "seed.mp3"))
|
||||||
|
// Same audio-stream hash: an exact pair, whatever the prints say.
|
||||||
|
pair := func(name string, b byte, seed uint64) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 200000,
|
||||||
|
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d.mp3", name, i)), FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
|
||||||
|
TrackID: tr.ID, AudioStreamSha256: bytes.Repeat([]byte{b}, 32),
|
||||||
|
Chromaprint: randomPrint(seed+uint64(i), printLen), FingerprintVersion: fingerprintVersion,
|
||||||
|
ChromaprintLengthSec: defaultChromaprintLengthSec,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("fingerprint %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sweep := func() {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage); err != nil {
|
||||||
|
t.Fatalf("sweep: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markAllRead := func() {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
|
||||||
|
t.Fatalf("mark read: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pair("first", 1, 100)
|
||||||
|
sweep()
|
||||||
|
got := unreadNotices(t, pool, admin)
|
||||||
|
if len(got) != 1 || got[0].kind != string(notifications.KindDuplicatesFound) || got[0].count != 1 {
|
||||||
|
t.Fatalf("after the first sweep notices = %+v, want one duplicates_found counting 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read, then swept again with nothing new: no reminder.
|
||||||
|
markAllRead()
|
||||||
|
sweep()
|
||||||
|
if got := unreadNotices(t, pool, admin); len(got) != 0 {
|
||||||
|
t.Fatalf("a sweep that found nothing new notified: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new pair is news, and the count is everything awaiting review.
|
||||||
|
pair("second", 2, 200)
|
||||||
|
sweep()
|
||||||
|
got = unreadNotices(t, pool, admin)
|
||||||
|
if len(got) != 1 || got[0].count != 2 {
|
||||||
|
t.Fatalf("after a new pair notices = %+v, want one counting 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ func (s *Scanner) reconcileMissing(
|
|||||||
// this line.
|
// this line.
|
||||||
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
||||||
"count", n, "library_total", len(rows))
|
"count", n, "library_total", len(rows))
|
||||||
|
if n > 0 {
|
||||||
|
notifyAdmins(ctx, notifications.KindTracksMissing, notifications.Payload{Count: n})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ func RunScan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
|
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
|
||||||
|
notifyScanFinished(ctx, errMsg)
|
||||||
publishScanEvent("scan.run_finished", row.ID, map[string]any{
|
publishScanEvent("scan.run_finished", row.ID, map[string]any{
|
||||||
"error_message": errMsg,
|
"error_message": errMsg,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ type Reconciler struct {
|
|||||||
clientFn func() *lidarr.Client
|
clientFn func() *lidarr.Client
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
bus *eventbus.Bus
|
bus *eventbus.Bus
|
||||||
|
notifier *notifications.Notifier // nil: no inbox notifications
|
||||||
tick time.Duration
|
tick time.Duration
|
||||||
batch int32
|
batch int32
|
||||||
// releaseGroup names the MusicBrainz release group of a release id, to
|
// releaseGroup names the MusicBrainz release group of a release id, to
|
||||||
@@ -61,9 +63,28 @@ func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishCompleted broadcasts a request.status_changed event scoped to
|
// SetNotifier makes completions land in the requester's notifications inbox
|
||||||
// the original requester. No-op when bus is nil.
|
// (M489). Without one, completions are only broadcast on the bus.
|
||||||
func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) {
|
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
|
||||||
|
|
||||||
|
// publishCompleted tells the requester their request arrived: a
|
||||||
|
// request.status_changed event for open screens, and a notification that
|
||||||
|
// outlives the connection.
|
||||||
|
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.LidarrRequest) {
|
||||||
|
p := notifications.Payload{
|
||||||
|
RequestID: formatUUIDForBus(row.ID),
|
||||||
|
RequestKind: string(row.Kind),
|
||||||
|
Name: DisplayName(row),
|
||||||
|
Artist: row.ArtistName,
|
||||||
|
Title: requestedTitle(row),
|
||||||
|
}
|
||||||
|
if row.MatchedAlbumID.Valid {
|
||||||
|
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
|
||||||
|
} else if row.MatchedArtistID.Valid {
|
||||||
|
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
|
||||||
|
}
|
||||||
|
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
|
||||||
|
|
||||||
if r.bus == nil {
|
if r.bus == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,7 +329,7 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.publishCompleted(completed)
|
r.publishCompleted(ctx, completed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +354,7 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.publishCompleted(completed)
|
r.publishCompleted(ctx, completed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +395,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.publishCompleted(completed)
|
r.publishCompleted(ctx, completed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package lidarrrequests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// M489: an album request that arrives lands in the requester's inbox once,
|
||||||
|
// linked to the album it matched.
|
||||||
|
func TestReconciler_CompletionNotifiesRequester(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
enableLidarrForPool(t, pool)
|
||||||
|
user := seedUser(t, pool)
|
||||||
|
artist := seedArtist(t, q, "Notify Artist", "notify-artist-mbid")
|
||||||
|
album := seedAlbum(t, q, artist.ID, "Notify Album", "notify-album-mbid")
|
||||||
|
_ = seedTrack(t, q, album.ID, artist.ID, "Notify Track", "/music/notify/01.flac")
|
||||||
|
seedApprovedRequestDirect(t, q, user, CreateParams{
|
||||||
|
Kind: "album", LidarrArtistMBID: "notify-artist-mbid", LidarrAlbumMBID: "notify-album-mbid",
|
||||||
|
ArtistName: "Notify Artist", AlbumTitle: "Notify Album",
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
|
||||||
|
rec.SetNotifier(notifications.New(pool, nil, nil))
|
||||||
|
for i := 0; i < 2; i++ { // the second tick finds nothing left to complete
|
||||||
|
if err := rec.tickOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("tickOnce: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list notifications: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].Kind != string(notifications.KindRequestCompleted) {
|
||||||
|
t.Fatalf("inbox = %+v, want one request_completed", rows)
|
||||||
|
}
|
||||||
|
var p notifications.Payload
|
||||||
|
if err := json.Unmarshal(rows[0].Payload, &p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Name != "Notify Artist – Notify Album" || p.AlbumID != formatUUIDForBus(album.ID) || p.ArtistID != "" {
|
||||||
|
t.Errorf("payload = %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,8 +80,16 @@ func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *
|
|||||||
// Create validates the kind→required-fields invariant and inserts a
|
// Create validates the kind→required-fields invariant and inserts a
|
||||||
// pending row.
|
// pending row.
|
||||||
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
|
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
|
||||||
|
row, _, err := s.CreateTracked(ctx, userID, p)
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTracked is Create, also reporting whether a row was inserted. A
|
||||||
|
// request that dedupes into one already in flight is not new, so callers
|
||||||
|
// that announce new requests (M489) say nothing for it.
|
||||||
|
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, bool, error) {
|
||||||
if err := validateKindFields(p); err != nil {
|
if err := validateKindFields(p); err != nil {
|
||||||
return dbq.LidarrRequest{}, err
|
return dbq.LidarrRequest{}, false, err
|
||||||
}
|
}
|
||||||
q := dbq.New(s.pool)
|
q := dbq.New(s.pool)
|
||||||
// Dedup: if a non-terminal request for this MBID already exists,
|
// Dedup: if a non-terminal request for this MBID already exists,
|
||||||
@@ -97,9 +105,9 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
|
|||||||
dedupMBID = p.LidarrTrackMBID
|
dedupMBID = p.LidarrTrackMBID
|
||||||
}
|
}
|
||||||
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
|
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
|
||||||
return existing, nil
|
return existing, false, nil
|
||||||
} else if !errors.Is(derr, pgx.ErrNoRows) {
|
} else if !errors.Is(derr, pgx.ErrNoRows) {
|
||||||
return dbq.LidarrRequest{}, fmt.Errorf("create: dedup check: %w", derr)
|
return dbq.LidarrRequest{}, false, fmt.Errorf("create: dedup check: %w", derr)
|
||||||
}
|
}
|
||||||
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
|
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -112,9 +120,37 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
|
|||||||
TrackTitle: strPtr(p.TrackTitle),
|
TrackTitle: strPtr(p.TrackTitle),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err)
|
return dbq.LidarrRequest{}, false, fmt.Errorf("create: %w", err)
|
||||||
}
|
}
|
||||||
return row, nil
|
return row, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayName is a request as a person would say it: "Moe Shop" for an
|
||||||
|
// artist, "Moe Shop – WWW" for an album or a track.
|
||||||
|
func DisplayName(row dbq.LidarrRequest) string {
|
||||||
|
switch row.Kind {
|
||||||
|
case dbq.LidarrRequestKindTrack:
|
||||||
|
if row.TrackTitle != nil && *row.TrackTitle != "" {
|
||||||
|
return row.ArtistName + " – " + *row.TrackTitle
|
||||||
|
}
|
||||||
|
case dbq.LidarrRequestKindAlbum:
|
||||||
|
if row.AlbumTitle != nil && *row.AlbumTitle != "" {
|
||||||
|
return row.ArtistName + " – " + *row.AlbumTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.ArtistName
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedTitle is the album or track a request names, or "" for an
|
||||||
|
// artist request. DisplayName is the artist and this, joined.
|
||||||
|
func requestedTitle(row dbq.LidarrRequest) string {
|
||||||
|
switch {
|
||||||
|
case row.Kind == dbq.LidarrRequestKindTrack && row.TrackTitle != nil:
|
||||||
|
return *row.TrackTitle
|
||||||
|
case row.Kind == dbq.LidarrRequestKindAlbum && row.AlbumTitle != nil:
|
||||||
|
return *row.AlbumTitle
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateKindFields(p CreateParams) error {
|
func validateKindFields(p CreateParams) error {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("mailer: load config: %w", err)
|
return fmt.Errorf("mailer: load config: %w", err)
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" {
|
if !Configured(cfg) {
|
||||||
return ErrNotConfigured
|
return ErrNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,13 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured reports whether cfg can send at all: enabled, with a host and a
|
||||||
|
// from address. Send refuses with ErrNotConfigured otherwise, and settings
|
||||||
|
// screens use it to say why email is unavailable before anyone tries.
|
||||||
|
func Configured(cfg dbq.SmtpConfig) bool {
|
||||||
|
return cfg.Enabled && cfg.Host != "" && cfg.FromAddress != ""
|
||||||
|
}
|
||||||
|
|
||||||
// sendMail wraps net/smtp's SendMail with optional TLS verification.
|
// sendMail wraps net/smtp's SendMail with optional TLS verification.
|
||||||
// Mostly identical to smtp.SendMail but explicitly handles the
|
// Mostly identical to smtp.SendMail but explicitly handles the
|
||||||
// use_tls flag.
|
// use_tls flag.
|
||||||
@@ -144,7 +153,7 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "From: %s\r\n", from)
|
fmt.Fprintf(&buf, "From: %s\r\n", from)
|
||||||
fmt.Fprintf(&buf, "To: %s\r\n", to)
|
fmt.Fprintf(&buf, "To: %s\r\n", to)
|
||||||
fmt.Fprintf(&buf, "Subject: %s\r\n", subject)
|
fmt.Fprintf(&buf, "Subject: %s\r\n", encodeSubject(subject))
|
||||||
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
|
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
|
||||||
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
||||||
|
|
||||||
@@ -162,6 +171,15 @@ func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string)
|
|||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeSubject makes subject safe as a header value. Line breaks are
|
||||||
|
// replaced, so a name carried into a subject cannot start a header of its
|
||||||
|
// own, and non-ASCII text ("Moe Shop – WWW") is RFC 2047 encoded, which
|
||||||
|
// mime.QEncoding leaves alone when there is nothing to encode.
|
||||||
|
func encodeSubject(subject string) string {
|
||||||
|
subject = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(subject)
|
||||||
|
return mime.QEncoding.Encode("utf-8", subject)
|
||||||
|
}
|
||||||
|
|
||||||
// SentEmail is the in-memory record FakeSender keeps. Tests assert
|
// SentEmail is the in-memory record FakeSender keeps. Tests assert
|
||||||
// against these.
|
// against these.
|
||||||
type SentEmail struct {
|
type SentEmail struct {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncodeSubject(t *testing.T) {
|
||||||
|
if got := encodeSubject("Minstrel: 2 updates"); got != "Minstrel: 2 updates" {
|
||||||
|
t.Errorf("plain ASCII changed: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := encodeSubject("Minstrel: Moe Shop – WWW")
|
||||||
|
if !strings.HasPrefix(enc, "=?utf-8?q?") {
|
||||||
|
t.Errorf("non-ASCII not RFC 2047 encoded: %q", enc)
|
||||||
|
}
|
||||||
|
if dec, err := new(mime.WordDecoder).DecodeHeader(enc); err != nil || dec != "Minstrel: Moe Shop – WWW" {
|
||||||
|
t.Errorf("round trip = %q, %v", dec, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := encodeSubject("Hi\r\nBcc: everyone@example.com"); strings.ContainsAny(got, "\r\n") {
|
||||||
|
t.Errorf("a line break survived into the header: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The email digest (#5346). Nothing is emailed per event. Operator,
|
||||||
|
// 2026-10-08: "music and stuff coming in should land as a summary email and
|
||||||
|
// not everytime same for a approvals the emails should be grouped by a
|
||||||
|
// reasonable amount of time like an hour or so."
|
||||||
|
//
|
||||||
|
// - EmailSummary kinds (new music) go out at most once a day, at the summary
|
||||||
|
// hour in the user's own timezone, listing everything since the last one.
|
||||||
|
// - EmailBatch kinds go out a batch window after the first un-emailed item,
|
||||||
|
// holding whatever accumulated.
|
||||||
|
// - A row the user has read is never emailed: they have seen it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// digestInterval is how often the digest looks for due groups. It bounds
|
||||||
|
// how late an email can be, not how often one is sent.
|
||||||
|
digestInterval = 5 * time.Minute
|
||||||
|
// maxEmailAge: an item older than this when the digest first gets to it is
|
||||||
|
// not news worth an email. It covers a user who adds an address, or an
|
||||||
|
// SMTP server set up, after weeks of unread notifications.
|
||||||
|
maxEmailAge = 7 * 24 * time.Hour
|
||||||
|
// A failed send waits retryBase, doubling with each failure in a row, up
|
||||||
|
// to retryMax.
|
||||||
|
retryBase = 5 * time.Minute
|
||||||
|
retryMax = 6 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
groupBatch = "batch"
|
||||||
|
groupSummary = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g EmailGroup) key() string {
|
||||||
|
if g == EmailSummary {
|
||||||
|
return groupSummary
|
||||||
|
}
|
||||||
|
return groupBatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupState is where one user's group stands; user_notification_email_state.
|
||||||
|
type groupState struct {
|
||||||
|
BatchOpenedAt time.Time
|
||||||
|
LastSentAt time.Time
|
||||||
|
Failures int32
|
||||||
|
RetryAfter time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingItem is one unread, un-emailed notification.
|
||||||
|
type pendingItem struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Kind Kind
|
||||||
|
Payload []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPlan is what the digest does for one user on one tick.
|
||||||
|
type userPlan struct {
|
||||||
|
// Skip is stamped emailed without sending: email is off for its kind now,
|
||||||
|
// or it is too old to be news.
|
||||||
|
Skip []pendingItem
|
||||||
|
|
||||||
|
Batch []pendingItem
|
||||||
|
BatchDue bool
|
||||||
|
// BatchOpenedAt is the open batch's start, kept until it is sent. Zero
|
||||||
|
// when nothing is pending in the batch group.
|
||||||
|
BatchOpenedAt time.Time
|
||||||
|
|
||||||
|
Summary []pendingItem
|
||||||
|
SummaryDue bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// planUser decides, from the clock and stored state alone, what goes out for
|
||||||
|
// one user. items are oldest first. emailOn is the user's effective email
|
||||||
|
// channel per kind.
|
||||||
|
func planUser(now time.Time, cfg EmailSettings, loc *time.Location, items []pendingItem,
|
||||||
|
emailOn map[Kind]bool, batch, summary groupState) userPlan {
|
||||||
|
var p userPlan
|
||||||
|
for _, it := range items {
|
||||||
|
switch {
|
||||||
|
case !emailOn[it.Kind] || now.Sub(it.CreatedAt) > maxEmailAge:
|
||||||
|
p.Skip = append(p.Skip, it)
|
||||||
|
case it.Kind.EmailGroup() == EmailSummary:
|
||||||
|
p.Summary = append(p.Summary, it)
|
||||||
|
default:
|
||||||
|
p.Batch = append(p.Batch, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Batch) > 0 {
|
||||||
|
p.BatchOpenedAt = batch.BatchOpenedAt
|
||||||
|
if p.BatchOpenedAt.IsZero() {
|
||||||
|
p.BatchOpenedAt = p.Batch[0].CreatedAt
|
||||||
|
}
|
||||||
|
p.BatchDue = !now.Before(batch.RetryAfter) && !now.Before(p.BatchOpenedAt.Add(cfg.BatchWindow()))
|
||||||
|
}
|
||||||
|
if len(p.Summary) > 0 {
|
||||||
|
slot := summarySlot(now, loc, int(cfg.SummaryHour))
|
||||||
|
p.SummaryDue = !now.Before(summary.RetryAfter) && !now.Before(slot) && summary.LastSentAt.Before(slot)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarySlot is today's summary time in loc: the summary hour on the user's
|
||||||
|
// local calendar day. Across a DST change the hour keeps its local meaning,
|
||||||
|
// so the instant moves by the shift. An hour that does not exist that day
|
||||||
|
// (inside a spring-forward gap) is normalised by time.Date to one that does.
|
||||||
|
func summarySlot(now time.Time, loc *time.Location, hour int) time.Time {
|
||||||
|
l := now.In(loc)
|
||||||
|
return time.Date(l.Year(), l.Month(), l.Day(), hour, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryDelay is the wait after the failures-th failure in a row.
|
||||||
|
func retryDelay(failures int32) time.Duration {
|
||||||
|
d := retryBase
|
||||||
|
for i := int32(1); i < failures && d < retryMax; i++ {
|
||||||
|
d *= 2
|
||||||
|
}
|
||||||
|
return min(d, retryMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest sends the grouped emails. One per process, on a ticker.
|
||||||
|
type Digest struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
sender mailer.Sender
|
||||||
|
logger *slog.Logger
|
||||||
|
publicURL func(context.Context) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDigest returns a Digest sending through sender. Links in its emails are
|
||||||
|
// built from the public address in admin Settings, never from a request.
|
||||||
|
func NewDigest(pool *pgxpool.Pool, sender mailer.Sender, logger *slog.Logger) *Digest {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
d := &Digest{pool: pool, sender: sender, logger: logger}
|
||||||
|
d.publicURL = func(ctx context.Context) string {
|
||||||
|
row, err := dbq.New(pool).GetNetworkSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row.PublicUrl
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is cancelled. It ticks once at startup, so a process
|
||||||
|
// that has been down past a batch or a summary hour catches up, then every
|
||||||
|
// few minutes.
|
||||||
|
func (d *Digest) Run(ctx context.Context) {
|
||||||
|
d.Tick(ctx, time.Now())
|
||||||
|
t := time.NewTicker(digestInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
d.Tick(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick sends every group that is due as of now. Failures are logged and
|
||||||
|
// retried on a later tick; nothing here is fatal.
|
||||||
|
func (d *Digest) Tick(ctx context.Context, now time.Time) {
|
||||||
|
q := dbq.New(d.pool)
|
||||||
|
cfg, err := LoadEmailSettings(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("notification digest: using default settings", "err", err)
|
||||||
|
}
|
||||||
|
rows, err := q.ListEmailPendingNotifications(ctx)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("notification digest: list pending", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
states, err := d.loadStates(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("notification digest: load state", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := d.publicURL(ctx)
|
||||||
|
|
||||||
|
handled := map[pgtype.UUID]bool{}
|
||||||
|
for start := 0; start < len(rows); {
|
||||||
|
end := start + 1
|
||||||
|
for end < len(rows) && rows[end].UserID == rows[start].UserID {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
u := rows[start]
|
||||||
|
handled[u.UserID] = true
|
||||||
|
d.digestUser(ctx, q, now, cfg, base, rows[start:end], states[u.UserID])
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with nothing pending at all whose batch is still open read
|
||||||
|
// everything before it came due. Close it, so their next item opens a
|
||||||
|
// fresh batch rather than going out at once.
|
||||||
|
for userID, st := range states {
|
||||||
|
if !handled[userID] && !st[groupBatch].BatchOpenedAt.IsZero() {
|
||||||
|
closed := st[groupBatch]
|
||||||
|
closed.BatchOpenedAt = time.Time{}
|
||||||
|
d.saveState(ctx, q, userID, groupBatch, closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestUser handles one user's pending rows.
|
||||||
|
func (d *Digest) digestUser(ctx context.Context, q *dbq.Queries, now time.Time, cfg EmailSettings, base string,
|
||||||
|
rows []dbq.ListEmailPendingNotificationsRow, st map[string]groupState) {
|
||||||
|
u := rows[0]
|
||||||
|
emailOn, err := emailChannels(ctx, q, u.UserID)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("notification digest: read prefs", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]pendingItem, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
items[i] = pendingItem{ID: r.ID, Kind: Kind(r.Kind), Payload: r.Payload, CreatedAt: r.CreatedAt.Time}
|
||||||
|
}
|
||||||
|
plan := planUser(now, cfg, userLocation(u.Timezone), items, emailOn, st[groupBatch], st[groupSummary])
|
||||||
|
readAsOf := u.ReadAsOf.Time
|
||||||
|
|
||||||
|
if len(plan.Skip) > 0 {
|
||||||
|
if _, err := q.MarkNotificationsEmailed(ctx, dbq.MarkNotificationsEmailedParams{
|
||||||
|
Ids: itemIDs(plan.Skip), ReadAsOf: ts(readAsOf),
|
||||||
|
}); err != nil {
|
||||||
|
d.logger.Warn("notification digest: stamp skipped", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The batch's start is recorded before anything is sent, so a failed send
|
||||||
|
// below keeps it along with its retry. With nothing left in the batch
|
||||||
|
// group (all read, or skipped), an open batch is closed.
|
||||||
|
batchSt := st[groupBatch]
|
||||||
|
if !plan.BatchOpenedAt.Equal(batchSt.BatchOpenedAt) {
|
||||||
|
batchSt.BatchOpenedAt = plan.BatchOpenedAt
|
||||||
|
d.saveState(ctx, q, u.UserID, groupBatch, batchSt)
|
||||||
|
}
|
||||||
|
|
||||||
|
to := recipient{email: u.Email, name: displayName(u)}
|
||||||
|
if plan.SummaryDue {
|
||||||
|
d.send(ctx, q, now, u.UserID, to, EmailSummary, plan.Summary, st[groupSummary], base, readAsOf)
|
||||||
|
}
|
||||||
|
if plan.BatchDue {
|
||||||
|
d.send(ctx, q, now, u.UserID, to, EmailBatch, plan.Batch, batchSt, base, readAsOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recipient struct{ email, name string }
|
||||||
|
|
||||||
|
// send renders and sends one group's email. Only once the mailer has accepted
|
||||||
|
// it are its rows stamped and the group's state reset, in one transaction, so
|
||||||
|
// a failure sends again later and a success never sends twice.
|
||||||
|
func (d *Digest) send(ctx context.Context, q *dbq.Queries, now time.Time, userID pgtype.UUID, to recipient,
|
||||||
|
group EmailGroup, items []pendingItem, st groupState, base string, readAsOf time.Time) {
|
||||||
|
msg, err := renderDigest(group, to.name, items, base)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("notification digest: render", "group", group.key(), "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = d.sender.Send(ctx, to.email, msg.Subject, msg.Text, msg.HTML)
|
||||||
|
if errors.Is(err, mailer.ErrNotConfigured) {
|
||||||
|
// Not a failure to back off from: nothing can go until SMTP is set
|
||||||
|
// up, and the rows wait, unread and un-emailed, until it is.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
st.Failures++
|
||||||
|
st.RetryAfter = now.Add(retryDelay(st.Failures))
|
||||||
|
d.logger.Warn("notification digest: send failed",
|
||||||
|
"group", group.key(), "failures", st.Failures, "retry_after", st.RetryAfter, "err", err)
|
||||||
|
d.saveState(ctx, q, userID, group.key(), st)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := groupState{LastSentAt: now}
|
||||||
|
if err := pgx.BeginFunc(ctx, d.pool, func(tx pgx.Tx) error {
|
||||||
|
tq := dbq.New(tx)
|
||||||
|
if _, err := tq.MarkNotificationsEmailed(ctx, dbq.MarkNotificationsEmailedParams{
|
||||||
|
Ids: itemIDs(items), ReadAsOf: ts(readAsOf),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tq.UpsertNotificationEmailState(ctx, stateParams(userID, group.key(), sent))
|
||||||
|
}); err != nil {
|
||||||
|
// The email is out but its rows are not stamped, so the next due
|
||||||
|
// group would repeat them. Logged at ERROR: it needs a look.
|
||||||
|
d.logger.Error("notification digest: sent but not recorded", "group", group.key(), "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Digest) loadStates(ctx context.Context, q *dbq.Queries) (map[pgtype.UUID]map[string]groupState, error) {
|
||||||
|
rows, err := q.ListNotificationEmailState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[pgtype.UUID]map[string]groupState, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if out[r.UserID] == nil {
|
||||||
|
out[r.UserID] = map[string]groupState{}
|
||||||
|
}
|
||||||
|
out[r.UserID][r.EmailGroup] = groupState{
|
||||||
|
BatchOpenedAt: tsTime(r.BatchOpenedAt),
|
||||||
|
LastSentAt: tsTime(r.LastSentAt),
|
||||||
|
Failures: r.Failures,
|
||||||
|
RetryAfter: tsTime(r.RetryAfter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Digest) saveState(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, group string, st groupState) {
|
||||||
|
if err := q.UpsertNotificationEmailState(ctx, stateParams(userID, group, st)); err != nil {
|
||||||
|
d.logger.Warn("notification digest: save state", "group", group, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailChannels is the user's effective email channel for every kind: their
|
||||||
|
// stored preference, or the kind's default.
|
||||||
|
func emailChannels(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (map[Kind]bool, error) {
|
||||||
|
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prefs: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[Kind]bool, len(order))
|
||||||
|
for _, k := range order {
|
||||||
|
out[k] = k.Defaults().Effective().Email
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}.Effective().Email
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLocation is the user's timezone, or UTC when it does not parse.
|
||||||
|
func userLocation(tz string) *time.Location {
|
||||||
|
if loc, err := time.LoadLocation(tz); err == nil && tz != "" {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayName(u dbq.ListEmailPendingNotificationsRow) string {
|
||||||
|
if u.DisplayName != nil && *u.DisplayName != "" {
|
||||||
|
return *u.DisplayName
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemIDs(items []pendingItem) []pgtype.UUID {
|
||||||
|
out := make([]pgtype.UUID, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.ID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateParams(userID pgtype.UUID, group string, st groupState) dbq.UpsertNotificationEmailStateParams {
|
||||||
|
return dbq.UpsertNotificationEmailStateParams{
|
||||||
|
UserID: userID,
|
||||||
|
EmailGroup: group,
|
||||||
|
BatchOpenedAt: ts(st.BatchOpenedAt),
|
||||||
|
LastSentAt: ts(st.LastSentAt),
|
||||||
|
Failures: st.Failures,
|
||||||
|
RetryAfter: ts(st.RetryAfter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ts(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} }
|
||||||
|
|
||||||
|
func tsTime(t pgtype.Timestamptz) time.Time {
|
||||||
|
if !t.Valid {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return t.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package notifications_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
// emailUser is a user with an address, so the digest can reach them.
|
||||||
|
func emailUser(t *testing.T, pool *pgxpool.Pool, name string, admin bool) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
id := mkUser(t, dbq.New(pool), name, admin)
|
||||||
|
_, err := pool.Exec(context.Background(), "UPDATE users SET email = $2 WHERE id = $1", id, name+"@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func digestWith(pool *pgxpool.Pool) (*notifications.Digest, *mailer.FakeSender) {
|
||||||
|
fake := &mailer.FakeSender{}
|
||||||
|
return notifications.NewDigest(pool, fake, nil), fake
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_BatchGoesOutOnceAWindowAfterTheFirstItem(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := emailUser(t, pool, "alice", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice),
|
||||||
|
notifications.Payload{Name: "Moe Shop – WWW"}.Map()))
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestRejected, notifications.ToUser(alice),
|
||||||
|
notifications.Payload{Name: "Tycho – Awake", Reason: "already have it"}.Map()))
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(30*time.Minute))
|
||||||
|
require.Empty(t, fake.Sent, "inside the window nothing goes")
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(61*time.Minute))
|
||||||
|
require.Len(t, fake.Sent, 1, "both items, one email")
|
||||||
|
require.Equal(t, "alice@example.com", fake.Sent[0].To)
|
||||||
|
require.Equal(t, "Minstrel: 2 updates", fake.Sent[0].Subject)
|
||||||
|
require.Contains(t, fake.Sent[0].TextBody, "Moe Shop – WWW is on its way.")
|
||||||
|
require.Contains(t, fake.Sent[0].TextBody, "Tycho – Awake was declined: already have it")
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(3*time.Hour))
|
||||||
|
require.Len(t, fake.Sent, 1, "never sent twice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_ReadBeforeTheEmailIsNotEmailed(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
alice := emailUser(t, pool, "alice", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
|
||||||
|
_, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: alice})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(2*time.Hour))
|
||||||
|
require.Empty(t, fake.Sent, "everything was read: nothing to send")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_EmailOffAtNotifyTimeIsNeverSent(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
alice := emailUser(t, pool, "alice", false)
|
||||||
|
off := false
|
||||||
|
_, err := notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{
|
||||||
|
{Kind: notifications.KindRequestApproved, Email: &off},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
|
||||||
|
on := true
|
||||||
|
_, err = notifications.SaveSettings(ctx, q, alice, false, []notifications.SettingChange{
|
||||||
|
{Kind: notifications.KindRequestApproved, Email: &on},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(2*time.Hour))
|
||||||
|
require.Empty(t, fake.Sent, "turning email on later does not send what came before")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_NoAddressNoEmail(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
bob := mkUser(t, dbq.New(pool), "bob", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(bob), nil))
|
||||||
|
d.Tick(ctx, time.Now().Add(2*time.Hour))
|
||||||
|
require.Empty(t, fake.Sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_CoalescedItemAppearsOnceWithItsLatestCount(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
root := emailUser(t, pool, "root", true)
|
||||||
|
on := true
|
||||||
|
_, err := notifications.SaveSettings(ctx, q, root, true, []notifications.SettingChange{
|
||||||
|
{Kind: notifications.KindTracksMissing, Email: &on},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for _, c := range []int64{3, 2} {
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing,
|
||||||
|
notifications.ToAdmins(pgtype.UUID{}), notifications.Payload{Count: c}.Map()))
|
||||||
|
}
|
||||||
|
d.Tick(ctx, start.Add(2*time.Hour))
|
||||||
|
require.Len(t, fake.Sent, 1)
|
||||||
|
require.Equal(t, "Minstrel: 5 tracks went missing", fake.Sent[0].Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_MailerFailureRetriesWithBackoffAndSendsOnce(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := emailUser(t, pool, "alice", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
|
||||||
|
|
||||||
|
fake.FailNext = errors.New("smtp: 421 try later")
|
||||||
|
d.Tick(ctx, start.Add(61*time.Minute))
|
||||||
|
require.Empty(t, fake.Sent)
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(63*time.Minute))
|
||||||
|
require.Empty(t, fake.Sent, "waits out the first retry gap")
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(67*time.Minute))
|
||||||
|
require.Len(t, fake.Sent, 1, "then goes")
|
||||||
|
|
||||||
|
d.Tick(ctx, start.Add(80*time.Minute))
|
||||||
|
require.Len(t, fake.Sent, 1, "and is not sent again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_NewMusicWaitsForTheSummaryHour(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := emailUser(t, pool, "alice", false) // timezone defaults to UTC
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
slot := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, time.UTC)
|
||||||
|
if !slot.After(now) {
|
||||||
|
slot = slot.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range []notifications.Payload{
|
||||||
|
{Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW"},
|
||||||
|
{Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure"},
|
||||||
|
} {
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestCompleted, notifications.ToUser(alice), p.Map()))
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Tick(ctx, slot.Add(-time.Minute))
|
||||||
|
require.Empty(t, fake.Sent, "new music never goes out in an hourly batch")
|
||||||
|
|
||||||
|
d.Tick(ctx, slot)
|
||||||
|
require.Len(t, fake.Sent, 1)
|
||||||
|
require.Equal(t, "Minstrel: new music in your library", fake.Sent[0].Subject)
|
||||||
|
require.Contains(t, fake.Sent[0].TextBody, "Moe Shop\n - WWW")
|
||||||
|
require.Contains(t, fake.Sent[0].TextBody, " - Pure")
|
||||||
|
|
||||||
|
d.Tick(ctx, slot.Add(5*time.Hour))
|
||||||
|
require.Len(t, fake.Sent, 1, "one summary a day")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailSettings_DefaultsMatchTheMigrationAndRoundTrip(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
got, err := notifications.LoadEmailSettings(ctx, q)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, notifications.DefaultEmailSettings, got)
|
||||||
|
|
||||||
|
saved, err := notifications.SaveEmailSettings(ctx, q, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, notifications.EmailSettings{SummaryHour: 7, BatchWindowMinutes: 30}, saved)
|
||||||
|
got, err = notifications.LoadEmailSettings(ctx, q)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, saved, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigest_UsesTheConfiguredBatchWindow(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := emailUser(t, pool, "alice", false)
|
||||||
|
_, err := notifications.SaveEmailSettings(ctx, dbq.New(pool), notifications.EmailSettings{SummaryHour: 9, BatchWindowMinutes: 15})
|
||||||
|
require.NoError(t, err)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
d, fake := digestWith(pool)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
|
||||||
|
d.Tick(ctx, start.Add(16*time.Minute))
|
||||||
|
require.Len(t, fake.Sent, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
htmltemplate "html/template"
|
||||||
|
"strings"
|
||||||
|
texttemplate "text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/digest.txt templates/digest.html
|
||||||
|
var digestFS embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
digestText = texttemplate.Must(texttemplate.ParseFS(digestFS, "templates/digest.txt"))
|
||||||
|
digestHTML = htmltemplate.Must(htmltemplate.ParseFS(digestFS, "templates/digest.html"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// digestEmail is one rendered email.
|
||||||
|
type digestEmail struct {
|
||||||
|
Subject string
|
||||||
|
Text string
|
||||||
|
HTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
type digestLine struct {
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type digestArtist struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Titles []digestLine
|
||||||
|
}
|
||||||
|
|
||||||
|
type digestVars struct {
|
||||||
|
Name string
|
||||||
|
Intro string
|
||||||
|
Items []digestLine
|
||||||
|
Artists []digestArtist
|
||||||
|
SettingsURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDigest renders one group's email. base is the public address; with
|
||||||
|
// none set, the email still goes out, without links.
|
||||||
|
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
|
||||||
|
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings#notifications")}
|
||||||
|
var subject string
|
||||||
|
if group == EmailSummary {
|
||||||
|
vars.Artists = summaryArtists(items, base)
|
||||||
|
vars.Intro = "New in your library since the last summary:"
|
||||||
|
subject = "Minstrel: new music in your library"
|
||||||
|
} else {
|
||||||
|
for _, it := range items {
|
||||||
|
r := Render(it.Kind, it.Payload)
|
||||||
|
vars.Items = append(vars.Items, digestLine{Title: r.Title, Body: r.Body, URL: joinURL(base, r.Link)})
|
||||||
|
}
|
||||||
|
vars.Intro = "Here's what happened on Minstrel:"
|
||||||
|
subject = fmt.Sprintf("Minstrel: %d updates", len(items))
|
||||||
|
if len(items) == 1 {
|
||||||
|
subject = "Minstrel: " + vars.Items[0].Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text, html bytes.Buffer
|
||||||
|
if err := digestText.Execute(&text, vars); err != nil {
|
||||||
|
return digestEmail{}, fmt.Errorf("render text: %w", err)
|
||||||
|
}
|
||||||
|
if err := digestHTML.Execute(&html, vars); err != nil {
|
||||||
|
return digestEmail{}, fmt.Errorf("render html: %w", err)
|
||||||
|
}
|
||||||
|
return digestEmail{Subject: subject, Text: text.String(), HTML: html.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summaryArtists groups new-music arrivals by artist, in the order each
|
||||||
|
// artist first arrived, with each artist's albums and tracks under them. An
|
||||||
|
// artist request has no title of its own; the artist links to its page.
|
||||||
|
func summaryArtists(items []pendingItem, base string) []digestArtist {
|
||||||
|
var out []digestArtist
|
||||||
|
index := map[string]int{}
|
||||||
|
for _, it := range items {
|
||||||
|
var p Payload
|
||||||
|
_ = json.Unmarshal(it.Payload, &p)
|
||||||
|
artist, title := p.Artist, p.Title
|
||||||
|
if artist == "" {
|
||||||
|
// Rows from before Artist was recorded carry only "Artist – Title".
|
||||||
|
artist, title, _ = strings.Cut(p.Name, " – ")
|
||||||
|
}
|
||||||
|
if artist == "" {
|
||||||
|
artist = "Something you asked for"
|
||||||
|
}
|
||||||
|
i, ok := index[artist]
|
||||||
|
if !ok {
|
||||||
|
i = len(out)
|
||||||
|
index[artist] = i
|
||||||
|
out = append(out, digestArtist{Name: artist})
|
||||||
|
}
|
||||||
|
link := Render(it.Kind, it.Payload).Link
|
||||||
|
if title == "" {
|
||||||
|
if p.ArtistID != "" {
|
||||||
|
out[i].URL = joinURL(base, link)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i].Titles = append(out[i].Titles, digestLine{Title: title, URL: joinURL(base, link)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinURL is base followed by path, or "" when no public address is set.
|
||||||
|
func joinURL(base, path string) string {
|
||||||
|
if base == "" || path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/") + path
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
digestCfg = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60}
|
||||||
|
t0 = time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
func item(k Kind, at time.Time) pendingItem {
|
||||||
|
return pendingItem{ID: pgtype.UUID{Bytes: [16]byte{byte(at.Minute()), byte(len(k))}, Valid: true}, Kind: k, CreatedAt: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allEmail() map[Kind]bool {
|
||||||
|
m := map[Kind]bool{}
|
||||||
|
for _, k := range Kinds() {
|
||||||
|
m[k] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUser_BatchWindow(t *testing.T) {
|
||||||
|
first := item(KindRequestApproved, t0)
|
||||||
|
later := item(KindRequestRejected, t0.Add(40*time.Minute))
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
now time.Time
|
||||||
|
state groupState
|
||||||
|
items []pendingItem
|
||||||
|
wantDue bool
|
||||||
|
wantOpen time.Time
|
||||||
|
}{
|
||||||
|
{"opens at the first item and is not due inside the window", t0.Add(59 * time.Minute), groupState{}, []pendingItem{first, later}, false, t0},
|
||||||
|
{"due a window after the first item, carrying everything since", t0.Add(60 * time.Minute), groupState{}, []pendingItem{first, later}, true, t0},
|
||||||
|
{"a recorded start wins over an item that moved later", t0.Add(61 * time.Minute), groupState{BatchOpenedAt: t0}, []pendingItem{later}, true, t0},
|
||||||
|
{"a failed send waits out its retry", t0.Add(90 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, false, t0},
|
||||||
|
{"and goes once the retry has passed", t0.Add(96 * time.Minute), groupState{BatchOpenedAt: t0, RetryAfter: t0.Add(95 * time.Minute)}, []pendingItem{first}, true, t0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
p := planUser(c.now, digestCfg, time.UTC, c.items, allEmail(), c.state, groupState{})
|
||||||
|
require.Equal(t, c.wantDue, p.BatchDue)
|
||||||
|
require.Equal(t, c.wantOpen, p.BatchOpenedAt)
|
||||||
|
require.Len(t, p.Batch, len(c.items))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUser_QuietBatchSendsNothing(t *testing.T) {
|
||||||
|
p := planUser(t0.Add(3*time.Hour), digestCfg, time.UTC, nil, allEmail(), groupState{BatchOpenedAt: t0}, groupState{})
|
||||||
|
require.False(t, p.BatchDue)
|
||||||
|
require.True(t, p.BatchOpenedAt.IsZero(), "nothing pending leaves no batch open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUser_SummaryHourInUserTimezone(t *testing.T) {
|
||||||
|
ny, err := time.LoadLocation("America/New_York")
|
||||||
|
require.NoError(t, err)
|
||||||
|
arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 7, 1, 3, 0, 0, 0, time.UTC))}
|
||||||
|
// 09:00 in New York in July is 13:00 UTC (EDT, UTC-4).
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
now time.Time
|
||||||
|
lastSent time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"before the local hour", time.Date(2026, 7, 1, 12, 59, 0, 0, time.UTC), time.Time{}, false},
|
||||||
|
{"at the local hour", time.Date(2026, 7, 1, 13, 0, 0, 0, time.UTC), time.Time{}, true},
|
||||||
|
{"once a day: already sent after today's hour", time.Date(2026, 7, 1, 18, 0, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false},
|
||||||
|
{"yesterday's summary does not hold today's", time.Date(2026, 7, 2, 13, 5, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), true},
|
||||||
|
// 03:30 UTC on 2 July is 23:30 on 1 July in New York: still the 1st there.
|
||||||
|
{"the local day, not the UTC day", time.Date(2026, 7, 2, 3, 30, 0, 0, time.UTC), time.Date(2026, 7, 1, 13, 1, 0, 0, time.UTC), false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
p := planUser(c.now, digestCfg, ny, arrived, allEmail(), groupState{}, groupState{LastSentAt: c.lastSent})
|
||||||
|
require.Equal(t, c.want, p.SummaryDue)
|
||||||
|
require.False(t, p.BatchDue, "new music never goes out in a batch")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUser_SummaryAcrossDST(t *testing.T) {
|
||||||
|
ny, err := time.LoadLocation("America/New_York")
|
||||||
|
require.NoError(t, err)
|
||||||
|
arrived := []pendingItem{item(KindRequestCompleted, time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC))}
|
||||||
|
// Clocks go back on 1 November 2026: 09:00 the day before is 13:00 UTC
|
||||||
|
// (EDT), and 09:00 that day is 14:00 UTC (EST).
|
||||||
|
before := groupState{LastSentAt: time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC)}
|
||||||
|
p := planUser(time.Date(2026, 11, 1, 13, 30, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before)
|
||||||
|
require.False(t, p.SummaryDue, "08:30 local after the change is not yet the hour")
|
||||||
|
p = planUser(time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), digestCfg, ny, arrived, allEmail(), groupState{}, before)
|
||||||
|
require.True(t, p.SummaryDue, "09:00 local after the change")
|
||||||
|
|
||||||
|
// Spring forward, 8 March 2026: 02:00 does not exist. A summary hour of
|
||||||
|
// 2 still goes out that day, once.
|
||||||
|
gap := EmailSettings{SummaryHour: 2, BatchWindowMinutes: 60}
|
||||||
|
spring := []pendingItem{item(KindRequestCompleted, time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC))}
|
||||||
|
at := time.Date(2026, 3, 8, 9, 0, 0, 0, time.UTC) // 05:00 EDT
|
||||||
|
p = planUser(at, gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: time.Date(2026, 3, 7, 7, 0, 0, 0, time.UTC)})
|
||||||
|
require.True(t, p.SummaryDue)
|
||||||
|
p = planUser(at.Add(time.Hour), gap, ny, spring, allEmail(), groupState{}, groupState{LastSentAt: at})
|
||||||
|
require.False(t, p.SummaryDue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanUser_EmailOffAndStaleItemsAreSkippedNotSent(t *testing.T) {
|
||||||
|
off := allEmail()
|
||||||
|
off[KindRequestRejected] = false
|
||||||
|
items := []pendingItem{
|
||||||
|
item(KindRequestRejected, t0),
|
||||||
|
item(KindRequestApproved, t0.Add(-8*24*time.Hour)),
|
||||||
|
}
|
||||||
|
p := planUser(t0.Add(2*time.Hour), digestCfg, time.UTC, items, off, groupState{}, groupState{})
|
||||||
|
require.Len(t, p.Skip, 2)
|
||||||
|
require.Empty(t, p.Batch)
|
||||||
|
require.False(t, p.BatchDue, "nothing left after filtering: no email")
|
||||||
|
require.False(t, p.SummaryDue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryDelay_DoublesToACap(t *testing.T) {
|
||||||
|
require.Equal(t, 5*time.Minute, retryDelay(1))
|
||||||
|
require.Equal(t, 10*time.Minute, retryDelay(2))
|
||||||
|
require.Equal(t, 40*time.Minute, retryDelay(4))
|
||||||
|
require.Equal(t, 6*time.Hour, retryDelay(20))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDigest_SummaryGroupsByArtist(t *testing.T) {
|
||||||
|
arrival := func(p Payload) pendingItem {
|
||||||
|
b, _ := json.Marshal(p.Map())
|
||||||
|
return pendingItem{Kind: KindRequestCompleted, Payload: b}
|
||||||
|
}
|
||||||
|
items := []pendingItem{
|
||||||
|
arrival(Payload{Name: "Moe Shop – WWW", Artist: "Moe Shop", Title: "WWW", AlbumID: "al-1"}),
|
||||||
|
arrival(Payload{Name: "Boards of Canada", Artist: "Boards of Canada", ArtistID: "ar-2"}),
|
||||||
|
arrival(Payload{Name: "Moe Shop – Pure", Artist: "Moe Shop", Title: "Pure", AlbumID: "al-3"}),
|
||||||
|
arrival(Payload{Name: "Tycho – Awake"}), // stored before Artist was recorded
|
||||||
|
}
|
||||||
|
e, err := renderDigest(EmailSummary, "alice", items, "https://music.example/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Minstrel: new music in your library", e.Subject)
|
||||||
|
|
||||||
|
moe := strings.Index(e.Text, "Moe Shop")
|
||||||
|
require.GreaterOrEqual(t, moe, 0)
|
||||||
|
require.Equal(t, moe, strings.LastIndex(e.Text, "Moe Shop\n"), "one heading per artist")
|
||||||
|
require.Less(t, moe, strings.Index(e.Text, "Boards of Canada"), "artists in the order they arrived")
|
||||||
|
require.Contains(t, e.Text, " - WWW\n https://music.example/albums/al-1")
|
||||||
|
require.Contains(t, e.Text, " - Pure\n https://music.example/albums/al-3")
|
||||||
|
require.Contains(t, e.Text, "Boards of Canada\n https://music.example/artists/ar-2")
|
||||||
|
require.Contains(t, e.Text, "Tycho\n - Awake")
|
||||||
|
require.Contains(t, e.Text, "https://music.example/settings#notifications")
|
||||||
|
require.Contains(t, e.HTML, `<a href="https://music.example/albums/al-1"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDigest_BatchListsEachItemAndEscapesHTML(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(Payload{Name: "<script>x</script>", Reason: "dup"}.Map())
|
||||||
|
items := []pendingItem{
|
||||||
|
{Kind: KindRequestApproved, Payload: []byte(`{"name":"WWW"}`)},
|
||||||
|
{Kind: KindRequestRejected, Payload: b},
|
||||||
|
}
|
||||||
|
e, err := renderDigest(EmailBatch, "alice", items, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Minstrel: 2 updates", e.Subject)
|
||||||
|
require.Contains(t, e.Text, "- Request approved\n WWW is on its way.")
|
||||||
|
require.NotContains(t, e.Text, "http", "no public address: no links")
|
||||||
|
require.Contains(t, e.Text, "Settings → Notifications")
|
||||||
|
require.NotContains(t, e.HTML, "<script>")
|
||||||
|
|
||||||
|
one, err := renderDigest(EmailBatch, "alice", items[:1], "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Minstrel: Request approved", one.Subject)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailSettings are the digest's two knobs, in admin Settings (rule 25).
|
||||||
|
type EmailSettings struct {
|
||||||
|
// SummaryHour is the local hour (0-23, in each user's own timezone) the
|
||||||
|
// daily new-music summary goes out.
|
||||||
|
SummaryHour int32
|
||||||
|
// BatchWindowMinutes is how long a batch stays open after its first item.
|
||||||
|
BatchWindowMinutes int32
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minBatchWindowMinutes = 15
|
||||||
|
maxBatchWindowMinutes = 1440
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEmailSettings mirrors migration 0074's column defaults.
|
||||||
|
var DefaultEmailSettings = EmailSettings{SummaryHour: 9, BatchWindowMinutes: 60}
|
||||||
|
|
||||||
|
// ErrEmailSettingOutOfRange is a value migration 0074's CHECKs would reject,
|
||||||
|
// so the API answers 400 naming the field rather than a constraint violation.
|
||||||
|
var ErrEmailSettingOutOfRange = errors.New("notification email setting out of range")
|
||||||
|
|
||||||
|
// BatchWindow is BatchWindowMinutes as a duration.
|
||||||
|
func (s EmailSettings) BatchWindow() time.Duration {
|
||||||
|
return time.Duration(s.BatchWindowMinutes) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEmailSettings reads the settings. A failed read returns the defaults
|
||||||
|
// with the error, so the digest keeps its shipped cadence rather than stopping.
|
||||||
|
func LoadEmailSettings(ctx context.Context, q *dbq.Queries) (EmailSettings, error) {
|
||||||
|
row, err := q.GetNotificationEmailSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DefaultEmailSettings, fmt.Errorf("notification email settings: load: %w", err)
|
||||||
|
}
|
||||||
|
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveEmailSettings validates and writes the settings, returning them as saved.
|
||||||
|
// The digest reads them on its next tick; nothing is cached.
|
||||||
|
func SaveEmailSettings(ctx context.Context, q *dbq.Queries, in EmailSettings) (EmailSettings, error) {
|
||||||
|
switch {
|
||||||
|
case in.SummaryHour < 0 || in.SummaryHour > 23:
|
||||||
|
return EmailSettings{}, fmt.Errorf("%w: summary_hour must be 0-23", ErrEmailSettingOutOfRange)
|
||||||
|
case in.BatchWindowMinutes < minBatchWindowMinutes || in.BatchWindowMinutes > maxBatchWindowMinutes:
|
||||||
|
return EmailSettings{}, fmt.Errorf("%w: batch_window_minutes must be %d-%d",
|
||||||
|
ErrEmailSettingOutOfRange, minBatchWindowMinutes, maxBatchWindowMinutes)
|
||||||
|
}
|
||||||
|
row, err := q.UpdateNotificationEmailSettings(ctx, dbq.UpdateNotificationEmailSettingsParams{
|
||||||
|
SummaryHour: in.SummaryHour,
|
||||||
|
BatchWindowMinutes: in.BatchWindowMinutes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return EmailSettings{}, fmt.Errorf("notification email settings: save: %w", err)
|
||||||
|
}
|
||||||
|
return EmailSettings{SummaryHour: row.SummaryHour, BatchWindowMinutes: row.BatchWindowMinutes}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Package notifications is the one writer of the per-user notifications inbox
|
||||||
|
// (M489, #726). Producers across the server call Notifier.Notify; nothing else
|
||||||
|
// inserts into user_notifications.
|
||||||
|
//
|
||||||
|
// The event bus alone was fire-and-forget: a client that was not connected
|
||||||
|
// when a request completed, or when tracks went missing, never heard of it. A
|
||||||
|
// row here is the durable record. The bus only nudges open clients to come
|
||||||
|
// and read it.
|
||||||
|
package notifications
|
||||||
|
|
||||||
|
// Kind names one sort of notification. The set is CHECK-gated in migration
|
||||||
|
// 0073 (rule 36): a new kind adds its value there, in the same change, and
|
||||||
|
// TestKindsMatchMigrationCheck fails until it does.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindRequestApproved Kind = "request_approved"
|
||||||
|
KindRequestRejected Kind = "request_rejected"
|
||||||
|
KindRequestCompleted Kind = "request_completed"
|
||||||
|
KindRequestPending Kind = "request_pending"
|
||||||
|
KindQuarantineFlagged Kind = "quarantine_flagged"
|
||||||
|
KindScanFailed Kind = "scan_failed"
|
||||||
|
KindTracksMissing Kind = "tracks_missing"
|
||||||
|
KindDuplicatesFound Kind = "duplicates_found"
|
||||||
|
KindPlaybackErrors Kind = "playback_errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audience is who a kind can reach. Admin kinds are never offered to, or
|
||||||
|
// stored for, a non-admin.
|
||||||
|
type Audience int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AudienceRequester Audience = iota
|
||||||
|
AudienceAdmin
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailGroup is how a kind's emails are grouped. Nothing is emailed per event.
|
||||||
|
type EmailGroup int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EmailBatch: one email per batch window, holding everything that
|
||||||
|
// accumulated since the first un-emailed item.
|
||||||
|
EmailBatch EmailGroup = iota
|
||||||
|
// EmailSummary: at most one summary a day, at a set local hour. For new
|
||||||
|
// music arriving, which comes in bursts and is not urgent.
|
||||||
|
EmailSummary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Channels are where a kind reaches a user. Inbox is the master switch: with
|
||||||
|
// it off nothing is stored, so there is nothing for the phone or email to
|
||||||
|
// deliver. Effective applies that.
|
||||||
|
type Channels struct {
|
||||||
|
Inbox bool
|
||||||
|
Phone bool
|
||||||
|
Email bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective is what will actually be delivered: phone and email ride on the
|
||||||
|
// inbox row, so they cannot be on without it.
|
||||||
|
func (c Channels) Effective() Channels {
|
||||||
|
return Channels{Inbox: c.Inbox, Phone: c.Inbox && c.Phone, Email: c.Inbox && c.Email}
|
||||||
|
}
|
||||||
|
|
||||||
|
type spec struct {
|
||||||
|
audience Audience
|
||||||
|
// coalesce: while one of these is unread, a new event updates it in place
|
||||||
|
// rather than adding a row. For the burst-prone admin kinds.
|
||||||
|
coalesce bool
|
||||||
|
// sumCount: a coalesced update adds the payload's `count` to the unread
|
||||||
|
// row's, because each event is "N more". Without it the newer payload
|
||||||
|
// replaces the older, because each event states the whole current total.
|
||||||
|
sumCount bool
|
||||||
|
group EmailGroup
|
||||||
|
defaults Channels
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
allOn = Channels{Inbox: true, Phone: true, Email: true}
|
||||||
|
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
|
||||||
|
)
|
||||||
|
|
||||||
|
var specs = map[Kind]spec{
|
||||||
|
KindRequestApproved: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
|
||||||
|
KindRequestRejected: {audience: AudienceRequester, group: EmailBatch, defaults: allOn},
|
||||||
|
KindRequestCompleted: {audience: AudienceRequester, group: EmailSummary, defaults: allOn},
|
||||||
|
KindRequestPending: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
|
||||||
|
KindQuarantineFlagged: {audience: AudienceAdmin, group: EmailBatch, defaults: allOn},
|
||||||
|
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
||||||
|
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
||||||
|
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
||||||
|
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
||||||
|
}
|
||||||
|
|
||||||
|
// order is the display order for settings screens: the requester's own kinds
|
||||||
|
// first, then the admin ones.
|
||||||
|
var order = []Kind{
|
||||||
|
KindRequestApproved,
|
||||||
|
KindRequestRejected,
|
||||||
|
KindRequestCompleted,
|
||||||
|
KindRequestPending,
|
||||||
|
KindQuarantineFlagged,
|
||||||
|
KindScanFailed,
|
||||||
|
KindTracksMissing,
|
||||||
|
KindDuplicatesFound,
|
||||||
|
KindPlaybackErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kinds returns every kind in display order.
|
||||||
|
func Kinds() []Kind { return append([]Kind(nil), order...) }
|
||||||
|
|
||||||
|
// Valid reports whether k is a known kind.
|
||||||
|
func (k Kind) Valid() bool { _, ok := specs[k]; return ok }
|
||||||
|
|
||||||
|
// AdminOnly reports whether k reaches only admins.
|
||||||
|
func (k Kind) AdminOnly() bool { return specs[k].audience == AudienceAdmin }
|
||||||
|
|
||||||
|
// Defaults are the channels a user has for k until they change them.
|
||||||
|
func (k Kind) Defaults() Channels { return specs[k].defaults }
|
||||||
|
|
||||||
|
// EmailGroup is how k's emails are grouped.
|
||||||
|
func (k Kind) EmailGroup() EmailGroup { return specs[k].group }
|
||||||
|
|
||||||
|
// coalesceKey is the key k's rows coalesce on, or "" when k never coalesces.
|
||||||
|
// One key per kind: two unread "tracks missing" rows would only split a count.
|
||||||
|
func (k Kind) coalesceKey() string {
|
||||||
|
if specs[k].coalesce {
|
||||||
|
return string(k)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEffective_PhoneAndEmailRideOnInbox(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want Channels
|
||||||
|
}{
|
||||||
|
{Channels{true, true, true}, Channels{true, true, true}},
|
||||||
|
{Channels{true, false, true}, Channels{true, false, true}},
|
||||||
|
{Channels{false, true, true}, Channels{false, false, false}},
|
||||||
|
{Channels{false, false, false}, Channels{false, false, false}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
require.Equal(t, c.want, c.in.Effective(), "in=%+v", c.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The display order and the spec table must name the same kinds, or a kind
|
||||||
|
// is either unreachable from settings or has no behaviour.
|
||||||
|
func TestKinds_OrderAndSpecsAgree(t *testing.T) {
|
||||||
|
require.Len(t, order, len(specs))
|
||||||
|
seen := map[Kind]bool{}
|
||||||
|
for _, k := range Kinds() {
|
||||||
|
require.True(t, k.Valid(), "%s in order but not in specs", k)
|
||||||
|
require.False(t, seen[k], "%s listed twice", k)
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) {
|
||||||
|
// The burst-prone health kinds stay out of email unless an admin opts
|
||||||
|
// in; the request queue and quarantine flags are things an admin acts on.
|
||||||
|
for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindPlaybackErrors} {
|
||||||
|
require.True(t, k.AdminOnly(), k)
|
||||||
|
require.False(t, k.Defaults().Email, k)
|
||||||
|
require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k)
|
||||||
|
}
|
||||||
|
for _, k := range []Kind{KindRequestApproved, KindRequestRejected, KindRequestCompleted} {
|
||||||
|
require.False(t, k.AdminOnly(), k)
|
||||||
|
require.Equal(t, Channels{true, true, true}, k.Defaults(), k)
|
||||||
|
require.Empty(t, k.coalesceKey(), "%s must never coalesce: each request is its own news", k)
|
||||||
|
}
|
||||||
|
require.Equal(t, EmailSummary, KindRequestCompleted.EmailGroup())
|
||||||
|
require.Equal(t, EmailBatch, KindRequestApproved.EmailGroup())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_NilNotifierIsANoOp(t *testing.T) {
|
||||||
|
var n *Notifier
|
||||||
|
require.NoError(t, n.Notify(context.Background(), KindRequestApproved, Recipients{}, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_UnknownKindIsRefusedBeforeTouchingTheDB(t *testing.T) {
|
||||||
|
n := New(nil, nil, nil)
|
||||||
|
require.Error(t, n.Notify(context.Background(), Kind("bogus"), Recipients{}, nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventCreated is the bus event a recipient's open clients receive when a
|
||||||
|
// row lands for them. It is a nudge with no content: the client fetches the
|
||||||
|
// inbox. A frame that carried the notification could not be told apart from
|
||||||
|
// a repeat after a reconnect, and a frame nobody received is then one nobody
|
||||||
|
// needed, because the client pulls on reconnect anyway (Roundtable #2535).
|
||||||
|
const EventCreated = "notification.created"
|
||||||
|
|
||||||
|
// Recipients names who an event is for.
|
||||||
|
type Recipients struct {
|
||||||
|
Users []pgtype.UUID
|
||||||
|
// Admins adds every admin.
|
||||||
|
Admins bool
|
||||||
|
// Except is never notified, even when it is in Users or is an admin. The
|
||||||
|
// admin who filed a request needs no notice that it is pending.
|
||||||
|
Except pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToUser addresses one user.
|
||||||
|
func ToUser(id pgtype.UUID) Recipients { return Recipients{Users: []pgtype.UUID{id}} }
|
||||||
|
|
||||||
|
// ToAdmins addresses every admin except one (pass the zero UUID for none).
|
||||||
|
func ToAdmins(except pgtype.UUID) Recipients { return Recipients{Admins: true, Except: except} }
|
||||||
|
|
||||||
|
// Notifier writes notifications and nudges recipients' clients. A nil
|
||||||
|
// *Notifier is valid and does nothing, so producers built without one (most
|
||||||
|
// tests) need no special case.
|
||||||
|
type Notifier struct {
|
||||||
|
db dbq.DBTX
|
||||||
|
bus *eventbus.Bus
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Notifier writing through db. bus may be nil.
|
||||||
|
func New(db dbq.DBTX, bus *eventbus.Bus, logger *slog.Logger) *Notifier {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Notifier{db: db, bus: bus, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify records kind for each recipient whose inbox preference allows it,
|
||||||
|
// then nudges their open clients.
|
||||||
|
//
|
||||||
|
// For a coalescing kind, an unread row of that kind is updated in place
|
||||||
|
// rather than a new one added. payload["count"] then adds to the unread
|
||||||
|
// row's count for kinds whose events each mean "N more".
|
||||||
|
//
|
||||||
|
// The error is for logging. A notification must never fail the action that
|
||||||
|
// caused it, so producers log and carry on; NotifyLogged does exactly that.
|
||||||
|
func (n *Notifier) Notify(ctx context.Context, kind Kind, to Recipients, payload map[string]any) error {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !kind.Valid() {
|
||||||
|
return fmt.Errorf("notifications: unknown kind %q", kind)
|
||||||
|
}
|
||||||
|
q := dbq.New(n.db)
|
||||||
|
|
||||||
|
ids, err := n.resolve(ctx, q, kind, to)
|
||||||
|
if err != nil || len(ids) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
channels, err := channelsFor(ctx, q, kind, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload == nil {
|
||||||
|
payload = map[string]any{}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notifications: encode %s payload: %w", kind, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var delivered []pgtype.UUID
|
||||||
|
for _, id := range ids {
|
||||||
|
if !channels[id].Inbox {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := write(ctx, q, kind, id, body, channels[id].Email); err != nil {
|
||||||
|
return fmt.Errorf("notifications: write %s: %w", kind, err)
|
||||||
|
}
|
||||||
|
delivered = append(delivered, id)
|
||||||
|
}
|
||||||
|
n.nudge(delivered)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyLogged is Notify for producers: a failure is logged at WARN and
|
||||||
|
// otherwise ignored.
|
||||||
|
func (n *Notifier) NotifyLogged(ctx context.Context, kind Kind, to Recipients, payload map[string]any) {
|
||||||
|
if err := n.Notify(ctx, kind, to, payload); err != nil {
|
||||||
|
n.logger.Warn("notifications: notify failed", "kind", string(kind), "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve turns Recipients into a deduplicated list of user ids. Admin kinds
|
||||||
|
// are delivered only to admins, whatever Users says, so a producer that
|
||||||
|
// addresses a non-admin by mistake stores nothing for them.
|
||||||
|
func (n *Notifier) resolve(ctx context.Context, q *dbq.Queries, kind Kind, to Recipients) ([]pgtype.UUID, error) {
|
||||||
|
var admins []pgtype.UUID
|
||||||
|
if to.Admins || kind.AdminOnly() {
|
||||||
|
var err error
|
||||||
|
if admins, err = q.ListAdminUserIDs(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("notifications: list admins: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isAdmin := make(map[pgtype.UUID]bool, len(admins))
|
||||||
|
for _, a := range admins {
|
||||||
|
isAdmin[a] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []pgtype.UUID
|
||||||
|
candidates = append(candidates, to.Users...)
|
||||||
|
if to.Admins {
|
||||||
|
candidates = append(candidates, admins...)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[pgtype.UUID]bool, len(candidates))
|
||||||
|
out := make([]pgtype.UUID, 0, len(candidates))
|
||||||
|
for _, id := range candidates {
|
||||||
|
if !id.Valid || seen[id] || (to.Except.Valid && id == to.Except) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kind.AdminOnly() && !isAdmin[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelsFor returns each recipient's effective channels for kind: their
|
||||||
|
// stored preference, or the kind's defaults when they have none.
|
||||||
|
func channelsFor(ctx context.Context, q *dbq.Queries, kind Kind, ids []pgtype.UUID) (map[pgtype.UUID]Channels, error) {
|
||||||
|
rows, err := q.ListNotificationPrefsForKind(ctx, dbq.ListNotificationPrefsForKindParams{
|
||||||
|
Kind: string(kind),
|
||||||
|
UserIds: ids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notifications: read prefs: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[pgtype.UUID]Channels, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = kind.Defaults().Effective()
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.UserID] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}.Effective()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// write stores one row. emailWanted is the recipient's email channel for the
|
||||||
|
// kind: a row nobody wants emailed is stamped emailed_at at once, so the
|
||||||
|
// digest never sends it, even if email is switched on later.
|
||||||
|
func write(ctx context.Context, q *dbq.Queries, kind Kind, userID pgtype.UUID, body []byte, emailWanted bool) error {
|
||||||
|
key := kind.coalesceKey()
|
||||||
|
if key == "" {
|
||||||
|
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{
|
||||||
|
UserID: userID, Kind: string(kind), Payload: body, EmailWanted: emailWanted,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := q.UpsertCoalescedNotification(ctx, dbq.UpsertCoalescedNotificationParams{
|
||||||
|
UserID: userID,
|
||||||
|
Kind: string(kind),
|
||||||
|
Payload: body,
|
||||||
|
CoalesceKey: &key,
|
||||||
|
EmailWanted: emailWanted,
|
||||||
|
SumCount: specs[kind].sumCount,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) nudge(ids []pgtype.UUID) {
|
||||||
|
if n.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
n.bus.Publish(eventbus.Event{
|
||||||
|
Kind: EventCreated,
|
||||||
|
UserID: uuid.UUID(id.Bytes).String(),
|
||||||
|
Data: map[string]any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package notifications_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkUser(t *testing.T, q *dbq.Queries, name string, admin bool) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + name,
|
||||||
|
PasswordHash: "x",
|
||||||
|
ApiTokenHash: "tok-" + name, // unique per user
|
||||||
|
IsAdmin: admin,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowsFor(t *testing.T, q *dbq.Queries, user pgtype.UUID) []dbq.ListNotificationsRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := q.ListNotifications(context.Background(), dbq.ListNotificationsParams{UserID: user, PageLimit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(t *testing.T, payload []byte) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var p struct {
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(payload, &p))
|
||||||
|
return p.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_WritesARowAndNudgesOnlyThatUser(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := mkUser(t, q, "alice", false)
|
||||||
|
bob := mkUser(t, q, "bob", false)
|
||||||
|
|
||||||
|
bus := eventbus.New()
|
||||||
|
events, unsub := bus.Subscribe(8)
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
n := notifications.New(pool, bus, nil)
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved,
|
||||||
|
notifications.ToUser(alice), map[string]any{"request_id": "r1", "title": "WWW"}))
|
||||||
|
|
||||||
|
rows := rowsFor(t, q, alice)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.Equal(t, "request_approved", rows[0].Kind)
|
||||||
|
require.False(t, rows[0].ReadAt.Valid)
|
||||||
|
require.JSONEq(t, `{"request_id":"r1","title":"WWW"}`, string(rows[0].Payload))
|
||||||
|
require.Empty(t, rowsFor(t, q, bob))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case e := <-events:
|
||||||
|
require.Equal(t, notifications.EventCreated, e.Kind)
|
||||||
|
require.Equal(t, uuid.UUID(alice.Bytes).String(), e.UserID)
|
||||||
|
require.Empty(t, e.Data, "the nudge carries no content; the client fetches")
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("no nudge published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_AdminKindReachesAdminsOnly_AndNeverTheExcepted(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
requester := mkUser(t, q, "admin-requester", true)
|
||||||
|
other := mkUser(t, q, "admin-other", true)
|
||||||
|
plain := mkUser(t, q, "plain", false)
|
||||||
|
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
// A plain user named directly must still get nothing: the kind is admin-only.
|
||||||
|
to := notifications.ToAdmins(requester)
|
||||||
|
to.Users = []pgtype.UUID{plain}
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestPending, to, nil))
|
||||||
|
|
||||||
|
require.Empty(t, rowsFor(t, q, requester), "the admin who filed it needs no notice")
|
||||||
|
require.Len(t, rowsFor(t, q, other), 1)
|
||||||
|
require.Empty(t, rowsFor(t, q, plain))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_CoalescedCountAddsUpWhileUnread_AndStartsAfreshOnceRead(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := mkUser(t, q, "admin", true)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
to := notifications.ToUser(admin)
|
||||||
|
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 3}))
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 4}))
|
||||||
|
rows := rowsFor(t, q, admin)
|
||||||
|
require.Len(t, rows, 1, "a burst is one notification")
|
||||||
|
require.Equal(t, int64(7), count(t, rows[0].Payload))
|
||||||
|
|
||||||
|
_, err := q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: rows[0].ID, UserID: admin})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindTracksMissing, to, map[string]any{"count": 2}))
|
||||||
|
|
||||||
|
rows = rowsFor(t, q, admin)
|
||||||
|
require.Len(t, rows, 2, "after it is read, the next event is new news")
|
||||||
|
require.Equal(t, int64(2), count(t, rows[0].Payload))
|
||||||
|
require.False(t, rows[0].ReadAt.Valid)
|
||||||
|
require.True(t, rows[1].ReadAt.Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_CoalescedTotalReplacesWhenEventsStateTheWhole(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := mkUser(t, q, "admin", true)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
|
||||||
|
// duplicates_found states the pending total each time; it must not sum.
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 5}))
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindDuplicatesFound, notifications.ToUser(admin), map[string]any{"count": 3}))
|
||||||
|
rows := rowsFor(t, q, admin)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.Equal(t, int64(3), count(t, rows[0].Payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_RequestKindsNeverCoalesce(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := mkUser(t, q, "alice", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
for _, id := range []string{"r1", "r2"} {
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestCompleted, notifications.ToUser(alice), map[string]any{"request_id": id}))
|
||||||
|
}
|
||||||
|
require.Len(t, rowsFor(t, q, alice), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotify_InboxOffStoresNothing_AndDefaultsApplyWithoutAPref(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
muted := mkUser(t, q, "muted", false)
|
||||||
|
fresh := mkUser(t, q, "fresh", false)
|
||||||
|
require.NoError(t, q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
|
||||||
|
UserID: muted, Kind: "request_rejected", Inbox: false, Phone: true, Email: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
to := notifications.Recipients{Users: []pgtype.UUID{muted, fresh}}
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestRejected, to, nil))
|
||||||
|
|
||||||
|
require.Empty(t, rowsFor(t, q, muted), "inbox off is off, whatever phone and email say")
|
||||||
|
require.Len(t, rowsFor(t, q, fresh), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkRead_ScopedToTheOwner_AndIdempotent(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := mkUser(t, q, "alice", false)
|
||||||
|
mallory := mkUser(t, q, "mallory", false)
|
||||||
|
n := notifications.New(pool, nil, nil)
|
||||||
|
require.NoError(t, n.Notify(ctx, notifications.KindRequestApproved, notifications.ToUser(alice), nil))
|
||||||
|
id := rowsFor(t, q, alice)[0].ID
|
||||||
|
|
||||||
|
got, err := q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: id, UserID: mallory})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, got, "another user's id matches nothing")
|
||||||
|
require.False(t, rowsFor(t, q, alice)[0].ReadAt.Valid)
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
got, err = q.MarkNotificationRead(ctx, dbq.MarkNotificationReadParams{ID: id, UserID: alice})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), got, "a repeat still matches, so it is not mistaken for 'not yours'")
|
||||||
|
}
|
||||||
|
unread, err := q.CountUnreadNotifications(ctx, alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every kind the code knows must pass both CHECKs in the real schema. This is
|
||||||
|
// rule 36's guard: a kind added in Go without the migration fails here, not in
|
||||||
|
// production at INSERT time.
|
||||||
|
func TestEveryKindPassesTheSchemaChecks(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := mkUser(t, q, "kinds", true)
|
||||||
|
for _, k := range notifications.Kinds() {
|
||||||
|
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{UserID: user, Kind: string(k), Payload: []byte(`{}`)})
|
||||||
|
require.NoError(t, err, "user_notifications rejects %s", k)
|
||||||
|
require.NoError(t, q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
|
||||||
|
UserID: user, Kind: string(k), Inbox: true, Phone: true, Email: true,
|
||||||
|
}), "user_notification_prefs rejects %s", k)
|
||||||
|
}
|
||||||
|
// And the check is real: an unknown kind is refused.
|
||||||
|
_, err := q.InsertNotification(ctx, dbq.InsertNotificationParams{UserID: user, Kind: "bogus", Payload: []byte(`{}`)})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetention_TrimsOldReadRowsAndAncientUnreadOnes(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := mkUser(t, q, "retention", false)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
insert := func(created time.Time, read *time.Time) {
|
||||||
|
var readAt any
|
||||||
|
if read != nil {
|
||||||
|
readAt = *read
|
||||||
|
}
|
||||||
|
_, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO user_notifications (user_id, kind, created_at, read_at) VALUES ($1, 'request_approved', $2, $3)`,
|
||||||
|
user, created, readAt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
day := 24 * time.Hour
|
||||||
|
oldRead := now.Add(-100 * day)
|
||||||
|
recentRead := now.Add(-10 * day)
|
||||||
|
insert(now.Add(-120*day), &oldRead) // read 100 days ago: goes
|
||||||
|
insert(now.Add(-20*day), &recentRead) // read 10 days ago: stays
|
||||||
|
insert(now.Add(-400*day), nil) // unread but over a year old: goes
|
||||||
|
insert(now.Add(-200*day), nil) // unread, 200 days: stays
|
||||||
|
|
||||||
|
r := notifications.NewRetention(pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
require.Equal(t, int64(2), r.TrimOnce(ctx, now))
|
||||||
|
require.Len(t, rowsFor(t, q, user), 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rendered is a notification as people read it. The server renders it once,
|
||||||
|
// so the web inbox, the Android inbox, the phone's shade and the email digest
|
||||||
|
// all say the same thing, and a wording change is one edit here rather than
|
||||||
|
// a phrase table per client.
|
||||||
|
type Rendered struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// Link is the web path the notification opens. Android maps the same
|
||||||
|
// paths onto its screens.
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload is what producers put in a notification. Every field is optional;
|
||||||
|
// each kind documents the ones it uses in Render.
|
||||||
|
type Payload struct {
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
RequestKind string `json:"request_kind,omitempty"` // artist | album | track
|
||||||
|
// Name is what was requested or flagged, as the user would say it:
|
||||||
|
// "WWW", "Moe Shop", "Moe Shop – WWW".
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// Artist and Title are Name's parts, for request_completed: the new-music
|
||||||
|
// summary email groups arrivals by artist. Title is empty for an artist
|
||||||
|
// request.
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
// Actor is the other person involved: who asked, who flagged.
|
||||||
|
Actor string `json:"actor,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
// Count is the coalesced kinds' running total.
|
||||||
|
Count int64 `json:"count,omitempty"`
|
||||||
|
// Detail is free text for scan_failed (the error message).
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map turns a Payload into the map Notify takes, dropping empty fields.
|
||||||
|
func (p Payload) Map() map[string]any {
|
||||||
|
b, _ := json.Marshal(p)
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render turns a stored row into what people read. An unreadable payload
|
||||||
|
// still renders, from the kind alone, rather than failing the whole inbox.
|
||||||
|
func Render(kind Kind, payload []byte) Rendered {
|
||||||
|
var p Payload
|
||||||
|
_ = json.Unmarshal(payload, &p)
|
||||||
|
name := p.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Your request"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case KindRequestApproved:
|
||||||
|
return Rendered{Title: "Request approved", Body: name + " is on its way.", Link: "/requests"}
|
||||||
|
case KindRequestRejected:
|
||||||
|
body := name + " was declined."
|
||||||
|
if p.Reason != "" {
|
||||||
|
body = name + " was declined: " + p.Reason
|
||||||
|
}
|
||||||
|
return Rendered{Title: "Request declined", Body: body, Link: "/requests"}
|
||||||
|
case KindRequestCompleted:
|
||||||
|
link := "/requests"
|
||||||
|
switch {
|
||||||
|
case p.AlbumID != "":
|
||||||
|
link = "/albums/" + p.AlbumID
|
||||||
|
case p.ArtistID != "":
|
||||||
|
link = "/artists/" + p.ArtistID
|
||||||
|
}
|
||||||
|
return Rendered{Title: "Now in your library", Body: name + " has arrived.", Link: link}
|
||||||
|
case KindRequestPending:
|
||||||
|
who := p.Actor
|
||||||
|
if who == "" {
|
||||||
|
who = "Someone"
|
||||||
|
}
|
||||||
|
return Rendered{Title: "Request to review", Body: who + " asked for " + name + ".", Link: "/admin/requests"}
|
||||||
|
case KindQuarantineFlagged:
|
||||||
|
who := p.Actor
|
||||||
|
if who == "" {
|
||||||
|
who = "Someone"
|
||||||
|
}
|
||||||
|
body := who + " flagged " + name + "."
|
||||||
|
if p.Reason != "" {
|
||||||
|
body = who + " flagged " + name + ": " + p.Reason
|
||||||
|
}
|
||||||
|
return Rendered{Title: "Track flagged", Body: body, Link: "/admin/quarantine"}
|
||||||
|
case KindScanFailed:
|
||||||
|
title := "Library scan failed"
|
||||||
|
if p.Count > 1 {
|
||||||
|
title = fmt.Sprintf("Library scan failed %d times", p.Count)
|
||||||
|
}
|
||||||
|
body := p.Detail
|
||||||
|
if body == "" {
|
||||||
|
body = "The last scan stopped with an error."
|
||||||
|
}
|
||||||
|
return Rendered{Title: title, Body: body, Link: "/admin"}
|
||||||
|
case KindTracksMissing:
|
||||||
|
return Rendered{
|
||||||
|
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||||||
|
Body: "Their files are no longer where the library expects them.",
|
||||||
|
Link: "/admin/missing-files",
|
||||||
|
}
|
||||||
|
case KindDuplicatesFound:
|
||||||
|
return Rendered{
|
||||||
|
Title: plural(p.Count, "duplicate group to review", "duplicate groups to review"),
|
||||||
|
Body: "The duplicate sweep found tracks holding the same recording.",
|
||||||
|
Link: "/admin/duplicates",
|
||||||
|
}
|
||||||
|
case KindPlaybackErrors:
|
||||||
|
return Rendered{
|
||||||
|
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||||||
|
Body: "A track failed to play for someone.",
|
||||||
|
Link: "/admin/playback-errors",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Rendered{Title: "Notification", Link: "/"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int64, one, many string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + one
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
// A coalesced kind always carries a count; this only guards a
|
||||||
|
// malformed row from reading "0 tracks went missing".
|
||||||
|
return "Some " + many
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s", n, many)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// render stores p the way Notify does (through Map) and reads it back.
|
||||||
|
func render(k Kind, p Payload) Rendered {
|
||||||
|
b, err := json.Marshal(p.Map())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return Render(k, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_EveryKindHasATitleAndALink(t *testing.T) {
|
||||||
|
for _, k := range Kinds() {
|
||||||
|
r := Render(k, []byte(`{}`))
|
||||||
|
require.NotEmpty(t, r.Title, k)
|
||||||
|
require.NotEqual(t, "Notification", r.Title, "%s falls through to the default", k)
|
||||||
|
require.NotEmpty(t, r.Link, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_RequestCompletedLinksToWhatArrived(t *testing.T) {
|
||||||
|
require.Equal(t, "/albums/al-1", render(KindRequestCompleted, Payload{Name: "WWW", AlbumID: "al-1", ArtistID: "ar-1"}).Link)
|
||||||
|
require.Equal(t, "/artists/ar-1", render(KindRequestCompleted, Payload{Name: "Moe Shop", ArtistID: "ar-1"}).Link)
|
||||||
|
require.Equal(t, "/requests", render(KindRequestCompleted, Payload{Name: "WWW"}).Link)
|
||||||
|
require.Equal(t, "WWW has arrived.", render(KindRequestCompleted, Payload{Name: "WWW"}).Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_RejectionCarriesTheReasonWhenThereIsOne(t *testing.T) {
|
||||||
|
require.Equal(t, "WWW was declined.", render(KindRequestRejected, Payload{Name: "WWW"}).Body)
|
||||||
|
require.Equal(t, "WWW was declined: already owned", render(KindRequestRejected, Payload{Name: "WWW", Reason: "already owned"}).Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_CoalescedCountsReadNaturally(t *testing.T) {
|
||||||
|
require.Equal(t, "1 track went missing", render(KindTracksMissing, Payload{Count: 1}).Title)
|
||||||
|
require.Equal(t, "14 tracks went missing", render(KindTracksMissing, Payload{Count: 14}).Title)
|
||||||
|
require.Equal(t, "Library scan failed", render(KindScanFailed, Payload{Count: 1, Detail: "x"}).Title)
|
||||||
|
require.Equal(t, "Library scan failed 3 times", render(KindScanFailed, Payload{Count: 3}).Title)
|
||||||
|
require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_AnUnreadablePayloadStillRenders(t *testing.T) {
|
||||||
|
r := Render(KindRequestApproved, []byte(`not json`))
|
||||||
|
require.Equal(t, "Request approved", r.Title)
|
||||||
|
require.Equal(t, "Your request is on its way.", r.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayloadMap_DropsEmptyFields(t *testing.T) {
|
||||||
|
require.Equal(t, map[string]any{"name": "WWW", "count": float64(2)}, Payload{Name: "WWW", Count: 2}.Map())
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ReadRetention is how long a read notification is kept. An inbox is a
|
||||||
|
// record of what happened lately, not an archive.
|
||||||
|
ReadRetention = 90 * 24 * time.Hour
|
||||||
|
// AnyRetention bounds an inbox nobody opens: past it, even unread rows go.
|
||||||
|
AnyRetention = 365 * 24 * time.Hour
|
||||||
|
// retentionInterval matches the library_changes compactor's daily tick.
|
||||||
|
retentionInterval = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Retention trims old notifications on a daily tick, in the shape of the
|
||||||
|
// library_changes compactor (internal/sync/compactor.go).
|
||||||
|
type Retention struct {
|
||||||
|
db dbq.DBTX
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRetention returns a Retention trimming through db.
|
||||||
|
func NewRetention(db dbq.DBTX, logger *slog.Logger) *Retention {
|
||||||
|
return &Retention{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is cancelled. It trims once at startup, so a process
|
||||||
|
// that has been down a while catches up, then daily.
|
||||||
|
func (r *Retention) Run(ctx context.Context) {
|
||||||
|
r.TrimOnce(ctx, time.Now())
|
||||||
|
t := time.NewTicker(retentionInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
r.TrimOnce(ctx, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrimOnce deletes what has outlived its retention as of now. Errors are
|
||||||
|
// logged, never fatal: the next tick tries again.
|
||||||
|
func (r *Retention) TrimOnce(ctx context.Context, now time.Time) int64 {
|
||||||
|
n, err := dbq.New(r.db).TrimNotifications(ctx, dbq.TrimNotificationsParams{
|
||||||
|
ReadCutoff: pgtype.Timestamptz{Time: now.Add(-ReadRetention), Valid: true},
|
||||||
|
AnyCutoff: pgtype.Timestamptz{Time: now.Add(-AnyRetention), Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Warn("notifications retention: trim failed", "err", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
r.logger.Info("notifications retention: trimmed rows", "count", n)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindSetting is one row of a user's notification settings.
|
||||||
|
type KindSetting struct {
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
AdminOnly bool `json:"admin_only"`
|
||||||
|
Inbox bool `json:"inbox"`
|
||||||
|
Phone bool `json:"phone"`
|
||||||
|
Email bool `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingChange changes one kind. A nil field is left as it is, so a client
|
||||||
|
// replaying a queued toggle offline sends only what the user touched.
|
||||||
|
type SettingChange struct {
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
Inbox *bool `json:"inbox,omitempty"`
|
||||||
|
Phone *bool `json:"phone,omitempty"`
|
||||||
|
Email *bool `json:"email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrSettingInvalid is a change naming an unknown kind, or an admin kind for
|
||||||
|
// a user who is not an admin.
|
||||||
|
var ErrSettingInvalid = errors.New("notification setting invalid")
|
||||||
|
|
||||||
|
// LoadSettings returns every kind the user can receive, in display order,
|
||||||
|
// with their stored channels or the kind's defaults. Admin kinds are listed
|
||||||
|
// only for admins.
|
||||||
|
//
|
||||||
|
// The stored values are returned as set, not as Effective: a user who turns
|
||||||
|
// the inbox off and on again gets back the phone and email choices they had.
|
||||||
|
func LoadSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool) ([]KindSetting, error) {
|
||||||
|
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notifications: load settings: %w", err)
|
||||||
|
}
|
||||||
|
stored := make(map[Kind]Channels, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
stored[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}
|
||||||
|
}
|
||||||
|
out := make([]KindSetting, 0, len(order))
|
||||||
|
for _, k := range order {
|
||||||
|
if k.AdminOnly() && !isAdmin {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, ok := stored[k]
|
||||||
|
if !ok {
|
||||||
|
c = k.Defaults()
|
||||||
|
}
|
||||||
|
out = append(out, KindSetting{Kind: k, AdminOnly: k.AdminOnly(), Inbox: c.Inbox, Phone: c.Phone, Email: c.Email})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSettings applies changes and returns the settings as they now stand.
|
||||||
|
// The whole batch is validated before anything is written.
|
||||||
|
func SaveSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool, changes []SettingChange) ([]KindSetting, error) {
|
||||||
|
for _, c := range changes {
|
||||||
|
if !c.Kind.Valid() {
|
||||||
|
return nil, fmt.Errorf("%w: unknown kind %q", ErrSettingInvalid, c.Kind)
|
||||||
|
}
|
||||||
|
if c.Kind.AdminOnly() && !isAdmin {
|
||||||
|
return nil, fmt.Errorf("%w: %s is for admins", ErrSettingInvalid, c.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current, err := LoadSettings(ctx, q, userID, isAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byKind := make(map[Kind]KindSetting, len(current))
|
||||||
|
for _, s := range current {
|
||||||
|
byKind[s.Kind] = s
|
||||||
|
}
|
||||||
|
for _, c := range changes {
|
||||||
|
s := byKind[c.Kind]
|
||||||
|
if c.Inbox != nil {
|
||||||
|
s.Inbox = *c.Inbox
|
||||||
|
}
|
||||||
|
if c.Phone != nil {
|
||||||
|
s.Phone = *c.Phone
|
||||||
|
}
|
||||||
|
if c.Email != nil {
|
||||||
|
s.Email = *c.Email
|
||||||
|
}
|
||||||
|
byKind[c.Kind] = s
|
||||||
|
if err := q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
|
||||||
|
UserID: userID, Kind: string(c.Kind), Inbox: s.Inbox, Phone: s.Phone, Email: s.Email,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("notifications: save %s: %w", c.Kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LoadSettings(ctx, q, userID, isAdmin)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: -apple-system, system-ui, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #2c2c2c;">
|
||||||
|
<p>Hello <strong>{{.Name}}</strong>,</p>
|
||||||
|
|
||||||
|
<p>{{.Intro}}</p>
|
||||||
|
{{range .Artists}}
|
||||||
|
<p style="margin: 20px 0 4px; font-weight: 600;">{{if .URL}}<a href="{{.URL}}" style="color: #2c2c2c;">{{.Name}}</a>{{else}}{{.Name}}{{end}}</p>
|
||||||
|
{{if .Titles}}<ul style="margin: 0; padding-left: 20px;">
|
||||||
|
{{range .Titles}}<li style="margin: 2px 0;">{{if .URL}}<a href="{{.URL}}" style="color: #4a6b5c;">{{.Title}}</a>{{else}}{{.Title}}{{end}}</li>
|
||||||
|
{{end}}</ul>{{end}}
|
||||||
|
{{end}}{{if .Items}}
|
||||||
|
<ul style="margin: 16px 0; padding-left: 20px;">
|
||||||
|
{{range .Items}}<li style="margin: 8px 0;">
|
||||||
|
{{if .URL}}<a href="{{.URL}}" style="color: #2c2c2c; font-weight: 600;">{{.Title}}</a>{{else}}<strong>{{.Title}}</strong>{{end}}
|
||||||
|
{{if .Body}}<br><span style="color: #555;">{{.Body}}</span>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}</ul>
|
||||||
|
{{end}}
|
||||||
|
<p style="font-size: 12px; color: #999; margin-top: 32px; border-top: 1px solid #eee; padding-top: 16px;">
|
||||||
|
{{if .SettingsURL}}<a href="{{.SettingsURL}}" style="color: #999;">Choose which notifications are emailed to you</a>{{else}}Choose which notifications are emailed to you in Minstrel, under Settings → Notifications.{{end}}<br>
|
||||||
|
— Minstrel
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Hello {{.Name}},
|
||||||
|
|
||||||
|
{{.Intro}}
|
||||||
|
{{range .Artists}}
|
||||||
|
{{.Name}}{{if .URL}}
|
||||||
|
{{.URL}}{{end}}{{range .Titles}}
|
||||||
|
- {{.Title}}{{if .URL}}
|
||||||
|
{{.URL}}{{end}}{{end}}
|
||||||
|
{{end}}{{range .Items}}
|
||||||
|
- {{.Title}}{{if .Body}}
|
||||||
|
{{.Body}}{{end}}{{if .URL}}
|
||||||
|
{{.URL}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .SettingsURL}}Choose which notifications are emailed to you: {{.SettingsURL}}{{else}}Choose which notifications are emailed to you in Minstrel, under Settings → Notifications.{{end}}
|
||||||
|
|
||||||
|
— Minstrel
|
||||||
@@ -95,8 +95,11 @@ type discoverPools struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
|
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
|
||||||
|
// The caps go into the query too (#5356): applied only here, after its
|
||||||
|
// LIMIT, they left a heavily tagged artist or two owning the whole bucket.
|
||||||
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
||||||
UserID: userID, Column2: dateStr,
|
UserID: userID, DateSeed: dateStr,
|
||||||
|
MaxPerAlbum: discoverMaxTracksPerAlbum, MaxPerArtist: discoverMaxTracksPerArtist,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
|
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package playlists_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setGenre(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID, genre string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE tracks SET genre = $2 WHERE id = $1`, trackID, genre); err != nil {
|
||||||
|
t.Fatalf("set genre: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, tag, weight); err != nil {
|
||||||
|
t.Fatalf("seed taste tag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit is the deploy's shape
|
||||||
|
// (#5356): one artist whose every track carries the whole taste profile
|
||||||
|
// outscores everything on summed weight. Capped only after the LIMIT, that
|
||||||
|
// artist took all 120 rows and the bucket shrank to its 3. Capped before, the
|
||||||
|
// artist keeps 3 and the single-tag artists below it fill the rest.
|
||||||
|
func TestListTasteUnheardTracksForDiscover_CapsBeforeLimit(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
u := seedUser(t, pool, "tastecap")
|
||||||
|
|
||||||
|
profile := []string{"Lo-Fi", "Downtempo", "Hip Hop", "Instrumental", "Chillwave"}
|
||||||
|
for _, tag := range profile {
|
||||||
|
seedTasteTag(t, pool, u.ID, tag, 10)
|
||||||
|
}
|
||||||
|
allTags := "Lo-Fi; Downtempo; Hip Hop; Instrumental; Chillwave"
|
||||||
|
|
||||||
|
// 13 albums x 10 tracks = 130, more than the query's LIMIT of 120.
|
||||||
|
heavy := seedTrack(t, pool, "Heavy 0", "Heavy Artist")
|
||||||
|
setGenre(t, pool, heavy.ID, allTags)
|
||||||
|
album := heavy.AlbumID
|
||||||
|
for i := 1; i < 130; i++ {
|
||||||
|
if i%10 == 0 {
|
||||||
|
album = seedAlbumForArtist(t, pool, fmt.Sprintf("Heavy Album %d", i/10), heavy.ArtistID)
|
||||||
|
}
|
||||||
|
tr := seedTrackForArtist(t, pool, fmt.Sprintf("Heavy %d", i), album, heavy.ArtistID)
|
||||||
|
setGenre(t, pool, tr.ID, allTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twelve artists with one track each, matching a single profile tag.
|
||||||
|
const light = 12
|
||||||
|
for i := 0; i < light; i++ {
|
||||||
|
tr := seedTrack(t, pool, fmt.Sprintf("Light %d", i), fmt.Sprintf("Light Artist %d", i))
|
||||||
|
setGenre(t, pool, tr.ID, profile[i%len(profile)])
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := dbq.New(pool).ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
||||||
|
UserID: u.ID, DateSeed: "2026-10-08", MaxPerAlbum: 2, MaxPerArtist: 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list taste unheard: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
perArtist := map[pgtype.UUID]int{}
|
||||||
|
perAlbum := map[pgtype.UUID]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
perArtist[r.ArtistID]++
|
||||||
|
perAlbum[r.AlbumID]++
|
||||||
|
}
|
||||||
|
if got := perArtist[heavy.ArtistID]; got != 3 {
|
||||||
|
t.Errorf("heavy artist rows = %d, want exactly the artist cap of 3", got)
|
||||||
|
}
|
||||||
|
for id, n := range perAlbum {
|
||||||
|
if n > 2 {
|
||||||
|
t.Errorf("album %v has %d rows, over the album cap of 2", id, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, want := len(perArtist), 1+light; got != want {
|
||||||
|
t.Errorf("distinct artists = %d, want %d (the heavy artist plus every single-tag artist)", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(rows), 3+light; got != want {
|
||||||
|
t.Errorf("rows = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
// Still ranked by weight: the heavy artist's three lead.
|
||||||
|
for i := 0; i < 3 && i < len(rows); i++ {
|
||||||
|
if rows[i].ArtistID != heavy.ArtistID {
|
||||||
|
t.Errorf("row %d is not the heavy artist's; higher summed weight should rank first", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ import (
|
|||||||
// narrowed to an interface so the pass can be tested without a Lidarr client
|
// narrowed to an interface so the pass can be tested without a Lidarr client
|
||||||
// or an approval path that talks to one.
|
// or an approval path that talks to one.
|
||||||
type requestCreator interface {
|
type requestCreator interface {
|
||||||
Create(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, error)
|
CreateTracked(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, bool, error)
|
||||||
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
|
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,9 @@ type Sweeper struct {
|
|||||||
settings *SettingsService
|
settings *SettingsService
|
||||||
requests requestCreator
|
requests requestCreator
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
// notifier tells admins about a request the sweeper filed that still
|
||||||
|
// needs their approval (M489). Nil: nobody is told.
|
||||||
|
notifier *notifications.Notifier
|
||||||
tick time.Duration
|
tick time.Duration
|
||||||
// releaseGroup names the MusicBrainz release group of a release id, for an
|
// releaseGroup names the MusicBrainz release group of a release id, for an
|
||||||
// album whose tags never carried one (#5241). Lidarr knows albums only by
|
// album whose tags never carried one (#5241). Lidarr knows albums only by
|
||||||
@@ -92,6 +97,10 @@ type PassResult struct {
|
|||||||
Unresolved int
|
Unresolved int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNotifier makes requests the sweeper files, and cannot approve itself,
|
||||||
|
// reach the admins' notifications inbox (M489).
|
||||||
|
func (s *Sweeper) SetNotifier(n *notifications.Notifier) { s.notifier = n }
|
||||||
|
|
||||||
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
|
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
|
||||||
// now" without waiting out the tick, and so tests drive it directly.
|
// now" without waiting out the tick, and so tests drive it directly.
|
||||||
func (s *Sweeper) SweepOnce(ctx context.Context) error {
|
func (s *Sweeper) SweepOnce(ctx context.Context) error {
|
||||||
@@ -182,7 +191,7 @@ func (s *Sweeper) attempt(
|
|||||||
return fmt.Errorf("release group: %w", err)
|
return fmt.Errorf("release group: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := s.requests.Create(ctx, adminID, lidarrrequests.CreateParams{
|
req, created, err := s.requests.CreateTracked(ctx, adminID, lidarrrequests.CreateParams{
|
||||||
Kind: "album",
|
Kind: "album",
|
||||||
LidarrArtistMBID: *album.ArtistMbid,
|
LidarrArtistMBID: *album.ArtistMbid,
|
||||||
ArtistName: album.ArtistName,
|
ArtistName: album.ArtistName,
|
||||||
@@ -206,11 +215,15 @@ func (s *Sweeper) attempt(
|
|||||||
return fmt.Errorf("record attempt: %w", err)
|
return fmt.Errorf("record attempt: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A request the sweeper filed and could not approve waits on an admin.
|
||||||
|
// One it deduped into was announced when it was first filed.
|
||||||
|
awaitingAdmin := created
|
||||||
if cfg.AutoApprove {
|
if cfg.AutoApprove {
|
||||||
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
|
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
|
||||||
switch {
|
switch {
|
||||||
case aerr == nil:
|
case aerr == nil:
|
||||||
res.Approved++
|
res.Approved++
|
||||||
|
awaitingAdmin = false
|
||||||
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
|
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
|
||||||
// Leave it pending rather than treating it as a failure. The
|
// Leave it pending rather than treating it as a failure. The
|
||||||
// request is still the right record of intent, and it becomes
|
// request is still the right record of intent, and it becomes
|
||||||
@@ -225,6 +238,16 @@ func (s *Sweeper) attempt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if awaitingAdmin {
|
||||||
|
s.notifier.NotifyLogged(ctx, notifications.KindRequestPending, notifications.ToAdmins(pgtype.UUID{}),
|
||||||
|
notifications.Payload{
|
||||||
|
RequestID: uuid.UUID(req.ID.Bytes).String(),
|
||||||
|
RequestKind: string(req.Kind),
|
||||||
|
Name: lidarrrequests.DisplayName(req),
|
||||||
|
Actor: "Re-acquisition",
|
||||||
|
}.Map())
|
||||||
|
}
|
||||||
|
|
||||||
if row.Attempts >= cfg.MaxAttempts {
|
if row.Attempts >= cfg.MaxAttempts {
|
||||||
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
|
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
|
||||||
return fmt.Errorf("mark gave up: %w", err)
|
return fmt.Errorf("mark gave up: %w", err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user