mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface UserSettings {
|
||||
onboarding?: {
|
||||
tourCompleted?: boolean;
|
||||
};
|
||||
notificationPreferences?: import("./notification").NotificationPreferences;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from "./book-metadata-fetch";
|
||||
export * from "./statistics";
|
||||
export * from "./user-statistics";
|
||||
export * from "./migration";
|
||||
export * from "./notification";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+18
-1
@@ -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": {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1776162198181,
|
||||
"tag": "0000_baseline",
|
||||
"when": 1776287072981,
|
||||
"tag": "0000_next_timeslip",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from './email-providers';
|
||||
export * from './email-recipients';
|
||||
export * from './email-preferences';
|
||||
export * from './email-send-log';
|
||||
export * from './notifications';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
+4
@@ -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,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,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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user