Skip to content

Notifications & Announcements ​

Overview ​

  1. DamNotification.vue — the bell icon component in the sidebar nav; renders a two-tab dropdown (Notifications + Announcements) with real-time badge counts.
  2. useDamNotification — the composable that drives DamNotification.vue; manages all state, pagination, tab switching, read/unread toggling, and real-time event wiring.
  3. useNotificationApi — wraps the user-level and org-level notification preference endpoints; separate from the composable that fetches notification list data.
  4. useAnnouncementsApi — wraps announcement CRUD endpoints used by the Announcements management page.
  5. echo.client.ts — initializes Laravel Echo over Pusher (client-only); subscribed channels per user drive real-time badge updates and subscription plan changes.
  6. snackbar.client.ts — a client-only plugin that mounts DOM-injected toast notifications; exposed as $snackbar and consumed via the useSnackbar composable facade.

Architecture ​

The notification system has two distinct surfaces. The in-app notification panel (DamNotification.vue) is driven entirely by useDamNotification, which handles both the System Notifications tab and the Announcements tab through separate paginated API calls. Real-time badge count updates arrive via a Pusher private channel (user.{id}.projectBatchNotification) rather than polling, so the badge number stays live without any interval timer.

The snackbar system is independent — it is not connected to the notifications panel. snackbar.client.ts creates styled DOM elements appended to document.body with slide-in/slide-out animations; it never uses Vue's rendering pipeline. useSnackbar provides an SSR-safe facade that reads $snackbar from the Nuxt app at call time (after hydration) so composables can call it safely on the server without errors.

The System Notifications tab is subscription-gated: when subscription_features.system_notifications.enable is false, the tab is disabled and the composable defaults to the Announcements tab.

File Structure ​

Vue Component Files ​

  • app/components/global/DamNotification.vue — Bell icon sidebar nav item; v-menu dropdown with two v-tab panels (Notifications, Announcements); inline announcement detail dialog
  • app/components/dialogs/AnnouncementDialog.vue — Rich-text announcement composer dialog for admin users; used on the Announcements management page
  • app/components/org-settings/NotificationSettingsTab.vue — Per-workspace org-level notification preferences UI; admin-only

Composable Files ​

  • app/composables/core/components/useDamNotification.ts — Primary notification composable; manages system notification list, announcement list, badge counts, tab state, pagination, read/unread operations, real-time Echo subscriptions, and Amplitude tracking
  • app/composables/api/useNotificationApi.ts — User-level (getUserNotifications, updateUserNotification) and org-level (getOrgNotifications, updateOrgNotification, pauseWorkspaceNotification) notification preference endpoints
  • app/composables/api/useAnnouncementsApi.ts — Announcement CRUD: list, create, read/unread, mark-all-read, delete
  • app/composables/core/common/useSnackbar.ts — SSR-safe facade over $snackbar; exposes success, error, warning, info methods
  • app/composables/queries/useNotificationsQuery.ts — TanStack Query cache-aside for notification settings (org-level)
  • app/composables/queries/useNotificationsMutations.ts — TanStack Query mutations for updating notification settings
  • app/composables/core/pages/useNotificationSettings.ts — Page composable for the org Notification Settings page

Plugin Files ​

  • app/plugins/echo.client.ts — Initializes Laravel Echo with Pusher (broadcaster: 'pusher'); lazy-loads on first use, skips auth pages, returns a safe mock on SSR; exposes $echo via nuxtApp.provide
  • app/plugins/snackbar.client.ts — DOM-injected toast notification system; provides four severity variants (success, error, warning, info) with slide-in animation and auto-dismiss

Page Files ​

  • app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/announcements/list.vue — Announcements management page; admin-only; uses AnnouncementDialog and useAnnouncementsApi
  • app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/notifications/index.vue — Notification Settings page; uses NotificationSettingsTab and useNotificationSettings

Page Details ​

Announcements List (workspace-settings/dam/[instance_id]/announcements/list.vue) ​

FieldValue
Route/:workspace_id/workspace-settings/dam/:instance_id/announcements/list
Layoutgeneral-settings-layout
Middlewareauth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended
Primary composableuseAnnouncementsPage()

Filterable by type and text-searchable (debounced) with infinite scroll. Status chips: Draft, Scheduled, Published. "Publish for" column renders an avatar stack (up to 3 visible with overflow).

List columns: Announcement Name (sortable), Announcement By, Status (chip), Publish Date, Publish For (avatar stack)

