Skip to content

Real-Time Updates ​

Overview ​

  1. Pusher via Laravel Echo: The app uses @pranavraut033/laravel-echo as a Nuxt build module, configured with the Pusher broadcaster. The $echo instance is available globally in every component after authentication.
  2. Private channels: All active channels are private ($echo.private(...)), authenticated against the backend via a dedicated Pusher auth endpoint. The auth request carries the same JWT token used for all other API calls.
  3. Notification badge channel: DamNotification.vue subscribes to a per-user private channel to receive live badge counts. Origin validation prevents cross-tenant notification leakage on white-label deployments.
  4. Subscription assignment channel: A second per-user channel fires when a plan is assigned or updated on the backend, triggering an immediate $auth.fetchUser() refresh.
  5. Socket.IO module: nuxt-socket-io is also installed as a Nuxt module for lower-level socket needs, though the primary real-time mechanism for notifications and subscriptions is Laravel Echo / Pusher.
  6. Lifecycle management: Every channel subscription registered in mounted() is explicitly left in beforeDestroy() to prevent memory leaks.

Architecture ​

The real-time layer has two configuration points. The first is nuxt.config.js, where the @pranavraut033/laravel-echo build module is declared with the Pusher broadcaster, auth endpoint, and connection lifecycle flags. The second is the component layer, where this.$echo is used to subscribe and listen.

Authentication to private channels happens automatically. The authModule: true option in nuxt.config.js instructs Laravel Echo to reuse the Nuxt Auth module's token for the Pusher auth handshake, so no manual header setup is needed in components.

When a Pusher event arrives the raw event object e is passed to the .listen() callback. Event payloads can arrive as either a plain object or a JSON-encoded string, depending on the backend broadcasting driver. DamNotification.vue defensively handles both shapes.

White-label deployments introduce a multi-tenant challenge: a single Pusher app key may serve several custom domains. Each backend event includes an origin_url field. The notification handler normalises and compares this URL against window.location.origin before updating the badge count, discarding events intended for other tenants. When BASE_URL matches the current origin (the primary domain) this check is bypassed so the primary domain always processes notifications.

File Structure ​

JavaScript Files (.js) ​

  • nuxt.config.js - Pusher and Socket.IO module configuration

    • Registers @pranavraut033/laravel-echo with broadcaster: 'pusher'
    • Provides PUSHER_KEY, PUSHER_CLUSTER, and PUSHER_AUTH_ENDPOINT from environment
    • Sets connectOnLogin: true and disconnectOnLogout: true for automatic lifecycle
    • Registers nuxt-socket-io as a module for Socket.IO support
  • plugins/event-bus.js - Application-wide EventBus

    • Exports a named EventBus Vue instance
    • Used for intra-component communication alongside (not replacing) Pusher channels
    • Listeners registered via EventBus.$on() must be cleaned up in beforeDestroy

Vue Component Files (.vue) ​

  • components/theme/global/DamNotification.vue - Primary real-time subscriber

    • Subscribes to user.{id}.projectBatchNotification for live badge count updates
    • Subscribes to user.{id}.getUserSubscriptionChannel for plan assignment notifications
    • Validates origin_url in event payloads to filter cross-tenant events
    • Leaves both channels in beforeDestroy to prevent listener accumulation
  • pages/_workspace_id/external/verify.vue - External OTP verification page

    • Uses the external-uploads mixin which includes socket-aware upload state
    • Connected to upload session tracking for external guest flows

SVG Icon Components ​

  • components/svg/CollageBallIcon.vue - Notification bell icon with badge overlay

DamNotification Component ​

File: components/theme/global/DamNotification.vue ​

Central real-time component. Mounted in the DAM layout so it is active for the entire authenticated session.

Features ​

  • Live notification badge count via Pusher
  • Subscription plan assignment detection with auto-refresh of auth user
  • Per-tenant origin validation for white-label environments
  • Paginated notification and announcement lists with infinite scroll
  • Read/unread toggle for individual items and mark-all-as-read
  • Separate tabs for system notifications (plan-gated) and workspace announcements

Props ​

javascript
{
  navCollapsed: {
    type: Boolean,
    default: false
  }
}

Events ​

