Files
indee-demo/src/services/indeehub-api.service.ts
Dorian 023653eec5 docs: update README and improve zap handling in webhooks and services
- Added a production checklist for Zaps (Lightning) in README.md, detailing necessary steps for historic zap visibility.
- Enhanced comments in webhooks.service.ts to clarify zap handling and webhook requirements for BTCPay Server.
- Improved logging in zaps.service.ts to provide more detailed information on zap payouts and recorded stats.
- Updated error handling in zaps.service.ts to ensure robust logging if zap stats recording fails.
- Refined getZapStats method in indeehub-api.service.ts to clarify mock data usage in development.

These changes improve documentation and enhance the handling of zap-related functionalities across the application.
2026-02-14 16:35:21 +00:00

268 lines
8.2 KiB
TypeScript

/**
* IndeeHub Self-Hosted API Service
*
* Dedicated API client for our self-hosted NestJS backend.
* Uses the /api/ proxy configured in nginx.
* Auth tokens are managed by nip98.service.ts.
*/
import axios, { type AxiosInstance } from 'axios'
import { indeehubApiConfig } from '../config/api.config'
import { nip98Service } from './nip98.service'
class IndeehubApiService {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: indeehubApiConfig.baseURL,
timeout: indeehubApiConfig.timeout,
headers: {
'Content-Type': 'application/json',
},
})
// 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')) {
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}`
}
return config
})
// Auto-refresh on 401 (fallback if the proactive refresh above
// 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
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)
}
)
}
/**
* Generic typed GET request
*/
async get<T>(url: string): Promise<T> {
const response = await this.client.get<T>(url)
return response.data
}
/**
* Generic typed POST request
*/
async post<T>(url: string, data?: any): Promise<T> {
const response = await this.client.post<T>(url, data)
return response.data
}
/**
* Generic typed PATCH request
*/
async patch<T>(url: string, data?: any): Promise<T> {
const response = await this.client.patch<T>(url, data)
return response.data
}
/**
* Generic typed DELETE request
*/
async delete<T = void>(url: string): Promise<T> {
const response = await this.client.delete<T>(url)
return response.data
}
/**
* Check if the API is reachable
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/nostr-auth/health', { timeout: 5000 })
return true
} catch {
return false
}
}
/**
* 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
type?: string
genre?: string
limit?: number
offset?: number
fresh?: boolean
}): Promise<any[]> {
const params = new URLSearchParams()
if (filters?.status) params.append('status', filters.status)
if (filters?.type) params.append('type', filters.type)
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)
return response.data
}
/**
* Get a single project by ID
*/
async getProject(id: string): Promise<any> {
const response = await this.client.get(`/projects/${id}`)
return response.data
}
/**
* Get streaming URL for a content item.
* Returns different data based on deliveryMode (native vs partner).
*
* @param contentId - The content or project ID
* @param format - Optional format override. Pass 'raw' to skip HLS and
* get a presigned URL to the original file (useful as a
* fallback when HLS decryption fails).
*/
async getStreamingUrl(contentId: string, format?: string): Promise<{
url: string
deliveryMode: 'native' | 'partner'
keyUrl?: string
drmToken?: string
}> {
const params = format ? { format } : undefined
const response = await this.client.get(`/contents/${contentId}/stream`, { params })
return response.data
}
/**
* Get user's library items
*/
async getLibrary(): Promise<any[]> {
const response = await this.client.get('/library')
return response.data
}
/**
* Add a project to user's library
*/
async addToLibrary(projectId: string): Promise<any> {
const response = await this.client.post('/library', { projectId })
return response.data
}
/**
* Remove a project from user's library
*/
async removeFromLibrary(projectId: string): Promise<void> {
await this.client.delete(`/library/${projectId}`)
}
/**
* Get current user profile (requires auth)
*/
async getMe(): Promise<any> {
const response = await this.client.get('/auth/me')
return response.data
}
/**
* Get genres
*/
async getGenres(): Promise<any[]> {
const response = await this.client.get('/genres')
return response.data
}
/**
* Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id.
* Mock data only in dev so the UI can be tested; production shows only real backend data.
*/
async getZapStats(projectIds: string[]): Promise<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>> {
if (projectIds.length === 0) return {}
const ids = projectIds.join(',')
let data: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
try {
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
'/zaps/stats',
{ params: { projectIds: ids } },
)
data = response.data ?? {}
} catch (err) {
if (import.meta.env.DEV) {
console.warn('[getZapStats] API failed, using empty stats:', err)
}
data = {}
}
if (import.meta.env.DEV) {
Object.assign(data, this.getMockZapStats(projectIds))
}
return data
}
/**
* Mock zap stats for dev so cards and modal show the zap UI.
* Uses fake pubkeys so robohash avatars display.
*/
private getMockZapStats(projectIds: string[]): Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> {
const mockPubkeys = [
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'c7937c1d0f8d0a2e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a',
]
const result: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
const mockCounts = [3, 7, 1, 12, 2]
const mockSats = [2100, 50000, 1000, 100000, 420]
projectIds.slice(0, 5).forEach((id, i) => {
result[id] = {
zapCount: mockCounts[i % mockCounts.length],
zapAmountSats: mockSats[i % mockSats.length],
recentZapperPubkeys: mockPubkeys.slice(0, (i % 3) + 1),
}
})
return result
}
/**
* Get the CDN URL for a storage path
*/
getCdnUrl(path: string): string {
if (!path) return ''
if (path.startsWith('http')) return path
if (path.startsWith('/')) return path
return `${indeehubApiConfig.cdnURL}/${path}`
}
}
export const indeehubApiService = new IndeehubApiService()