feat: enhance webhook event handling and improve profile fetching logic

- Updated the WebhooksService to differentiate between 'InvoiceSettled' and 'InvoicePaymentSettled' events, ensuring accurate payment confirmation and logging.
- Enhanced the useAccounts composable to improve display name handling by skipping generic placeholder names and ensuring the most recent profile metadata is fetched from multiple relays.
- Modified the IndeehubApiService to handle JWT token refresh failures gracefully, allowing public endpoints to function without authentication.
- Updated content store logic to fetch all published projects from the public API, ensuring backstage content is visible to all users regardless of their active content source.

These changes improve the reliability of payment processing, enhance user profile representation, and ensure content visibility across the application.
This commit is contained in:
Dorian
2026-02-14 12:05:32 +00:00
parent bbac44854c
commit d1ac281ad9
6 changed files with 150 additions and 87 deletions

View File

@@ -63,8 +63,12 @@ export class WebhooksService implements OnModuleInit {
);
switch (event.type) {
case 'InvoiceSettled':
case 'InvoicePaymentSettled': {
// Only process InvoiceSettled — this fires when the invoice is
// confirmed paid. InvoicePaymentSettled fires earlier (when
// payment is received but not yet confirmed) and can cause
// "Invoice not paid" errors due to the invoice still being
// in Processing state.
case 'InvoiceSettled': {
try {
const invoiceId = event.invoiceId;
const invoice = await this.btcpayService.getInvoice(invoiceId);
@@ -97,6 +101,16 @@ export class WebhooksService implements OnModuleInit {
}
}
case 'InvoicePaymentSettled': {
// Payment received but invoice may still be processing.
// We wait for InvoiceSettled to confirm the payment.
Logger.log(
`Invoice ${event.invoiceId} payment received — waiting for InvoiceSettled to confirm`,
'WebhooksService',
);
break;
}
case 'InvoiceExpired':
case 'InvoiceInvalid': {
Logger.log(

View File

@@ -686,6 +686,9 @@ function formatTime(seconds: number): string {
background: #0a0a0a;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100vh;
height: 100dvh; /* dynamic viewport height for mobile */
}
.video-player-container {
@@ -694,6 +697,7 @@ function formatTime(seconds: number): string {
flex-direction: column;
position: relative;
cursor: none;
min-height: 0; /* prevent video intrinsic size from overflowing */
}
.video-player-container:hover {
@@ -734,10 +738,13 @@ function formatTime(seconds: number): string {
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0; /* prevent video intrinsic size from overflowing flex container */
}
.video-area video {
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
/* YouTube iframe fills the entire area */

View File

@@ -57,15 +57,21 @@ export function useAccounts() {
})
/**
* Display name: prefer Nostr profile → test persona → pubkey slice.
* Display name: prefer Nostr profile → test persona → npub slice.
* Skips generic placeholder names like "Nostr" so the user sees
* a more useful identifier while they set up their profile.
*/
const activeName = computed(() => {
if (!activeAccount.value) return null
// First: check if we have kind 0 profile metadata
const profile = activeProfile.value
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
if (profile?.display_name && !/^nostr$/i.test(profile.display_name.trim())) {
return profile.display_name
}
if (profile?.name && !/^nostr$/i.test(profile.name.trim())) {
return profile.name
}
// Second: check test personas
const allPersonas: Persona[] = [
@@ -77,8 +83,10 @@ export function useAccounts() {
)
if (match?.name) return match.name
// Fallback: first 8 chars of pubkey
return activeAccount.value?.pubkey?.slice(0, 8) ?? 'Unknown'
// Fallback: truncated npub for a friendlier display
const pk = activeAccount.value?.pubkey
if (!pk) return 'Unknown'
return `npub...${pk.slice(-6)}`
})
/**
@@ -93,11 +101,15 @@ export function useAccounts() {
/**
* Fetch kind 0 profile metadata for a pubkey from relays.
* Results are cached to avoid redundant relay queries.
* Queries multiple relays and keeps the MOST RECENT event
* (highest created_at) to ensure we get the latest profile.
*/
function fetchProfile(pubkey: string) {
if (!pubkey || profileCache.value.has(pubkey)) return
// Track the best (most recent) event seen so far for this fetch
let bestCreatedAt = 0
const relays = [...APP_RELAYS, ...LOOKUP_RELAYS]
try {
const profileSub = pool
@@ -108,18 +120,34 @@ export function useAccounts() {
.subscribe({
next: (event: any) => {
try {
// Only accept this event if it's newer than what we already have
const eventTime = event.created_at ?? 0
if (eventTime < bestCreatedAt) return
const metadata: NostrProfileMetadata = JSON.parse(event.content)
const updated = new Map(profileCache.value)
updated.set(pubkey, metadata)
profileCache.value = updated
// Skip profiles with no meaningful data (empty or
// generic placeholder names without a picture)
const hasName = !!(metadata.display_name || metadata.name)
const isGeneric = hasName && /^nostr$/i.test((metadata.display_name || metadata.name || '').trim())
const hasPicture = !!metadata.picture
// Accept if: has a non-generic name, has a picture, or
// this is the first event we've received
if (bestCreatedAt === 0 || !isGeneric || hasPicture) {
bestCreatedAt = eventTime
const updated = new Map(profileCache.value)
updated.set(pubkey, metadata)
profileCache.value = updated
}
} catch {
// Invalid JSON in profile event
}
},
})
// Close subscription after 5 seconds
setTimeout(() => profileSub.unsubscribe(), 5000)
// Close subscription after 8 seconds (allows slower relays to respond)
setTimeout(() => profileSub.unsubscribe(), 8000)
subscriptions.push(profileSub)
} catch (err) {
console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err)

View File

@@ -31,8 +31,15 @@ function getAppRelays(): string[] {
// App relays (local dev relay or proxied production relay)
export const APP_RELAYS = getAppRelays()
// Lookup relays for profile metadata
export const LOOKUP_RELAYS = ['wss://purplepag.es']
// Lookup relays for profile metadata (kind 0, kind 3, NIP-65).
// Multiple popular relays ensures we find the user's most up-to-date profile.
export const LOOKUP_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.primal.net',
]
// Observable relay list for reactive subscriptions
export const appRelays = new BehaviorSubject<string[]>([...APP_RELAYS])

View File

@@ -22,13 +22,18 @@ class IndeehubApiService {
},
})
// Attach JWT token from NIP-98 session.
// If the token has expired but we have a refresh token, proactively
// refresh before sending the request to avoid unnecessary 401 round-trips.
// Attach JWT token from NIP-98 session if available.
// Refresh failures are caught so public endpoints still work
// without auth (e.g. GET /projects after switching users).
this.client.interceptors.request.use(async (config) => {
let token = nip98Service.accessToken
if (!token && sessionStorage.getItem('indeehub_api_refresh')) {
token = await nip98Service.refresh()
try {
token = await nip98Service.refresh()
} catch {
// Refresh failed (stale token, wrong user, etc.)
// Continue without auth — public endpoints don't need it
}
}
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -37,17 +42,23 @@ class IndeehubApiService {
})
// Auto-refresh on 401 (fallback if the proactive refresh above
// didn't happen or the token expired mid-flight)
// didn't happen or the token expired mid-flight).
// Refresh failures are caught so the original error propagates
// cleanly instead of masking it with a refresh error.
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const newToken = await nip98Service.refresh()
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return this.client(originalRequest)
try {
const newToken = await nip98Service.refresh()
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return this.client(originalRequest)
}
} catch {
// Refresh failed — fall through to reject the original error
}
}
return Promise.reject(error)
@@ -100,7 +111,10 @@ class IndeehubApiService {
}
/**
* Get all published projects
* Get all published projects.
* Pass `fresh: true` to bypass the backend's 5-minute cache
* (e.g. after login or content source switch) so newly published
* backstage content appears immediately.
*/
async getProjects(filters?: {
status?: string
@@ -108,6 +122,7 @@ class IndeehubApiService {
genre?: string
limit?: number
offset?: number
fresh?: boolean
}): Promise<any[]> {
const params = new URLSearchParams()
if (filters?.status) params.append('status', filters.status)
@@ -115,6 +130,7 @@ class IndeehubApiService {
if (filters?.genre) params.append('genre', filters.genre)
if (filters?.limit) params.append('limit', String(filters.limit))
if (filters?.offset) params.append('offset', String(filters.offset))
if (filters?.fresh) params.append('_t', String(Date.now()))
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
const response = await this.client.get(url)

View File

@@ -7,7 +7,8 @@ import { contentService } from '../services/content.service'
import { mapApiProjectsToContents } from '../utils/mappers'
import { useContentSourceStore } from './contentSource'
import { indeehubApiService } from '../services/indeehub-api.service'
import { useFilmmaker } from '../composables/useFilmmaker'
// useFilmmaker import removed — mergePublishedFilmmakerProjects
// now fetches from the public API so all users see backstage content
import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock'
export const useContentStore = defineStore('content', () => {
@@ -73,7 +74,7 @@ export const useContentStore = defineStore('content', () => {
*/
async function fetchContentFromIndeehubApi() {
try {
const response = await indeehubApiService.getProjects()
const response = await indeehubApiService.getProjects({ fresh: true })
// Handle both array responses and wrapped responses like { data: [...] }
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
@@ -181,77 +182,67 @@ export const useContentStore = defineStore('content', () => {
}
/**
* Convert published filmmaker projects to Content format and merge
* them into the existing content rows so they appear on the browse page.
* Fetch ALL published projects from the public API and merge them
* into the existing content rows so backstage content appears for
* every user, regardless of which content source is active.
*
* If the filmmaker projects haven't been loaded yet (e.g. first visit
* is the homepage, not backstage), attempt to fetch them first — but
* only when the user has an active session.
* Ownership is determined by comparing the project's filmmaker ID
* to the current user's filmmaker profile. Only the actual creator
* sees the "my movie" flag — not other logged-in users.
*/
async function mergePublishedFilmmakerProjects() {
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
if (!apiUrl) return // No backend configured
try {
const filmmaker = useFilmmaker()
// Fetch ALL published projects from the public API endpoint.
// This works for any user (authenticated or not) — no auth required.
// Use fresh: true to bypass the backend's 5-minute cache
// so newly published backstage content appears immediately.
const response = await indeehubApiService.getProjects({ fresh: true })
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
if (!Array.isArray(projects) || projects.length === 0) return
// If projects aren't loaded yet, check whether the user has
// session tokens. We check sessionStorage directly because
// authStore.initialize() may still be in-flight on first load.
if (filmmaker.projects.value.length === 0) {
const nostrToken = sessionStorage.getItem('nostr_token')
const cognitoToken = sessionStorage.getItem('auth_token')
const hasSession = !!nostrToken || !!cognitoToken
if (hasSession) {
// Always sync nip98Service tokens on page load. The token
// may exist in sessionStorage but its expiry record may be
// stale, causing nip98Service.accessToken to return null.
// Re-storing refreshes the expiry so the interceptor can
// include the token; if it really expired the backend 401
// interceptor will auto-refresh via the refresh token.
if (nostrToken) {
const { nip98Service } = await import('../services/nip98.service')
nip98Service.storeTokens(
nostrToken,
sessionStorage.getItem('indeehub_api_refresh') ?? sessionStorage.getItem('refresh_token') ?? '',
)
}
try {
await filmmaker.fetchProjects()
} catch (err) {
console.warn('[content-store] Failed to fetch filmmaker projects for merge:', err)
}
}
// Determine the current user's filmmaker ID (if any) for
// ownership tagging. Import lazily to avoid circular deps.
let currentFilmmakerId: string | null = null
try {
const { useAuthStore } = await import('./auth')
const authStore = useAuthStore()
currentFilmmakerId = authStore.user?.filmmaker?.id ?? null
} catch {
// Auth store not ready — ownership will be unknown
}
const published = filmmaker.projects.value.filter(p => p.status === 'published')
if (published.length === 0) return
const publishedContent: Content[] = projects.map((p: any) => {
const projectFilmmakerId = p.filmmaker?.id ?? p.filmmakerId ?? null
return {
id: p.id,
contentId: p.film?.id,
title: p.title || p.name,
description: p.synopsis || '',
thumbnail: p.poster || '/images/placeholder-poster.jpg',
backdrop: p.poster || '/images/placeholder-poster.jpg',
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
rating: p.format || undefined,
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
categories: p.genres?.map((g: any) => g.name) || [],
slug: p.slug,
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status,
apiData: p,
// Only mark as "own project" if the current user is the actual filmmaker
isOwnProject: !!(currentFilmmakerId && projectFilmmakerId && currentFilmmakerId === projectFilmmakerId),
}
})
console.debug(
'[content-store] Merging published projects:',
published.map(p => ({ id: p.id, title: p.title, filmId: p.film?.id, rentalPrice: p.film?.rentalPrice ?? p.rentalPrice })),
'[content-store] Merging API published projects:',
publishedContent.map(c => ({ id: c.id, title: c.title, isOwn: c.isOwnProject })),
)
const publishedContent: Content[] = published.map(p => ({
id: p.id,
contentId: p.film?.id,
title: p.title || p.name,
description: p.synopsis || '',
thumbnail: p.poster || '/images/placeholder-poster.jpg',
backdrop: p.poster || '/images/placeholder-poster.jpg',
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
rating: p.format || undefined,
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
categories: p.genres?.map(g => g.name) || [],
slug: p.slug,
rentalPrice: p.film?.rentalPrice ?? p.rentalPrice,
status: p.status,
apiData: p,
isOwnProject: true,
}))
// Merge into each content row (prepend so they appear first)
for (const key of Object.keys(contentRows.value)) {
// Avoid duplicates by filtering out any already-present IDs
const existingIds = new Set(contentRows.value[key].map(c => c.id))
const newItems = publishedContent.filter(c => !existingIds.has(c.id))
if (newItems.length > 0) {
@@ -263,8 +254,8 @@ export const useContentStore = defineStore('content', () => {
if (!featuredContent.value && publishedContent.length > 0) {
featuredContent.value = publishedContent[0]
}
} catch {
// Filmmaker composable may not be initialized yet — safe to ignore
} catch (err) {
console.warn('[content-store] Failed to merge API published projects:', err)
}
}