Appearance
Real-Time Updates ​
Overview ​
- Pusher via Laravel Echo: The app uses
@pranavraut033/laravel-echoas a Nuxt build module, configured with the Pusher broadcaster. The$echoinstance is available globally in every component after authentication. - 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. - Notification badge channel:
DamNotification.vuesubscribes to a per-user private channel to receive live badge counts. Origin validation prevents cross-tenant notification leakage on white-label deployments. - 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. - Socket.IO module:
nuxt-socket-iois 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. - Lifecycle management: Every channel subscription registered in
mounted()is explicitly left inbeforeDestroy()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-echowithbroadcaster: 'pusher' - Provides
PUSHER_KEY,PUSHER_CLUSTER, andPUSHER_AUTH_ENDPOINTfrom environment - Sets
connectOnLogin: trueanddisconnectOnLogout: truefor automatic lifecycle - Registers
nuxt-socket-ioas a module for Socket.IO support
- Registers
plugins/event-bus.js- Application-wide EventBus- Exports a named
EventBusVue instance - Used for intra-component communication alongside (not replacing) Pusher channels
- Listeners registered via
EventBus.$on()must be cleaned up inbeforeDestroy
- Exports a named
Vue Component Files (.vue) ​
components/theme/global/DamNotification.vue- Primary real-time subscriber- Subscribes to
user.{id}.projectBatchNotificationfor live badge count updates - Subscribes to
user.{id}.getUserSubscriptionChannelfor plan assignment notifications - Validates
origin_urlin event payloads to filter cross-tenant events - Leaves both channels in
beforeDestroyto prevent listener accumulation
- Subscribes to
pages/_workspace_id/external/verify.vue- External OTP verification page- Uses the
external-uploadsmixin which includes socket-aware upload state - Connected to upload session tracking for external guest flows
- Uses the
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/authChannel 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 processedNotification 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 === truePlan 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 visibilityWhite-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 normallyNotification 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 openAPI Integration ​
Notification Endpoints ​
| Method | Path | Description |
|---|---|---|
GET | user-badges-count | Fetch current unread badge count on component mount |
GET | digital-assets/notification/system-notification-list | Paginated system notifications |
POST | digital-assets/notification/read-unread-system-notification | Toggle read/unread on one notification |
POST | digital-assets/notification/mark-all-read-unread-notification | Mark all notifications read |
GET | announcement/notification-list | Paginated workspace announcements |
POST | announcement/read | Mark a single announcement read |
POST | announcement/read-unread | Toggle read/unread on one announcement |
POST | announcement/mark-all-read | Mark 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>Related Documentation ​
- Subscription Plans - Plan assignment event handled via Pusher
- File Upload - Upload completion status can be broadcast via real-time
- Mixins - Common Functions - Shared helpers used alongside notification logic
- Plugins - Event Bus - Application-level EventBus for intra-component messaging