diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3988729e..492fc566 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b09ec99f..a23ca32d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt index c54356c5..3a223b45 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/cache/mutations/SupersededToggleIdsTest.kt @@ -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))) + } } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt new file mode 100644 index 00000000..f21eebac --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/events/ReconnectBackoffTest.kt @@ -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") + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt new file mode 100644 index 00000000..bd034ae1 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationSettingsRepositoryTest.kt @@ -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() + coEvery { dao.upsertSettings(capture(saved)) } returns Unit + val body = slot() + 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()) } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt new file mode 100644 index 00000000..cc3e7343 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/data/NotificationsRepositoryTest.kt @@ -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(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() + 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>() + 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())) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt new file mode 100644 index 00000000..e4e2c3a7 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/delivery/DeliveryPlanTest.kt @@ -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(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(announcementFor(admin)).channel) + val mixed = admin + notice("r", 9) + assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs(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)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt new file mode 100644 index 00000000..763e13ba --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/notifications/ui/NotificationUiTest.kt @@ -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)) + } +} diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 92a7ac43..cff4d8c5 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -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 diff --git a/docs/security.md b/docs/security.md index 7452b8df..7ed8472a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/internal/api/admin_notification_email.go b/internal/api/admin_notification_email.go new file mode 100644 index 00000000..c4916856 --- /dev/null +++ b/internal/api/admin_notification_email.go @@ -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)) +} diff --git a/internal/api/admin_notification_email_test.go b/internal/api/admin_notification_email_test.go new file mode 100644 index 00000000..76b41e2d --- /dev/null +++ b/internal/api/admin_notification_email_test.go @@ -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) + } + } +} diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go index 69a61bdb..1d63e487 100644 --- a/internal/api/admin_requests.go +++ b/internal/api/admin_requests.go @@ -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)) } diff --git a/internal/api/api.go b/internal/api/api.go index 9d4bebaa..8d2aa202 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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. @@ -327,20 +337,23 @@ type handlers struct { // librarySize memoises the track count that sizes the candidate pool // (#3880). Held here rather than counted per request: the count is a // full table scan, and library size only moves when a scan runs. - librarySize *recommendation.LibrarySize - lidarrCfg *lidarrconfig.Service - lidarrRequests *lidarrrequests.Service - lidarrQuarantine *lidarrquarantine.Service - tracks *tracks.Service - playlists *playlists.Service - coverart *coverart.Enricher - coverSettings *coverart.SettingsService - tagSettings *tags.SettingsService - scanner *library.Scanner - scanCfg library.RunScanConfig - dataDir string - mailer mailer.Sender - eventbus *eventbus.Bus + librarySize *recommendation.LibrarySize + lidarrCfg *lidarrconfig.Service + lidarrRequests *lidarrrequests.Service + lidarrQuarantine *lidarrquarantine.Service + tracks *tracks.Service + playlists *playlists.Service + coverart *coverart.Enricher + coverSettings *coverart.SettingsService + tagSettings *tags.SettingsService + scanner *library.Scanner + scanCfg library.RunScanConfig + 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. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 4f04a6ef..feb137ae 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 } diff --git a/internal/api/me_notifications.go b/internal/api/me_notifications.go new file mode 100644 index 00000000..8c7ae485 --- /dev/null +++ b/internal/api/me_notifications.go @@ -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 +} diff --git a/internal/api/me_notifications_test.go b/internal/api/me_notifications_test.go new file mode 100644 index 00000000..1839b3ab --- /dev/null +++ b/internal/api/me_notifications_test.go @@ -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) + } +} diff --git a/internal/api/notify_producers.go b/internal/api/notify_producers.go new file mode 100644 index 00000000..e690f581 --- /dev/null +++ b/internal/api/notify_producers.go @@ -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()) +} diff --git a/internal/api/notify_producers_test.go b/internal/api/notify_producers_test.go new file mode 100644 index 00000000..d5f7d0e7 --- /dev/null +++ b/internal/api/notify_producers_test.go @@ -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)) +} diff --git a/internal/api/playback_errors.go b/internal/api/playback_errors.go index fb7da802..ccc358c7 100644 --- a/internal/api/playback_errors.go +++ b/internal/api/playback_errors.go @@ -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)}) } diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go index 1beec8b2..c5df1b22 100644 --- a/internal/api/quarantine.go +++ b/internal/api/quarantine.go @@ -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)) } diff --git a/internal/api/requests.go b/internal/api/requests.go index fcd84b95..0c1edd47 100644 --- a/internal/api/requests.go +++ b/internal/api/requests.go @@ -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)) } diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go index 7abd362a..3689aeba 100644 --- a/internal/db/dbq/discover.sql.go +++ b/internal/db/dbq/discover.sql.go @@ -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 - 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 - 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 - 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 - ) - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 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) +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 = $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 = $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 = $3 AND gl.track_id = t.id + ) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $3 AND q.track_id = t.id + ) + GROUP BY t.id, t.album_id, t.artist_id +), +album_capped AS ( + SELECT s.id, s.album_id, s.artist_id, s.weight, s.tiebreak, + row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank + FROM scored s +), +artist_capped AS ( + SELECT a.id, a.album_id, a.artist_id, a.weight, a.tiebreak, a.album_rank, + row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank + FROM album_capped a + WHERE a.album_rank <= $4::int +) +SELECT c.id, c.album_id, c.artist_id + FROM artist_capped c + WHERE c.artist_rank <= $1::int + ORDER BY c.weight DESC, c.tiebreak LIMIT 120 ` type ListTasteUnheardTracksForDiscoverParams struct { - UserID pgtype.UUID - Column2 string + MaxPerArtist int32 + DateSeed string + UserID pgtype.UUID + 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 } diff --git a/internal/db/dbq/duplicates.sql.go b/internal/db/dbq/duplicates.sql.go index c3dab489..beb0161e 100644 --- a/internal/db/dbq/duplicates.sql.go +++ b/internal/db/dbq/duplicates.sql.go @@ -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 diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 3fb3d24f..03a4aa19 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 diff --git a/internal/db/dbq/notifications.sql.go b/internal/db/dbq/notifications.sql.go new file mode 100644 index 00000000..d5c64d7f --- /dev/null +++ b/internal/db/dbq/notifications.sql.go @@ -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 +} diff --git a/internal/db/dbq/playback_errors.sql.go b/internal/db/dbq/playback_errors.sql.go index 607441b9..53988c4e 100644 --- a/internal/db/dbq/playback_errors.sql.go +++ b/internal/db/dbq/playback_errors.sql.go @@ -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) diff --git a/internal/db/migrations/0073_user_notifications.down.sql b/internal/db/migrations/0073_user_notifications.down.sql new file mode 100644 index 00000000..df94a29d --- /dev/null +++ b/internal/db/migrations/0073_user_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS user_notification_prefs; +DROP TABLE IF EXISTS user_notifications; diff --git a/internal/db/migrations/0073_user_notifications.up.sql b/internal/db/migrations/0073_user_notifications.up.sql new file mode 100644 index 00000000..945bb0d1 --- /dev/null +++ b/internal/db/migrations/0073_user_notifications.up.sql @@ -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) +); diff --git a/internal/db/migrations/0074_notification_email.down.sql b/internal/db/migrations/0074_notification_email.down.sql new file mode 100644 index 00000000..e8c2d908 --- /dev/null +++ b/internal/db/migrations/0074_notification_email.down.sql @@ -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; diff --git a/internal/db/migrations/0074_notification_email.up.sql b/internal/db/migrations/0074_notification_email.up.sql new file mode 100644 index 00000000..f1129e66 --- /dev/null +++ b/internal/db/migrations/0074_notification_email.up.sql @@ -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; diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql index 86385609..2c297610 100644 --- a/internal/db/queries/discover.sql +++ b/internal/db/queries/discover.sql @@ -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 - 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 - 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 - 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 - ) - AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q - WHERE q.user_id = $1 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) +-- +-- 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 = 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 = 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 = sqlc.arg(user_id) AND gl.track_id = t.id + ) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id + ) + GROUP BY t.id, t.album_id, t.artist_id +), +album_capped AS ( + SELECT s.*, + row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank + FROM scored s +), +artist_capped AS ( + SELECT a.*, + row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank + FROM album_capped a + WHERE a.album_rank <= sqlc.arg(max_per_album)::int +) +SELECT c.id, c.album_id, c.artist_id + FROM artist_capped c + WHERE c.artist_rank <= sqlc.arg(max_per_artist)::int + ORDER BY c.weight DESC, c.tiebreak LIMIT 120; diff --git a/internal/db/queries/duplicates.sql b/internal/db/queries/duplicates.sql index 9150ccc5..0aada774 100644 --- a/internal/db/queries/duplicates.sql +++ b/internal/db/queries/duplicates.sql @@ -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); diff --git a/internal/db/queries/notifications.sql b/internal/db/queries/notifications.sql new file mode 100644 index 00000000..fa82ca38 --- /dev/null +++ b/internal/db/queries/notifications.sql @@ -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 *; diff --git a/internal/db/queries/playback_errors.sql b/internal/db/queries/playback_errors.sql index c89da41f..823a76ee 100644 --- a/internal/db/queries/playback_errors.sql +++ b/internal/db/queries/playback_errors.sql @@ -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; diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index 4dc79322..2d80157a 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -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) + } } diff --git a/internal/library/duplicate_sweep.go b/internal/library/duplicate_sweep.go index 34dd2cb8..46e6cba7 100644 --- a/internal/library/duplicate_sweep.go +++ b/internal/library/duplicate_sweep.go @@ -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) { diff --git a/internal/library/notify.go b/internal/library/notify.go new file mode 100644 index 00000000..01b4f361 --- /dev/null +++ b/internal/library/notify.go @@ -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}) +} diff --git a/internal/library/notify_test.go b/internal/library/notify_test.go new file mode 100644 index 00000000..0ca635e2 --- /dev/null +++ b/internal/library/notify_test.go @@ -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) + } +} diff --git a/internal/library/reconcile.go b/internal/library/reconcile.go index 534b42e8..1f13474b 100644 --- a/internal/library/reconcile.go +++ b/internal/library/reconcile.go @@ -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 } diff --git a/internal/library/scanrun.go b/internal/library/scanrun.go index e907eaab..f25081f8 100644 --- a/internal/library/scanrun.go +++ b/internal/library/scanrun.go @@ -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, }) diff --git a/internal/lidarrrequests/reconciler.go b/internal/lidarrrequests/reconciler.go index 122bdb4d..eac3c103 100644 --- a/internal/lidarrrequests/reconciler.go +++ b/internal/lidarrrequests/reconciler.go @@ -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 } diff --git a/internal/lidarrrequests/reconciler_notify_test.go b/internal/lidarrrequests/reconciler_notify_test.go new file mode 100644 index 00000000..53e71751 --- /dev/null +++ b/internal/lidarrrequests/reconciler_notify_test.go @@ -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) + } +} diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go index c5721732..b177ae33 100644 --- a/internal/lidarrrequests/service.go +++ b/internal/lidarrrequests/service.go @@ -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 { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index f0ebfb0c..feea93dc 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -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 { diff --git a/internal/mailer/subject_test.go b/internal/mailer/subject_test.go new file mode 100644 index 00000000..b3cc2fb7 --- /dev/null +++ b/internal/mailer/subject_test.go @@ -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) + } +} diff --git a/internal/notifications/digest.go b/internal/notifications/digest.go new file mode 100644 index 00000000..a1cd60c2 --- /dev/null +++ b/internal/notifications/digest.go @@ -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 +} diff --git a/internal/notifications/digest_db_test.go b/internal/notifications/digest_db_test.go new file mode 100644 index 00000000..a61bf940 --- /dev/null +++ b/internal/notifications/digest_db_test.go @@ -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) +} diff --git a/internal/notifications/digest_render.go b/internal/notifications/digest_render.go new file mode 100644 index 00000000..59213582 --- /dev/null +++ b/internal/notifications/digest_render.go @@ -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 +} diff --git a/internal/notifications/digest_test.go b/internal/notifications/digest_test.go new file mode 100644 index 00000000..b89539ff --- /dev/null +++ b/internal/notifications/digest_test.go @@ -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, `x", 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, "
@@ -238,7 +238,7 @@ + + {/if} +
diff --git a/web/src/lib/components/NotificationEmailCard.test.ts b/web/src/lib/components/NotificationEmailCard.test.ts new file mode 100644 index 00000000..0d7f01d8 --- /dev/null +++ b/web/src/lib/components/NotificationEmailCard.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +vi.mock('#lib/api/admin.js', () => ({ + getNotificationEmailSettings: vi.fn(), + updateNotificationEmailSettings: vi.fn() +})); + +vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() })); + +import NotificationEmailCard from './NotificationEmailCard.svelte'; +import { getNotificationEmailSettings, updateNotificationEmailSettings } from '#lib/api/admin.js'; +import { pushToast } from '#lib/stores/toast.svelte.js'; + +afterEach(() => vi.clearAllMocks()); + +async function renderCard() { + vi.mocked(getNotificationEmailSettings).mockResolvedValue({ summary_hour: 9, batch_window_minutes: 60 }); + render(NotificationEmailCard); + return screen.findByRole('combobox', { name: /new music summary/i }); +} + +describe('NotificationEmailCard', () => { + test('shows the summary hour and the batch window', async () => { + const hour = await renderCard(); + expect(hour).toHaveValue('9'); + expect(screen.getByRole('option', { name: '09:00' })).toBeInTheDocument(); + expect(screen.getByRole('spinbutton', { name: /batch everything else/i })).toHaveValue(60); + }); + + test('save stays off until something changes, then sends both values', async () => { + const hour = await renderCard(); + const save = screen.getByRole('button', { name: 'Save' }); + expect(save).toBeDisabled(); + + vi.mocked(updateNotificationEmailSettings).mockResolvedValue({ summary_hour: 7, batch_window_minutes: 60 }); + await fireEvent.change(hour, { target: { value: '7' } }); + expect(save).toBeEnabled(); + await fireEvent.click(save); + + await waitFor(() => + expect(updateNotificationEmailSettings).toHaveBeenCalledWith({ summary_hour: 7, batch_window_minutes: 60 }) + ); + await waitFor(() => expect(save).toBeDisabled()); + }); + + test("a refused save shows the server's reason", async () => { + await renderCard(); + vi.mocked(updateNotificationEmailSettings).mockRejectedValue({ + code: 'invalid_setting', + message: 'batch_window_minutes must be 15-1440', + status: 400 + }); + const batch = screen.getByRole('spinbutton', { name: /batch everything else/i }); + await fireEvent.input(batch, { target: { value: '5' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringContaining('15-1440'), 'error')); + }); + + test('a failed load offers a retry', async () => { + vi.mocked(getNotificationEmailSettings).mockRejectedValue(new Error('down')); + render(NotificationEmailCard); + expect(await screen.findByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); +}); diff --git a/web/src/lib/components/NotificationSettings.svelte b/web/src/lib/components/NotificationSettings.svelte new file mode 100644 index 00000000..c068d6fd --- /dev/null +++ b/web/src/lib/components/NotificationSettings.svelte @@ -0,0 +1,132 @@ + + +{#snippet rows(list: NotificationKindSetting[])} +
+ + {#each channels as c (c.key)} + {c.label} + {/each} + {#each list as row (row.kind)} + {label(row.kind)} + {#each channels as c (c.key)} + + toggle(row, c.key)} + /> + + {/each} + {/each} +
+{/snippet} + +
+

Notifications

+ + {#if !settings} +

Loading…

+ {:else} + {@render rows(forEveryone)} + + {#if forAdmins.length > 0} +

Library health

+ {@render rows(forAdmins)} + {/if} + + {#if !settings.email_available} +

+ {#if settings.email_unavailable_reason === 'no_address'} + Email is off: add an email address in your profile. + {:else if user.value?.is_admin} + Email is off: SMTP isn't set up. + {:else} + Email is off: this server doesn't send email. + {/if} +

+ {/if} + {/if} +
diff --git a/web/src/lib/components/NotificationSettings.test.ts b/web/src/lib/components/NotificationSettings.test.ts new file mode 100644 index 00000000..f644de0e --- /dev/null +++ b/web/src/lib/components/NotificationSettings.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +const setQueryData = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) }; +}); + +vi.mock('#lib/api/notifications.js', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + createNotificationSettingsQuery: vi.fn(), + putNotificationSettings: vi.fn() + }; +}); + +const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } })); +vi.mock('#lib/auth/store.svelte.js', () => ({ + user: { get value() { return userState.current; } } +})); + +const pushToast = vi.fn(); +vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) })); + +import NotificationSettings from './NotificationSettings.svelte'; +import { + createNotificationSettingsQuery, + putNotificationSettings, + type NotificationKindSetting, + type NotificationSettings as Settings +} from '#lib/api/notifications.js'; + +const requesterKinds: NotificationKindSetting[] = [ + { kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true }, + { kind: 'request_completed', admin_only: false, inbox: true, phone: true, email: true } +]; +const adminKinds: NotificationKindSetting[] = [ + { kind: 'tracks_missing', admin_only: true, inbox: true, phone: true, email: false } +]; + +function setSettings(s: Partial) { + const data: Settings = { kinds: requesterKinds, email_available: true, ...s }; + (createNotificationSettingsQuery as ReturnType).mockReturnValue(mockQuery({ data })); + return data; +} + +beforeEach(() => { + vi.clearAllMocks(); + userState.current = { id: '1', username: 'alice', is_admin: false }; +}); + +describe('NotificationSettings', () => { + test('a listener sees their kinds with a toggle per channel and no admin heading', () => { + setSettings({}); + render(NotificationSettings); + expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'New music arrived: Email' })).toBeChecked(); + expect(screen.queryByText('Library health')).toBeNull(); + }); + + test('an admin also sees library health under its own heading', () => { + userState.current = { id: '1', username: 'root', is_admin: true }; + setSettings({ kinds: [...requesterKinds, ...adminKinds] }); + render(NotificationSettings); + expect(screen.getByText('Library health')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Tracks gone missing: Email' })).not.toBeChecked(); + }); + + test('toggling sends only that kind and channel, and shows the change at once', async () => { + const data = setSettings({}); + (putNotificationSettings as ReturnType).mockResolvedValue(data); + render(NotificationSettings); + + await fireEvent.click(screen.getByRole('checkbox', { name: 'New music arrived: Email' })); + + expect(putNotificationSettings).toHaveBeenCalledWith([{ kind: 'request_completed', email: false }]); + const optimistic = setQueryData.mock.calls[0][1] as Settings; + expect(optimistic.kinds.find((k) => k.kind === 'request_completed')?.email).toBe(false); + }); + + test('a failed save puts the switch back and says so', async () => { + const data = setSettings({}); + (putNotificationSettings as ReturnType).mockRejectedValue(new Error('offline')); + render(NotificationSettings); + + await fireEvent.click(screen.getByRole('checkbox', { name: 'Request approved: Phone' })); + + await waitFor(() => expect(pushToast).toHaveBeenCalled()); + expect(setQueryData).toHaveBeenLastCalledWith(['notificationSettings'], data); + }); + + test('inbox off disables phone and email for that kind', () => { + setSettings({ + kinds: [{ kind: 'request_approved', admin_only: false, inbox: false, phone: true, email: true }] + }); + render(NotificationSettings); + expect(screen.getByRole('checkbox', { name: 'Request approved: Phone' })).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeEnabled(); + }); + + test('no email address: the email column is off and points at the profile', () => { + setSettings({ email_available: false, email_unavailable_reason: 'no_address' }); + render(NotificationSettings); + expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled(); + expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute( + 'href', + '#profile' + ); + }); + + test('no SMTP: an admin is sent to the SMTP settings, a listener is just told', () => { + setSettings({ email_available: false, email_unavailable_reason: 'smtp_not_configured' }); + const { unmount } = render(NotificationSettings); + expect(screen.getByTestId('email-unavailable')).toHaveTextContent("this server doesn't send email"); + expect(screen.queryByRole('link', { name: "SMTP isn't set up" })).toBeNull(); + unmount(); + + userState.current = { id: '1', username: 'root', is_admin: true }; + render(NotificationSettings); + expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute( + 'href', + '/admin/integrations' + ); + }); +}); diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index f9173f87..424f2a26 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -10,11 +10,11 @@ togglePlay, skipNext, skipPrev, seekTo, setVolume, toggleShuffle, cycleRepeat, playQueue, toggleQueueDrawer - } from '$lib/player/store.svelte'; - import { formatDuration } from '$lib/media/duration'; - import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; - import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor'; - import { useSmoothPosition } from '$lib/player/smoothPosition.svelte'; + } from '#lib/player/store.svelte.js'; + import { formatDuration } from '#lib/media/duration.js'; + import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js'; + import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js'; + import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; @@ -118,7 +118,7 @@ {#if player.state === 'loading' || player.state === 'idle'} @@ -281,7 +281,7 @@ {#if player.state === 'loading' || player.state === 'idle'} diff --git a/web/src/lib/components/PlayerBar.test.ts b/web/src/lib/components/PlayerBar.test.ts index d47d95b3..85c953f4 100644 --- a/web/src/lib/components/PlayerBar.test.ts +++ b/web/src/lib/components/PlayerBar.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/svelte'; -import type { TrackRef } from '$lib/api/types'; +import type { TrackRef } from '#lib/api/types.js'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; -import { makeTrack } from '$test-utils/fixtures/track'; +import { makeTrack } from '#test-utils/fixtures/track.js'; // Mutable state the mocked store reads from. const state = vi.hoisted(() => ({ @@ -20,7 +20,7 @@ const state = vi.hoisted(() => ({ queueDrawerOpen: false })); -vi.mock('$lib/player/store.svelte', () => ({ +vi.mock('#lib/player/store.svelte.js', () => ({ player: { get queue() { return state.queue; }, get index() { return state.index; }, @@ -46,16 +46,16 @@ vi.mock('$lib/player/store.svelte', () => ({ toggleQueueDrawer: vi.fn() })); -vi.mock('$lib/api/likes', () => emptyLikesMock()); +vi.mock('#lib/api/likes.js', () => emptyLikesMock()); -vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); +vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); import PlayerBar from './PlayerBar.svelte'; import { togglePlay, skipNext, skipPrev, seekTo, setVolume, toggleShuffle, cycleRepeat, playQueue, toggleQueueDrawer -} from '$lib/player/store.svelte'; +} from '#lib/player/store.svelte.js'; function track(): TrackRef { return makeTrack({ diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 95642a6b..98e1f906 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -1,15 +1,15 @@ {#if toast.value} diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index 74661f4f..ef7f0377 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -18,11 +18,11 @@ import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte'; - import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes'; - import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; - import { qk } from '$lib/api/queries'; - import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte'; - import type { TrackRef } from '$lib/api/types'; + import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js'; + import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js'; + import { qk } from '#lib/api/queries.js'; + import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js'; + import type { TrackRef } from '#lib/api/types.js'; let { track, diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index 31213cb8..1b0ece70 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; -import { makeTrack } from '$test-utils/fixtures/track'; +import { makeTrack } from '#test-utils/fixtures/track.js'; // Mutable mock-user handle so individual tests can flip is_admin / null // without re-importing the module under test. @@ -13,7 +13,7 @@ const userState = vi.hoisted(() => ({ | null })); -vi.mock('$lib/auth/store.svelte', () => ({ +vi.mock('#lib/auth/store.svelte.js', () => ({ user: { get value() { return userState.current; } } })); @@ -21,20 +21,20 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); -vi.mock('$lib/api/likes', () => emptyLikesMock()); +vi.mock('#lib/api/likes.js', () => emptyLikesMock()); -vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); +vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); -vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); +vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock()); -vi.mock('$lib/player/store.svelte', () => ({ +vi.mock('#lib/player/store.svelte.js', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn(), playRadio: vi.fn() })); import TrackMenu from './TrackMenu.svelte'; -import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte'; +import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js'; const track = makeTrack({ title: 'Roygbiv' }); diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index d46a2e8c..9bf61400 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -1,13 +1,13 @@ diff --git a/web/src/routes/admin/+layout.ts b/web/src/routes/admin/+layout.ts index e3f246f6..2f10dd9f 100644 --- a/web/src/routes/admin/+layout.ts +++ b/web/src/routes/admin/+layout.ts @@ -1,5 +1,5 @@ import { redirect } from '@sveltejs/kit'; -import { user } from '$lib/auth/store.svelte'; +import { user } from '#lib/auth/store.svelte.js'; import type { LayoutLoad } from './$types'; // Hard route gate: runs before the layout (and any child page) renders. diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 8c7ec2d3..eedd4efd 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,8 +1,8 @@