Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -295,12 +295,13 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
# What ships to browsers: `dependencies` and the runtime they pull in
|
||||
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
||||
# kit's dev server) is left out because none of it reaches a user, and
|
||||
# its open advisories need major-version upgrades tracked separately.
|
||||
- name: npm audit (shipped dependencies)
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
# The whole tree, build and test tooling included. Until #5021 this
|
||||
# audited only what ships to browsers (`--omit=dev`), because vite,
|
||||
# vitest, tailwind and kit carried advisories that needed major
|
||||
# upgrades. Those upgrades landed and the full tree audits clean, so
|
||||
# the tooling that builds the shipped bundle is held to the same bar.
|
||||
- name: npm audit (all dependencies)
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
- name: Type-check + svelte-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_MEDIA_PLAYBACK" />
|
||||
<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
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
@@ -57,6 +61,29 @@
|
||||
</intent-filter>
|
||||
</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
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -16,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
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.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
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.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
@@ -35,6 +45,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -42,41 +56,72 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
@Inject lateinit var authStore: AuthStore
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
// Set when the user taps a notification: the media one asks for the full
|
||||
// player, a Minstrel notice for what it is about. The App composable
|
||||
// navigates there once the NavHost is ready, then calls back to clear it
|
||||
// so the navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingRoute = MutableStateFlow<Any?>(null)
|
||||
|
||||
// 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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
consumeRouteIntent(intent)
|
||||
askToNotifyOnceWanted()
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
pendingRoute = pendingRoute.asStateFlow(),
|
||||
onOpenedRoute = { pendingRoute.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
consumeRouteIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
private fun consumeRouteIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
|
||||
pendingRoute.value = NowPlaying
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
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 {
|
||||
@@ -84,6 +129,10 @@ class MainActivity : ComponentActivity() {
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
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,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
pendingRoute: StateFlow<Any?>,
|
||||
onOpenedRoute: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
val pending by pendingRoute.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
@@ -119,16 +168,14 @@ private fun App(
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
// mounted. launchSingleTop avoids stacking copies of a
|
||||
// screen if the user taps the notification while already
|
||||
// on it; the callback clears it so a later recomposition
|
||||
// (config change, theme switch) doesn't re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
val route = pending ?: return@LaunchedEffect
|
||||
navController.navigate(route) { launchSingleTop = true }
|
||||
onOpenedRoute()
|
||||
}
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
@@ -75,6 +76,14 @@ class MinstrelApplication :
|
||||
*/
|
||||
@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
|
||||
* 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)
|
||||
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 }
|
||||
|
||||
// Serialises every cookie persist with the one-time hydration, so a
|
||||
@@ -104,6 +110,7 @@ class AuthStore @Inject constructor(
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +212,17 @@ class AuthStore @Inject constructor(
|
||||
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?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
@@ -287,6 +305,7 @@ class AuthStore @Inject constructor(
|
||||
NormalizationPrefs.serializer(),
|
||||
normalizationState.value,
|
||||
),
|
||||
backgroundDelivery = backgroundDeliveryState.value,
|
||||
)
|
||||
|
||||
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.CachedLikeDao
|
||||
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.CachedResumeStateDao
|
||||
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.CachedLikeEntity
|
||||
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.CachedResumeStateEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
@@ -66,7 +69,14 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedHistorySnapshotEntity::class,
|
||||
AuthSessionEntity::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/
|
||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||
// 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 —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 10,
|
||||
version = 12,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -102,6 +112,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||
}
|
||||
|
||||
/** 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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.CachedLikeDao
|
||||
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.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
@@ -37,7 +38,7 @@ object DatabaseModule {
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// 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)
|
||||
.build()
|
||||
|
||||
@@ -112,5 +113,10 @@ object DatabaseModule {
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
||||
db.cachedNotificationDao()
|
||||
|
||||
private const val DATABASE_NAME = "minstrel.db"
|
||||
}
|
||||
|
||||
+9
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
@Dao
|
||||
interface AuthSessionDao {
|
||||
@@ -50,4 +51,12 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the serialized normalization preference. */
|
||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||
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
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
@@ -47,6 +48,17 @@ object MutationKind {
|
||||
// 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.
|
||||
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),
|
||||
)
|
||||
|
||||
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(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload(
|
||||
val detail: String? = null,
|
||||
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.LikesApi
|
||||
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.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
@@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
private val notificationsApi: NotificationsApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -170,10 +174,22 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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.
|
||||
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 {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
@@ -293,6 +309,27 @@ class MutationReplayer @Inject constructor(
|
||||
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 {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -355,6 +392,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.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.
|
||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.fabledsword.minstrel.events
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,9 +32,6 @@ import javax.inject.Singleton
|
||||
|
||||
private const val SSE_PATH = "/api/events/stream"
|
||||
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
|
||||
@@ -45,11 +46,16 @@ private const val BACKOFF_FACTOR = 2
|
||||
* - No client-side timeout — the server emits 15s heartbeats which
|
||||
* okhttp-sse handles transparently.
|
||||
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
||||
* restart, network blip) it reconnects with exponential backoff
|
||||
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
|
||||
* restart, network blip) it reconnects after [ReconnectBackoff]'s
|
||||
* 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
|
||||
* retry. Without this a single blip silently kills cross-device
|
||||
* 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
|
||||
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
||||
@@ -62,6 +68,7 @@ class EventsStream @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val json: Json,
|
||||
private val connectivity: ConnectivityObserver,
|
||||
) {
|
||||
private val factory = EventSources.createFactory(okHttpClient)
|
||||
|
||||
@@ -72,10 +79,13 @@ class EventsStream @Inject constructor(
|
||||
)
|
||||
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
||||
|
||||
private val connectedState = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
|
||||
|
||||
private var currentSource: EventSource? = null
|
||||
@Volatile private var signedIn = false
|
||||
private var reconnectJob: Job? = null
|
||||
private var backoffMs = BASE_BACKOFF_MS
|
||||
private var backoffMs = ReconnectBackoff.BASE_MS
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -85,13 +95,30 @@ class EventsStream @Inject constructor(
|
||||
.collect { isSignedIn ->
|
||||
signedIn = isSignedIn
|
||||
if (isSignedIn) {
|
||||
backoffMs = BASE_BACKOFF_MS
|
||||
backoffMs = ReconnectBackoff.BASE_MS
|
||||
connect()
|
||||
} else {
|
||||
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
|
||||
@@ -106,20 +133,22 @@ class EventsStream @Inject constructor(
|
||||
reconnectJob?.cancel()
|
||||
currentSource?.cancel()
|
||||
currentSource = null
|
||||
connectedState.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnect after the current backoff, then double it
|
||||
* (capped). No-op when signed out — sign-out's [disconnect]
|
||||
* Schedule a reconnect after the current backoff, jittered, then
|
||||
* double it (capped). No-op when signed out — sign-out's [disconnect]
|
||||
* cancels the pending job. A successful [Listener.onOpen] resets
|
||||
* the backoff to the floor.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun scheduleReconnect() {
|
||||
connectedState.value = false
|
||||
if (!signedIn) return
|
||||
reconnectJob?.cancel()
|
||||
val waitMs = backoffMs
|
||||
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
|
||||
val waitMs = ReconnectBackoff.jittered(backoffMs)
|
||||
backoffMs = ReconnectBackoff.next(backoffMs)
|
||||
reconnectJob = scope.launch {
|
||||
delay(waitMs)
|
||||
if (signedIn) connect()
|
||||
@@ -136,7 +165,8 @@ class EventsStream @Inject constructor(
|
||||
|
||||
private inner class Listener : EventSourceListener() {
|
||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||
backoffMs = BASE_BACKOFF_MS
|
||||
backoffMs = ReconnectBackoff.BASE_MS
|
||||
connectedState.value = true
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -30,6 +31,7 @@ import javax.inject.Singleton
|
||||
class LiveEventsDispatcher @Inject constructor(
|
||||
private val eventsStream: EventsStream,
|
||||
private val likes: LikesRepository,
|
||||
private val notifications: NotificationsRepository,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
@@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
"artist.liked",
|
||||
"artist.unliked",
|
||||
-> refreshLikes()
|
||||
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||
"notification.created" -> refreshNotifications()
|
||||
}
|
||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||
@@ -58,9 +62,18 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
// 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.
|
||||
eventsStream.reconnectNow()
|
||||
refreshLikes()
|
||||
refreshNotifications()
|
||||
}
|
||||
|
||||
private fun refreshNotifications() {
|
||||
scope.launch {
|
||||
runCatching { notifications.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
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.ArtistDetailScreen
|
||||
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.QueueScreen
|
||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||
@@ -59,6 +61,7 @@ fun MinstrelNavGraph(
|
||||
) {
|
||||
inShellTopLevel(navController, expandPlayer)
|
||||
inShellDetail(navController, expandPlayer)
|
||||
inShellNotifications(navController, expandPlayer)
|
||||
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) {
|
||||
composable<NowPlaying>(
|
||||
// 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 Admin
|
||||
@Serializable data object Requests
|
||||
@Serializable data object Notifications
|
||||
@Serializable data object NotificationSettings
|
||||
|
||||
// ── 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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Bell
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.LogOut
|
||||
@@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shield
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.nav.ServerUrl
|
||||
@@ -98,6 +100,7 @@ fun SettingsScreen(
|
||||
themeMode = themeMode,
|
||||
onPickTheme = themeVm::setThemeMode,
|
||||
onNavToRequests = { navController.navigate(Requests) },
|
||||
onNavToNotifications = { navController.navigate(NotificationSettings) },
|
||||
onNavToAdmin = { navController.navigate(Admin) },
|
||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||
onSignOutClick = { showSignOutConfirm = true },
|
||||
@@ -121,6 +124,7 @@ private fun SettingsList(
|
||||
themeMode: ThemeMode,
|
||||
onPickTheme: (ThemeMode) -> Unit,
|
||||
onNavToRequests: () -> Unit,
|
||||
onNavToNotifications: () -> Unit,
|
||||
onNavToAdmin: () -> Unit,
|
||||
onToggleDiagnostics: (Boolean) -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
@@ -144,6 +148,12 @@ private fun SettingsList(
|
||||
subtitle = "Track what you've asked Minstrel to add",
|
||||
onClick = onNavToRequests,
|
||||
)
|
||||
NavTile(
|
||||
icon = Lucide.Bell,
|
||||
title = "Notifications",
|
||||
subtitle = "What reaches you, and where",
|
||||
onClick = onNavToNotifications,
|
||||
)
|
||||
if (state.isAdmin) {
|
||||
NavTile(
|
||||
icon = Lucide.Shield,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -28,6 +29,7 @@ data class SettingsState(
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authController: AuthController,
|
||||
private val authStore: AuthStore,
|
||||
private val notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val transient = MutableStateFlow(TransientState())
|
||||
@@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
transient.update { it.copy(isSigningOut = true) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
@HiltViewModel
|
||||
class AppBarActionsViewModel @Inject constructor(
|
||||
authController: AuthController,
|
||||
notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||
.map { it?.isAdmin == true }
|
||||
@@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
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
|
||||
|
||||
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.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
|
||||
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.House
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
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.Home
|
||||
import com.fabledsword.minstrel.nav.Library
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
import com.fabledsword.minstrel.nav.Playlists
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
@@ -50,6 +55,7 @@ fun MainAppBarActions(
|
||||
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||
Row {
|
||||
if (currentRouteName != Home::class.qualifiedName) {
|
||||
IconButton(onClick = { navController.navigate(Home) }) {
|
||||
@@ -66,10 +72,47 @@ fun MainAppBarActions(
|
||||
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
if (currentRouteName != Notifications::class.qualifiedName) {
|
||||
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
|
||||
}
|
||||
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
|
||||
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
||||
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
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
@@ -153,4 +154,34 @@ class SupersededToggleIdsTest {
|
||||
)
|
||||
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/lidarrrequests"
|
||||
"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/reacquisition"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
@@ -313,7 +315,14 @@ func run() error {
|
||||
}
|
||||
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.SetNotifier(notifier)
|
||||
library.SetNotifier(notifier)
|
||||
go lidarrReconciler.Run(ctx)
|
||||
|
||||
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
||||
@@ -331,12 +340,14 @@ func run() error {
|
||||
if reacqErr != nil {
|
||||
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
||||
}
|
||||
go reacquisition.NewSweeper(
|
||||
reacqSweeper := reacquisition.NewSweeper(
|
||||
pool,
|
||||
reacqSettings,
|
||||
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
|
||||
logger.With("component", "reacquisition"),
|
||||
).Run(ctx)
|
||||
)
|
||||
reacqSweeper.SetNotifier(notifier)
|
||||
go reacqSweeper.Run(ctx)
|
||||
|
||||
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||
// 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"))
|
||||
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
|
||||
// active user's daily build at 03:00 in their stored timezone.
|
||||
// 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
|
||||
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`.
|
||||
|
||||
## 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/lidarr"
|
||||
"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.
|
||||
@@ -125,6 +126,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyRequestDecided(r.Context(), notifications.KindRequestApproved, admin, row, "")
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||
}
|
||||
@@ -165,6 +167,7 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.notifyRequestDecided(r.Context(), notifications.KindRequestRejected, admin, row, body.Notes)
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"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/playlists"
|
||||
"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,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
notifier: notifications.New(pool, bus, logger.With("component", "notifications")),
|
||||
playlistScheduler: playlistScheduler,
|
||||
streamSecret: streamSecret,
|
||||
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.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||
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/{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.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||
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
|
||||
// profiles + taste-build knobs, DB-backed, live effect.
|
||||
@@ -341,6 +351,9 @@ type handlers struct {
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
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
|
||||
// reacqSettings is the DB-backed policy for auto re-acquisition of
|
||||
// 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/lidarrrequests"
|
||||
"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/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
@@ -72,7 +73,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
dataDir := t.TempDir()
|
||||
tracksSvc := tracks.NewService(pool, logger, nil, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
return
|
||||
}
|
||||
h.notifyPlaybackErrors(r.Context())
|
||||
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/db/dbq"
|
||||
"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
|
||||
@@ -70,6 +71,10 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
||||
// Broadcast: the flagging user's other clients invalidate their
|
||||
// Hidden tab; admins' clients invalidate their quarantine queue.
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"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.
|
||||
@@ -130,7 +131,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
LidarrArtistMBID: body.LidarrArtistMBID,
|
||||
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)
|
||||
writeJSON(w, http.StatusCreated, requestViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -216,35 +216,55 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
|
||||
}
|
||||
|
||||
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
WITH scored AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
SUM(nt.weight) AS weight,
|
||||
md5(t.id::text || $2::text) AS tiebreak
|
||||
FROM tracks t
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||
JOIN taste_profile_tags nt ON nt.user_id = $3 AND trim(g_split.g) = nt.tag
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND nt.weight > 0
|
||||
AND trim(g_split.g) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
WHERE pe.user_id = $3
|
||||
AND pe.track_id = t.id
|
||||
AND pe.was_skipped = false
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM general_likes gl
|
||||
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||
WHERE gl.user_id = $3 AND gl.track_id = t.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
WHERE q.user_id = $3 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.album_id, t.artist_id
|
||||
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||
),
|
||||
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
|
||||
`
|
||||
|
||||
type ListTasteUnheardTracksForDiscoverParams struct {
|
||||
MaxPerArtist int32
|
||||
DateSeed string
|
||||
UserID pgtype.UUID
|
||||
Column2 string
|
||||
MaxPerAlbum int32
|
||||
}
|
||||
|
||||
type ListTasteUnheardTracksForDiscoverRow struct {
|
||||
@@ -261,9 +281,21 @@ type ListTasteUnheardTracksForDiscoverRow struct {
|
||||
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||
// when the user has no taste tags yet (cold start), so the caller
|
||||
// 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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -27,6 +27,24 @@ func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateG
|
||||
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
|
||||
SELECT count(*)::bigint
|
||||
FROM duplicate_groups g
|
||||
|
||||
@@ -472,6 +472,13 @@ type NetworkSetting struct {
|
||||
PublicUrl string
|
||||
}
|
||||
|
||||
type NotificationEmailSetting struct {
|
||||
ID bool
|
||||
SummaryHour int32
|
||||
BatchWindowMinutes int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type PasswordReset struct {
|
||||
Token string
|
||||
UserID pgtype.UUID
|
||||
@@ -831,6 +838,35 @@ type UserNormalizationPref struct {
|
||||
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 {
|
||||
UserID 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"
|
||||
)
|
||||
|
||||
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
|
||||
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
|
||||
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
|
||||
-- when the user has no taste tags yet (cold start), so the caller
|
||||
-- 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
|
||||
-- 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.
|
||||
WITH scored AS (
|
||||
SELECT t.id, t.album_id, t.artist_id,
|
||||
SUM(nt.weight) AS weight,
|
||||
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
|
||||
FROM tracks t
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||
JOIN taste_profile_tags nt ON nt.user_id = sqlc.arg(user_id) AND trim(g_split.g) = nt.tag
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND nt.weight > 0
|
||||
AND trim(g_split.g) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
WHERE pe.user_id = sqlc.arg(user_id)
|
||||
AND pe.track_id = t.id
|
||||
AND pe.was_skipped = false
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM general_likes gl
|
||||
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||
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 = $1 AND q.track_id = t.id
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.album_id, t.artist_id
|
||||
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||
),
|
||||
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;
|
||||
|
||||
@@ -154,3 +154,13 @@ SELECT p.id AS group_id,
|
||||
UPDATE duplicate_groups
|
||||
SET status = 'dismissed', resolved_at = now()
|
||||
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,
|
||||
resolution = $3
|
||||
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
|
||||
"album_loudness", // 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",
|
||||
"albums",
|
||||
"artists",
|
||||
@@ -154,4 +159,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
||||
); err != nil {
|
||||
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"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
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",
|
||||
"candidates", res.Candidates, "groups", res.Groups, "proposed", res.Proposed,
|
||||
"suppressed", res.Suppressed, "retired", res.Retired, "oversize", res.Oversize, "err", 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(
|
||||
ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, cfg FingerprintSettings, pageSize int32,
|
||||
) (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"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
@@ -128,6 +129,9 @@ func (s *Scanner) reconcileMissing(
|
||||
// this line.
|
||||
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
||||
"count", n, "library_total", len(rows))
|
||||
if n > 0 {
|
||||
notifyAdmins(ctx, notifications.KindTracksMissing, notifications.Payload{Count: n})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ func RunScan(
|
||||
}
|
||||
|
||||
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
|
||||
notifyScanFinished(ctx, errMsg)
|
||||
publishScanEvent("scan.run_finished", row.ID, map[string]any{
|
||||
"error_message": errMsg,
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,7 @@ type Reconciler struct {
|
||||
clientFn func() *lidarr.Client
|
||||
logger *slog.Logger
|
||||
bus *eventbus.Bus
|
||||
notifier *notifications.Notifier // nil: no inbox notifications
|
||||
tick time.Duration
|
||||
batch int32
|
||||
// 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
|
||||
// the original requester. No-op when bus is nil.
|
||||
func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) {
|
||||
// SetNotifier makes completions land in the requester's notifications inbox
|
||||
// (M489). Without one, completions are only broadcast on the bus.
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -308,7 +329,7 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
r.publishCompleted(ctx, completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -333,7 +354,7 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
r.publishCompleted(ctx, completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -374,7 +395,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
r.publishCompleted(ctx, completed)
|
||||
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
|
||||
// pending row.
|
||||
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 {
|
||||
return dbq.LidarrRequest{}, err
|
||||
return dbq.LidarrRequest{}, false, err
|
||||
}
|
||||
q := dbq.New(s.pool)
|
||||
// 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
|
||||
}
|
||||
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
|
||||
return existing, nil
|
||||
return existing, false, nil
|
||||
} 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{
|
||||
UserID: userID,
|
||||
@@ -112,9 +120,37 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
|
||||
TrackTitle: strPtr(p.TrackTitle),
|
||||
})
|
||||
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 {
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -57,7 +59,7 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailer: load config: %w", err)
|
||||
}
|
||||
if !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" {
|
||||
if !Configured(cfg) {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
|
||||
@@ -86,6 +88,13 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
|
||||
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.
|
||||
// Mostly identical to smtp.SendMail but explicitly handles the
|
||||
// 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, "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, "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()
|
||||
}
|
||||
|
||||
// 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
|
||||
// against these.
|
||||
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 {
|
||||
// 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{
|
||||
UserID: userID, Column2: dateStr,
|
||||
UserID: userID, DateSeed: dateStr,
|
||||
MaxPerAlbum: discoverMaxTracksPerAlbum, MaxPerArtist: discoverMaxTracksPerArtist,
|
||||
})
|
||||
if err != nil {
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"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
|
||||
// or an approval path that talks to one.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -37,6 +39,9 @@ type Sweeper struct {
|
||||
settings *SettingsService
|
||||
requests requestCreator
|
||||
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
|
||||
// releaseGroup names the MusicBrainz release group of a release id, for an
|
||||
// album whose tags never carried one (#5241). Lidarr knows albums only by
|
||||
@@ -92,6 +97,10 @@ type PassResult struct {
|
||||
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
|
||||
// now" without waiting out the tick, and so tests drive it directly.
|
||||
func (s *Sweeper) SweepOnce(ctx context.Context) error {
|
||||
@@ -182,7 +191,7 @@ func (s *Sweeper) attempt(
|
||||
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",
|
||||
LidarrArtistMBID: *album.ArtistMbid,
|
||||
ArtistName: album.ArtistName,
|
||||
@@ -206,11 +215,15 @@ func (s *Sweeper) attempt(
|
||||
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 {
|
||||
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
|
||||
switch {
|
||||
case aerr == nil:
|
||||
res.Approved++
|
||||
awaitingAdmin = false
|
||||
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
|
||||
// Leave it pending rather than treating it as a failure. The
|
||||
// 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 err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
|
||||
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