feat(notifications): add notification center with real-time updates

- add notifications DB schema, repository, service, gateway, and controller
- add shared notification types, categories, and preference models
- add NotificationAccess permission with authorization matrix probe
- add frontend composable with Socket.IO subscription and REST API
- add NotificationPopover, NotificationItem, and NotificationPreferences components
- integrate notification producers in scanner, metadata fetch, book bucket, author enrichment, email, migration, and file write-back services
- add 30-day auto-cleanup via existing CleanupService cron
- add 29 backend unit tests for notification service and controller
This commit is contained in:
neon
2026-04-15 03:15:05 -06:00
parent f3d185ccb2
commit 40d829a8a9
53 changed files with 2193 additions and 62 deletions
+24
View File
@@ -297,10 +297,34 @@
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
/* Webkit scrollbar styles */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
border: 2px solid transparent;
background-clip: padding-box;
}
}
@layer components {
+8
View File
@@ -9,6 +9,7 @@
- Dark mode overrides lightness + slightly raises --tint-c.
────────────────────────────────────────────────────────────── */
:root {
color-scheme: light;
/* Tint controls — warm amber base */
--tint-h: 80;
--tint-c: 0.006;
@@ -70,9 +71,13 @@
--radius: 0.625rem;
--dot-color: oklch(0.82 var(--tint-c-surface) var(--tint-h));
--scrollbar-thumb: oklch(0.8 var(--tint-c) var(--tint-h));
--scrollbar-thumb-hover: oklch(0.7 var(--tint-c) var(--tint-h));
}
.dark {
color-scheme: dark;
--tint-c: 0.0175;
--tint-c-surface: var(--tint-c);
--bg-lift: 0;
@@ -129,4 +134,7 @@
--elevation-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.7);
--dot-color: oklch(calc(0.3 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h));
--scrollbar-thumb: oklch(0.35 var(--tint-c) var(--tint-h));
--scrollbar-thumb-hover: oklch(0.45 var(--tint-c) var(--tint-h));
}
+14
View File
@@ -31,6 +31,8 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useBookBucketSummary } from '@/features/book-bucket/composables/useBookBucketSummary'
import NotificationSheet from '@/features/notifications/components/NotificationSheet.vue'
import { useNotifications } from '@/features/notifications/composables/useNotifications'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types'
import { useThemeStore } from '@/stores/theme'
@@ -42,6 +44,7 @@ const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: bookBucketSummary, fetchSummary: fetchBookBucketSummary, subscribe: subscribeBookBucketSummary } = useBookBucketSummary()
const { subscribe: subscribeNotifications } = useNotifications()
const themeStore = useThemeStore()
const isBookBucketActive = computed(() => route.name === 'book-bucket')
@@ -152,6 +155,9 @@ onMounted(() => {
fetchBookBucketSummary()
subscribeBookBucketSummary()
}
if (hasPermission('notification_access')) {
subscribeNotifications()
}
})
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
@@ -433,6 +439,11 @@ function formatBadgeClass(fmt: string): string {
<TooltipContent>Search</TooltipContent>
</Tooltip>
<!-- Mobile: Notifications bell -->
<div class="md:hidden">
<NotificationSheet v-if="hasPermission('notification_access')" :icon-radius-class="iconRadiusClass" />
</div>
<!-- Mobile: Kebab Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
@@ -533,6 +544,9 @@ function formatBadgeClass(fmt: string): string {
<TooltipContent>Book Bucket</TooltipContent>
</Tooltip>
<!-- Notifications button -->
<NotificationSheet v-if="hasPermission('notification_access')" :icon-radius-class="iconRadiusClass" />
<!-- Statistics button -->
<Tooltip>
<TooltipTrigger as-child>
@@ -500,14 +500,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.title"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('title')"
/>
</MetadataFieldLabel>
<MetadataFieldLabel label="Subtitle" field="subtitle" :locked="isLocked('subtitle')" :is-updating="isUpdatingLock" @toggle="handleLockToggle">
<input
v-model="form.subtitle"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('subtitle')"
/>
</MetadataFieldLabel>
@@ -515,8 +515,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<!-- Authors | Narrators (audio only) -->
<div class="grid gap-3" :class="isPrimaryAudio ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'">
<MetadataFieldLabel label="Authors" field="authors" :locked="isLocked('authors')" :is-updating="isUpdatingLock" @toggle="handleLockToggle">
<ChipInput v-model="form.authors" :search-fn="searchAuthors" :disabled="isLocked('authors')" control-class="pr-10" />
<MetadataFieldLabel
label="Authors"
field="authors"
:locked="isLocked('authors')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.authors" :search-fn="searchAuthors" :disabled="isLocked('authors')" control-class="pr-12" />
</MetadataFieldLabel>
<MetadataFieldLabel
v-if="isPrimaryAudio"
@@ -524,15 +531,23 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="narrators"
:locked="isLocked('narrators')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.narrators" :search-fn="searchNarrators" :disabled="isLocked('narrators')" control-class="pr-10" />
<ChipInput v-model="form.narrators" :search-fn="searchNarrators" :disabled="isLocked('narrators')" control-class="pr-12" />
</MetadataFieldLabel>
</div>
<!-- Genres -->
<MetadataFieldLabel label="Genres" field="genres" :locked="isLocked('genres')" :is-updating="isUpdatingLock" @toggle="handleLockToggle">
<ChipInput v-model="form.genres" :search-fn="searchGenres" :disabled="isLocked('genres')" control-class="pr-10" />
<MetadataFieldLabel
label="Genres"
field="genres"
:locked="isLocked('genres')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.genres" :search-fn="searchGenres" :disabled="isLocked('genres')" control-class="pr-12" />
</MetadataFieldLabel>
<!-- Tags | Rating -->
@@ -543,9 +558,10 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="tags"
:locked="isLocked('tags')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.tags" :search-fn="searchTags" :disabled="isLocked('tags')" control-class="pr-10" />
<ChipInput v-model="form.tags" :search-fn="searchTags" :disabled="isLocked('tags')" control-class="pr-12" />
</MetadataFieldLabel>
<MetadataFieldLabel
class="w-full sm:w-auto sm:shrink-0"
@@ -556,7 +572,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
@toggle="handleLockToggle"
>
<div
class="flex h-8 items-center gap-0.5 rounded-lg border border-input bg-background px-2 py-2 pr-10"
class="flex h-8 items-center gap-0.5 rounded-lg border border-input bg-background px-2 py-2 pr-12"
:class="isLocked('rating') ? 'opacity-50 cursor-not-allowed' : ''"
@mouseleave="hoverRating = null"
>
@@ -602,7 +618,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.seriesName"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('seriesName')"
/>
</MetadataFieldLabel>
@@ -619,7 +635,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
type="number"
step="0.1"
min="0"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('seriesIndex')"
@input="setFloatField('seriesIndex', $event)"
/>
@@ -634,7 +650,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.publisher"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('publisher')"
/>
</MetadataFieldLabel>
@@ -652,7 +668,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.language"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
maxlength="10"
:disabled="isLocked('language')"
/>
@@ -670,7 +686,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
type="number"
min="1"
max="2100"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('publishedYear')"
@input="setIntField('publishedYear', $event)"
/>
@@ -687,7 +703,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
:value="form.pageCount ?? ''"
type="number"
min="1"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('pageCount')"
@input="setIntField('pageCount', $event)"
/>
@@ -702,7 +718,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.isbn13"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
maxlength="13"
:disabled="isLocked('isbn13')"
/>
@@ -717,7 +733,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.isbn10"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
maxlength="10"
:disabled="isLocked('isbn10')"
/>
@@ -735,7 +751,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
:value="form.durationSeconds ?? ''"
type="number"
min="1"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('durationSeconds')"
@input="setIntField('durationSeconds', $event)"
/>
@@ -750,7 +766,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
@toggle="handleLockToggle"
>
<div
class="flex h-8 items-center rounded-lg border border-input bg-background px-3 pr-10"
class="flex h-8 items-center rounded-lg border border-input bg-background px-3 pr-12"
:class="isLocked('abridged') ? 'opacity-50 cursor-not-allowed' : ''"
>
<input
@@ -781,7 +797,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<MetadataFieldLabel :label="label" :field="field" :locked="isLocked(field)" :is-updating="isUpdatingLock" @toggle="handleLockToggle">
<input
v-model="form[field]"
class="w-full h-8 rounded-md border border-input bg-background px-2.5 pr-10 text-xs font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-md border border-input bg-background px-2.5 pr-12 text-xs font-mono outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked(field)"
/>
</MetadataFieldLabel>
@@ -812,7 +828,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.comicIssueNumber"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('comicIssueNumber')"
/>
</MetadataFieldLabel>
@@ -825,7 +841,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
>
<input
v-model="form.comicVolumeName"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('comicVolumeName')"
/>
</MetadataFieldLabel>
@@ -836,9 +852,10 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicStoryArcs"
:locked="isLocked('comicStoryArcs')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.comicStoryArcs" :search-fn="searchComicMetadata" :disabled="isLocked('comicStoryArcs')" control-class="pr-10" />
<ChipInput v-model="form.comicStoryArcs" :search-fn="searchComicMetadata" :disabled="isLocked('comicStoryArcs')" control-class="pr-12" />
</MetadataFieldLabel>
<!-- Pencillers | Inkers -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -847,13 +864,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicPencillers"
:locked="isLocked('comicPencillers')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput
v-model="form.comicPencillers"
:search-fn="searchComicMetadata"
:disabled="isLocked('comicPencillers')"
control-class="pr-10"
control-class="pr-12"
/>
</MetadataFieldLabel>
<MetadataFieldLabel
@@ -861,9 +879,10 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicInkers"
:locked="isLocked('comicInkers')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.comicInkers" :search-fn="searchComicMetadata" :disabled="isLocked('comicInkers')" control-class="pr-10" />
<ChipInput v-model="form.comicInkers" :search-fn="searchComicMetadata" :disabled="isLocked('comicInkers')" control-class="pr-12" />
</MetadataFieldLabel>
</div>
<!-- Colorists | Letterers -->
@@ -873,13 +892,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicColorists"
:locked="isLocked('comicColorists')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput
v-model="form.comicColorists"
:search-fn="searchComicMetadata"
:disabled="isLocked('comicColorists')"
control-class="pr-10"
control-class="pr-12"
/>
</MetadataFieldLabel>
<MetadataFieldLabel
@@ -887,13 +907,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicLetterers"
:locked="isLocked('comicLetterers')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput
v-model="form.comicLetterers"
:search-fn="searchComicMetadata"
:disabled="isLocked('comicLetterers')"
control-class="pr-10"
control-class="pr-12"
/>
</MetadataFieldLabel>
</div>
@@ -903,13 +924,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicCoverArtists"
:locked="isLocked('comicCoverArtists')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput
v-model="form.comicCoverArtists"
:search-fn="searchComicMetadata"
:disabled="isLocked('comicCoverArtists')"
control-class="pr-10"
control-class="pr-12"
/>
</MetadataFieldLabel>
<!-- Characters | Teams -->
@@ -919,13 +941,14 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicCharacters"
:locked="isLocked('comicCharacters')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput
v-model="form.comicCharacters"
:search-fn="searchComicMetadata"
:disabled="isLocked('comicCharacters')"
control-class="pr-10"
control-class="pr-12"
/>
</MetadataFieldLabel>
<MetadataFieldLabel
@@ -933,9 +956,10 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicTeams"
:locked="isLocked('comicTeams')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.comicTeams" :search-fn="searchComicMetadata" :disabled="isLocked('comicTeams')" control-class="pr-10" />
<ChipInput v-model="form.comicTeams" :search-fn="searchComicMetadata" :disabled="isLocked('comicTeams')" control-class="pr-12" />
</MetadataFieldLabel>
</div>
<!-- Locations -->
@@ -944,9 +968,10 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
field="comicLocations"
:locked="isLocked('comicLocations')"
:is-updating="isUpdatingLock"
multiline
@toggle="handleLockToggle"
>
<ChipInput v-model="form.comicLocations" :search-fn="searchComicMetadata" :disabled="isLocked('comicLocations')" control-class="pr-10" />
<ChipInput v-model="form.comicLocations" :search-fn="searchComicMetadata" :disabled="isLocked('comicLocations')" control-class="pr-12" />
</MetadataFieldLabel>
</div>
</div>
@@ -963,13 +988,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
<textarea
v-model="form.description"
rows="6"
class="w-full resize-y rounded-lg border border-input bg-background px-3 py-2 pr-10 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full resize-y rounded-lg border border-input bg-background px-3 py-2 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('description')"
/>
</MetadataFieldLabel>
<!-- Mobile: sticky Save/Cancel bar -->
<div class="lg:hidden sticky bottom-0 bg-background/95 backdrop-blur-sm border-t border-border -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 flex gap-2">
<div
class="lg:hidden sticky bottom-0 z-20 bg-background/95 backdrop-blur-sm border-t border-border -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 flex gap-2"
>
<button
class="flex items-center gap-1.5 h-9 px-4 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
:disabled="!isDirty || saving"
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { X, FolderSync, BookOpen, PackageOpen, UserCheck, Mail, Tablet, ArrowRightLeft, FileDown } from 'lucide-vue-next'
import type { NotificationItem } from '@projectx/types'
import { useNotifications } from '../composables/useNotifications'
const props = defineProps<{ notification: NotificationItem }>()
const emit = defineEmits<{ read: [id: number]; dismiss: [id: number] }>()
const router = useRouter()
const { formatRelativeTime } = useNotifications()
const FAILED_TYPES = new Set([
'scan_failed',
'metadata_fetch_failed',
'author_enrichment_failed',
'email_failed',
'migration_failed',
'file_write_back_failed',
])
const ICON_MAP: Record<string, typeof FolderSync> = {
scan_completed: FolderSync,
scan_failed: FolderSync,
metadata_fetch_completed: BookOpen,
metadata_fetch_failed: BookOpen,
book_bucket_ready: PackageOpen,
book_bucket_finalized: PackageOpen,
author_enrichment_completed: UserCheck,
author_enrichment_failed: UserCheck,
email_sent: Mail,
email_failed: Mail,
kobo_sync_completed: Tablet,
migration_completed: ArrowRightLeft,
migration_failed: ArrowRightLeft,
file_write_back_completed: FileDown,
file_write_back_failed: FileDown,
}
const icon = computed(() => ICON_MAP[props.notification.type] ?? FolderSync)
const isFailed = computed(() => FAILED_TYPES.has(props.notification.type))
const relativeTime = computed(() => formatRelativeTime(props.notification.createdAt))
function handleClick() {
if (!props.notification.read) {
emit('read', props.notification.id)
}
if (props.notification.actionUrl) {
if (props.notification.actionUrl.startsWith('/')) {
router.push(props.notification.actionUrl)
} else {
window.open(props.notification.actionUrl, '_blank')
}
}
}
function handleDismiss(e: Event) {
e.stopPropagation()
emit('dismiss', props.notification.id)
}
</script>
<template>
<button
class="group relative flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition-all hover:shadow-sm"
:class="
notification.read
? 'border-border/30 bg-muted/20 hover:border-border/50 hover:bg-muted/40'
: 'border-border/60 bg-card shadow-sm hover:bg-muted/30'
"
@click="handleClick"
>
<div class="relative mt-0.5 shrink-0">
<div class="flex items-center justify-center rounded-lg p-1.5" :class="isFailed ? 'bg-destructive/10' : 'bg-green-500/10'">
<component :is="icon" :size="15" :class="isFailed ? 'text-destructive' : 'text-green-500'" />
</div>
<span v-if="!notification.read" class="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-primary ring-2 ring-background" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-sm leading-tight" :class="notification.read ? 'text-foreground/80' : 'font-semibold text-foreground'">
{{ notification.title }}
</p>
<span class="shrink-0 text-[11px] text-muted-foreground/60">{{ relativeTime }}</span>
</div>
<p v-if="notification.message" class="mt-1 text-xs text-muted-foreground truncate">
{{ notification.message }}
</p>
</div>
<button
class="mt-0.5 shrink-0 rounded-md p-1 text-muted-foreground/50 opacity-0 transition-opacity hover:bg-muted hover:text-foreground group-hover:opacity-100"
@click="handleDismiss"
>
<X :size="14" />
</button>
</button>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Save } from 'lucide-vue-next'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, type NotificationCategory, type NotificationPreferences } from '@projectx/types'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
const { user, me } = useAuth()
const saving = ref(false)
const preferences = ref<NotificationPreferences>({})
function loadFromUser() {
const userPrefs = user.value?.settings?.notificationPreferences
const result: NotificationPreferences = {}
for (const key of Object.keys(NOTIFICATION_CATEGORIES) as NotificationCategory[]) {
result[key] = userPrefs?.[key] !== false
}
preferences.value = result
}
loadFromUser()
const hasChanges = computed(() => {
const userPrefs = user.value?.settings?.notificationPreferences
for (const key of Object.keys(NOTIFICATION_CATEGORIES) as NotificationCategory[]) {
const current = preferences.value[key] !== false
const saved = userPrefs?.[key] !== false
if (current !== saved) return true
}
return false
})
function handleToggle(category: NotificationCategory) {
preferences.value = {
...preferences.value,
[category]: !preferences.value[category],
}
}
async function handleSave() {
saving.value = true
try {
const existingSettings = user.value?.settings ?? {}
const res = await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
...existingSettings,
notificationPreferences: { ...preferences.value },
},
}),
})
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message)
? (payload.message[0] ?? 'Failed to save preferences')
: (payload?.message ?? 'Failed to save preferences')
toast.error(message)
return
}
await me()
loadFromUser()
toast.success('Notification preferences saved')
} finally {
saving.value = false
}
}
</script>
<template>
<SettingsPageHeader class="hidden md:flex" title="Notifications" subtitle="Choose which notification categories you want to receive." />
<div class="md:hidden px-1">
<h1 class="text-xl font-semibold tracking-tight text-foreground">Notifications</h1>
<p
class="mt-1 text-sm text-muted-foreground leading-5 overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]"
>
Choose which notification categories you want to receive.
</p>
</div>
<div class="mt-5 md:mt-0 space-y-4">
<section class="rounded-xl border border-border bg-card p-4 md:p-5 space-y-4">
<div
v-for="category in Object.keys(NOTIFICATION_CATEGORIES) as NotificationCategory[]"
:key="category"
class="flex items-center justify-between gap-4 py-1.5"
>
<span class="text-sm text-foreground">{{ NOTIFICATION_CATEGORY_LABELS[category] }}</span>
<button
type="button"
role="switch"
:aria-checked="preferences[category] !== false"
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
:class="preferences[category] !== false ? 'bg-primary' : 'bg-muted-foreground/30'"
@click="handleToggle(category)"
>
<span
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="preferences[category] !== false ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<div class="flex items-center gap-2 pt-2 border-t border-border">
<button class="settings-btn-primary" :disabled="!hasChanges || saving" @click="handleSave">
<Save :size="14" />
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="hasChanges" class="text-xs text-muted-foreground">Unsaved changes</span>
</div>
</section>
</div>
</template>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Bell, BellOff, CheckCheck, Trash2 } from 'lucide-vue-next'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { useNotifications } from '../composables/useNotifications'
import NotificationItemVue from './NotificationItem.vue'
defineProps<{ iconRadiusClass: string }>()
const { notifications, unreadCount, loading, hasMore, fetchNotifications, markAsRead, markAllAsRead, dismiss, clearAll } = useNotifications()
const sheetOpen = ref(false)
watch(sheetOpen, (open) => {
if (open) {
fetchNotifications(true)
}
})
function handleMarkAsRead(id: number) {
markAsRead(id)
}
function handleDismiss(id: number) {
dismiss(id)
}
function handleMarkAllRead() {
markAllAsRead()
}
function handleClearAll() {
clearAll()
}
function handleLoadMore() {
fetchNotifications()
}
</script>
<template>
<Sheet v-model:open="sheetOpen">
<SheetTrigger as-child>
<Button
variant="ghost"
size="icon"
class="relative h-8 w-8 border transition-colors border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground"
:class="iconRadiusClass"
>
<Bell :size="15" />
<span
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground tabular-nums leading-none"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</Button>
</SheetTrigger>
<SheetContent side="right" class="w-full sm:max-w-md p-0 flex flex-col gap-0">
<SheetHeader class="flex flex-row items-center justify-between border-b px-4 py-3 h-14 shrink-0 space-y-0">
<SheetTitle class="text-sm font-semibold text-foreground">Notifications</SheetTitle>
<div class="flex items-center gap-1 pr-8">
<Button v-if="unreadCount > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleMarkAllRead">
<CheckCheck :size="14" />
Mark all read
</Button>
<Button v-if="notifications.length > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleClearAll">
<Trash2 :size="14" />
Clear
</Button>
</div>
</SheetHeader>
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="notifications.length === 0 && !loading" class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<BellOff :size="32" class="mb-4 opacity-20" />
<p class="text-sm">No notifications yet</p>
</div>
<div v-else class="flex flex-col gap-1.5 px-2 py-2">
<NotificationItemVue v-for="item in notifications" :key="item.id" :notification="item" @read="handleMarkAsRead" @dismiss="handleDismiss" />
</div>
<div v-if="loading" class="flex justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
<div v-if="hasMore && !loading" class="p-4">
<Button variant="ghost" size="sm" class="w-full text-xs text-muted-foreground border border-dashed" @click="handleLoadMore">
Load more notifications
</Button>
</div>
</div>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,243 @@
import { computed, ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import { api, getAccessToken } from '@/lib/api'
import type { NotificationItem, NotificationPage } from '@projectx/types'
const PAGE_SIZE = 20
const notifications = ref<NotificationItem[]>([])
const unreadCount = ref(0)
const total = ref(0)
const loading = ref(false)
const socketConnected = ref(true)
let socket: Socket | null = null
let fetchPromise: Promise<void> | null = null
let loaded = false
let offset = 0
const hasMore = computed(() => notifications.value.length < total.value)
function requestNotifications(reset: boolean, markLoading: boolean): Promise<void> {
if (fetchPromise) return fetchPromise
if (markLoading) loading.value = true
if (reset) offset = 0
fetchPromise = api(`/api/v1/notifications?limit=${PAGE_SIZE}&offset=${offset}`)
.then(async (res) => {
if (res.ok) {
const data: NotificationPage = await res.json()
if (reset) {
notifications.value = data.items
} else {
notifications.value = [...notifications.value, ...data.items]
}
total.value = data.total
offset = notifications.value.length
loaded = true
}
})
.catch(() => {})
.finally(() => {
if (markLoading) loading.value = false
fetchPromise = null
})
return fetchPromise
}
function requestUnreadCount(): Promise<void> {
return api('/api/v1/notifications/unread-count')
.then(async (res) => {
if (res.ok) {
const data = await res.json()
unreadCount.value = data.count
}
})
.catch(() => {})
}
function getSocket(): Socket {
if (!socket) {
socket = io('/notifications', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
})
socket.on('notification:new', (item: NotificationItem) => {
notifications.value = [item, ...notifications.value]
total.value++
unreadCount.value++
})
socket.on('notification:unread-count', (data: { count: number }) => {
unreadCount.value = data.count
})
socket.on('notification:read', (data: { id: number }) => {
const item = notifications.value.find((n) => n.id === data.id)
if (item && !item.read) {
item.read = true
}
})
socket.on('notification:dismissed', (data: { id: number }) => {
notifications.value = notifications.value.filter((n) => n.id !== data.id)
total.value = Math.max(0, total.value - 1)
})
socket.on('notification:cleared', () => {
notifications.value = []
total.value = 0
unreadCount.value = 0
})
socket.on('connect', () => {
socketConnected.value = true
requestNotifications(true, false)
requestUnreadCount()
})
socket.on('disconnect', () => {
socketConnected.value = false
})
}
return socket
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 14) return `${days}d ago`
const weeks = Math.floor(days / 7)
return `${weeks}w ago`
}
export function useNotifications() {
async function fetchNotifications(reset = false): Promise<void> {
if (!reset && loaded && !hasMore.value) return
return requestNotifications(reset, true)
}
async function fetchUnreadCount(): Promise<void> {
return requestUnreadCount()
}
async function markAsRead(id: number): Promise<void> {
const item = notifications.value.find((n) => n.id === id)
if (!item || item.read) return
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
try {
const res = await api(`/api/v1/notifications/${id}/read`, { method: 'PATCH' })
if (!res.ok) {
item.read = false
unreadCount.value++
}
} catch {
item.read = false
unreadCount.value++
}
}
async function markAllAsRead(): Promise<void> {
const previousStates = notifications.value.map((n) => ({ id: n.id, read: n.read }))
const previousUnread = unreadCount.value
notifications.value.forEach((n) => (n.read = true))
unreadCount.value = 0
try {
const res = await api('/api/v1/notifications/read-all', { method: 'PATCH' })
if (!res.ok) {
previousStates.forEach((s) => {
const item = notifications.value.find((n) => n.id === s.id)
if (item) item.read = s.read
})
unreadCount.value = previousUnread
}
} catch {
previousStates.forEach((s) => {
const item = notifications.value.find((n) => n.id === s.id)
if (item) item.read = s.read
})
unreadCount.value = previousUnread
}
}
async function dismiss(id: number): Promise<void> {
const idx = notifications.value.findIndex((n) => n.id === id)
if (idx === -1) return
const removed = notifications.value[idx]!
notifications.value = notifications.value.filter((n) => n.id !== id)
total.value = Math.max(0, total.value - 1)
if (!removed.read) unreadCount.value = Math.max(0, unreadCount.value - 1)
try {
const res = await api(`/api/v1/notifications/${id}`, { method: 'DELETE' })
if (!res.ok) {
notifications.value.splice(idx, 0, removed)
total.value++
if (!removed.read) unreadCount.value++
}
} catch {
notifications.value.splice(idx, 0, removed)
total.value++
if (!removed.read) unreadCount.value++
}
}
async function clearAll(): Promise<void> {
const previousNotifications = [...notifications.value]
const previousTotal = total.value
const previousUnread = unreadCount.value
notifications.value = []
total.value = 0
unreadCount.value = 0
try {
const res = await api('/api/v1/notifications', { method: 'DELETE' })
if (!res.ok) {
notifications.value = previousNotifications
total.value = previousTotal
unreadCount.value = previousUnread
}
} catch {
notifications.value = previousNotifications
total.value = previousTotal
unreadCount.value = previousUnread
}
}
function subscribe() {
getSocket()
}
function disconnect() {
if (socket) {
socket.disconnect()
socket = null
}
}
return {
notifications,
unreadCount,
total,
loading,
hasMore,
socketConnected,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
dismiss,
clearAll,
subscribe,
disconnect,
formatRelativeTime,
}
}
@@ -55,6 +55,8 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Account', routeName: 'settings-account' })
result.push({ label: 'Notifications', routeName: 'settings-notifications' })
if (su) {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
}
+6
View File
@@ -60,6 +60,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/settings/AccountSettings.vue'),
meta: { title: 'Account' },
},
{
path: 'notifications',
name: 'settings-notifications',
component: () => import('@/features/notifications/components/NotificationPreferences.vue'),
meta: { title: 'Notifications' },
},
{
path: 'libraries',
name: 'settings-libraries',
+1
View File
@@ -20,6 +20,7 @@ export interface UserSettings {
onboarding?: {
tourCompleted?: boolean;
};
notificationPreferences?: import("./notification").NotificationPreferences;
}
export interface AuthUser {
+1
View File
@@ -26,3 +26,4 @@ export * from "./book-metadata-fetch";
export * from "./statistics";
export * from "./user-statistics";
export * from "./migration";
export * from "./notification";
+82
View File
@@ -0,0 +1,82 @@
export const NotificationType = {
ScanCompleted: "scan_completed",
ScanFailed: "scan_failed",
MetadataFetchCompleted: "metadata_fetch_completed",
MetadataFetchFailed: "metadata_fetch_failed",
BookBucketReady: "book_bucket_ready",
BookBucketFinalized: "book_bucket_finalized",
AuthorEnrichmentCompleted: "author_enrichment_completed",
AuthorEnrichmentFailed: "author_enrichment_failed",
EmailSent: "email_sent",
EmailFailed: "email_failed",
KoboSyncCompleted: "kobo_sync_completed",
MigrationCompleted: "migration_completed",
MigrationFailed: "migration_failed",
FileWriteBackCompleted: "file_write_back_completed",
FileWriteBackFailed: "file_write_back_failed",
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
export const NOTIFICATION_CATEGORIES = {
scanning: [NotificationType.ScanCompleted, NotificationType.ScanFailed],
metadata: [
NotificationType.MetadataFetchCompleted,
NotificationType.MetadataFetchFailed,
],
bookBucket: [
NotificationType.BookBucketReady,
NotificationType.BookBucketFinalized,
],
authorEnrichment: [
NotificationType.AuthorEnrichmentCompleted,
NotificationType.AuthorEnrichmentFailed,
],
email: [NotificationType.EmailSent, NotificationType.EmailFailed],
koboSync: [NotificationType.KoboSyncCompleted],
migration: [
NotificationType.MigrationCompleted,
NotificationType.MigrationFailed,
],
fileWriteBack: [
NotificationType.FileWriteBackCompleted,
NotificationType.FileWriteBackFailed,
],
} as const;
export type NotificationCategory = keyof typeof NOTIFICATION_CATEGORIES;
export const NOTIFICATION_CATEGORY_LABELS: Record<
NotificationCategory,
string
> = {
scanning: "Library Scanning",
metadata: "Metadata Fetching",
bookBucket: "Book Bucket",
authorEnrichment: "Author Enrichment",
email: "Email Delivery",
koboSync: "Kobo Sync",
migration: "Data Migration",
fileWriteBack: "File Write-back",
};
export type NotificationPreferences = {
[K in NotificationCategory]?: boolean;
};
export interface NotificationItem {
id: number;
type: NotificationType;
title: string;
message: string | null;
actionUrl: string | null;
meta: Record<string, unknown> | null;
read: boolean;
createdAt: string;
}
export interface NotificationPage {
items: NotificationItem[];
total: number;
}
+4
View File
@@ -20,6 +20,9 @@ export enum Permission {
ManageAppSettings = "manage_app_settings",
ManageUsers = "manage_users",
ViewAuditLog = "view_audit_log",
// Notifications
NotificationAccess = "notification_access",
}
export const PERMISSION_LABELS: Record<Permission, string> = {
@@ -37,4 +40,5 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.ManageAppSettings]: "App settings",
[Permission.ManageUsers]: "Manage users",
[Permission.ViewAuditLog]: "View audit log",
[Permission.NotificationAccess]: "Notifications",
};
+2
View File
@@ -52,6 +52,7 @@ import { ReadingSessionModule } from './modules/reading-session/reading-session.
import { UserBookStatusModule } from './modules/user-book-status/user-book-status.module';
import { AuditModule } from './modules/audit/audit.module';
import { MigrationModule } from './modules/migration/migration.module';
import { NotificationModule } from './modules/notification/notification.module';
@Module({
imports: [
@@ -112,6 +113,7 @@ import { MigrationModule } from './modules/migration/migration.module';
UserBookStatusModule,
AuditModule,
MigrationModule,
NotificationModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
@@ -847,6 +847,19 @@ CREATE TABLE "email_send_log" (
CONSTRAINT "email_send_log_sent_after_created_chk" CHECK ("email_send_log"."sent_at" is null or "email_send_log"."sent_at" >= "email_send_log"."created_at")
);
--> statement-breakpoint
CREATE TABLE "notifications" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"type" varchar(50) NOT NULL,
"title" varchar(255) NOT NULL,
"message" text,
"action_url" varchar(2048),
"meta" jsonb,
"read" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -953,6 +966,7 @@ ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_id_books_id_fk"
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_provider_id_email_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."email_providers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_send_log" ADD CONSTRAINT "email_send_log_template_id_email_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."email_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_audit_user_id" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_audit_resource" ON "audit_log" USING btree ("resource","resource_id");--> statement-breakpoint
CREATE INDEX "idx_audit_action" ON "audit_log" USING btree ("action");--> statement-breakpoint
@@ -1062,4 +1076,7 @@ CREATE UNIQUE INDEX "email_recipients_user_email_lower_uidx" ON "email_recipient
CREATE UNIQUE INDEX "email_recipients_one_default_per_user_uidx" ON "email_recipients" USING btree ("user_id") WHERE "email_recipients"."is_default" = true;--> statement-breakpoint
CREATE INDEX "email_send_log_user_created_at_idx" ON "email_send_log" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "email_send_log_created_at_idx" ON "email_send_log" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "email_send_log_status_next_retry_idx" ON "email_send_log" USING btree ("status","next_retry_at");
CREATE INDEX "email_send_log_status_next_retry_idx" ON "email_send_log" USING btree ("status","next_retry_at");--> statement-breakpoint
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "notifications_user_unread_idx" ON "notifications" USING btree ("user_id","read");--> statement-breakpoint
CREATE INDEX "notifications_created_at_idx" ON "notifications" USING btree ("created_at");
@@ -1,5 +1,5 @@
{
"id": "9d2f4780-40c3-4969-a269-081c3ec44766",
"id": "3fabe569-c909-4ba3-9099-3f93343a59d1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -7836,6 +7836,144 @@
}
},
"isRLSEnabled": false
},
"public.notifications": {
"name": "notifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action_url": {
"name": "action_url",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": false
},
"meta": {
"name": "meta",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"read": {
"name": "read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"notifications_user_id_idx": {
"name": "notifications_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"notifications_user_unread_idx": {
"name": "notifications_user_unread_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "read",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"notifications_created_at_idx": {
"name": "notifications_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"notifications_user_id_users_id_fk": {
"name": "notifications_user_id_users_id_fk",
"tableFrom": "notifications",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1776162198181,
"tag": "0000_baseline",
"when": 1776287072981,
"tag": "0000_next_timeslip",
"breakpoints": true
}
]
+1
View File
@@ -20,3 +20,4 @@ export * from './email-providers';
export * from './email-recipients';
export * from './email-preferences';
export * from './email-send-log';
export * from './notifications';
+32
View File
@@ -0,0 +1,32 @@
import { boolean, index, integer, jsonb, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const notifications = pgTable(
'notifications',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: varchar('type', { length: 50 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
message: text('message'),
actionUrl: varchar('action_url', { length: 2048 }),
meta: jsonb('meta'),
read: boolean('read').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
index('notifications_user_id_idx').on(t.userId),
index('notifications_user_unread_idx').on(t.userId, t.read),
index('notifications_created_at_idx').on(t.createdAt),
],
);
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
@@ -98,6 +98,7 @@ describe('AuthorEnrichmentOrchestratorService', () => {
enrichmentConfig as never,
metadataEvents as never,
session,
{ notify: vi.fn() } as never,
gateway as never,
);
});
@@ -1,7 +1,9 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import type { AuthorEnrichmentFailedPage } from '@projectx/types';
import { NotificationType } from '@projectx/types';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { NotificationService } from '../notification/notification.service';
import { METADATA_AUTHORS_REPLACED, MetadataAuthorsReplacedEvent, MetadataEventsService } from '../metadata/metadata-events.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
@@ -36,6 +38,7 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
private readonly enrichmentConfig: AuthorEnrichmentConfigService,
private readonly metadataEvents: MetadataEventsService,
private readonly session: AuthorEnrichmentSessionService,
private readonly notificationService: NotificationService,
@Optional() private readonly gateway?: AuthorEnrichmentGateway,
) {}
@@ -180,6 +183,19 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
private async checkAndResetSession(): Promise<void> {
const summary = await this.queueRepo.getStatusSummary();
if (summary.queued === 0 && summary.processing === 0 && summary.rateLimited === 0) {
if (this.session.sessionTotal > 0) {
const hasFailed = summary.failed > 0;
this.notificationService
.notify({
type: hasFailed ? NotificationType.AuthorEnrichmentFailed : NotificationType.AuthorEnrichmentCompleted,
title: hasFailed ? 'Author enrichment completed with errors' : 'Author enrichment completed',
message:
`Processed ${this.session.sessionDone} of ${this.session.sessionTotal} authors` + (hasFailed ? `, ${summary.failed} failed` : ''),
scope: { kind: 'all' },
meta: { sessionTotal: this.session.sessionTotal, sessionDone: this.session.sessionDone, failed: summary.failed },
})
.catch(() => {});
}
this.session.reset();
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
@@ -8,6 +8,7 @@ import { AuthModule } from '../auth/auth.module';
import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
@@ -32,6 +33,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider];
LibraryModule,
AppSettingsModule,
MetadataModule,
forwardRef(() => NotificationModule),
AuthModule,
JwtModule.registerAsync({
inject: [ConfigService],
@@ -69,6 +69,7 @@ function makeService() {
processor as never,
events as never,
gateway as never,
{ notify: vi.fn().mockResolvedValue(undefined) } as never,
);
return { service, db, repo, libraryService, appSettings, metadataService, validator, storage, processor, events, gateway };
@@ -5,7 +5,8 @@ import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { BookBucketAutoFinalizeMetadataMode, BookBucketFinalizeFileResult, BookBucketFinalizeResult, BookBucketMetadata } from '@projectx/types';
import { resolveUploadPath } from '@projectx/types';
import { NotificationType, resolveUploadPath } from '@projectx/types';
import { NotificationService } from '../notification/notification.service';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata, books, libraries, libraryFolders } from '../../db/schema';
@@ -56,6 +57,7 @@ export class BookBucketFinalizeService implements OnModuleInit {
private readonly processor: UploadProcessorService,
private readonly events: BookBucketEventsService,
private readonly gateway: BookBucketGateway,
private readonly notificationService: NotificationService,
) {}
onModuleInit() {
@@ -137,6 +139,16 @@ export class BookBucketFinalizeService implements OnModuleInit {
await this.emitSummary();
this.notificationService
.notify({
type: NotificationType.BookBucketFinalized,
title: 'Book Bucket finalization completed',
message: `${succeeded} succeeded, ${failed} failed`,
scope: { kind: 'user', userId },
meta: { total: results.length, succeeded, failed },
})
.catch(() => {});
return { total: results.length, succeeded, failed, results };
}
@@ -244,6 +256,16 @@ export class BookBucketFinalizeService implements OnModuleInit {
if (result.success) {
this.logger.log(`Auto-finalized Book Bucket file ${fileId} -> book ${result.bookId} (confidence ${row.confidence}%)`);
await this.emitSummary();
this.notificationService
.notify({
type: NotificationType.BookBucketFinalized,
title: 'Book auto-finalized',
message: `"${row.fileName}" was added to your library`,
scope: row.uploadedBy ? { kind: 'user', userId: row.uploadedBy } : { kind: 'all' },
meta: { fileId, bookId: result.bookId },
})
.catch(() => {});
} else {
this.logger.warn(`Auto-finalize skipped for Book Bucket file ${fileId}: ${result.message}`);
}
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
@@ -8,6 +8,7 @@ import { AuthModule } from '../auth/auth.module';
import { LibraryModule } from '../library/library.module';
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { UploadModule } from '../upload/upload.module';
import { BookBucketController } from './book-bucket.controller';
import { BookBucketEventsService } from './book-bucket-events.service';
@@ -26,6 +27,7 @@ import { BookBucketRepository } from './book-bucket.repository';
LibraryModule,
MetadataFetchModule,
MetadataModule,
forwardRef(() => NotificationModule),
AppSettingsModule,
JwtModule.registerAsync({
inject: [ConfigService],
@@ -70,6 +70,9 @@ function makeService(withGateway = true) {
const gateway = {
emitStatus: vi.fn(),
};
const notificationService = {
notify: vi.fn().mockResolvedValue(undefined),
};
const service = new BookMetadataFetchOrchestratorService(
queueRepo as never,
@@ -82,6 +85,7 @@ function makeService(withGateway = true) {
bookMetadataLockService as never,
session,
throttleTracker as never,
notificationService as never,
withGateway ? (gateway as never) : undefined,
);
@@ -1,6 +1,7 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy, Optional } from '@nestjs/common';
import type { BookMetadataFetchReason, MetadataField } from '@projectx/types';
import { MetadataProviderKey } from '@projectx/types';
import { MetadataProviderKey, NotificationType } from '@projectx/types';
import { NotificationService } from '../notification/notification.service';
import { BookReadService } from '../book/book-read.service';
import * as schema from '../../db/schema';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
@@ -37,6 +38,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private readonly bookMetadataLockService: BookMetadataLockService,
private readonly session: BookMetadataFetchSessionService,
private readonly throttleTracker: ProviderThrottleTracker,
private readonly notificationService: NotificationService,
@Optional() private readonly gateway?: BookMetadataFetchGateway,
) {}
@@ -404,6 +406,19 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private async checkAndResetSession(): Promise<void> {
const summary = await this.queueRepo.getStatusSummary();
if (summary.queued === 0 && summary.processing === 0) {
const snapshot = this.session.getSnapshot();
if (snapshot.sessionTotal > 0) {
const hasFailed = summary.failed > 0;
this.notificationService
.notify({
type: hasFailed ? NotificationType.MetadataFetchFailed : NotificationType.MetadataFetchCompleted,
title: hasFailed ? 'Metadata fetch completed with errors' : 'Metadata fetch completed',
message: `Processed ${snapshot.sessionDone} of ${snapshot.sessionTotal} books` + (hasFailed ? `, ${summary.failed} failed` : ''),
scope: { kind: 'all' },
meta: { sessionTotal: snapshot.sessionTotal, sessionDone: snapshot.sessionDone, failed: summary.failed },
})
.catch(() => {});
}
this.session.reset();
}
}
@@ -9,6 +9,7 @@ import { BookMetadataLockModule } from '../book-metadata-lock/book-metadata-lock
import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.service';
import { BookMetadataFetchController } from './book-metadata-fetch.controller';
import { BookMetadataFetchEligibilityService } from './book-metadata-fetch-eligibility.service';
@@ -24,6 +25,7 @@ import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.s
MetadataModule,
MetadataFetchModule,
MetadataScoreModule,
forwardRef(() => NotificationModule),
AuthModule,
JwtModule.registerAsync({
inject: [ConfigService],
@@ -11,6 +11,7 @@ import { EmailPreferencesService } from './email-preferences.service';
import { EmailSendLogService } from './email-send-log.service';
import { EmailTransportService } from './email-transport.service';
import { EmailBookAccessService } from './email-book-access.service';
import { NotificationService } from '../notification/notification.service';
import type { RequestUser } from '../../common/types/request-user';
import type { SendBookDto } from './dto/send-book.dto';
import * as fs from 'fs';
@@ -118,6 +119,10 @@ describe('EmailSendOrchestrator', () => {
assertUserCanAccessBooks: vi.fn().mockResolvedValue(undefined),
},
},
{
provide: NotificationService,
useValue: { notify: vi.fn().mockResolvedValue(undefined) },
},
],
}).compile();
@@ -1,8 +1,10 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { createReadStream } from 'fs';
import { NotificationType } from '@projectx/types';
import type { BookFile } from '../../db/schema';
import type { RequestUser } from '../../common/types/request-user';
import { NotificationService } from '../notification/notification.service';
import { EmailBookAccessService } from './email-book-access.service';
import { EmailFileSelector } from './email-file-selector';
import { EmailPreferencesService } from './email-preferences.service';
@@ -19,6 +21,7 @@ import type { SendBookDto } from './dto/send-book.dto';
interface SendTask {
bookId: number;
userId: number;
recipientId: number;
recipientEmail: string;
recipientName: string;
@@ -47,6 +50,7 @@ export class EmailSendOrchestrator {
private readonly preferencesService: EmailPreferencesService,
private readonly sendLogService: EmailSendLogService,
private readonly transportService: EmailTransportService,
private readonly notificationService: NotificationService,
) {}
async send(dto: SendBookDto, user: RequestUser): Promise<{ queued: number }> {
@@ -68,7 +72,7 @@ export class EmailSendOrchestrator {
let queued = 0;
for (const task of tasks) {
for (const bookId of dto.bookIds) {
await this.enqueueOne({ ...task, bookId }, resolved, dto.fileId ?? null, user);
await this.enqueueOne({ ...task, bookId, userId: user.id }, resolved, dto.fileId ?? null, user);
queued++;
}
}
@@ -115,6 +119,7 @@ export class EmailSendOrchestrator {
const task: SendTask = {
bookId: logEntry.bookId,
userId: user.id,
recipientId: 0,
recipientEmail: logEntry.toEmail,
recipientName: logEntry.toName ?? '',
@@ -154,6 +159,7 @@ export class EmailSendOrchestrator {
await this.enqueueOne(
{
bookId,
userId: user.id,
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
@@ -178,7 +184,7 @@ export class EmailSendOrchestrator {
}
}
private async buildTasks(dto: SendBookDto, user: RequestUser, defaultTemplateId: number | null): Promise<Omit<SendTask, 'bookId'>[]> {
private async buildTasks(dto: SendBookDto, user: RequestUser, defaultTemplateId: number | null): Promise<Omit<SendTask, 'bookId' | 'userId'>[]> {
const recipientIds = new Set<number>(dto.recipientIds ?? []);
if (dto.groupIds?.length) {
@@ -257,6 +263,16 @@ export class EmailSendOrchestrator {
});
await this.sendLogService.markSent(logId);
this.notificationService
.notify({
type: NotificationType.EmailSent,
title: 'Email sent successfully',
message: `Sent to ${task.recipientEmail}`,
scope: { kind: 'user', userId: task.userId },
meta: { logId, bookId: task.bookId, recipientEmail: task.recipientEmail },
})
.catch(() => {});
} catch (error) {
const errorClass = this.getErrorClass(error);
const errorMessage = this.getErrorMessage(error);
@@ -277,6 +293,16 @@ export class EmailSendOrchestrator {
this.logger.error(
`[${EMAIL_DISPATCH_EVENT}] [fail] logId=${logId} toEmail=${task.recipientEmail} attempt=${attempt} willRetry=false errorClass=${errorClass} error="${errorMessage}" - dispatch failed`,
);
this.notificationService
.notify({
type: NotificationType.EmailFailed,
title: 'Email delivery failed',
message: `Failed to send to ${task.recipientEmail}`,
scope: { kind: 'user', userId: task.userId },
meta: { logId, bookId: task.bookId, recipientEmail: task.recipientEmail },
})
.catch(() => {});
}
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { LibraryModule } from '../library/library.module';
import { NotificationModule } from '../notification/notification.module';
import { EmailAdminLogController } from './email-admin-log.controller';
import { EmailBookAccessService } from './email-book-access.service';
import { EmailBookReadRepository } from './email-book-read.repository';
@@ -33,7 +34,7 @@ import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
@Module({
imports: [forwardRef(() => BookModule), forwardRef(() => LibraryModule)],
imports: [forwardRef(() => BookModule), forwardRef(() => LibraryModule), forwardRef(() => NotificationModule)],
controllers: [
EmailProviderController,
EmailRecipientController,
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { NotificationModule } from '../notification/notification.module';
import { FileLockService } from './file-lock.service';
import { FileWriteRepository } from './file-write.repository';
import { FileWriteService } from './file-write.service';
@@ -11,6 +12,7 @@ import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
@Module({
imports: [forwardRef(() => NotificationModule)],
providers: [
FileWriteService,
FileWriteRepository,
@@ -50,7 +50,9 @@ describe('FileWriteService', () => {
get: vi.fn().mockImplementation((key: string) => (key === 'storage.appDataPath' ? '/books' : configValues[key])),
} as unknown as ConfigService;
const service = new FileWriteService(fileWriteRepo as never, registry as never, lockService as never, config);
const service = new FileWriteService(fileWriteRepo as never, registry as never, lockService as never, config, {
notify: vi.fn().mockResolvedValue(undefined),
} as never);
return { service, fileWriteRepo, registry, writer, lockService };
}
@@ -4,7 +4,9 @@ import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import type { WriteResult } from '@projectx/types';
import { NotificationType } from '@projectx/types';
import { bookCoverDirPath, findPreferredBookCoverFileName } from '../../common/book-cover-storage';
import { NotificationService } from '../notification/notification.service';
import { FORMAT_CB7, FORMAT_CBZ, FORMAT_EPUB, FORMAT_PDF, createBookWriteFieldMask } from './file-write.constants';
import { FileLockService } from './file-lock.service';
import { FileWriteRepository } from './file-write.repository';
@@ -34,6 +36,7 @@ export class FileWriteService implements OnModuleDestroy {
private readonly registry: FormatWriterRegistry,
private readonly lockService: FileLockService,
private readonly config: ConfigService,
private readonly notificationService: NotificationService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
this.debounceMs = resolvePositiveInteger(this.config.get('fileWrite.debounceMs'), DEFAULT_WRITE_DEBOUNCE_MS);
@@ -178,12 +181,37 @@ export class FileWriteService implements OnModuleDestroy {
result = { status: 'failed', fieldsWritten: [], durationMs: 0, reason };
this.logWriteFail(bookId, format, triggeredBy, userId, dryRun, startedAt, error);
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
if (userId && triggeredBy === 'sync') {
this.notificationService
.notify({
type: NotificationType.FileWriteBackFailed,
title: 'File write-back failed',
message: reason.slice(0, 200),
scope: { kind: 'user', userId },
meta: { bookId },
})
.catch(() => {});
}
return result;
}
await this.fileWriteRepo.insertLog({ bookId, bookFileId: file.id, userId: userId ?? null, format, result, triggeredBy });
if (result.status === 'success') {
await this.fileWriteRepo.setLastWrittenAt(bookId, new Date());
if (userId && triggeredBy === 'sync') {
this.notificationService
.notify({
type: NotificationType.FileWriteBackCompleted,
title: 'File metadata updated',
message: `Updated ${result.fieldsWritten.length} fields`,
scope: { kind: 'user', userId },
meta: { bookId, fieldsWritten: result.fieldsWritten },
})
.catch(() => {});
}
}
this.logWriteEnd(bookId, format, triggeredBy, userId, dryRun, startedAt, result);
return result;
@@ -25,6 +25,7 @@ function makeExecutorService() {
config as never,
encryption as never,
progressGateway as never,
{ notify: vi.fn().mockResolvedValue(undefined) } as never,
);
return { service, repo, sharedOverlays, covers, userState, progressGateway };
@@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { MigrationProgressEvent } from '@projectx/types';
import { NotificationType } from '@projectx/types';
import { NotificationService } from '../../notification/notification.service';
import { asRecord } from '../core/coerce';
import { parseConnectionConfig } from '../core/connection-config';
import { MigrationEncryptionService } from '../core/migration-encryption.service';
@@ -30,6 +32,7 @@ export class MigrationExecutorService {
private readonly config: ConfigService,
private readonly encryption: MigrationEncryptionService,
private readonly progressGateway: MigrationProgressGateway,
private readonly notificationService: NotificationService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@@ -112,6 +115,18 @@ export class MigrationExecutorService {
this.logger.log(
`[migration.execute] [end] runId=${runId} durationMs=${durationMs} matchedBooks=${planned.plan.matchedBooks.length} unresolvedBooks=${planned.plan.unresolvedBooks.length} - migration execution completed`,
);
if (run.triggeredByUserId) {
this.notificationService
.notify({
type: NotificationType.MigrationCompleted,
title: 'Migration completed',
message: `Matched ${planned.plan.matchedBooks.length} books, ${planned.plan.unresolvedBooks.length} unresolved`,
scope: { kind: 'user', userId: run.triggeredByUserId },
meta: { runId, matchedBooks: planned.plan.matchedBooks.length, unresolvedBooks: planned.plan.unresolvedBooks.length },
})
.catch(() => {});
}
} catch (error) {
const durationMs = Date.now() - startMs;
if (error instanceof RunInterruptedError) {
@@ -128,6 +143,18 @@ export class MigrationExecutorService {
);
const failedStage = await this.failRun(runId, message);
await this.emitRunProgress(runId, 'failed', failedStage);
if (run.triggeredByUserId) {
this.notificationService
.notify({
type: NotificationType.MigrationFailed,
title: 'Migration failed',
message: message.slice(0, 200),
scope: { kind: 'user', userId: run.triggeredByUserId },
meta: { runId },
})
.catch(() => {});
}
}
}
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { AuthModule } from '../auth/auth.module';
import { NotificationModule } from '../notification/notification.module';
import { ScannerModule } from '../scanner/scanner.module';
import { MigrationController } from './migration.controller';
import { MigrationProgressGateway } from './migration-progress.gateway';
@@ -28,6 +29,7 @@ import { MigrationReportingService } from './reporting/migration-reporting.servi
@Module({
imports: [
AuthModule,
forwardRef(() => NotificationModule),
ScannerModule,
JwtModule.registerAsync({
inject: [ConfigService],
@@ -0,0 +1,17 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListNotificationsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number = 0;
}
@@ -0,0 +1,127 @@
import { NotFoundException } from '@nestjs/common';
import { Permission } from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
import { NotificationController } from './notification.controller';
describe('NotificationController', () => {
let controller: NotificationController;
let service: {
list: ReturnType<typeof vi.fn>;
getUnreadCount: ReturnType<typeof vi.fn>;
markAsRead: ReturnType<typeof vi.fn>;
markAllAsRead: ReturnType<typeof vi.fn>;
dismiss: ReturnType<typeof vi.fn>;
clearAll: ReturnType<typeof vi.fn>;
};
const mockUser: RequestUser = {
id: 42,
username: 'testuser',
name: 'Test User',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [Permission.NotificationAccess],
};
beforeEach(() => {
service = {
list: vi.fn(),
getUnreadCount: vi.fn(),
markAsRead: vi.fn(),
markAllAsRead: vi.fn(),
dismiss: vi.fn(),
clearAll: vi.fn(),
};
controller = new NotificationController(service as never);
});
describe('list()', () => {
it('delegates to service with default limit=20 and offset=0', () => {
const expected = { items: [], total: 0 };
service.list.mockReturnValue(expected);
const result = controller.list(mockUser, {});
expect(service.list).toHaveBeenCalledWith(42, 20, 0);
expect(result).toBe(expected);
});
it('uses DTO values when provided', async () => {
const expected = { items: [], total: 0 };
service.list.mockResolvedValue(expected);
await controller.list(mockUser, { limit: 50, offset: 10 });
expect(service.list).toHaveBeenCalledWith(42, 50, 10);
});
});
describe('getUnreadCount()', () => {
it('returns count object', async () => {
service.getUnreadCount.mockResolvedValue(7);
const result = await controller.getUnreadCount(mockUser);
expect(result).toEqual({ count: 7 });
expect(service.getUnreadCount).toHaveBeenCalledWith(42);
});
});
describe('markAllAsRead()', () => {
it('calls service with user id', async () => {
service.markAllAsRead.mockResolvedValue(undefined);
await controller.markAllAsRead(mockUser);
expect(service.markAllAsRead).toHaveBeenCalledWith(42);
});
});
describe('markAsRead()', () => {
it('calls service and succeeds when notification exists', async () => {
service.markAsRead.mockResolvedValue(true);
await controller.markAsRead(mockUser, 7);
expect(service.markAsRead).toHaveBeenCalledWith(42, 7);
});
it('throws NotFoundException when service returns false', async () => {
service.markAsRead.mockResolvedValue(false);
await expect(controller.markAsRead(mockUser, 999)).rejects.toThrow(NotFoundException);
});
});
describe('dismiss()', () => {
it('calls service and succeeds when notification exists', async () => {
service.dismiss.mockResolvedValue(true);
await controller.dismiss(mockUser, 7);
expect(service.dismiss).toHaveBeenCalledWith(42, 7);
});
it('throws NotFoundException when service returns false', async () => {
service.dismiss.mockResolvedValue(false);
await expect(controller.dismiss(mockUser, 999)).rejects.toThrow(NotFoundException);
});
});
describe('clearAll()', () => {
it('calls service with user id', async () => {
service.clearAll.mockResolvedValue(undefined);
await controller.clearAll(mockUser);
expect(service.clearAll).toHaveBeenCalledWith(42);
});
});
});
@@ -0,0 +1,51 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, ParseIntPipe, Patch, Query } from '@nestjs/common';
import { Permission } from '@projectx/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ListNotificationsDto } from './dto/list-notifications.dto';
import { NotificationService } from './notification.service';
@Controller('notifications')
@RequirePermission(Permission.NotificationAccess)
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@Get()
list(@CurrentUser() user: RequestUser, @Query() dto: ListNotificationsDto) {
return this.notificationService.list(user.id, dto.limit ?? 20, dto.offset ?? 0);
}
@Get('unread-count')
async getUnreadCount(@CurrentUser() user: RequestUser) {
const count = await this.notificationService.getUnreadCount(user.id);
return { count };
}
@Patch('read-all')
@HttpCode(HttpStatus.NO_CONTENT)
async markAllAsRead(@CurrentUser() user: RequestUser) {
await this.notificationService.markAllAsRead(user.id);
}
@Patch(':id/read')
@HttpCode(HttpStatus.NO_CONTENT)
async markAsRead(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
const updated = await this.notificationService.markAsRead(user.id, id);
if (!updated) throw new NotFoundException('Notification not found');
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async dismiss(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) {
const deleted = await this.notificationService.dismiss(user.id, id);
if (!deleted) throw new NotFoundException('Notification not found');
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
async clearAll(@CurrentUser() user: RequestUser) {
await this.notificationService.clearAll(user.id);
}
}
@@ -0,0 +1,92 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Permission } from '@projectx/types';
import type { NotificationItem } from '@projectx/types';
import { Server, Socket } from 'socket.io';
import type { RequestUser } from '../../common/types/request-user';
import { UserService } from '../user/user.service';
import { NotificationRepository } from './notification.repository';
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
private readonly logger = new Logger(NotificationGateway.name);
private readonly clientOrigin: string;
constructor(
private readonly jwtService: JwtService,
private readonly userService: UserService,
private readonly notificationRepo: NotificationRepository,
config: ConfigService,
) {
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
}
afterInit(server: Server): void {
if (!server.engine?.opts) return;
server.engine.opts.cors = {
...(server.engine.opts.cors ?? {}),
origin: this.clientOrigin,
credentials: true,
};
}
async handleConnection(client: Socket): Promise<void> {
const event = 'notification.socket';
try {
const token = client.handshake.auth?.token as string | undefined;
if (!token) throw new Error('No token provided');
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
const user = await this.userService.findByIdWithPermissions(payload.sub);
if (!user || !user.active) throw new Error('User not found or inactive');
if (user.tokenVersion !== payload.ver) throw new Error('Token revoked');
this.assertHasAccess(user);
await client.join(`user:${user.id}`);
this.logger.debug(`[${event}] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
const unreadCount = await this.notificationRepo.countUnread(user.id);
client.emit('notification:unread-count', { count: unreadCount });
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const message = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
this.logger.warn(`[${event}] [fail] socketId=${client.id} errorClass=${errorClass} error="${message}" - websocket rejected`);
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
this.logger.debug(`[notification.socket] [end] socketId=${client.id} - websocket disconnected`);
}
emitNew(userId: number, notification: NotificationItem): void {
this.server?.to(`user:${userId}`).emit('notification:new', notification);
}
emitCountUpdate(userId: number, count: number): void {
this.server?.to(`user:${userId}`).emit('notification:unread-count', { count });
}
emitRead(userId: number, notificationId: number): void {
this.server?.to(`user:${userId}`).emit('notification:read', { id: notificationId });
}
emitDismissed(userId: number, notificationId: number): void {
this.server?.to(`user:${userId}`).emit('notification:dismissed', { id: notificationId });
}
emitCleared(userId: number): void {
this.server?.to(`user:${userId}`).emit('notification:cleared', {});
}
private assertHasAccess(user: RequestUser): void {
if (user.isSuperuser) return;
if (user.permissions.includes(Permission.NotificationAccess)) return;
throw new Error('Missing permission: notification_access');
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { UserModule } from '../user/user.module';
import { NotificationController } from './notification.controller';
import { NotificationGateway } from './notification.gateway';
import { NotificationRepository } from './notification.repository';
import { NotificationService } from './notification.service';
@Module({
imports: [
UserModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.getOrThrow<StringValue | number>('auth.jwtExpiresIn') },
}),
}),
],
controllers: [NotificationController],
providers: [NotificationService, NotificationRepository, NotificationGateway],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -0,0 +1,110 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, desc, eq, inArray, lt, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { notifications, users, userPermissions, userLibraryAccess } from '../../db/schema';
import type { NewNotification, Notification } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class NotificationRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async insertMany(rows: NewNotification[]): Promise<Notification[]> {
if (rows.length === 0) return [];
return this.db.insert(notifications).values(rows).returning();
}
async findByUser(userId: number, limit: number, offset: number): Promise<{ items: Notification[]; total: number }> {
const [items, [{ value: total }]] = await Promise.all([
this.db.select().from(notifications).where(eq(notifications.userId, userId)).orderBy(desc(notifications.createdAt)).limit(limit).offset(offset),
this.db.select({ value: count() }).from(notifications).where(eq(notifications.userId, userId)),
]);
return { items, total };
}
async countUnread(userId: number): Promise<number> {
const [{ value }] = await this.db
.select({ value: count() })
.from(notifications)
.where(and(eq(notifications.userId, userId), eq(notifications.read, false)));
return value;
}
async setRead(id: number, userId: number): Promise<boolean> {
const result = await this.db
.update(notifications)
.set({ read: true })
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
return (result.rowCount ?? 0) > 0;
}
async setAllRead(userId: number): Promise<number> {
const result = await this.db
.update(notifications)
.set({ read: true })
.where(and(eq(notifications.userId, userId), eq(notifications.read, false)));
return result.rowCount ?? 0;
}
async deleteOne(id: number, userId: number): Promise<boolean> {
const result = await this.db.delete(notifications).where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
return (result.rowCount ?? 0) > 0;
}
async deleteAllForUser(userId: number): Promise<number> {
const result = await this.db.delete(notifications).where(eq(notifications.userId, userId));
return result.rowCount ?? 0;
}
async deleteOlderThan(date: Date): Promise<number> {
const result = await this.db.delete(notifications).where(lt(notifications.createdAt, date));
return result.rowCount ?? 0;
}
async findUserIdsWithLibraryAccess(libraryId: number): Promise<number[]> {
const byAccess = this.db
.select({ userId: userLibraryAccess.userId })
.from(userLibraryAccess)
.innerJoin(users, eq(users.id, userLibraryAccess.userId))
.where(and(eq(userLibraryAccess.libraryId, libraryId), eq(users.active, true)));
const superusers = this.db
.select({ userId: users.id })
.from(users)
.where(and(eq(users.isSuperuser, true), eq(users.active, true)));
const rows = await this.db.selectDistinct({ userId: sql<number>`user_id` }).from(sql`(${byAccess} UNION ${superusers}) as combined`);
return rows.map((r) => r.userId);
}
async findUserIdsWithPermission(permission: string): Promise<number[]> {
const byPermission = this.db
.select({ userId: userPermissions.userId })
.from(userPermissions)
.innerJoin(users, eq(users.id, userPermissions.userId))
.where(and(eq(userPermissions.permissionName, permission), eq(users.active, true)));
const superusers = this.db
.select({ userId: users.id })
.from(users)
.where(and(eq(users.isSuperuser, true), eq(users.active, true)));
const rows = await this.db.selectDistinct({ userId: sql<number>`user_id` }).from(sql`(${byPermission} UNION ${superusers}) as combined`);
return rows.map((r) => r.userId);
}
async findAllActiveUserIds(): Promise<number[]> {
const rows = await this.db.select({ id: users.id }).from(users).where(eq(users.active, true));
return rows.map((r) => r.id);
}
async findUserSettings(userIds: number[]): Promise<Map<number, Record<string, unknown>>> {
if (userIds.length === 0) return new Map();
const rows = await this.db.select({ id: users.id, settings: users.settings }).from(users).where(inArray(users.id, userIds));
return new Map(rows.map((r) => [r.id, (r.settings ?? {}) as Record<string, unknown>]));
}
}
@@ -0,0 +1,353 @@
import { Permission } from '@projectx/types';
import type { NotifyPayload } from './notification.service';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
let repo: {
insertMany: ReturnType<typeof vi.fn>;
findByUser: ReturnType<typeof vi.fn>;
countUnread: ReturnType<typeof vi.fn>;
setRead: ReturnType<typeof vi.fn>;
setAllRead: ReturnType<typeof vi.fn>;
deleteOne: ReturnType<typeof vi.fn>;
deleteAllForUser: ReturnType<typeof vi.fn>;
deleteOlderThan: ReturnType<typeof vi.fn>;
findUserIdsWithLibraryAccess: ReturnType<typeof vi.fn>;
findUserIdsWithPermission: ReturnType<typeof vi.fn>;
findAllActiveUserIds: ReturnType<typeof vi.fn>;
findUserSettings: ReturnType<typeof vi.fn>;
};
let gateway: {
emitNew: ReturnType<typeof vi.fn>;
emitCountUpdate: ReturnType<typeof vi.fn>;
emitRead: ReturnType<typeof vi.fn>;
emitDismissed: ReturnType<typeof vi.fn>;
emitCleared: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
repo = {
insertMany: vi.fn(),
findByUser: vi.fn(),
countUnread: vi.fn(),
setRead: vi.fn(),
setAllRead: vi.fn(),
deleteOne: vi.fn(),
deleteAllForUser: vi.fn(),
deleteOlderThan: vi.fn(),
findUserIdsWithLibraryAccess: vi.fn(),
findUserIdsWithPermission: vi.fn(),
findAllActiveUserIds: vi.fn(),
findUserSettings: vi.fn(),
};
gateway = {
emitNew: vi.fn(),
emitCountUpdate: vi.fn(),
emitRead: vi.fn(),
emitDismissed: vi.fn(),
emitCleared: vi.fn(),
};
service = new NotificationService(repo as never, gateway as never);
});
function makeInserted(userId: number, overrides?: Partial<{ id: number; type: string; title: string }>) {
return {
id: overrides?.id ?? 1,
userId,
type: overrides?.type ?? 'scan_completed',
title: overrides?.title ?? 'Library scan completed',
message: null,
actionUrl: null,
meta: null,
read: false,
createdAt: new Date('2024-01-01'),
};
}
function makePayload(scope: NotifyPayload['scope'], overrides?: Partial<NotifyPayload>): NotifyPayload {
return {
type: 'scan_completed',
title: 'Library scan completed',
scope,
...overrides,
};
}
// ---------- notify() ----------
describe('notify()', () => {
it('resolves user IDs for "user" scope', async () => {
repo.findUserSettings.mockResolvedValue(new Map());
repo.insertMany.mockResolvedValue([makeInserted(42)]);
await service.notify(makePayload({ kind: 'user', userId: 42 }));
expect(repo.findUserSettings).toHaveBeenCalledWith([42]);
expect(repo.insertMany).toHaveBeenCalled();
expect(repo.insertMany.mock.calls[0][0][0]).toMatchObject({ userId: 42 });
});
it('resolves user IDs for "library" scope', async () => {
repo.findUserIdsWithLibraryAccess.mockResolvedValue([10, 20]);
repo.findUserSettings.mockResolvedValue(new Map());
repo.insertMany.mockResolvedValue([makeInserted(10), makeInserted(20, { id: 2 })]);
await service.notify(makePayload({ kind: 'library', libraryId: 5 }));
expect(repo.findUserIdsWithLibraryAccess).toHaveBeenCalledWith(5);
expect(repo.insertMany).toHaveBeenCalled();
});
it('resolves user IDs for "permission" scope', async () => {
repo.findUserIdsWithPermission.mockResolvedValue([30]);
repo.findUserSettings.mockResolvedValue(new Map());
repo.insertMany.mockResolvedValue([makeInserted(30)]);
await service.notify(makePayload({ kind: 'permission', permission: Permission.NotificationAccess }));
expect(repo.findUserIdsWithPermission).toHaveBeenCalledWith(Permission.NotificationAccess);
});
it('resolves user IDs for "all" scope', async () => {
repo.findAllActiveUserIds.mockResolvedValue([1, 2, 3]);
repo.findUserSettings.mockResolvedValue(new Map());
repo.insertMany.mockResolvedValue([makeInserted(1), makeInserted(2, { id: 2 }), makeInserted(3, { id: 3 })]);
await service.notify(makePayload({ kind: 'all' }));
expect(repo.findAllActiveUserIds).toHaveBeenCalled();
expect(repo.insertMany.mock.calls[0][0]).toHaveLength(3);
});
it('skips when no target users resolved', async () => {
repo.findAllActiveUserIds.mockResolvedValue([]);
await service.notify(makePayload({ kind: 'all' }));
expect(repo.findUserSettings).not.toHaveBeenCalled();
expect(repo.insertMany).not.toHaveBeenCalled();
expect(gateway.emitNew).not.toHaveBeenCalled();
});
it('filters by user preferences - skips users who disabled the category', async () => {
repo.findUserIdsWithLibraryAccess.mockResolvedValue([10, 20]);
repo.findUserSettings.mockResolvedValue(
new Map<number, Record<string, unknown>>([
[10, { notificationPreferences: { scanning: false } }],
[20, {}],
]),
);
repo.insertMany.mockResolvedValue([makeInserted(20)]);
await service.notify(makePayload({ kind: 'library', libraryId: 1 }));
const insertedRows = repo.insertMany.mock.calls[0][0];
expect(insertedRows).toHaveLength(1);
expect(insertedRows[0].userId).toBe(20);
});
it('defaults to enabled when no preferences set', async () => {
repo.findUserSettings.mockResolvedValue(new Map([[42, {}]]));
repo.insertMany.mockResolvedValue([makeInserted(42)]);
await service.notify(makePayload({ kind: 'user', userId: 42 }));
expect(repo.insertMany).toHaveBeenCalled();
expect(repo.insertMany.mock.calls[0][0]).toHaveLength(1);
});
it('inserts notifications and emits via gateway for each', async () => {
repo.findAllActiveUserIds.mockResolvedValue([10, 20]);
repo.findUserSettings.mockResolvedValue(new Map());
const inserted = [makeInserted(10, { id: 1 }), makeInserted(20, { id: 2 })];
repo.insertMany.mockResolvedValue(inserted);
await service.notify(makePayload({ kind: 'all' }, { message: 'Added 5 books', actionUrl: '/library/1', meta: { count: 5 } }));
expect(repo.insertMany).toHaveBeenCalledOnce();
const rows = repo.insertMany.mock.calls[0][0];
expect(rows[0]).toMatchObject({ userId: 10, type: 'scan_completed', title: 'Library scan completed' });
expect(gateway.emitNew).toHaveBeenCalledTimes(2);
expect(gateway.emitNew).toHaveBeenCalledWith(10, expect.objectContaining({ id: 1, type: 'scan_completed' }));
expect(gateway.emitNew).toHaveBeenCalledWith(20, expect.objectContaining({ id: 2 }));
});
it('handles unknown notification type category - defaults to enabled', async () => {
repo.findUserSettings.mockResolvedValue(new Map([[42, { notificationPreferences: { scanning: false } }]]));
repo.insertMany.mockResolvedValue([{ ...makeInserted(42), type: 'unknown_type' }]);
await service.notify(makePayload({ kind: 'user', userId: 42 }, { type: 'unknown_type' as never }));
expect(repo.insertMany).toHaveBeenCalled();
expect(repo.insertMany.mock.calls[0][0]).toHaveLength(1);
});
it('catches and re-throws errors', async () => {
const error = new Error('DB connection failed');
repo.findAllActiveUserIds.mockRejectedValue(error);
await expect(service.notify(makePayload({ kind: 'all' }))).rejects.toThrow('DB connection failed');
});
it('skips insert when all users filtered out by preferences', async () => {
repo.findUserSettings.mockResolvedValue(new Map<number, Record<string, unknown>>([[42, { notificationPreferences: { scanning: false } }]]));
await service.notify(makePayload({ kind: 'user', userId: 42 }));
expect(repo.insertMany).not.toHaveBeenCalled();
expect(gateway.emitNew).not.toHaveBeenCalled();
});
});
// ---------- list() ----------
describe('list()', () => {
it('returns paginated items transformed via toItem', async () => {
const dbRow = {
id: 1,
type: 'scan_completed',
title: 'Done',
message: 'All good',
actionUrl: '/lib/1',
meta: { count: 3 },
read: false,
createdAt: new Date('2024-06-15T12:00:00Z'),
};
repo.findByUser.mockResolvedValue({ items: [dbRow], total: 1 });
const result = await service.list(42, 20, 0);
expect(repo.findByUser).toHaveBeenCalledWith(42, 20, 0);
expect(result.total).toBe(1);
expect(result.items[0]).toEqual({
id: 1,
type: 'scan_completed',
title: 'Done',
message: 'All good',
actionUrl: '/lib/1',
meta: { count: 3 },
read: false,
createdAt: '2024-06-15T12:00:00.000Z',
});
});
});
// ---------- markAsRead() ----------
describe('markAsRead()', () => {
it('marks as read and emits events when notification exists', async () => {
repo.setRead.mockResolvedValue(true);
repo.countUnread.mockResolvedValue(3);
const result = await service.markAsRead(42, 7);
expect(result).toBe(true);
expect(repo.setRead).toHaveBeenCalledWith(7, 42);
expect(gateway.emitRead).toHaveBeenCalledWith(42, 7);
expect(repo.countUnread).toHaveBeenCalledWith(42);
expect(gateway.emitCountUpdate).toHaveBeenCalledWith(42, 3);
});
it('returns false and does not emit events when notification not found', async () => {
repo.setRead.mockResolvedValue(false);
const result = await service.markAsRead(42, 999);
expect(result).toBe(false);
expect(gateway.emitRead).not.toHaveBeenCalled();
expect(gateway.emitCountUpdate).not.toHaveBeenCalled();
});
});
// ---------- markAllAsRead() ----------
describe('markAllAsRead()', () => {
it('marks all as read and emits count update + cleared', async () => {
repo.setAllRead.mockResolvedValue(undefined);
repo.countUnread.mockResolvedValue(0);
await service.markAllAsRead(42);
expect(repo.setAllRead).toHaveBeenCalledWith(42);
expect(repo.countUnread).toHaveBeenCalledWith(42);
expect(gateway.emitCountUpdate).toHaveBeenCalledWith(42, 0);
expect(gateway.emitCleared).toHaveBeenCalledWith(42);
});
});
// ---------- dismiss() ----------
describe('dismiss()', () => {
it('deletes and emits events when notification exists', async () => {
repo.deleteOne.mockResolvedValue(true);
repo.countUnread.mockResolvedValue(2);
const result = await service.dismiss(42, 7);
expect(result).toBe(true);
expect(repo.deleteOne).toHaveBeenCalledWith(7, 42);
expect(gateway.emitDismissed).toHaveBeenCalledWith(42, 7);
expect(repo.countUnread).toHaveBeenCalledWith(42);
expect(gateway.emitCountUpdate).toHaveBeenCalledWith(42, 2);
});
it('returns false and does not emit events when notification not found', async () => {
repo.deleteOne.mockResolvedValue(false);
const result = await service.dismiss(42, 999);
expect(result).toBe(false);
expect(gateway.emitDismissed).not.toHaveBeenCalled();
expect(gateway.emitCountUpdate).not.toHaveBeenCalled();
});
});
// ---------- clearAll() ----------
describe('clearAll()', () => {
it('deletes all and emits count=0 + cleared', async () => {
repo.deleteAllForUser.mockResolvedValue(undefined);
await service.clearAll(42);
expect(repo.deleteAllForUser).toHaveBeenCalledWith(42);
expect(gateway.emitCountUpdate).toHaveBeenCalledWith(42, 0);
expect(gateway.emitCleared).toHaveBeenCalledWith(42);
});
});
// ---------- getUnreadCount() ----------
describe('getUnreadCount()', () => {
it('returns count from repo', async () => {
repo.countUnread.mockResolvedValue(5);
const result = await service.getUnreadCount(42);
expect(result).toBe(5);
expect(repo.countUnread).toHaveBeenCalledWith(42);
});
});
// ---------- deleteOlderThan() ----------
describe('deleteOlderThan()', () => {
it('calls repo with calculated cutoff date', async () => {
repo.deleteOlderThan.mockResolvedValue(10);
const now = new Date('2024-06-15T00:00:00Z');
vi.setSystemTime(now);
const result = await service.deleteOlderThan(30);
expect(result).toBe(10);
const cutoffArg = repo.deleteOlderThan.mock.calls[0][0] as Date;
const expectedCutoff = new Date('2024-05-16T00:00:00Z');
expect(cutoffArg.getTime()).toBe(expectedCutoff.getTime());
vi.useRealTimers();
});
});
});
@@ -0,0 +1,197 @@
import { Injectable, Logger } from '@nestjs/common';
import { NOTIFICATION_CATEGORIES, Permission } from '@projectx/types';
import type { NotificationCategory, NotificationItem, NotificationType, NotificationPreferences } from '@projectx/types';
import { NotificationGateway } from './notification.gateway';
import { NotificationRepository } from './notification.repository';
export type NotificationScope =
| { kind: 'library'; libraryId: number }
| { kind: 'user'; userId: number }
| { kind: 'permission'; permission: Permission }
| { kind: 'all' };
export interface NotifyPayload {
type: NotificationType;
title: string;
message?: string;
actionUrl?: string;
meta?: Record<string, unknown>;
scope: NotificationScope;
}
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly repo: NotificationRepository,
private readonly gateway: NotificationGateway,
) {}
async notify(payload: NotifyPayload): Promise<void> {
const event = 'notification.notify';
const scopeLabel = this.formatScope(payload.scope);
this.logger.log(`[${event}] [start] type=${payload.type} ${scopeLabel} - notification dispatch started`);
const startedAt = Date.now();
try {
const targetUserIds = await this.resolveUserIds(payload.scope);
if (targetUserIds.length === 0) {
this.logger.log(
`[${event}] [end] type=${payload.type} targetUsers=0 eligible=0 durationMs=${Date.now() - startedAt} - notification dispatch completed`,
);
return;
}
const settingsMap = await this.repo.findUserSettings(targetUserIds);
const category = this.findCategory(payload.type);
const eligibleUserIds = targetUserIds.filter((uid) => this.isEnabled(settingsMap.get(uid), category));
if (eligibleUserIds.length === 0) {
this.logger.log(
`[${event}] [end] type=${payload.type} targetUsers=${targetUserIds.length} eligible=0 durationMs=${Date.now() - startedAt} - notification dispatch completed`,
);
return;
}
const rows = eligibleUserIds.map((userId) => ({
userId,
type: payload.type,
title: payload.title,
message: payload.message ?? null,
actionUrl: payload.actionUrl ?? null,
meta: payload.meta ?? null,
}));
const inserted = await this.repo.insertMany(rows);
for (const notification of inserted) {
const item = this.toItem(notification);
this.gateway.emitNew(notification.userId, item);
}
this.logger.log(
`[${event}] [end] type=${payload.type} targetUsers=${targetUserIds.length} eligible=${eligibleUserIds.length} durationMs=${Date.now() - startedAt} - notification dispatch completed`,
);
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(/"/g, '\\"');
this.logger.error(
`[${event}] [fail] type=${payload.type} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - notification dispatch failed`,
);
throw error;
}
}
async list(userId: number, limit: number, offset: number) {
const { items, total } = await this.repo.findByUser(userId, limit, offset);
return { items: items.map((n) => this.toItem(n)), total };
}
async markAsRead(userId: number, id: number): Promise<boolean> {
const updated = await this.repo.setRead(id, userId);
if (updated) {
this.gateway.emitRead(userId, id);
const count = await this.repo.countUnread(userId);
this.gateway.emitCountUpdate(userId, count);
}
return updated;
}
async markAllAsRead(userId: number): Promise<void> {
await this.repo.setAllRead(userId);
const count = await this.repo.countUnread(userId);
this.gateway.emitCountUpdate(userId, count);
this.gateway.emitCleared(userId);
}
async dismiss(userId: number, id: number): Promise<boolean> {
const deleted = await this.repo.deleteOne(id, userId);
if (deleted) {
this.gateway.emitDismissed(userId, id);
const count = await this.repo.countUnread(userId);
this.gateway.emitCountUpdate(userId, count);
}
return deleted;
}
async clearAll(userId: number): Promise<void> {
await this.repo.deleteAllForUser(userId);
this.gateway.emitCountUpdate(userId, 0);
this.gateway.emitCleared(userId);
}
async getUnreadCount(userId: number): Promise<number> {
return this.repo.countUnread(userId);
}
async deleteOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
return this.repo.deleteOlderThan(cutoff);
}
private async resolveUserIds(scope: NotificationScope): Promise<number[]> {
switch (scope.kind) {
case 'user':
return [scope.userId];
case 'library':
return this.repo.findUserIdsWithLibraryAccess(scope.libraryId);
case 'permission':
return this.repo.findUserIdsWithPermission(scope.permission);
case 'all':
return this.repo.findAllActiveUserIds();
}
}
private findCategory(type: NotificationType): NotificationCategory | null {
for (const [category, types] of Object.entries(NOTIFICATION_CATEGORIES)) {
if ((types as readonly string[]).includes(type)) {
return category as NotificationCategory;
}
}
return null;
}
private isEnabled(settings: Record<string, unknown> | undefined, category: NotificationCategory | null): boolean {
if (!category) return true;
const prefs = settings?.notificationPreferences as NotificationPreferences | undefined;
if (!prefs) return true;
return prefs[category] !== false;
}
private toItem(n: {
id: number;
type: string;
title: string;
message: string | null;
actionUrl: string | null;
meta: unknown;
read: boolean;
createdAt: Date;
}): NotificationItem {
return {
id: n.id,
type: n.type as NotificationType,
title: n.title,
message: n.message,
actionUrl: n.actionUrl,
meta: (n.meta as Record<string, unknown>) ?? null,
read: n.read,
createdAt: n.createdAt.toISOString(),
};
}
private formatScope(scope: NotificationScope): string {
switch (scope.kind) {
case 'library':
return `scope=library libraryId=${scope.libraryId}`;
case 'user':
return `scope=user userId=${scope.userId}`;
case 'permission':
return `scope=permission permission=${scope.permission}`;
case 'all':
return 'scope=all';
}
}
}
@@ -3,10 +3,12 @@ import 'reflect-metadata';
vi.mock('../auth/auth.module', () => ({ AuthModule: class AuthModule {} }));
vi.mock('../book-metadata-fetch/book-metadata-fetch.module', () => ({ BookMetadataFetchModule: class BookMetadataFetchModule {} }));
vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} }));
vi.mock('../notification/notification.module', () => ({ NotificationModule: class NotificationModule {} }));
import { AuthModule } from '../auth/auth.module';
import { BookMetadataFetchModule } from '../book-metadata-fetch/book-metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { FileEventProcessorService } from './file-event-processor.service';
import { FileWatcherService } from './file-watcher.service';
import { ScanGateway } from './scan.gateway';
@@ -22,7 +24,8 @@ describe('ScannerModule', () => {
expect(imports[0]).toBe(MetadataModule);
expect(imports[1]).toBe(AuthModule);
expect(imports[2]).toEqual(expect.objectContaining({ forwardRef: expect.any(Function) }));
expect(imports[3]).toEqual(expect.objectContaining({ module: expect.any(Function) }));
expect(imports[3]).toEqual(expect.objectContaining({ forwardRef: expect.any(Function) }));
expect(imports[4]).toEqual(expect.objectContaining({ module: expect.any(Function) }));
expect(Reflect.getMetadata('controllers', ScannerModule)).toEqual([ScannerController]);
expect(Reflect.getMetadata('providers', ScannerModule)).toEqual([
@@ -36,9 +39,11 @@ describe('ScannerModule', () => {
expect(Reflect.getMetadata('exports', ScannerModule)).toEqual([ScannerService, FileWatcherService, ScanGateway]);
});
it('wires BookMetadataFetchModule via forwardRef to avoid circular import issues', () => {
it('wires NotificationModule and BookMetadataFetchModule via forwardRef to avoid circular import issues', () => {
const imports = Reflect.getMetadata('imports', ScannerModule) as Array<{ forwardRef?: () => unknown }>;
const forwardRefImport = imports.find((entry) => typeof entry?.forwardRef === 'function');
expect(forwardRefImport?.forwardRef?.()).toBe(BookMetadataFetchModule);
const forwardRefs = imports.filter((entry) => typeof entry?.forwardRef === 'function');
const resolved = forwardRefs.map((entry) => entry.forwardRef?.());
expect(resolved).toContain(NotificationModule);
expect(resolved).toContain(BookMetadataFetchModule);
});
});
@@ -6,6 +6,7 @@ import type { StringValue } from 'ms';
import { AuthModule } from '../auth/auth.module';
import { BookMetadataFetchModule } from '../book-metadata-fetch/book-metadata-fetch.module';
import { MetadataModule } from '../metadata/metadata.module';
import { NotificationModule } from '../notification/notification.module';
import { FileEventProcessorService } from './file-event-processor.service';
import { FileWatcherService } from './file-watcher.service';
import { ScanGateway } from './scan.gateway';
@@ -18,6 +19,7 @@ import { ScannerService } from './scanner.service';
imports: [
MetadataModule,
AuthModule,
forwardRef(() => NotificationModule),
forwardRef(() => BookMetadataFetchModule),
JwtModule.registerAsync({
inject: [ConfigService],
@@ -131,7 +131,13 @@ const mockMetadata = {
function makeService(repo: ReturnType<typeof makeRepo>) {
const jobStore = new ScanJobStore();
const service = new ScannerService(repo as any, mockMetadata as any, jobStore, mockGateway as any);
const service = new ScannerService(
repo as any,
mockMetadata as any,
jobStore,
mockGateway as any,
{ notify: vi.fn().mockResolvedValue(undefined) } as any,
);
return { service, jobStore };
}
@@ -1,8 +1,10 @@
import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common';
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanBooksAddedEvent, ScanProgressEvent } from '@projectx/types';
import { NotificationType } from '@projectx/types';
import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/book-metadata-fetch-orchestrator.service';
import { MetadataService } from '../metadata/metadata.service';
import { NotificationService } from '../notification/notification.service';
import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service';
import { basename, dirname, relative, sep } from 'path';
@@ -40,6 +42,7 @@ export class ScannerService implements OnApplicationBootstrap {
private readonly metadataService: MetadataService,
private readonly scanJobStore: ScanJobStore,
private readonly scanGateway: ScanGateway,
private readonly notificationService: NotificationService,
@Optional() private readonly autoFetchOrchestrator?: BookMetadataFetchOrchestratorService,
) {}
@@ -464,6 +467,16 @@ export class ScannerService implements OnApplicationBootstrap {
);
this.scanJobStore.increment(libraryId, { added: totals.addedCount, updated: totals.updatedCount });
this.emitFromStore(libraryId, jobId, 'completed');
this.notificationService
.notify({
type: NotificationType.ScanCompleted,
title: 'Library scan completed',
message: `Added ${totals.addedCount} books, updated ${totals.updatedCount}, ${totals.missingCount} missing`,
scope: { kind: 'library', libraryId },
meta: { libraryId, jobId, ...totals },
})
.catch(() => {});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.scannerRepo.failScanJob(jobId, message).catch(() => {
@@ -473,6 +486,16 @@ export class ScannerService implements OnApplicationBootstrap {
`[${event}] [fail] libraryId=${libraryId} jobId=${jobId} durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'Error'} error="${message.replace(/"/g, '\\"')}" - scan job failed`,
);
this.emitFromStore(libraryId, jobId, 'failed', message);
this.notificationService
.notify({
type: NotificationType.ScanFailed,
title: 'Library scan failed',
message: message.slice(0, 200),
scope: { kind: 'library', libraryId },
meta: { libraryId, jobId },
})
.catch(() => {});
} finally {
this.flushBookEmitBuffer(libraryId);
this.scanJobStore.delete(libraryId);
@@ -7,7 +7,9 @@ function makeDb(options?: {
refreshDeleteResult?: { rowCount?: number };
resetDeleteResult?: { rowCount?: number };
oidcDeleteResult?: { rowCount?: number };
notificationDeleteResult?: { rowCount?: number };
authStateTablesPresent?: boolean;
notificationsTablePresent?: boolean;
}) {
const refreshDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.refreshDeleteResult ?? { rowCount: 2 }),
@@ -18,18 +20,32 @@ function makeDb(options?: {
const oidcDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.oidcDeleteResult ?? { rowCount: 3 }),
};
const notificationDeleteBuilder = {
where: vi.fn().mockResolvedValue(options?.notificationDeleteResult ?? { rowCount: 5 }),
};
const authTablesResult = {
rows:
(options?.authStateTablesPresent ?? true)
? [{ table_name: 'refresh_tokens' }, { table_name: 'password_reset_tokens' }, { table_name: 'oidc_sessions' }]
: [],
};
const notificationsTableResult = {
rows: (options?.notificationsTablePresent ?? true) ? [{ table_name: 'notifications' }] : [],
};
return {
execute: vi.fn().mockResolvedValue({
rows:
(options?.authStateTablesPresent ?? true)
? [{ table_name: 'refresh_tokens' }, { table_name: 'password_reset_tokens' }, { table_name: 'oidc_sessions' }]
: [],
}),
delete: vi.fn().mockReturnValueOnce(refreshDeleteBuilder).mockReturnValueOnce(resetDeleteBuilder).mockReturnValueOnce(oidcDeleteBuilder),
execute: vi.fn().mockResolvedValueOnce(authTablesResult).mockResolvedValueOnce(notificationsTableResult),
delete: vi
.fn()
.mockReturnValueOnce(refreshDeleteBuilder)
.mockReturnValueOnce(resetDeleteBuilder)
.mockReturnValueOnce(oidcDeleteBuilder)
.mockReturnValueOnce(notificationDeleteBuilder),
__refreshDeleteBuilder: refreshDeleteBuilder,
__resetDeleteBuilder: resetDeleteBuilder,
__oidcDeleteBuilder: oidcDeleteBuilder,
__notificationDeleteBuilder: notificationDeleteBuilder,
};
}
@@ -55,15 +71,19 @@ describe('CleanupService', () => {
await service.cleanup();
expect(db.execute).toHaveBeenCalledTimes(1);
expect(db.execute).toHaveBeenCalledTimes(2);
expect(db.delete).toHaveBeenNthCalledWith(1, schema.refreshTokens);
expect(db.delete).toHaveBeenNthCalledWith(2, schema.passwordResetTokens);
expect(db.delete).toHaveBeenNthCalledWith(3, schema.oidcSessions);
expect(db.delete).toHaveBeenNthCalledWith(4, schema.notifications);
expect(db.__refreshDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(db.__resetDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(db.__oidcDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(db.__notificationDeleteBuilder.where).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [start]'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[seed.cleanup_auth_state] [end] refreshDeleted=2 resetDeleted=1 oidcDeleted=3'));
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('[seed.cleanup_auth_state] [end] refreshDeleted=2 resetDeleted=1 oidcDeleted=3 notificationsDeleted=5'),
);
});
it('skips cleanup when auth state tables are missing', async () => {
+18 -1
View File
@@ -18,6 +18,16 @@ export class CleanupService implements OnApplicationBootstrap {
await this.cleanup();
}
private async hasNotificationsTable(): Promise<boolean> {
const result = await this.db.execute<{ table_name: string }>(sql`
select table_name
from information_schema.tables
where table_schema = 'public'
and table_name = 'notifications'
`);
return result.rows.length === 1;
}
private async hasAuthStateTables(): Promise<boolean> {
const result = await this.db.execute<{ table_name: string }>(sql`
select table_name
@@ -53,8 +63,15 @@ export class CleanupService implements OnApplicationBootstrap {
.delete(schema.oidcSessions)
.where(or(lt(schema.oidcSessions.expiresAt, now), eq(schema.oidcSessions.revoked, true)));
let notificationCount = 0;
if (await this.hasNotificationsTable()) {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const { rowCount } = await this.db.delete(schema.notifications).where(lt(schema.notifications.createdAt, thirtyDaysAgo));
notificationCount = rowCount ?? 0;
}
this.logger.log(
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} oidcDeleted=${oidcCount ?? 0} durationMs=${Date.now() - startedAt} - auth state cleanup completed`,
`[${event}] [end] refreshDeleted=${refreshCount ?? 0} resetDeleted=${resetCount ?? 0} oidcDeleted=${oidcCount ?? 0} notificationsDeleted=${notificationCount} durationMs=${Date.now() - startedAt} - auth state cleanup completed`,
);
} catch (error) {
const { errorClass, errorMessage } = getSanitizedErrorInfo(error);
@@ -411,6 +411,11 @@ describe('Authorization matrix (e2e)', () => {
path: '/audit-log',
token: 'allPerms',
},
[Permission.NotificationAccess]: {
method: 'GET',
path: '/notifications',
token: 'allPerms',
},
};
const failures: MatrixFailure[] = [];