javascript
// No events emitted — component manages its own menu state internally
{}

Methods ​

javascript
{
  // Origin validation
  normalizeOrigin: (url) => {},       // Strips path, normalises to lowercase origin
  getBaseUrlOrigin: () => {},         // Returns normalised BASE_URL origin from $config
  getCurrentOrigin: () => {},         // Returns window.location.origin safely
  shouldBypassOriginCheck: () => {},  // True when BASE_URL === current origin
  validatePusherOrigin: (url) => {},  // Compares Pusher origin_url to current origin

  // Notification management
  getBadgesCount: async () => {},               // GET user-badges-count on mount
  openList: () => {},                           // Load first page of notifications
  initialLoadNotifications: async () => {},     // First page fetch for system notifications
  initialLoadAnnouncements: async () => {},     // First page fetch for announcements
  loadMoreNotifications: async () => {},        // Pagination: next page of notifications
  loadMoreAnnouncements: async () => {},        // Pagination: next page of announcements
  readUnreadNotification: async (nId, redirect) => {},  // Toggle read state, optionally navigate
  readAllNotifications: async () => {},         // Mark all notifications read
  readAnnouncement: (aId) => {},               // Mark a single announcement read
  readUnreadAnnouncement: (aId) => {},         // Toggle read state on an announcement
  readAllAnnouncements: () => {},              // Mark all announcements read
  changeTab: (id) => {},                       // Switch between notification and announcement tabs
  onScroll: (event) => {},                     // Infinite scroll handler
}

Usage ​

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

<script>
export default {
  components: {
    DamNotification: () => import('~/components/theme/global/DamNotification.vue'),
  },
  data() {
    return {
      isNavCollapsed: false,
    }
  },
}
</script>

Pusher Configuration ​

nuxt.config.js Setup ​

javascript
// nuxt.config.js — relevant section
buildModules: [
  [
    '@pranavraut033/laravel-echo',
    {
      broadcaster: 'pusher',
      key: process.env.PUSHER_KEY,
      cluster: process.env.PUSHER_CLUSTER,
      authModule: true,            // Reuse Nuxt Auth JWT for Pusher auth
      connectOnLogin: true,        // Auto-connect when $auth.loggedIn becomes true
      disconnectOnLogout: true,    // Auto-disconnect on logout
      authEndpoint: process.env.PUSHER_AUTH_ENDPOINT,
    },
  ],
],
modules: [
  'nuxt-socket-io',  // Socket.IO for lower-level socket usage
  // ...other modules
],

Required Environment Variables ​

bash
PUSHER_KEY=your-pusher-app-key
PUSHER_CLUSTER=us3
PUSHER_AUTH_ENDPOINT=https://api.example.com/broadcasting/auth

Channel Reference ​

Per-User Notification Badge Channel ​

javascript
// Channel name pattern
`user.${this.$auth.user.id}.projectBatchNotification`

// Event name
'.ProjectUserNotificationBadgesEvent'

// Payload structure
{
  data: {
    dam_badges_count: 1,     // Number of new badges to add
    origin_url: 'https://app.collage.inc'  // Tenant origin for validation
  },
  origin_url: 'https://app.collage.inc'    // Also available at top level
}

Per-User Subscription Assignment Channel ​

javascript
// Channel name pattern
`user.${this.$auth.user.id}.getUserSubscriptionChannel`

// Event name
'.GetUserSubscriptionEvent'

// Payload structure
{
  plan_assign: true   // Boolean — true when a plan has been activated/changed
}

Workflows ​

Channel Subscription Lifecycle ​

1. Component mounted
   DamNotification.vue mounted() executes
   ↓
2. Subscribe to notification badge channel
   this.$echo.private(`user.${userId}.projectBatchNotification`)
     .listen('.ProjectUserNotificationBadgesEvent', handler)
   ↓
3. Subscribe to subscription assignment channel
   this.$echo.private(`user.${userId}.getUserSubscriptionChannel`)
     .listen('.GetUserSubscriptionEvent', handler)
   ↓
4. Fetch initial badge count
   GET user-badges-count
   this.badgesCount = data.dam_badges_count
   ↓
5. Pusher maintains persistent WebSocket connection
   Laravel Echo handles reconnection automatically
   ↓
