- 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.
268 lines
8.2 KiB
TypeScript
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()
|