Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -2,6 +2,7 @@ import { computed } from 'vue'
import { libraryService } from '../services/library.service'
import { subscriptionService } from '../services/subscription.service'
import { useAuthStore } from '../stores/auth'
import { USE_MOCK } from '../utils/mock'
/**
* Access Control Composable
@@ -27,10 +28,7 @@ export function useAccess() {
return { hasAccess: true, method: 'subscription' }
}
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
if (USE_MOCK) {
// In dev mode without subscription, no access (prompt rental)
return { hasAccess: false }
}

View File

@@ -131,14 +131,18 @@ export function useAccounts() {
}
/**
* Logout current account
* Logout current account.
* removeAccount already clears the active account if it's the one being removed.
*/
function logout() {
const current = accountManager.active
if (current) {
accountManager.removeAccount(current)
try {
accountManager.removeAccount(current)
} catch (err) {
console.debug('Account removal cleanup:', err)
}
}
accountManager.setActive(null as any)
}
/**

View File

@@ -0,0 +1,340 @@
/**
* useFilmmaker composable
*
* Reactive state and actions for the filmmaker/creator dashboard.
* In dev mode without a backend, uses local mock data so the
* backstage UI is fully functional for prototyping.
*/
import { ref, computed } from 'vue'
import { filmmakerService } from '../services/filmmaker.service'
import type {
ApiProject,
ApiFilmmakerAnalytics,
ApiWatchAnalytics,
ApiPaymentMethod,
ApiPayment,
ApiGenre,
CreateProjectData,
UpdateProjectData,
ProjectType,
} from '../types/api'
import { USE_MOCK } from '../utils/mock'
// ── Shared reactive state (singleton across components) ─────────────────────────
const projects = ref<ApiProject[]>([])
const projectsCount = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
const genres = ref<ApiGenre[]>([])
// Analytics state
const analytics = ref<ApiFilmmakerAnalytics | null>(null)
const watchAnalytics = ref<ApiWatchAnalytics | null>(null)
// Payment state
const paymentMethods = ref<ApiPaymentMethod[]>([])
const payments = ref<ApiPayment[]>([])
// Filters
const activeTab = ref<'projects' | 'resume' | 'stakeholder'>('projects')
const typeFilter = ref<ProjectType | null>(null)
const sortBy = ref<'a-z' | 'z-a' | 'recent'>('recent')
const searchQuery = ref('')
// ── Mock data helpers ────────────────────────────────────────────────────────────
let mockIdCounter = 1
function createMockProject(data: CreateProjectData): ApiProject {
const id = `mock-project-${mockIdCounter++}`
const now = new Date().toISOString()
return {
id,
name: data.name,
title: data.name,
type: data.type,
status: 'draft',
slug: data.name.toLowerCase().replace(/\s+/g, '-'),
createdAt: now,
updatedAt: now,
} as ApiProject
}
const MOCK_GENRES: ApiGenre[] = [
{ id: '1', name: 'Documentary', slug: 'documentary' },
{ id: '2', name: 'Drama', slug: 'drama' },
{ id: '3', name: 'Thriller', slug: 'thriller' },
{ id: '4', name: 'Comedy', slug: 'comedy' },
{ id: '5', name: 'Sci-Fi', slug: 'sci-fi' },
{ id: '6', name: 'Animation', slug: 'animation' },
{ id: '7', name: 'Horror', slug: 'horror' },
{ id: '8', name: 'Action', slug: 'action' },
{ id: '9', name: 'Romance', slug: 'romance' },
{ id: '10', name: 'Music', slug: 'music' },
]
// ── Composable ───────────────────────────────────────────────────────────────────
export function useFilmmaker() {
/**
* Sorted and filtered projects based on active filters
*/
const filteredProjects = computed(() => {
let result = [...projects.value]
// Filter by type
if (typeFilter.value) {
result = result.filter((p) => p.type === typeFilter.value)
}
// Filter by search
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
result = result.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.title?.toLowerCase().includes(q)
)
}
// Sort
if (sortBy.value === 'a-z') {
result.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''))
} else if (sortBy.value === 'z-a') {
result.sort((a, b) => (b.title || b.name || '').localeCompare(a.title || a.name || ''))
} else {
result.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}
return result
})
// ── Project Actions ─────────────────────────────────────────────────────────
async function fetchProjects() {
isLoading.value = true
error.value = null
try {
if (USE_MOCK) {
// Mock mode: projects are already in local state
projectsCount.value = projects.value.length
return
}
const [list, count] = await Promise.all([
filmmakerService.getPrivateProjects({
type: typeFilter.value || undefined,
search: searchQuery.value || undefined,
}),
filmmakerService.getPrivateProjectsCount({
type: typeFilter.value || undefined,
search: searchQuery.value || undefined,
}),
])
projects.value = list
projectsCount.value = count
} catch (err: any) {
error.value = err.message || 'Failed to load projects'
console.debug('Failed to load projects:', err)
} finally {
isLoading.value = false
}
}
async function createNewProject(data: CreateProjectData): Promise<ApiProject | null> {
isLoading.value = true
error.value = null
try {
let project: ApiProject
if (USE_MOCK) {
// Simulate brief delay
await new Promise(resolve => setTimeout(resolve, 300))
project = createMockProject(data)
} else {
project = await filmmakerService.createProject(data)
}
// Add to local list
projects.value = [project, ...projects.value]
projectsCount.value++
return project
} catch (err: any) {
error.value = err.message || 'Failed to create project'
console.error('Failed to create project:', err)
return null
} finally {
isLoading.value = false
}
}
async function saveProject(id: string, data: UpdateProjectData): Promise<ApiProject | null> {
error.value = null
try {
let updated: ApiProject
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 200))
const idx = projects.value.findIndex((p) => p.id === id)
if (idx === -1) throw new Error('Project not found')
updated = { ...projects.value[idx], ...data, updatedAt: new Date().toISOString() } as ApiProject
projects.value[idx] = updated
} else {
updated = await filmmakerService.updateProject(id, data)
const idx = projects.value.findIndex((p) => p.id === id)
if (idx !== -1) projects.value[idx] = updated
}
return updated
} catch (err: any) {
error.value = err.message || 'Failed to save project'
console.error('Failed to save project:', err)
return null
}
}
async function removeProject(id: string): Promise<boolean> {
error.value = null
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 200))
} else {
await filmmakerService.deleteProject(id)
}
projects.value = projects.value.filter((p) => p.id !== id)
projectsCount.value--
return true
} catch (err: any) {
error.value = err.message || 'Failed to delete project'
console.error('Failed to delete project:', err)
return false
}
}
// ── Analytics Actions ───────────────────────────────────────────────────────
async function fetchAnalytics(projectIds?: string[]) {
isLoading.value = true
error.value = null
try {
if (USE_MOCK) {
analytics.value = {
balance: 125000,
totalEarnings: 450000,
myTotalEarnings: 360000,
averageSharePercentage: 80,
}
watchAnalytics.value = {
viewsByDate: {},
trailerViews: 3891,
averageWatchTime: 2340,
streamingRevenueSats: 280000,
rentalRevenueSats: 170000,
purchasesCount: 89,
purchasesByContent: [],
revenueByDate: {},
}
return
}
const [analyticData, watchData] = await Promise.all([
filmmakerService.getFilmmakerAnalytics(),
filmmakerService.getWatchAnalytics(projectIds),
])
analytics.value = analyticData
watchAnalytics.value = watchData
} catch (err: any) {
error.value = err.message || 'Failed to load analytics'
console.error('Failed to load analytics:', err)
} finally {
isLoading.value = false
}
}
// ── Payment Actions ─────────────────────────────────────────────────────────
async function fetchPaymentMethods() {
try {
if (USE_MOCK) {
paymentMethods.value = []
return
}
paymentMethods.value = await filmmakerService.getPaymentMethods()
} catch (err: any) {
console.error('Failed to load payment methods:', err)
}
}
async function fetchPayments(filmId?: string) {
try {
if (USE_MOCK) {
payments.value = []
return
}
payments.value = await filmmakerService.getPayments(filmId)
} catch (err: any) {
console.error('Failed to load payments:', err)
}
}
// ── Single Project Fetch ────────────────────────────────────────────────────
async function getProject(id: string): Promise<ApiProject | null> {
error.value = null
try {
if (USE_MOCK) {
// Look up from local projects list
const found = projects.value.find((p) => p.id === id)
return found || null
}
return await filmmakerService.getPrivateProject(id)
} catch (err: any) {
error.value = err.message || 'Failed to load project'
console.error('Failed to load project:', err)
return null
}
}
// ── Genres ──────────────────────────────────────────────────────────────────
async function fetchGenres() {
try {
if (USE_MOCK) {
genres.value = MOCK_GENRES
return
}
genres.value = await filmmakerService.getGenres()
} catch (err: any) {
console.error('Failed to load genres:', err)
}
}
return {
// State
projects,
projectsCount,
filteredProjects,
isLoading,
error,
genres,
analytics,
watchAnalytics,
paymentMethods,
payments,
// Filters
activeTab,
typeFilter,
sortBy,
searchQuery,
// Actions
fetchProjects,
createNewProject,
getProject,
saveProject,
removeProject,
fetchAnalytics,
fetchPaymentMethods,
fetchPayments,
fetchGenres,
}
}