Row menu: Edit (if editable) or View; always Delete

Dialogs: AnnouncementDialog (add/edit/preview, flag-driven header), ConfirmationDialog (delete)


Notification Settings (workspace-settings/dam/[instance_id]/notifications/index.vue) ​

FieldValue
Route/:workspace_id/workspace-settings/dam/:instance_id/notifications
Layoutgeneral-settings-layout
Middlewareauth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended
Primary composableuseNotificationSettings()

Global on/off toggle calls pauseWorkspaceNotification. Per-event System and Email switches are grouped by group_name; rows with is_hidden are skipped. When notificationsAllowed is false (subscription gate), the settings are replaced with StaticPlanNotificationSettings and an UpgradePlanSection. systemNotificationsAllowed and emailNotificationsAllowed individually gate each switch column.


DamNotification Component ​

File: app/components/global/DamNotification.vue

The notification bell rendered in the sidebar nav. Opens a 455×550px v-menu card with two tabs.

Features ​

  • Badge count (badgesCount) shown on bell icon and in tab title; displays 99+ when count exceeds MAX_BADGE_DISPLAY
  • System Notifications tab disabled with tooltip ("upgrade your plan") when damSystemNotificationAllowed is false
  • Infinite scroll: onScroll triggers loadMoreNotifications or loadMoreAnnouncements when the list reaches the bottom
  • Per-notification read/unread toggle; "Mark all as read" button appears when there are unread items
  • Clicking a system notification opens its notification_url in a new tab and marks it read
  • Clicking an announcement opens an inline v-dialog with sanitized HTML description (sanitizeHtml from ~/utils/htmlSanitizer)
  • "Notification Settings" link at the bottom routes to profile?activeTab=notification

Props ​

javascript
{
  navCollapsed: {
    type: Boolean,
    default: false  // controls whether to show tooltip on the bell icon
  }
}

Usage ​

vue
<template>
  <DamNotification :nav-collapsed="isSidebarCollapsed" />
</template>

useDamNotification ​

File: app/composables/core/components/useDamNotification.ts

The composable that owns all notification state and operations. Consumed directly by DamNotification.vue.

State ​

javascript
{
  // System notifications tab
  activeTab: Ref<number>,               // 0 = system, 1 = announcements
  systemNotifications: Ref<Notification[]>,
  unreadNotifications: Ref<number>,
  page: Ref<number>,
  lastPage: Ref<number>,
  initialLoading: Ref<boolean>,
  loadMoreLoading: Ref<boolean>,
  showOnlyUnreadNotifications: Ref<boolean>,

  // Announcements tab
  announcements: Ref<AnnouncementNotificationItem[]>,
  unreadAnnouncements: Ref<number>,
  page_: Ref<number>,
  lastPage_: Ref<number>,
  initialLoading_: Ref<boolean>,
  loadMoreLoading_: Ref<boolean>,
  showOnlyUnreadAnnouncements: Ref<boolean>,

  // Shared
  badgesCount: Ref<number>,
  isMenuOpen: Ref<boolean>,
  dialog: Ref<boolean>,                 // announcement detail dialog
  selectedAnnouncement: Ref<AnnouncementNotificationItem | null>,
  errorState: Ref<ErrorState>,

  // Computed
  damSystemNotificationAllowed: ComputedRef<boolean>,
  workspaceModule: ComputedRef<boolean>,
  displayBatchCount: ComputedRef<{ show: boolean; count_: string }>,
  showMarkAllAsReadNotification: ComputedRef<boolean>,
  showMarkAllAsReadAnnouncement: ComputedRef<boolean>,
  announcementComputed: ComputedRef<AnnouncementNotificationItem[]>,
}

Methods ​

javascript
{
  // Tab and panel control
  changeTab(tabIndex: number): void
  openList(): void
  closeList(): void
  openModal(announcement: AnnouncementNotificationItem): void
  closeDialog(): void

  // Data loading
  getBadgesCount(): Promise<void>
  initialLoadNotifications(): Promise<void>
  initialLoadAnnouncements(): Promise<void>
  loadMoreNotifications(): Promise<void>
  loadMoreAnnouncements(): Promise<void>

  // Read/unread operations
  readUnreadNotification(nId: number, redirect?: boolean): Promise<void>
  readAllNotifications(): Promise<void>
  readAnnouncement(id: number): Promise<void>
  readUnreadAnnouncement(aId: number): Promise<void>
  readAllAnnouncements(): Promise<void>

  // Scroll handler
  onScroll(event: Event): void

  // Utility
  getFirstCharClass(str: string): string
}