6. Component beforeDestroy
   this.$echo.leave(`user.${userId}.projectBatchNotification`)
   this.$echo.leave(`user.${userId}.getUserSubscriptionChannel`)
   Listeners removed, no further events processed

Notification Badge Event Flow ​

1. Backend action triggers notification
   e.g. another user shares an asset, adds a comment, completes a job
   ↓
2. Laravel broadcasts event
   Channel: private-user.{id}.projectBatchNotification
   Event: ProjectUserNotificationBadgesEvent
   Payload: { data: { dam_badges_count: 1, origin_url: '...' } }
   ↓
3. Pusher delivers event to connected client
   .listen() callback fires in DamNotification.vue
   ↓
4. Origin validation
   Extract origin_url from e.data or e directly
   Call shouldBypassOriginCheck() — true if BASE_URL === window.origin
   If bypass: process event unconditionally
   If no bypass: normalizeOrigin(origin_url) and compare to getCurrentOrigin()
   If mismatch: set badgesCount = 0 and discard
   ↓
5. Feature flag check
   this.damSystemNotificationAllowed checks
   $auth.user.subscription_features.system_notifications.enable
   If flag false: do not increment badge
   ↓
6. Badge count update
   this.badgesCount += 1 (for each valid inbound notification)
   v-badge in template updates reactively
   Badge hidden when navCollapsed === false (shown in sidebar text)
   Badge shown as overlap dot when navCollapsed === true

Plan Assignment Event Flow ​

1. Admin assigns plan to workspace
   Backend plan assignment logic runs
   ↓
2. Laravel broadcasts event
   Channel: private-user.{id}.getUserSubscriptionChannel
   Event: GetUserSubscriptionEvent
   Payload: { plan_assign: true }
   ↓
3. DamNotification receives event
   .listen('.GetUserSubscriptionEvent', async (e) => { ... })
   ↓
4. Check plan_assign flag
   If e.plan_assign === true:
     → this.$auth.fetchUser()
     Re-populates all subscription_features and subscription data
   ↓
5. Acknowledge event to backend
   GET /view-workspace?workspace_id={id}
   POST subscription/update-event-sent
   Body: { workspace_id, event_status: true }
   ↓
6. UI updates reactively
   subscription_features flags refreshed
   Previously locked features become available without page reload
   Trial/billing dialogs re-evaluate their visibility

White-Label Origin Validation Flow ​

1. Pusher event arrives from backend
   e.origin_url = 'https://brand-a.collage.inc'
   window.location.origin = 'https://brand-b.collage.inc'
   ↓
2. shouldBypassOriginCheck() called
   this.$config.baseUrl = 'https://app.collage.inc' (primary domain)
   normalizeOrigin('https://app.collage.inc') === window.location.origin?
   False — current origin is a white-label domain, not primary
   Bypass = false
   ↓
3. validatePusherOrigin(e.origin_url) called
   normalizeOrigin('https://brand-a.collage.inc')
     → 'https://brand-a.collage.inc'
   getCurrentOrigin()
     → 'https://brand-b.collage.inc'
   'https://brand-a.collage.inc' === 'https://brand-b.collage.inc' → false
   ↓
4. Event discarded
   badgesCount = 0
   No badge shown — cross-tenant notification suppressed
   ↓
5. Correct event arrives
   e.origin_url = 'https://brand-b.collage.inc'
   Matches current origin → shouldProcess = true
   Badge count incremented normally

Notification List Loading Flow ​

1. User opens notification menu (isMenuOpen → true)
   DamNotification watch: isMenuOpen handler fires
   ↓
2. Load initial data
   If activeTab === 0 (notifications) and list empty:
     → initialLoadNotifications()
     GET digital-assets/notification/system-notification-list
     Params: { workspace_id, show_unread: 0, page: 1, module_id: 2 }
   If activeTab === 1 (announcements) and list empty:
     → initialLoadAnnouncements()
     GET announcement/notification-list?page=1&module_id=2
   ↓
3. Data rendered
   systemNotifications[] or announcements[] populated
   unreadNotifications / unreadAnnouncements counts updated
   badgesCount reset to 0 (opening clears the indicator)
   ↓