View File

@@ -0,0 +1,186 @@
/**
* useUpload composable
*
* Chunked multipart upload with progress tracking.
* Ported from the original indeehub-frontend uploader library.
* Works with both the original API and our self-hosted MinIO backend.
*/
import { ref, computed } from 'vue'
import { filmmakerService } from '../services/filmmaker.service'
import axios from 'axios'
const CHUNK_SIZE = 20 * 1024 * 1024 // 20 MB
const MAX_PARALLEL_UPLOADS = 6
const MAX_RETRIES = 3
export interface UploadItem {
id: string
file: File
key: string
bucket: string
progress: number
status: 'pending' | 'uploading' | 'completed' | 'failed'
error?: string
}
// Shared upload queue (singleton across components)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = ref(false)
export function useUpload() {
const totalProgress = computed(() => {
if (uploadQueue.value.length === 0) return 0
const total = uploadQueue.value.reduce((sum, item) => sum + item.progress, 0)
return Math.round(total / uploadQueue.value.length)
})
const activeUploads = computed(() =>
uploadQueue.value.filter((u) => u.status === 'uploading')
)
const completedUploads = computed(() =>
uploadQueue.value.filter((u) => u.status === 'completed')
)
/**
* Add a file to the upload queue and start uploading
*/
async function addUpload(file: File, key: string, bucket: string = 'indeedhub-private'): Promise<string | null> {
const item: UploadItem = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
key,
bucket,
progress: 0,
status: 'pending',
}
uploadQueue.value.push(item)
return processUpload(item)
}
/**
* Process a single upload: initialize, chunk, upload, finalize
*/
async function processUpload(item: UploadItem): Promise<string | null> {
try {
item.status = 'uploading'
isUploading.value = true
// Step 1: Initialize multipart upload
const { UploadId, Key } = await filmmakerService.initializeUpload(
item.key,
item.bucket,
item.file.type
)
// Step 2: Calculate chunks
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE)
// Step 3: Get presigned URLs for all chunks
const { parts: presignedParts } = await filmmakerService.getPresignedUrls(
UploadId,
Key,
item.bucket,
totalChunks
)
// Step 4: Upload chunks in parallel with progress tracking
const completedParts: Array<{ PartNumber: number; ETag: string }> = []
let uploadedChunks = 0
// Process chunks in batches of MAX_PARALLEL_UPLOADS
for (let batchStart = 0; batchStart < presignedParts.length; batchStart += MAX_PARALLEL_UPLOADS) {
const batch = presignedParts.slice(batchStart, batchStart + MAX_PARALLEL_UPLOADS)
const batchResults = await Promise.all(
batch.map(async (part) => {
const start = (part.PartNumber - 1) * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, item.file.size)
const chunk = item.file.slice(start, end)
// Upload with retries
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await axios.put(part.signedUrl, chunk, {
headers: { 'Content-Type': item.file.type },
onUploadProgress: () => {
// Progress is tracked at the chunk level
},
})
uploadedChunks++
item.progress = Math.round((uploadedChunks / totalChunks) * 100)
const etag = response.headers.etag || response.headers.ETag
return {
PartNumber: part.PartNumber,
ETag: etag?.replace(/"/g, '') || '',
}
} catch (err: any) {
lastError = err
// Exponential backoff
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
)
}
}
throw lastError || new Error(`Failed to upload part ${part.PartNumber}`)
})
)
completedParts.push(...batchResults)
}
// Step 5: Finalize
await filmmakerService.finalizeUpload(UploadId, Key, item.bucket, completedParts)
item.status = 'completed'
item.progress = 100
// Check if all uploads done
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false
}
return Key
} catch (err: any) {
item.status = 'failed'
item.error = err.message || 'Upload failed'
console.error('Upload failed:', err)
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false
}
return null
}
}
/**
* Remove completed or failed upload from queue
*/
function removeUpload(id: string) {
uploadQueue.value = uploadQueue.value.filter((u) => u.id !== id)
}
/**
* Clear all completed uploads
*/
function clearCompleted() {
uploadQueue.value = uploadQueue.value.filter((u) => u.status !== 'completed')
}
return {
uploadQueue,
isUploading,
totalProgress,
activeUploads,
completedUploads,
addUpload,
removeUpload,
clearCompleted,
}
}