Real-Time Subscriptions ​

Set up in onMounted, torn down in onBeforeUnmount:

javascript
// Badge count updates from any DAM notification
echo.private(`user.${user.id}.projectBatchNotification`)
  .listen('.ProjectUserNotificationBadgesEvent', (e) => {
    badgesCount.value = damSystemNotificationAllowed.value
      ? e.data.dam_badges_count
      : 0
  })

// Subscription plan change events
echo.private(`user.${user.id}.getUserSubscriptionChannel`)
  .listen('.GetUserSubscriptionEvent', async (e) => {
    if (e.data.plan_assign) {
      await fetchUser()
      // refreshes subscription_features so damSystemNotificationAllowed recomputes
    }
  })

useNotificationApi ​

File: app/composables/api/useNotificationApi.ts

Wraps notification preference endpoints. Separate from the notification list fetching (which uses useCommonApis internally via useDamNotification).

Methods ​

javascript
{
  // Fetch the current user's notification preferences
  getUserNotifications(params: NotificationParams): Promise<ApiResponse<unknown[]>>

  // Update the current user's per-event notification preferences
  updateUserNotification(payload: NotificationUpdatePayload): Promise<ApiResponse>

  // Fetch org-level (workspace-wide) notification settings
  getOrgNotifications(params: { workspace_id: number | string }): Promise<ApiResponse<unknown>>

  // Update org-level notification settings
  updateOrgNotification(payload: OrgNotificationUpdatePayload): Promise<ApiResponse>

  // Pause all notifications for the workspace for a set duration
  pauseWorkspaceNotification(payload: PauseWorkspaceNotificationPayload): Promise<ApiResponse>
}

Usage ​

vue
<script setup lang="ts">
const { getUserNotifications, updateUserNotification, isLoading } = useNotificationApi()
const { workspaceId } = useWorkspaceId()

const preferences = ref(null)

onMounted(async () => {
  const res = await getUserNotifications({ workspace_id: workspaceId.value })
  preferences.value = res.data
})

async function toggleEvent(eventKey: string, enabled: boolean) {
  await updateUserNotification({
    workspace_id: workspaceId.value,
    [eventKey]: enabled ? 1 : 0,
  })
}
</script>

snackbar.client.ts Plugin ​

File: app/plugins/snackbar.client.ts

DOM-injected toast notification system. Does not use Vue's rendering pipeline — elements are created, styled, and appended to document.body directly. Exposed as $snackbar via nuxtApp.provide.

Features ​

  • Four variants: success, error, warning, info
  • Slide-in animation from the right (translateX(100%) → translateX(0))
  • Auto-dismiss with configurable timeout (default 3000ms)
  • Themed border, background, and SVG icon per variant
  • hideSnackbar slide-out + DOM removal after 300ms transition

Methods ​

javascript
{
  // Show a custom message with any variant
  show(message: string, icon: Component | null, color: SnackbarColor, timeout: number): void

  // Convenience variants — default timeout 3000ms
  success(message: string, timeout?: number): void
  error(message: string, timeout?: number): void
  warning(message: string, timeout?: number): void
  info(message: string, timeout?: number): void
}

useSnackbar ​

File: app/composables/core/common/useSnackbar.ts

SSR-safe composable facade over $snackbar. Reads nuxtApp.$snackbar at call time rather than at module scope so it is safe to call in composables that run during SSR (the $snackbar plugin is client-only and will be undefined on the server; useSnackbar handles this gracefully).

Usage ​

vue
<script setup lang="ts">
const snackbar = useSnackbar()

async function save() {
  try {
    await api.post(payload)
    snackbar.success('Saved successfully.')
  } catch (e) {
    snackbar.error('Save failed. Please try again.')
  }
}
</script>

echo.client.ts Plugin ​

File: app/plugins/echo.client.ts

Initializes Laravel Echo backed by Pusher. Client-only; SSR receives a safe mock object.

Features ​

  • Lazy initialization: the singleton echoInstance is created on first call to initializeEcho() and reused for the session lifetime
  • Skips initialization on auth pages (index, forgot-password, etc.) and returns a no-op mock
  • Returns a no-op mock when auth_token cookie is absent
  • Configured from runtimeConfig.public: pusherKey, pusherCluster, pusherAuthEndpoint
  • Authorizes private channels via Authorization: Bearer {token} header on the auth endpoint
  • Tears down on app:unmounted via destroyEcho() to prevent HMR channel stacking