4. User scrolls to bottom
   onScroll() checks: scrollHeight - scrollTop === clientHeight
   If notifications and page < lastPage → loadMoreNotifications()
   If announcements and page_ < lastPage_ → loadMoreAnnouncements()
   ↓
5. User closes menu (isMenuOpen → false)
   closeList() resets: activeTab, all arrays, page counters
   State cleared for next open

API Integration ​

Notification Endpoints ​

MethodPathDescription
GETuser-badges-countFetch current unread badge count on component mount
GETdigital-assets/notification/system-notification-listPaginated system notifications
POSTdigital-assets/notification/read-unread-system-notificationToggle read/unread on one notification
POSTdigital-assets/notification/mark-all-read-unread-notificationMark all notifications read
GETannouncement/notification-listPaginated workspace announcements
POSTannouncement/readMark a single announcement read
POSTannouncement/read-unreadToggle read/unread on one announcement
POSTannouncement/mark-all-readMark all announcements read

Badge Count Response ​

json
// GET user-badges-count
{
  "data": {
    "dam_badges_count": 3
  }
}

Notification List Request ​

json
// GET digital-assets/notification/system-notification-list
{
  "workspace_id": "abc123",
  "show_unread": 0,
  "page": 1,
  "module_id": 2
}

Notification List Response ​

json
{
  "data": {
    "data": [
      {
        "id": 101,
        "notification_text": "John shared an asset with you",
        "notification_url": "https://app.collage.inc/ws1/dam/files/99",
        "read_at": null,
        "created_date": "2 hours ago",
        "sender_user_detail": {
          "name": "John Smith",
          "profile_image": null,
          "display_profile_image": null
        }
      }
    ],
    "last_page": 4,
    "total_unread_notification": 3,
    "total_unread_announcement": 1
  }
}

Read Notification Request ​

json
// POST digital-assets/notification/read-unread-system-notification
{
  "notification_id": 101,
  "workspace_id": "abc123"
}

Mark-All-Read Response ​

json
{
  "data": {
    "total_unread_notification": 0
  }
}

Component Integration ​

Adding a Real-Time Listener in a Page Component ​

vue
<template>
  <div>
    <div v-if="assetProcessing" class="processing-indicator">
      Processing asset...
    </div>
    <AssetCard v-else :asset="asset" />
  </div>
</template>

<script>
export default {
  layout: 'damLayout',
  data() {
    return {
      assetProcessing: false,
      asset: null,
    }
  },
  mounted() {
    const workspaceId = this.$route.params.workspace_id || this.$getWorkspaceId()

    // Subscribe to workspace-scoped channel for asset processing events
    this.$echo
      .private(`workspace.${workspaceId}.assetProcessing`)
      .listen('.AssetProcessingCompleteEvent', (e) => {
        if (e.asset_id === this.assetId) {
          this.assetProcessing = false
          this.fetchAsset()
        }
      })
  },
  beforeDestroy() {
    const workspaceId = this.$route.params.workspace_id || this.$getWorkspaceId()
    // Always leave channels to prevent listener accumulation
    this.$echo.leave(`workspace.${workspaceId}.assetProcessing`)
  },
  methods: {
    async fetchAsset() {
      try {
        const { data } = await this.$axios.$get(
          `digital-assets/files/${this.assetId}`
        )
        this.asset = data
      } catch (e) {
        this.$snackbar.error(this.$getErrorMessage(e))
      }
    },
  },
}
</script>

Subscribing to a User Channel ​

vue
<script>
export default {
  mounted() {
    this.$echo
      .private(`user.${this.$auth.user.id}.projectBatchNotification`)
      .listen('.ProjectUserNotificationBadgesEvent', (e) => {
        try {
          let eventData = e.data
          if (typeof eventData === 'string') {
            eventData = JSON.parse(eventData)
          }
          const count = eventData?.data?.dam_badges_count ?? eventData?.dam_badges_count ?? 0
          // handle count update
        } catch (error) {
          console.error('Error processing Pusher event:', error)
        }
      })
  },
  beforeDestroy() {
    this.$echo.leave(`user.${this.$auth.user.id}.projectBatchNotification`)
  },
}
</script>