Introduce new API endpoints and backend logic for administrative user management, including fetching, viewing, and updating user roles, while ensuring sensitive data like password hashes are excluded from responses. Implement Replit user ID formatting for internal consistency and extend storage interfaces to support user retrieval and counting. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 170e18f0-0f13-4eca-8643-546bba1dd8cc Replit-Commit-Checkpoint-Type: full_checkpoint
1173 lines
40 KiB
TypeScript
1173 lines
40 KiB
TypeScript
import {
|
|
type Video, type InsertVideo, type UpdateVideo,
|
|
type User, type InsertUser,
|
|
type VideoUpload, type InsertVideoUpload,
|
|
type Category, type InsertCategory,
|
|
type Tag, type InsertTag,
|
|
videos, users, videoUploads, categories, tags, videoTags
|
|
} from "@shared/schema";
|
|
import { randomUUID } from "crypto";
|
|
import { BunnyService } from "./bunny";
|
|
import { videoSyncService } from "./videoSync";
|
|
import { db } from "./db";
|
|
import { eq, desc, asc, like, or, sql, and } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
export interface IStorage {
|
|
// Video operations
|
|
getVideos(limit?: number, offset?: number, search?: string): Promise<Video[]>;
|
|
getVideo(id: string): Promise<Video | undefined>;
|
|
createVideo(video: InsertVideo): Promise<Video>;
|
|
updateVideo(id: string, video: UpdateVideo): Promise<Video | undefined>;
|
|
updateVideoViews(id: string): Promise<void>;
|
|
getVideoCount(search?: string): Promise<number>;
|
|
deleteVideo(id: string): Promise<boolean>;
|
|
|
|
// User operations
|
|
getUser(id: string): Promise<User | undefined>;
|
|
getUserByEmail(email: string): Promise<User | undefined>;
|
|
getUserByUsername(username: string): Promise<User | undefined>;
|
|
getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]>;
|
|
getUserCount(search?: string): Promise<number>;
|
|
createUser(user: InsertUser): Promise<User>;
|
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
|
|
upsertUser(user: any): Promise<User>;
|
|
validateUserPassword(email: string, password: string): Promise<User | null>;
|
|
|
|
// Upload operations
|
|
createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload>;
|
|
getVideoUpload(id: string): Promise<VideoUpload | undefined>;
|
|
updateVideoUpload(id: string, upload: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined>;
|
|
getUserVideoUploads(userId: string): Promise<VideoUpload[]>;
|
|
|
|
// Category operations
|
|
getCategories(): Promise<Category[]>;
|
|
createCategory(category: InsertCategory): Promise<Category>;
|
|
updateCategory(id: string, category: Partial<InsertCategory>): Promise<Category | undefined>;
|
|
deleteCategory(id: string): Promise<boolean>;
|
|
|
|
// Tag operations
|
|
getTags(): Promise<Tag[]>;
|
|
getPopularTags(limit?: number): Promise<Tag[]>;
|
|
createTag(tag: InsertTag): Promise<Tag>;
|
|
incrementTagUse(name: string): Promise<void>;
|
|
}
|
|
|
|
// Database storage implementation using PostgreSQL
|
|
export class DatabaseStorage implements IStorage {
|
|
// Video operations
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
let query = db.select().from(videos);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(videos.title, searchTerm),
|
|
like(videos.description, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query
|
|
.orderBy(desc(videos.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
const result = await db.select().from(videos).where(eq(videos.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
const result = await db.insert(videos).values({
|
|
...video,
|
|
updatedAt: new Date()
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
const result = await db.update(videos)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(eq(videos.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
await db.update(videos)
|
|
.set({ views: sql`${videos.views} + 1` })
|
|
.where(eq(videos.id, id));
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
let query = db.select({ count: sql<number>`count(*)` }).from(videos);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(videos.title, searchTerm),
|
|
like(videos.description, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query;
|
|
return result[0].count;
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
const result = await db.delete(videos).where(eq(videos.id, id));
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// User operations
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.email, email));
|
|
return result[0];
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
const result = await db.select().from(users).where(eq(users.username, username));
|
|
return result[0];
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
// Hash password before storing
|
|
const hashedPassword = await bcrypt.hash(user.passwordHash, 12);
|
|
|
|
const { passwordHash: _, ...userWithoutPassword } = user;
|
|
const result = await db.insert(users).values({
|
|
...userWithoutPassword,
|
|
passwordHash: hashedPassword,
|
|
updatedAt: new Date()
|
|
} as any).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
const updateData: any = { ...updates, updatedAt: new Date() };
|
|
|
|
// Hash password if it's being updated
|
|
if (updates.passwordHash) {
|
|
updateData.passwordHash = await bcrypt.hash(updates.passwordHash, 12);
|
|
}
|
|
|
|
const result = await db.update(users)
|
|
.set(updateData)
|
|
.where(eq(users.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
const [user] = await db
|
|
.insert(users)
|
|
.values({
|
|
id: userData.id,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
username: userData.email || `user_${userData.id}`,
|
|
passwordHash: '', // No password for OAuth users
|
|
isAdmin: false,
|
|
isSuperAdmin: false,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: users.id,
|
|
set: {
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
updatedAt: new Date(),
|
|
},
|
|
})
|
|
.returning();
|
|
return user;
|
|
}
|
|
|
|
async getAllUsers(limit = 50, offset = 0, search?: string): Promise<User[]> {
|
|
let query = db.select().from(users);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(users.username, searchTerm),
|
|
like(users.email, searchTerm),
|
|
like(users.firstName, searchTerm),
|
|
like(users.lastName, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query
|
|
.orderBy(desc(users.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
let query = db.select({ count: sql<number>`count(*)` }).from(users);
|
|
|
|
if (search) {
|
|
const searchTerm = `%${search}%`;
|
|
query = query.where(
|
|
or(
|
|
like(users.username, searchTerm),
|
|
like(users.email, searchTerm),
|
|
like(users.firstName, searchTerm),
|
|
like(users.lastName, searchTerm)
|
|
)
|
|
) as any;
|
|
}
|
|
|
|
const result = await query;
|
|
return result[0].count;
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
const user = await this.getUserByEmail(email);
|
|
if (!user) return null;
|
|
|
|
const isValid = await bcrypt.compare(password, user.passwordHash);
|
|
return isValid ? user : null;
|
|
}
|
|
|
|
// Upload operations
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
const result = await db.insert(videoUploads).values({
|
|
...upload,
|
|
updatedAt: new Date()
|
|
}).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
const result = await db.select().from(videoUploads).where(eq(videoUploads.id, id));
|
|
return result[0];
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
const result = await db.update(videoUploads)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(eq(videoUploads.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return await db.select().from(videoUploads)
|
|
.where(eq(videoUploads.userId, userId))
|
|
.orderBy(desc(videoUploads.createdAt));
|
|
}
|
|
|
|
// Category operations
|
|
async getCategories(): Promise<Category[]> {
|
|
return await db.select().from(categories).orderBy(asc(categories.name));
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
const result = await db.insert(categories).values(category).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
const result = await db.update(categories)
|
|
.set(updates)
|
|
.where(eq(categories.id, id))
|
|
.returning();
|
|
return result[0];
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
const result = await db.delete(categories).where(eq(categories.id, id));
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
// Tag operations
|
|
async getTags(): Promise<Tag[]> {
|
|
return await db.select().from(tags).orderBy(desc(tags.useCount));
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
return await db.select().from(tags)
|
|
.orderBy(desc(tags.useCount))
|
|
.limit(limit);
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
const result = await db.insert(tags).values(tag).returning();
|
|
return result[0];
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
await db.update(tags)
|
|
.set({ useCount: sql`${tags.useCount} + 1` })
|
|
.where(eq(tags.name, name));
|
|
}
|
|
}
|
|
|
|
export class MemStorage implements IStorage {
|
|
private videos: Map<string, Video>;
|
|
private users: Map<string, User>;
|
|
private uploads: Map<string, VideoUpload>;
|
|
private categoriesMap: Map<string, Category>;
|
|
private tagsMap: Map<string, Tag>;
|
|
|
|
constructor() {
|
|
this.videos = new Map();
|
|
this.users = new Map();
|
|
this.uploads = new Map();
|
|
this.categoriesMap = new Map();
|
|
this.tagsMap = new Map();
|
|
this.initializeSampleData();
|
|
}
|
|
|
|
private async initializeSampleData() {
|
|
// Initialize sample categories
|
|
const sampleCategories: Category[] = [
|
|
{ id: randomUUID(), name: "Tutorial", description: "Educational content", color: "#3B82F6", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Business", description: "Business and professional content", color: "#10B981", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Design", description: "UI/UX and design content", color: "#F59E0B", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Analytics", description: "Data and analytics content", color: "#EF4444", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "Mobile", description: "Mobile development", color: "#8B5CF6", createdAt: new Date() },
|
|
{ id: randomUUID(), name: "DevOps", description: "DevOps and infrastructure", color: "#06B6D4", createdAt: new Date() }
|
|
];
|
|
|
|
sampleCategories.forEach(category => {
|
|
this.categoriesMap.set(category.id, category);
|
|
});
|
|
|
|
// Initialize sample tags
|
|
const sampleTags: Tag[] = [
|
|
{ id: randomUUID(), name: "react", useCount: 25, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "typescript", useCount: 20, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "javascript", useCount: 30, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "frontend", useCount: 18, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "backend", useCount: 15, createdAt: new Date() },
|
|
{ id: randomUUID(), name: "tutorial", useCount: 40, createdAt: new Date() },
|
|
];
|
|
|
|
sampleTags.forEach(tag => {
|
|
this.tagsMap.set(tag.id, tag);
|
|
});
|
|
|
|
this.initializeSampleVideos();
|
|
}
|
|
|
|
private initializeSampleVideos() {
|
|
const sampleVideos: InsertVideo[] = [
|
|
{
|
|
title: "Advanced Web Development Techniques and Best Practices",
|
|
description: "Learn modern web development techniques including React, TypeScript, and performance optimization strategies.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1560472355-536de3962603?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
|
duration: 754, // 12:34
|
|
views: 2100,
|
|
category: "Tutorial"
|
|
},
|
|
{
|
|
title: "Team Collaboration Strategies for Remote Work",
|
|
description: "Effective strategies for managing remote teams and improving collaboration in distributed environments.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
|
duration: 495, // 8:15
|
|
views: 856,
|
|
category: "Business"
|
|
},
|
|
{
|
|
title: "Modern UI/UX Design Principles and Workflows",
|
|
description: "Comprehensive guide to modern design principles, user experience optimization, and design system creation.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
|
duration: 942, // 15:42
|
|
views: 3700,
|
|
category: "Design"
|
|
},
|
|
{
|
|
title: "Data Analytics and Business Intelligence Tutorial",
|
|
description: "Learn how to analyze data effectively using modern tools and create meaningful business insights.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
|
duration: 1328, // 22:08
|
|
views: 1400,
|
|
category: "Analytics"
|
|
},
|
|
{
|
|
title: "Mobile App Development: From Concept to Launch",
|
|
description: "Complete guide to mobile app development covering design, development, testing, and deployment strategies.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
|
|
duration: 1113, // 18:33
|
|
views: 4200,
|
|
category: "Mobile"
|
|
},
|
|
{
|
|
title: "Cloud Infrastructure and DevOps Fundamentals",
|
|
description: "Understanding cloud computing, infrastructure as code, and DevOps practices for modern applications.",
|
|
thumbnailUrl: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&h=450",
|
|
videoUrl: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
|
duration: 1517, // 25:17
|
|
views: 987,
|
|
category: "DevOps"
|
|
}
|
|
];
|
|
|
|
sampleVideos.forEach(video => {
|
|
const id = randomUUID();
|
|
const fullVideo: Video = {
|
|
...video,
|
|
id,
|
|
description: video.description || "",
|
|
category: video.category || "",
|
|
customThumbnailUrl: null,
|
|
faceCenterPosition: null,
|
|
facesDetected: 0,
|
|
faceConfidence: 0,
|
|
videoUrlMp4: null,
|
|
videoUrlIframe: null,
|
|
tags: [],
|
|
isPublic: true,
|
|
views: video.views || 0,
|
|
contentType: video.contentType || 'video',
|
|
genre: video.genre || 'other',
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
uploadStatus: video.uploadStatus || "completed",
|
|
originalFileName: video.originalFileName || null,
|
|
fileSize: video.fileSize || null,
|
|
bitrate: video.bitrate || null,
|
|
resolution: video.resolution || null,
|
|
format: video.format || null,
|
|
encoding: video.encoding || null
|
|
};
|
|
this.videos.set(id, fullVideo);
|
|
});
|
|
}
|
|
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
let videos = Array.from(this.videos.values());
|
|
|
|
// Optimized search - only search meaningful queries (2+ chars)
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
videos = videos.filter(video => {
|
|
// Check title first (most common match)
|
|
if (video.title.toLowerCase().includes(searchLower)) return true;
|
|
// Check description if exists
|
|
return video.description?.toLowerCase().includes(searchLower) || false;
|
|
});
|
|
}
|
|
|
|
// Sort by created date (newest first) - more efficient sort
|
|
videos.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
|
|
return videos.slice(offset, offset + limit);
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
return this.videos.get(id);
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
const id = randomUUID();
|
|
const fullVideo: Video = {
|
|
...video,
|
|
id,
|
|
description: video.description || "",
|
|
category: video.category || "",
|
|
customThumbnailUrl: null,
|
|
faceCenterPosition: null,
|
|
facesDetected: 0,
|
|
faceConfidence: 0,
|
|
videoUrlMp4: null,
|
|
videoUrlIframe: null,
|
|
tags: video.tags || [],
|
|
isPublic: video.isPublic ?? true,
|
|
views: video.views || 0,
|
|
contentType: video.contentType || 'video',
|
|
genre: video.genre || 'other',
|
|
uploadStatus: video.uploadStatus || "completed",
|
|
originalFileName: video.originalFileName || null,
|
|
fileSize: video.fileSize || null,
|
|
bitrate: video.bitrate || null,
|
|
resolution: video.resolution || null,
|
|
format: video.format || null,
|
|
encoding: video.encoding || null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.videos.set(id, fullVideo);
|
|
return fullVideo;
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
const video = this.videos.get(id);
|
|
if (!video) return undefined;
|
|
|
|
const updatedVideo: Video = {
|
|
...video,
|
|
...updates,
|
|
updatedAt: new Date()
|
|
};
|
|
this.videos.set(id, updatedVideo);
|
|
return updatedVideo;
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
const video = this.videos.get(id);
|
|
if (video) {
|
|
video.views += 1;
|
|
this.videos.set(id, video);
|
|
}
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
// More efficient count without loading all data
|
|
let videos = Array.from(this.videos.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
videos = videos.filter(video =>
|
|
video.title.toLowerCase().includes(searchLower) ||
|
|
video.description?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
return videos.length;
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
return this.videos.delete(id);
|
|
}
|
|
|
|
// User operations
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
return this.users.get(id);
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(user => user.email === email);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return Array.from(this.users.values()).find(user => user.username === username);
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
const id = randomUUID();
|
|
const hashedPassword = await bcrypt.hash(user.passwordHash, 12);
|
|
|
|
const fullUser: User = {
|
|
...user,
|
|
id,
|
|
passwordHash: hashedPassword,
|
|
firstName: user.firstName || null,
|
|
lastName: user.lastName || null,
|
|
avatar: user.avatar || null,
|
|
isActive: user.isActive ?? true,
|
|
isAdmin: user.isAdmin ?? false,
|
|
isSuperAdmin: user.isSuperAdmin ?? false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.users.set(id, fullUser);
|
|
return fullUser;
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
const user = this.users.get(id);
|
|
if (!user) return undefined;
|
|
|
|
const updateData: any = { ...updates, updatedAt: new Date() };
|
|
if (updates.passwordHash) {
|
|
updateData.passwordHash = await bcrypt.hash(updates.passwordHash, 12);
|
|
}
|
|
|
|
const updatedUser: User = {
|
|
...user,
|
|
...updateData
|
|
};
|
|
this.users.set(id, updatedUser);
|
|
return updatedUser;
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
const existingUser = this.users.get(userData.id);
|
|
const user: User = {
|
|
id: userData.id,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
avatar: userData.profileImageUrl,
|
|
username: userData.email || `user_${userData.id}`,
|
|
passwordHash: '',
|
|
isActive: existingUser?.isActive ?? true,
|
|
isAdmin: existingUser?.isAdmin || false,
|
|
isSuperAdmin: existingUser?.isSuperAdmin || false,
|
|
createdAt: existingUser?.createdAt || new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
this.users.set(userData.id, user);
|
|
return user;
|
|
}
|
|
|
|
async getAllUsers(limit = 50, offset = 0, search?: string): Promise<User[]> {
|
|
let users = Array.from(this.users.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
users = users.filter(user =>
|
|
user.username.toLowerCase().includes(searchLower) ||
|
|
user.email.toLowerCase().includes(searchLower) ||
|
|
user.firstName?.toLowerCase().includes(searchLower) ||
|
|
user.lastName?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
// Sort by created date (newest first)
|
|
users.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
|
|
return users.slice(offset, offset + limit);
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
let users = Array.from(this.users.values());
|
|
|
|
if (search && search.length >= 2) {
|
|
const searchLower = search.toLowerCase();
|
|
users = users.filter(user =>
|
|
user.username.toLowerCase().includes(searchLower) ||
|
|
user.email.toLowerCase().includes(searchLower) ||
|
|
user.firstName?.toLowerCase().includes(searchLower) ||
|
|
user.lastName?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
return users.length;
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
const user = await this.getUserByEmail(email);
|
|
if (!user) return null;
|
|
|
|
const isValid = await bcrypt.compare(password, user.passwordHash);
|
|
return isValid ? user : null;
|
|
}
|
|
|
|
// Upload operations
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
const id = randomUUID();
|
|
const fullUpload: VideoUpload = {
|
|
...upload,
|
|
id,
|
|
uploadStatus: upload.uploadStatus || "uploading",
|
|
uploadProgress: upload.uploadProgress || 0,
|
|
videoId: upload.videoId || null,
|
|
errorMessage: upload.errorMessage || null,
|
|
uploadUrl: upload.uploadUrl || null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
};
|
|
this.uploads.set(id, fullUpload);
|
|
return fullUpload;
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
return this.uploads.get(id);
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
const upload = this.uploads.get(id);
|
|
if (!upload) return undefined;
|
|
|
|
const updatedUpload: VideoUpload = {
|
|
...upload,
|
|
...updates,
|
|
updatedAt: new Date()
|
|
};
|
|
this.uploads.set(id, updatedUpload);
|
|
return updatedUpload;
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return Array.from(this.uploads.values())
|
|
.filter(upload => upload.userId === userId)
|
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
}
|
|
|
|
// Category operations
|
|
async getCategories(): Promise<Category[]> {
|
|
return Array.from(this.categoriesMap.values())
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
const id = randomUUID();
|
|
const fullCategory: Category = {
|
|
...category,
|
|
id,
|
|
description: category.description || null,
|
|
color: category.color || "#000000",
|
|
createdAt: new Date()
|
|
};
|
|
this.categoriesMap.set(id, fullCategory);
|
|
return fullCategory;
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
const category = this.categoriesMap.get(id);
|
|
if (!category) return undefined;
|
|
|
|
const updatedCategory: Category = {
|
|
...category,
|
|
...updates
|
|
};
|
|
this.categoriesMap.set(id, updatedCategory);
|
|
return updatedCategory;
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
return this.categoriesMap.delete(id);
|
|
}
|
|
|
|
// Tag operations
|
|
async getTags(): Promise<Tag[]> {
|
|
return Array.from(this.tagsMap.values())
|
|
.sort((a, b) => b.useCount - a.useCount);
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
return this.getTags().then(tags => tags.slice(0, limit));
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
const id = randomUUID();
|
|
const fullTag: Tag = {
|
|
...tag,
|
|
id,
|
|
useCount: tag.useCount ?? 0,
|
|
createdAt: new Date()
|
|
};
|
|
this.tagsMap.set(id, fullTag);
|
|
return fullTag;
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
const tag = Array.from(this.tagsMap.values()).find(t => t.name === name);
|
|
if (tag) {
|
|
tag.useCount += 1;
|
|
this.tagsMap.set(tag.id, tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use Bunny.net storage if API keys are available, otherwise fallback to memory storage
|
|
class BunnyStorage implements IStorage {
|
|
private bunnyService: BunnyService;
|
|
private viewsCache: Map<string, number> = new Map();
|
|
private faceDataCache: Map<string, { faceCenterPosition?: string; facesDetected?: number; faceConfidence?: number }> = new Map();
|
|
|
|
constructor() {
|
|
this.bunnyService = new BunnyService();
|
|
}
|
|
|
|
async getVideos(limit = 20, offset = 0, search?: string): Promise<Video[]> {
|
|
console.log(`Fetching videos from cache: limit=${limit}, offset=${offset}, search=${search}`);
|
|
const result = videoSyncService.getVideos(limit, offset, search);
|
|
console.log(`Returning ${result.videos.length} videos from cache (age: ${result.cacheAge}ms)`);
|
|
|
|
// Apply face detection data from cache
|
|
return result.videos.map(video => {
|
|
const faceData = this.faceDataCache.get(video.id);
|
|
return faceData ? { ...video, ...faceData } : video;
|
|
});
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
// Try cache first for faster loading
|
|
const cachedVideo = videoSyncService.getVideos(100, 0).videos.find(v => v.id === id);
|
|
if (cachedVideo) {
|
|
// Apply cached view counts
|
|
if (this.viewsCache.has(cachedVideo.id)) {
|
|
cachedVideo.views += this.viewsCache.get(cachedVideo.id)!;
|
|
}
|
|
|
|
// Apply face detection data from cache
|
|
const faceData = this.faceDataCache.get(cachedVideo.id);
|
|
return faceData ? { ...cachedVideo, ...faceData } : cachedVideo;
|
|
}
|
|
|
|
// Fallback to direct API call
|
|
try {
|
|
const video = await this.bunnyService.getVideo(id);
|
|
if (!video) return undefined;
|
|
|
|
// Apply cached view counts
|
|
if (this.viewsCache.has(video.id)) {
|
|
video.views += this.viewsCache.get(video.id)!;
|
|
}
|
|
|
|
// Apply face detection data from cache
|
|
const faceData = this.faceDataCache.get(video.id);
|
|
return faceData ? { ...video, ...faceData } : video;
|
|
} catch (error) {
|
|
console.error(`Error fetching video ${id} from Bunny:`, error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
throw new Error("Creating videos is not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideo(id: string, updates: UpdateVideo): Promise<Video | undefined> {
|
|
// Allow updating face detection data and views even with Bunny.net integration
|
|
const allowedUpdates = ['faceCenterPosition', 'facesDetected', 'faceConfidence', 'views'];
|
|
const filteredUpdates = Object.fromEntries(
|
|
Object.entries(updates).filter(([key]) => allowedUpdates.includes(key))
|
|
);
|
|
|
|
if (Object.keys(filteredUpdates).length === 0) {
|
|
throw new Error("Only face detection and view updates are supported with Bunny.net integration");
|
|
}
|
|
|
|
try {
|
|
// Try to update in database first
|
|
try {
|
|
await db.update(videos).set(filteredUpdates as UpdateVideo).where(eq(videos.id, id));
|
|
} catch (dbError) {
|
|
console.warn(`Database update failed for video ${id}, using cache:`, dbError);
|
|
}
|
|
|
|
// Update face detection data in cache (always reliable)
|
|
const { views, ...faceData } = filteredUpdates;
|
|
if (Object.keys(faceData).length > 0) {
|
|
const existing = this.faceDataCache.get(id) || {};
|
|
this.faceDataCache.set(id, { ...existing, ...faceData });
|
|
}
|
|
|
|
// Update views cache
|
|
if (views !== undefined && typeof views === 'number') {
|
|
this.viewsCache.set(id, views);
|
|
}
|
|
|
|
// Return updated video
|
|
return await this.getVideo(id);
|
|
} catch (error) {
|
|
console.error(`Error updating video ${id}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
throw new Error("Deleting videos is not supported with Bunny.net integration");
|
|
}
|
|
|
|
// User operations (not supported with Bunny.net)
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateUser(id: string, updates: Partial<InsertUser>): Promise<User | undefined> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async upsertUser(userData: any): Promise<User> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
throw new Error("User operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Upload operations (not supported with Bunny.net)
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideoUpload(id: string, updates: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
throw new Error("Upload operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Category operations (not supported with Bunny.net)
|
|
async getCategories(): Promise<Category[]> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateCategory(id: string, updates: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
throw new Error("Category operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
// Tag operations (not supported with Bunny.net)
|
|
async getTags(): Promise<Tag[]> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async getPopularTags(limit = 10): Promise<Tag[]> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
throw new Error("Tag operations are not supported with Bunny.net integration");
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
// Since we can't update views in Bunny.net directly, we'll cache them locally
|
|
const currentViews = this.viewsCache.get(id) || 0;
|
|
this.viewsCache.set(id, currentViews + 1);
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
try {
|
|
// Get the total from Bunny API directly for better performance
|
|
const { total } = await this.bunnyService.getVideos(1, 1);
|
|
|
|
// If no search, return total count
|
|
if (!search) {
|
|
return total;
|
|
}
|
|
|
|
// For search, we need to get videos and count filtered results
|
|
// This is expensive but needed for accurate search counts
|
|
const { videos } = await this.bunnyService.getVideos(1, 1000);
|
|
const searchLower = search.toLowerCase();
|
|
const filteredCount = videos.filter(video =>
|
|
video.title.toLowerCase().includes(searchLower) ||
|
|
(video.description && video.description.toLowerCase().includes(searchLower))
|
|
).length;
|
|
|
|
return filteredCount;
|
|
} catch (error) {
|
|
console.error('Error getting video count from Bunny:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hybrid storage implementation that uses Bunny.net for videos and Database for users
|
|
export class HybridStorage implements IStorage {
|
|
private bunnyStorage: BunnyStorage;
|
|
private databaseStorage: DatabaseStorage;
|
|
|
|
constructor() {
|
|
this.bunnyStorage = new BunnyStorage();
|
|
this.databaseStorage = new DatabaseStorage();
|
|
}
|
|
|
|
// Video operations - use Bunny.net
|
|
async getVideos(limit?: number, offset?: number, search?: string): Promise<Video[]> {
|
|
return this.bunnyStorage.getVideos(limit, offset, search);
|
|
}
|
|
|
|
async getVideo(id: string): Promise<Video | undefined> {
|
|
return this.bunnyStorage.getVideo(id);
|
|
}
|
|
|
|
async createVideo(video: InsertVideo): Promise<Video> {
|
|
return this.bunnyStorage.createVideo(video);
|
|
}
|
|
|
|
async updateVideo(id: string, video: UpdateVideo): Promise<Video | undefined> {
|
|
return this.bunnyStorage.updateVideo(id, video);
|
|
}
|
|
|
|
async updateVideoViews(id: string): Promise<void> {
|
|
return this.bunnyStorage.updateVideoViews(id);
|
|
}
|
|
|
|
async getVideoCount(search?: string): Promise<number> {
|
|
return this.bunnyStorage.getVideoCount(search);
|
|
}
|
|
|
|
async deleteVideo(id: string): Promise<boolean> {
|
|
return this.bunnyStorage.deleteVideo(id);
|
|
}
|
|
|
|
// User operations - use Database
|
|
async getUser(id: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUser(id);
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUserByEmail(email);
|
|
}
|
|
|
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
|
return this.databaseStorage.getUserByUsername(username);
|
|
}
|
|
|
|
async createUser(user: InsertUser): Promise<User> {
|
|
return this.databaseStorage.createUser(user);
|
|
}
|
|
|
|
async updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined> {
|
|
return this.databaseStorage.updateUser(id, user);
|
|
}
|
|
|
|
async upsertUser(user: any): Promise<User> {
|
|
return this.databaseStorage.upsertUser(user);
|
|
}
|
|
|
|
async getAllUsers(limit?: number, offset?: number, search?: string): Promise<User[]> {
|
|
return this.databaseStorage.getAllUsers(limit, offset, search);
|
|
}
|
|
|
|
async getUserCount(search?: string): Promise<number> {
|
|
return this.databaseStorage.getUserCount(search);
|
|
}
|
|
|
|
async validateUserPassword(email: string, password: string): Promise<User | null> {
|
|
return this.databaseStorage.validateUserPassword(email, password);
|
|
}
|
|
|
|
// Upload operations - use Database
|
|
async createVideoUpload(upload: InsertVideoUpload): Promise<VideoUpload> {
|
|
return this.databaseStorage.createVideoUpload(upload);
|
|
}
|
|
|
|
async getVideoUpload(id: string): Promise<VideoUpload | undefined> {
|
|
return this.databaseStorage.getVideoUpload(id);
|
|
}
|
|
|
|
async updateVideoUpload(id: string, upload: Partial<InsertVideoUpload>): Promise<VideoUpload | undefined> {
|
|
return this.databaseStorage.updateVideoUpload(id, upload);
|
|
}
|
|
|
|
async getUserVideoUploads(userId: string): Promise<VideoUpload[]> {
|
|
return this.databaseStorage.getUserVideoUploads(userId);
|
|
}
|
|
|
|
// Category operations - use Database
|
|
async getCategories(): Promise<Category[]> {
|
|
return this.databaseStorage.getCategories();
|
|
}
|
|
|
|
async createCategory(category: InsertCategory): Promise<Category> {
|
|
return this.databaseStorage.createCategory(category);
|
|
}
|
|
|
|
async updateCategory(id: string, category: Partial<InsertCategory>): Promise<Category | undefined> {
|
|
return this.databaseStorage.updateCategory(id, category);
|
|
}
|
|
|
|
async deleteCategory(id: string): Promise<boolean> {
|
|
return this.databaseStorage.deleteCategory(id);
|
|
}
|
|
|
|
// Tag operations - use Database
|
|
async getTags(): Promise<Tag[]> {
|
|
return this.databaseStorage.getTags();
|
|
}
|
|
|
|
async getPopularTags(limit?: number): Promise<Tag[]> {
|
|
return this.databaseStorage.getPopularTags(limit);
|
|
}
|
|
|
|
async createTag(tag: InsertTag): Promise<Tag> {
|
|
return this.databaseStorage.createTag(tag);
|
|
}
|
|
|
|
async incrementTagUse(name: string): Promise<void> {
|
|
return this.databaseStorage.incrementTagUse(name);
|
|
}
|
|
}
|
|
|
|
// Storage selection logic - use hybrid approach when both Bunny.net and Database are available
|
|
let storage: IStorage;
|
|
|
|
const hasDatabase = process.env.DATABASE_URL;
|
|
const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID && process.env.BUNNY_HOSTNAME;
|
|
|
|
// Use hybrid storage when both Bunny.net and Database are available
|
|
if (hasBunnyConfig && hasDatabase) {
|
|
try {
|
|
storage = new HybridStorage();
|
|
console.log('✅ Using Hybrid storage (Bunny.net for videos + Database for users)');
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Hybrid storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
}
|
|
// Prioritize Bunny.net storage for video content only
|
|
else if (hasBunnyConfig) {
|
|
try {
|
|
storage = new BunnyStorage();
|
|
console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID);
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Bunny.net storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
} else if (hasDatabase) {
|
|
try {
|
|
storage = new DatabaseStorage();
|
|
console.log('✅ Using PostgreSQL database storage');
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize database storage:', error);
|
|
console.log('📁 Falling back to memory storage');
|
|
storage = new MemStorage();
|
|
}
|
|
} else {
|
|
console.log('📁 Using memory storage (no database or Bunny.net config found)');
|
|
storage = new MemStorage();
|
|
}
|
|
|
|
export { storage };
|