Configuration ​

javascript
// Runtime config keys required
{
  pusherKey: string,           // PUSHER_APP_KEY
  pusherCluster: string,       // PUSHER_APP_CLUSTER
  pusherAuthEndpoint: string,  // Backend private channel auth URL
}

API Integration ​

Notification Endpoints ​

MethodEndpointDescription
GETdigital-assets/notification/user-notification-listUser notification preferences
POSTdigital-assets/notification/set-user-profile-notificationUpdate user preferences
POSTdigital-assets/notification/set-organization-profile-notificationUpdate org preferences
POSTdigital-assets/notification/pause-workspace-notificationPause workspace notifications

Common API Endpoints (via useCommonApis) ​

MethodEndpointDescription
GETdigital-assets/notification/get-badge-countCurrent user badge count
GETdigital-assets/notification/system-listPaginated system notification list
GETdigital-assets/notification/announcement-listPaginated announcement list
POSTdigital-assets/notification/read-announcementMark announcement read
POSTdigital-assets/notification/read-unread-system-notificationToggle system notification read/unread
POSTdigital-assets/notification/read-all-notificationMark all system notifications read
POSTdigital-assets/notification/read-unread-announcementToggle announcement read/unread
POSTdigital-assets/notification/read-all-announcementMark all announcements read

Request/Response Examples ​

json
// GET digital-assets/notification/system-list
// Query: workspace_id=42&module_id=1&page=1&show_unread=0
{
  "data": {
    "data": [
      {
        "id": 1045,
        "notification_text": "Alex uploaded 3 new assets to Campaign Assets.",
        "notification_url": "https://app.collage.inc/42/dam/files/8822",
        "read_at": null,
        "created_date": "2 hours ago",
        "sender_user_detail": {
          "name": "Alex Chen",
          "profile_image": null,
          "display_profile_image": null
        }
      }
    ],
    "total_unread_notification": 7,
    "total_unread_announcement": 2,
    "last_page": 3
  }
}
json
// GET digital-assets/notification/announcement-list
// Query: workspace_id=42&module_id=1&page=1
{
  "data": {
    "data": [
      {
        "id": 88,
        "read_at": null,
        "created_date": "Yesterday",
        "announcements_detail": {
          "title": "New Brand Guidelines Released",
          "description": "<p>Please review the updated guidelines...</p>",
          "publisher": {
            "name": "Jordan Smith",
            "profile_image": null,
            "display_profile_image": null
          }
        }
      }
    ],
    "total_unread_announcement": 2,
    "last_page": 1
  }
}

Component Integration ​

Notification Settings page (app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/notifications/index.vue) ​

The page delegates entirely to useNotificationSettings(). When the subscription plan doesn't include notification management (notificationsAllowed is false) the page swaps to static upgrade-prompt components instead of the settings form.

vue
<script setup lang="ts">
definePageMeta({
  layout: 'general-settings-layout',
  middleware: ['auth-check', 'check-workspace', 'check-workspace-access', 'can-access-dam-module', 'check-if-suspended'],
})

const {
  contentLoading,
  notificationsEnabled,
  updatingGlobal,
  notifications,
  notificationsAllowed,
  systemNotificationsAllowed,
  emailNotificationsAllowed,
  updateGlobalNotification,
  updateNotification,
} = useNotificationSettings()
</script>

<template>
  <div class="collage-body account-settings">
    <AccountSettingsLeftMenu />
    <v-card class="boxview w-100">
      <!-- Subscription gate: no access → show upgrade prompt instead of settings -->
      <template v-if="!notificationsAllowed && !contentLoading">
        <StaticPlanNotificationSettings />
        <UpgradePlanSection />
      </template>

      <template v-else>
        <v-card-title>
          <h4>Notification Settings</h4>
        </v-card-title>
        <v-card-text>
          <!-- Global on/off switch — calls pauseWorkspaceNotification mutation -->
          <v-switch
            v-model="notificationsEnabled"
            inset
            :disabled="updatingGlobal"
            @update:model-value="(val) => updateGlobalNotification(!!val)"
          />

          <!-- Per-event rows: each notification has separate System and Email switches -->
          <template v-for="(category, index) in notifications" :key="category.id">
            <h5>{{ category.group_name }}</h5>
            <template v-for="notification in category.notification" :key="notification.id">
              <div class="notification-setting-list-body-list">
                <p>{{ notification.notification_name }}</p>
                <v-switch
                  v-if="!notification.is_hidden"
                  v-model="notification.is_system"
                  inset
                  :disabled="!notificationsEnabled || !systemNotificationsAllowed"
                  @update:model-value="(val) => updateNotification(notification.id, 'system', val ? 1 : 0)"
                />
                <v-switch
                  v-if="!notification.is_hidden"
                  v-model="notification.is_email"
                  inset
                  :disabled="!notificationsEnabled || !emailNotificationsAllowed"
                  @update:model-value="(val) => updateNotification(notification.id, 'email', val ? 1 : 0)"
                />
              </div>
            </template>
            <v-divider v-if="notifications.length - 1 !== index" />
          </template>
        </v-card-text>
      </template>
    </v-card>
  </div>
