mirror of
https://github.com/xavidop/cardex.git
synced 2026-09-04 09:13:28 +00:00
feat: refactors
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared API route handler utilities to eliminate duplication
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Validates request body has all required fields
|
||||
* @param body - Request body to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @returns Array of missing field names, empty if all present
|
||||
*/
|
||||
export function validateRequestBody(body: any, requiredFields: readonly string[]): string[] {
|
||||
return requiredFields.filter(field => !body[field]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API handler that wraps common error handling and validation
|
||||
* @param request - Next.js request object
|
||||
* @param requiredFields - Array of required field names to validate
|
||||
* @param handler - Async function that processes the request and returns result
|
||||
* @returns NextResponse with success or error
|
||||
*/
|
||||
export async function handleApiRequest<T>(
|
||||
request: NextRequest,
|
||||
requiredFields: readonly string[],
|
||||
handler: (body: any) => Promise<T>
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = validateRequestBody(body, requiredFields);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `${ERROR_MESSAGES.MISSING_REQUIRED_FIELDS}: ${missingFields.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call the handler
|
||||
const result = await handler(body);
|
||||
|
||||
// Check if result has error property
|
||||
if (result && typeof result === 'object' && 'error' in result && result.error) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('API route error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: ERROR_MESSAGES.INTERNAL_SERVER_ERROR;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Standard API response types
|
||||
*/
|
||||
export interface ApiSuccessResponse<T = any> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Creates a successful API response
|
||||
* @param data - Response data
|
||||
* @param status - HTTP status code (default: 200)
|
||||
* @returns NextResponse with success payload
|
||||
*/
|
||||
export function apiSuccess<T>(data: T, status: number = 200): NextResponse<ApiSuccessResponse<T>> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error API response
|
||||
* @param error - Error message or Error object
|
||||
* @param status - HTTP status code (default: 500)
|
||||
* @param details - Optional additional error details
|
||||
* @returns NextResponse with error payload
|
||||
*/
|
||||
export function apiError(
|
||||
error: string | Error,
|
||||
status: number = 500,
|
||||
details?: any
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const errorMessage = error instanceof Error ? error.message : error;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
...(details && { details }),
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bad request (400) response
|
||||
* @param message - Error message
|
||||
* @param details - Optional validation details
|
||||
*/
|
||||
export function apiBadRequest(message: string, details?: any): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 400, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unauthorized (401) response
|
||||
*/
|
||||
export function apiUnauthorized(message: string = ERROR_MESSAGES.AUTH_REQUIRED): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a forbidden (403) response
|
||||
*/
|
||||
export function apiForbidden(message: string): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a not found (404) response
|
||||
*/
|
||||
export function apiNotFound(message: string): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an internal server error (500) response
|
||||
*/
|
||||
export function apiInternalError(
|
||||
error?: Error | unknown,
|
||||
message: string = ERROR_MESSAGES.INTERNAL_SERVER_ERROR
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const errorMessage = error instanceof Error ? error.message : message;
|
||||
return apiError(errorMessage, 500);
|
||||
}
|
||||
+156
-118
@@ -14,33 +14,58 @@ import {
|
||||
orderBy,
|
||||
setDoc,
|
||||
} from 'firebase/firestore';
|
||||
import { db, auth, storage } from './firebase'; // Main Firebase config
|
||||
import { db, auth, storage } from './firebase';
|
||||
import type { PokemonCard, ScannedCardData, UserProfile, UserApiKeys } from '@/types';
|
||||
import { uploadCardImageToStorage, uploadVideoToStorage } from '@/utils/storageUtils';
|
||||
import { ref, deleteObject } from 'firebase/storage';
|
||||
import { filterUndefinedValues } from './utils';
|
||||
import { generateCardVideo } from '@/ai/flows/generate-card-video';
|
||||
|
||||
const CARDS_COLLECTION = 'users'; // Top-level collection for users
|
||||
import {
|
||||
FIRESTORE_COLLECTIONS,
|
||||
FILE_SIZE_LIMITS,
|
||||
ERROR_MESSAGES,
|
||||
DEFAULT_TCG_GAME,
|
||||
type VideoGenerationStatus
|
||||
} from '@/constants';
|
||||
|
||||
// Path: users/{userId}/pokemon_cards/{pokemonCardId}
|
||||
const getPokemonCardsCollectionRef = (userId: string) => {
|
||||
return collection(db, CARDS_COLLECTION, userId, 'pokemon_cards');
|
||||
return collection(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS);
|
||||
};
|
||||
|
||||
// Background function to generate and upload video for a card
|
||||
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard) => {
|
||||
/**
|
||||
* Validates that a video URL is valid for Firestore storage
|
||||
* @param videoUrl - The video URL to validate
|
||||
* @throws Error if URL is invalid
|
||||
*/
|
||||
const validateVideoUrl = (videoUrl: string): void => {
|
||||
if (!videoUrl.startsWith('http')) {
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_INVALID_URL);
|
||||
}
|
||||
|
||||
if (videoUrl.length > FILE_SIZE_LIMITS.URL_MAX_LENGTH) {
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_INVALID_DATA);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Background function to generate and upload video for a card
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to generate video for
|
||||
* @param card - The card data
|
||||
*/
|
||||
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard): Promise<void> => {
|
||||
try {
|
||||
console.log(`Starting background video generation for card: ${card.name}`);
|
||||
|
||||
// Update status to generating
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'generating'
|
||||
videoGenerationStatus: 'generating' as VideoGenerationStatus
|
||||
});
|
||||
|
||||
// Determine Pokemon type from generation params or default
|
||||
const pokemonType = card.generationParams?.pokemonType ||
|
||||
card.photoGenerationParams?.pokemonType ||
|
||||
const pokemonType = card.generationParams?.characterType ||
|
||||
card.photoGenerationParams?.characterType ||
|
||||
'Normal';
|
||||
|
||||
// Generate video using AI
|
||||
@@ -51,57 +76,33 @@ const generateVideoForCard = async (userId: string, cardId: string, card: Pokemo
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
console.log('Video generation result:', {
|
||||
hasError: !!videoResult.error,
|
||||
hasVideoBase64: !!videoResult.videoBase64,
|
||||
videoBase64Type: typeof videoResult.videoBase64,
|
||||
videoBase64Preview: videoResult.videoBase64?.substring(0, 50) + '...',
|
||||
});
|
||||
|
||||
if (videoResult.error || !videoResult.videoBase64) {
|
||||
console.error('Video generation failed:', videoResult.error);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have base64 data, not a URL
|
||||
if (videoResult.videoBase64.startsWith('http')) {
|
||||
console.error('Video generation returned a URL instead of base64 data:', videoResult.videoBase64);
|
||||
console.error('Video generation returned a URL instead of base64 data');
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload video to storage
|
||||
console.log('Uploading video to Firebase Storage...');
|
||||
const videoUrl = await uploadVideoToStorage(videoResult.videoBase64, userId, card.name);
|
||||
console.log('Video uploaded to Firebase Storage:', videoUrl);
|
||||
|
||||
// Validate video URL before storing
|
||||
validateVideoUrl(videoUrl);
|
||||
|
||||
// Validate that videoUrl is a proper Firebase Storage URL (production or emulator), not raw data
|
||||
if (!videoUrl.startsWith('http')) {
|
||||
console.error('Invalid video URL returned from storage upload:', videoUrl.substring(0, 100));
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional safety check - ensure we're not storing large data in Firestore
|
||||
if (videoUrl.length > 2000) { // URLs should be much shorter than this
|
||||
console.error('Video URL is suspiciously long, might be raw data:', videoUrl.length);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update card with video info (only storing the URL, not the video data)
|
||||
// Update card with video info
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoUrl, // This should only be a Firebase Storage URL
|
||||
videoGenerationStatus: 'completed',
|
||||
videoUrl,
|
||||
videoGenerationStatus: 'completed' as VideoGenerationStatus,
|
||||
videoPrompt: videoResult.prompt,
|
||||
});
|
||||
|
||||
@@ -109,72 +110,63 @@ const generateVideoForCard = async (userId: string, cardId: string, card: Pokemo
|
||||
} catch (error) {
|
||||
console.error('Error in background video generation:', error);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new card to the user's collection
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardData - The card data including imageDataUrl
|
||||
* @returns Promise resolving to the new card ID
|
||||
* @throws Error if userId is missing or card add fails
|
||||
*/
|
||||
export const addCardToCollection = async (
|
||||
userId: string,
|
||||
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'imageUrl'> & { imageDataUrl: string }
|
||||
): Promise<string> => {
|
||||
if (!userId) {
|
||||
console.error("addCardToCollection: User ID is missing.");
|
||||
throw new Error('User ID is required to add a card.');
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
console.log("DEBUG: Attempting to add card with userId:", userId);
|
||||
console.log("DEBUG: Current auth user:", auth.currentUser?.uid);
|
||||
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
|
||||
console.log("DEBUG: Card game:", cardData.game || 'pokemon'); // Default to pokemon for backward compatibility
|
||||
|
||||
try {
|
||||
// First, upload the image to Firebase Storage
|
||||
console.log("Uploading card image to Firebase Storage...");
|
||||
// Upload the image to Firebase Storage
|
||||
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name);
|
||||
|
||||
const collectionRef = getPokemonCardsCollectionRef(userId);
|
||||
const { imageDataUrl, ...cardDataWithoutImage } = cardData;
|
||||
const docPayload = {
|
||||
...cardDataWithoutImage,
|
||||
game: cardData.game || 'pokemon', // Default to pokemon for backward compatibility
|
||||
imageUrl, // Store the Firebase Storage URL instead of base64 data
|
||||
game: cardData.game || DEFAULT_TCG_GAME,
|
||||
imageUrl,
|
||||
userId,
|
||||
// Don't initialize video generation automatically - make it opt-in
|
||||
createdAt: serverTimestamp(),
|
||||
updatedAt: serverTimestamp(),
|
||||
};
|
||||
|
||||
console.log(
|
||||
"Attempting to add card to Firestore. Path:",
|
||||
collectionRef.path,
|
||||
"Payload keys:",
|
||||
Object.keys(docPayload).join(', '),
|
||||
"Image URL:",
|
||||
imageUrl,
|
||||
"Game:",
|
||||
docPayload.game
|
||||
);
|
||||
|
||||
const docRef = await addDoc(collectionRef, docPayload);
|
||||
console.log("Card successfully added to Firestore with ID:", docRef.id);
|
||||
|
||||
// Video generation is now opt-in - users can trigger it manually from the UI
|
||||
|
||||
// Video generation is opt-in - users can trigger it manually from the UI
|
||||
return docRef.id;
|
||||
} catch (error) {
|
||||
console.error('Error adding card to Firestore (full error object):', error);
|
||||
let detailedMessage = 'Failed to add card to collection.';
|
||||
console.error('Error adding card to Firestore:', error);
|
||||
if (error instanceof Error) {
|
||||
detailedMessage = error.message;
|
||||
const firebaseError = error as any;
|
||||
if (firebaseError.code) {
|
||||
detailedMessage = `Error: ${firebaseError.code} - ${error.message}`;
|
||||
throw new Error(`${firebaseError.code}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(detailedMessage);
|
||||
throw new Error(ERROR_MESSAGES.CARD_ADD_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets all cards for a user, ordered by most recently updated
|
||||
* @param userId - The user ID to fetch cards for
|
||||
* @returns Promise resolving to array of cards
|
||||
* @throws Error if fetch fails
|
||||
*/
|
||||
export const getUserCards = async (userId: string): Promise<PokemonCard[]> => {
|
||||
if (!userId) return [];
|
||||
try {
|
||||
@@ -186,14 +178,21 @@ export const getUserCards = async (userId: string): Promise<PokemonCard[]> => {
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching user cards: ', error);
|
||||
throw new Error('Failed to fetch card collection.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a card by its ID
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to fetch
|
||||
* @returns Promise resolving to the card or null if not found
|
||||
* @throws Error if fetch fails
|
||||
*/
|
||||
export const getCardById = async (userId: string, cardId: string): Promise<PokemonCard | null> => {
|
||||
if (!userId || !cardId) return null;
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
const cardSnap = await getDoc(cardDocRef);
|
||||
if (cardSnap.exists()) {
|
||||
return { id: cardSnap.id, ...cardSnap.data() } as PokemonCard;
|
||||
@@ -201,39 +200,45 @@ export const getCardById = async (userId: string, cardId: string): Promise<Pokem
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching card by ID: ', error);
|
||||
throw new Error('Failed to fetch card details.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a card in the collection
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to update
|
||||
* @param cardData - The partial card data to update
|
||||
* @throws Error if update fails or validation fails
|
||||
*/
|
||||
export const updateCardInCollection = async (
|
||||
userId: string,
|
||||
cardId: string,
|
||||
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'imageUrl'>> & { imageDataUrl?: string }
|
||||
): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
|
||||
let updateData: any = { ...cardData };
|
||||
|
||||
// Safety check: ensure we're not trying to store large video data in Firestore
|
||||
if (updateData.videoUrl && updateData.videoUrl.length > 2000) {
|
||||
console.error('Attempted to store large video data in Firestore. This is not allowed.');
|
||||
throw new Error('Invalid video data: videos must be stored in Firebase Storage, not Firestore');
|
||||
}
|
||||
|
||||
// Validate video URL format if present - allow both production and emulator URLs
|
||||
if (updateData.videoUrl && !updateData.videoUrl.startsWith('http')) {
|
||||
console.error('Invalid video URL format:', updateData.videoUrl.substring(0, 100));
|
||||
throw new Error('Video URL must be a valid HTTP URL from Firebase Storage');
|
||||
// Validate video URL if present
|
||||
if (updateData.videoUrl) {
|
||||
validateVideoUrl(updateData.videoUrl);
|
||||
}
|
||||
|
||||
// If a new image is provided, upload it to storage first
|
||||
if (cardData.imageDataUrl) {
|
||||
console.log("Uploading updated card image to Firebase Storage...");
|
||||
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name || 'updated_card');
|
||||
const imageUrl = await uploadCardImageToStorage(
|
||||
cardData.imageDataUrl,
|
||||
userId,
|
||||
cardData.name || 'updated_card'
|
||||
);
|
||||
updateData.imageUrl = imageUrl;
|
||||
delete updateData.imageDataUrl; // Remove the base64 data from the update
|
||||
delete updateData.imageDataUrl;
|
||||
}
|
||||
|
||||
await updateDoc(cardDocRef, {
|
||||
@@ -242,14 +247,23 @@ export const updateCardInCollection = async (
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating card: ', error);
|
||||
throw new Error('Failed to update card.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_UPDATE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a card from the collection and its associated files from storage
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to delete
|
||||
* @throws Error if delete fails
|
||||
*/
|
||||
export const deleteCardFromCollection = async (userId: string, cardId: string): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
|
||||
// Get the card data first to delete the associated image from storage
|
||||
const cardDoc = await getDoc(cardDocRef);
|
||||
@@ -294,41 +308,52 @@ export const deleteCardFromCollection = async (userId: string, cardId: string):
|
||||
await deleteDoc(cardDocRef);
|
||||
} catch (error) {
|
||||
console.error('Error deleting card: ', error);
|
||||
throw new Error('Failed to delete card.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_DELETE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
// Manual function to generate video for an existing card
|
||||
/**
|
||||
* Manually generates video for an existing card
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to generate video for
|
||||
* @throws Error if card not found or generation fails
|
||||
*/
|
||||
export const generateVideoForExistingCard = async (userId: string, cardId: string): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to generate video.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the card data
|
||||
const card = await getCardById(userId, cardId);
|
||||
if (!card) {
|
||||
throw new Error('Card not found');
|
||||
throw new Error(ERROR_MESSAGES.CARD_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Trigger video generation
|
||||
await generateVideoForCard(userId, cardId, card);
|
||||
} catch (error) {
|
||||
console.error('Error generating video for existing card:', error);
|
||||
throw new Error('Failed to generate video for card.');
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_GENERATION_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Profile Management Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates or updates a user profile in Firestore
|
||||
* @param userId - The user ID
|
||||
* @param profileData - Partial user profile data to create/update
|
||||
* @throws Error if userId is missing or operation fails
|
||||
*/
|
||||
export const createOrUpdateUserProfile = async (userId: string, profileData: Partial<UserProfile>): Promise<void> => {
|
||||
if (!userId) throw new Error('User ID is required to create/update profile.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
|
||||
// Filter out undefined values to prevent Firestore errors
|
||||
const filteredProfileData = filterUndefinedValues(profileData);
|
||||
|
||||
const updateData: any = {
|
||||
@@ -346,18 +371,23 @@ export const createOrUpdateUserProfile = async (userId: string, profileData: Par
|
||||
await setDoc(userRef, updateData, { merge: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating/updating user profile:', error);
|
||||
throw new Error('Failed to create/update user profile.');
|
||||
throw new Error(ERROR_MESSAGES.USER_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a user profile from Firestore
|
||||
* Gets a user profile from Firestore, creates basic profile if doesn't exist
|
||||
* @param userId - The user ID to fetch profile for
|
||||
* @returns Promise resolving to user profile or null
|
||||
* @throws Error if userId is missing or fetch fails
|
||||
*/
|
||||
export const getUserProfile = async (userId: string): Promise<UserProfile | null> => {
|
||||
if (!userId) throw new Error('User ID is required to get profile.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
const userSnap = await getDoc(userRef);
|
||||
|
||||
if (userSnap.exists()) {
|
||||
@@ -365,7 +395,6 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
}
|
||||
|
||||
// Create a basic user profile if it doesn't exist
|
||||
console.log(`User profile not found for ${userId}, creating basic profile...`);
|
||||
const basicProfile: Partial<UserProfile> = {
|
||||
email: '',
|
||||
displayName: 'Anonymous User',
|
||||
@@ -373,7 +402,6 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
|
||||
await createOrUpdateUserProfile(userId, basicProfile);
|
||||
|
||||
// Return the newly created profile
|
||||
const newUserSnap = await getDoc(userRef);
|
||||
if (newUserSnap.exists()) {
|
||||
return newUserSnap.data() as UserProfile;
|
||||
@@ -382,39 +410,49 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user profile:', error);
|
||||
throw new Error('Failed to get user profile.');
|
||||
throw new Error(ERROR_MESSAGES.USER_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates user API keys
|
||||
* @param userId - The user ID
|
||||
* @param apiKeys - The API keys to update
|
||||
* @throws Error if userId is missing or update fails
|
||||
*/
|
||||
export const updateUserApiKeys = async (userId: string, apiKeys: UserApiKeys): Promise<void> => {
|
||||
if (!userId) throw new Error('User ID is required to update API keys.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
await updateDoc(userRef, {
|
||||
apiKeys: apiKeys,
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user API keys:', error);
|
||||
throw new Error('Failed to update API keys.');
|
||||
throw new Error(ERROR_MESSAGES.API_KEYS_UPDATE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets user API keys
|
||||
* Gets user API keys from their profile
|
||||
* @param userId - The user ID
|
||||
* @returns Promise resolving to API keys or null
|
||||
* @throws Error if userId is missing or fetch fails
|
||||
*/
|
||||
export const getUserApiKeys = async (userId: string): Promise<UserApiKeys | null> => {
|
||||
if (!userId) throw new Error('User ID is required to get API keys.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await getUserProfile(userId);
|
||||
return profile?.apiKeys || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user API keys:', error);
|
||||
throw new Error('Failed to get API keys.');
|
||||
throw new Error(ERROR_MESSAGES.API_KEYS_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Centralized logging utility for the application
|
||||
* Provides structured logging with different levels and context
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger class for structured application logging
|
||||
*/
|
||||
class Logger {
|
||||
private isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Formats log message with context
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const contextStr = context ? ` | ${JSON.stringify(context)}` : '';
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs debug messages (only in development)
|
||||
*/
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.debug(this.formatMessage('debug', message, context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs informational messages
|
||||
*/
|
||||
info(message: string, context?: LogContext): void {
|
||||
console.log(this.formatMessage('info', message, context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs warning messages
|
||||
*/
|
||||
warn(message: string, context?: LogContext): void {
|
||||
console.warn(this.formatMessage('warn', message, context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs error messages
|
||||
*/
|
||||
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
||||
const errorContext = error instanceof Error
|
||||
? { ...context, error: error.message, stack: error.stack }
|
||||
: context;
|
||||
|
||||
console.error(this.formatMessage('error', message, errorContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scoped logger with default context
|
||||
*/
|
||||
scope(defaultContext: LogContext): ScopedLogger {
|
||||
return new ScopedLogger(this, defaultContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped logger that includes default context in all log calls
|
||||
*/
|
||||
class ScopedLogger {
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
private defaultContext: LogContext
|
||||
) {}
|
||||
|
||||
private mergeContext(context?: LogContext): LogContext {
|
||||
return { ...this.defaultContext, ...context };
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.logger.debug(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.logger.info(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.logger.warn(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
||||
this.logger.error(message, error, this.mergeContext(context));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton logger instance
|
||||
export const logger = new Logger();
|
||||
|
||||
// Export convenience functions
|
||||
export const log = {
|
||||
debug: (message: string, context?: LogContext) => logger.debug(message, context),
|
||||
info: (message: string, context?: LogContext) => logger.info(message, context),
|
||||
warn: (message: string, context?: LogContext) => logger.warn(message, context),
|
||||
error: (message: string, error?: Error | unknown, context?: LogContext) =>
|
||||
logger.error(message, error, context),
|
||||
};
|
||||
+63
-2
@@ -1,14 +1,25 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
/**
|
||||
* Combines class names using clsx and tailwind-merge
|
||||
* Merges Tailwind classes intelligently, removing conflicts
|
||||
* @param inputs - Class values to combine
|
||||
* @returns Merged class name string
|
||||
* @example
|
||||
* cn('px-2 py-1', 'px-4') // returns 'py-1 px-4'
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes undefined values from an object to prevent Firestore errors
|
||||
* @param obj The object to filter
|
||||
* Firestore does not allow undefined values in documents
|
||||
* @param obj - The object to filter
|
||||
* @returns A new object with undefined values removed
|
||||
* @example
|
||||
* filterUndefinedValues({ a: 1, b: undefined, c: 3 }) // returns { a: 1, c: 3 }
|
||||
*/
|
||||
export function filterUndefinedValues<T extends Record<string, any>>(obj: T): Partial<T> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
@@ -18,3 +29,53 @@ export function filterUndefinedValues<T extends Record<string, any>>(obj: T): Pa
|
||||
return acc;
|
||||
}, {} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes null and undefined values from an object
|
||||
* @param obj - The object to filter
|
||||
* @returns A new object with null and undefined values removed
|
||||
* @example
|
||||
* filterNullishValues({ a: 1, b: null, c: undefined, d: 0 }) // returns { a: 1, d: 0 }
|
||||
*/
|
||||
export function filterNullishValues<T extends Record<string, any>>(obj: T): Partial<T> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value != null) { // checks for both null and undefined
|
||||
acc[key as keyof T] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is not null or undefined
|
||||
* @param value - The value to check
|
||||
* @returns true if value is not null or undefined
|
||||
*/
|
||||
export function isNotNullish<T>(value: T | null | undefined): value is T {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
* @param ms - Milliseconds to delay
|
||||
* @returns Promise that resolves after the delay
|
||||
* @example
|
||||
* await delay(1000) // waits 1 second
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses JSON with a fallback value
|
||||
* @param jsonString - The JSON string to parse
|
||||
* @param fallback - Value to return if parsing fails
|
||||
* @returns Parsed object or fallback value
|
||||
*/
|
||||
export function safeJsonParse<T>(jsonString: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Validation utilities for common data validation tasks
|
||||
*/
|
||||
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Validates that a string is not empty
|
||||
* @param value - String to validate
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if string is empty or whitespace only
|
||||
*/
|
||||
export function validateNotEmpty(value: string, fieldName: string): void {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} cannot be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is defined
|
||||
* @param value - Value to check
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if value is null or undefined
|
||||
*/
|
||||
export function validateDefined<T>(value: T | null | undefined, fieldName: string): asserts value is T {
|
||||
if (value == null) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address format
|
||||
* @param email - Email to validate
|
||||
* @returns true if valid email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL format
|
||||
* @param url - URL to validate
|
||||
* @returns true if valid URL format
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a number is within a range
|
||||
* @param value - Number to validate
|
||||
* @param min - Minimum value (inclusive)
|
||||
* @param max - Maximum value (inclusive)
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if value is out of range
|
||||
*/
|
||||
export function validateNumberRange(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
fieldName: string
|
||||
): void {
|
||||
if (value < min || value > max) {
|
||||
throw new Error(`${fieldName} must be between ${min} and ${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an object has all required fields
|
||||
* @param obj - Object to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @returns Array of missing field names
|
||||
*/
|
||||
export function getMissingFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
requiredFields: readonly (keyof T)[]
|
||||
): string[] {
|
||||
return requiredFields.filter(field => !obj[field]).map(String);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an object has all required fields, throws if not
|
||||
* @param obj - Object to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @throws Error if any required fields are missing
|
||||
*/
|
||||
export function validateRequiredFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
requiredFields: readonly (keyof T)[]
|
||||
): void {
|
||||
const missing = getMissingFields(obj, requiredFields);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`${ERROR_MESSAGES.MISSING_REQUIRED_FIELDS}: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Firebase Storage URL
|
||||
* @param url - URL to validate
|
||||
* @returns true if valid Firebase Storage URL
|
||||
*/
|
||||
export function isFirebaseStorageUrl(url: string): boolean {
|
||||
return url.includes('firebasestorage.googleapis.com') ||
|
||||
url.includes('localhost:9199') ||
|
||||
url.includes('127.0.0.1:9199');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is an HTTP/HTTPS URL
|
||||
* @param url - URL to check
|
||||
* @returns true if URL starts with http:// or https://
|
||||
*/
|
||||
export function isHttpUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a data URI
|
||||
* @param url - String to check
|
||||
* @returns true if string is a data URI
|
||||
*/
|
||||
export function isDataUri(url: string): boolean {
|
||||
return url.startsWith('data:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a string length is within bounds
|
||||
* @param value - String to validate
|
||||
* @param minLength - Minimum length
|
||||
* @param maxLength - Maximum length
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if length is out of bounds
|
||||
*/
|
||||
export function validateStringLength(
|
||||
value: string,
|
||||
minLength: number,
|
||||
maxLength: number,
|
||||
fieldName: string
|
||||
): void {
|
||||
if (value.length < minLength || value.length > maxLength) {
|
||||
throw new Error(
|
||||
`${fieldName} must be between ${minLength} and ${maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user