</template>

Bell icon in the sidebar (app/components/global/DamNotification.vue) ​

DamNotification receives a single prop from whatever layout renders the sidebar. It is self-contained — the layout only needs to pass the collapsed state.

vue
<template>
  <DamNotification :nav-collapsed="isSidebarCollapsed" />
</template>

Workflows ​

Notification Panel Open Flow ​

1. User clicks bell icon in sidebar
   Component: DamNotification.vue
   → isMenuOpen set to true by v-model on v-menu
   ↓
2. watch(isMenuOpen) fires in useDamNotification
   → If system tab active and no notifications loaded
     → openList() → initialLoadNotifications()
   → If announcements tab active and none loaded
     → initialLoadAnnouncements()
   ↓
3. initialLoadNotifications()
   → Checks damSystemNotificationAllowed (subscription gate)
   → badgesCount reset to 0
   → getSystemNotificationList({ workspace_id, show_unread, page, module_id: 1 })
   → Populates systemNotifications, unreadNotifications, lastPage
   ↓
4. User scrolls to bottom of list
   → onScroll() detects scrollHeight − scrollTop === clientHeight
   → loadMoreNotifications() increments page, appends to systemNotifications
   ↓
5. User closes panel
   → isMenuOpen set to false
   → closeList() resets all list state and page counters

Notification Read/Redirect Flow ​

1. User clicks a system notification item
   → readUnreadNotification(notification.id, redirect: true)
   ↓
2. If unread and notification_url present
   → trackNotificationViewed() fires Amplitude event
     (collage viewed, folder viewed, or asset viewed with source: 'notification')
   → window.open(notification_url, '_blank')
   → Returns without toggling read state (redirect takes priority)
   ↓
3. If read and user clicks again (toggle to unread)
   → notification.read_at set to null optimistically
   → unreadNotifications counter incremented
   → readUnreadSystemNotification API called
   ↓
4. Mark All as Read
   → readAllNotifications()
   → All notifications mapped to { ...n, read_at: 'read' } (immutable)
   → unreadNotifications set to 0
   → markAllReadUnreadNotification API called

Real-Time Badge Update Flow ​

1. Another user uploads assets or takes an action
   → Laravel backend fires ProjectUserNotificationBadgesEvent
   → Pusher broadcasts to private channel:
     user.{userId}.projectBatchNotification
   ↓
2. echo plugin receives event in DamNotification mount
   → .ProjectUserNotificationBadgesEvent handler fires
   → If damSystemNotificationAllowed → badgesCount = e.data.dam_badges_count
   → If not allowed → badgesCount = 0 (subscription gate)
   ↓
3. displayBatchCount computed updates
   → { show: true, count_: '7' } or { show: true, count_: '99+' }
   → Badge renders on bell icon and in tab title

Snackbar Display Flow ​

1. Composable calls snackbar.error('Save failed.')
   → useSnackbar reads $snackbar from nuxtApp at call time
   ↓
2. snackbar.show() invoked (client-only)
   → Creates div element, applies border-left accent style
   → Appends to document.body at top-right (z-index: 9999)
   → requestAnimationFrame transitions translateX(100%) → translateX(0)
   ↓
3. After timeout (default 3000ms)
   → hideSnackbar() transitions to translateX(100%), opacity 0
   → After 300ms: element removed from DOM
  • Real-time Updates — Pusher channel setup and event reference
  • Workspace — Notification settings page location
  • Permissions — canManageNotifications and canViewAnnouncements gates
  • Subscription — system_notifications.enable subscription feature flag