diff --git a/pages/api/collections/[id].js b/pages/api/collections/[id].js deleted file mode 100644 index 194d71e..0000000 --- a/pages/api/collections/[id].js +++ /dev/null @@ -1,133 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { withCollectionPermission, getUserFromRequest, logCollectionActivity } from '../../../lib/permission-middleware'; - -async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - const { id } = req.query; - - if (req.method === 'GET') { - // GET requests use the permission from middleware - const collection = req.permission.collection; - const userRole = req.permission.role; - - try { - // Get detailed collection info with creator - const collectionResult = await sql` - SELECT - c.*, - u.email as creator_email - FROM collections c - LEFT JOIN users u ON c.user_id = u.id - WHERE c.id = ${id} - `; - - const collectionDetails = collectionResult.rows[0]; - - // Get cards in the collection - const cardsResult = await sql` - SELECT - cc.*, - cards.name, - cards.set_name, - cards.rarity, - cards.card_type, - cards.game, - cards.image_url, - cards.market_price - FROM collection_cards cc - JOIN cards ON cc.card_id = cards.id - WHERE cc.collection_id = ${id} - ORDER BY cc.created_at ASC - `; - - const cards = cardsResult.rows; - - // Calculate collection stats - const totalCards = cards.reduce((sum, card) => sum + card.quantity, 0); - const totalValue = cards.reduce((sum, card) => sum + (card.market_price * card.quantity), 0); - - res.status(200).json({ - collection: { - ...collectionDetails, - totalCards, - totalValue, - userRole - }, - cards - }); - - } catch (error) { - console.error('Error fetching collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'PUT') { - // Update collection - requires editor permissions - try { - const { name, description, isPublic } = req.body; - - const result = await sql` - UPDATE collections - SET - name = ${name}, - description = ${description}, - is_public = ${isPublic}, - updated_at = NOW() - WHERE id = ${id} - RETURNING * - `; - - // Log activity - await logCollectionActivity(id, req.user.userId, 'collection_updated', { - name, - description, - isPublic - }); - - res.status(200).json(result.rows[0]); - - } catch (error) { - console.error('Error updating collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'DELETE') { - // Delete collection - requires owner permissions - try { - // Log activity before deletion - await logCollectionActivity(id, req.user.userId, 'collection_deleted', {}); - - // Delete collection (cascade will handle related records) - const result = await sql`DELETE FROM collections WHERE id = ${id} RETURNING *`; - - res.status(200).json({ message: 'Collection deleted successfully' }); - - } catch (error) { - console.error('Error deleting collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else { - res.status(405).json({ error: 'Method not allowed' }); - } -} - -// Apply permission middleware based on method -export default async function(req, res) { - let requiredPermission = 'viewer'; // Default for GET - - if (req.method === 'PUT') { - requiredPermission = 'editor'; - } else if (req.method === 'DELETE') { - requiredPermission = 'owner'; - } - - return withCollectionPermission(requiredPermission)(handler)(req, res); -}; \ No newline at end of file diff --git a/pages/api/collections/[id]/activity.js b/pages/api/collections/[id]/activity.js deleted file mode 100644 index 0554493..0000000 --- a/pages/api/collections/[id]/activity.js +++ /dev/null @@ -1,44 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { withCollectionPermission } from '../../../../lib/permission-middleware'; - -async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - if (req.method !== 'GET') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - const { id } = req.query; // collection id - - try { - // Get activity log for the collection - const result = await sql` - SELECT - ca.*, - u.email as user_email - FROM collection_activity ca - LEFT JOIN users u ON ca.user_id = u.id - WHERE ca.collection_id = ${id} - ORDER BY ca.created_at DESC - LIMIT 50 - `; - - res.status(200).json(result.rows); - - } catch (error) { - console.error('Error fetching collection activity:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -// Apply permission middleware - requires viewer access to see activity -export default withCollectionPermission('viewer')(handler); diff --git a/pages/api/collections/[id]/cards.js b/pages/api/collections/[id]/cards.js deleted file mode 100644 index 8e47cdb..0000000 --- a/pages/api/collections/[id]/cards.js +++ /dev/null @@ -1,161 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { withCollectionPermission, logCollectionActivity } from '../../../../lib/permission-middleware'; - -async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - const { id } = req.query; // collection id - - if (req.method === 'POST') { - // Add card to collection - try { - const { cardId, quantity = 1 } = req.body; - - if (!cardId) { - return res.status(400).json({ error: 'Card ID is required' }); - } - - // Check if card already exists in collection - const existingResult = await sql` - SELECT * FROM collection_cards - WHERE collection_id = ${id} AND card_id = ${cardId} - `; - - if (existingResult.rows.length > 0) { - // Update quantity if card already exists - const result = await sql` - UPDATE collection_cards - SET quantity = quantity + ${quantity} - WHERE collection_id = ${id} AND card_id = ${cardId} - RETURNING * - `; - - await logCollectionActivity(id, req.user.userId, 'card_quantity_updated', { - cardId, - oldQuantity: existingCard.quantity, - newQuantity: quantity - }); - - res.status(200).json({ - message: 'Card quantity updated in collection', - card: result.rows[0] - }); - } else { - // Add new card to collection - const result = await sql` - INSERT INTO collection_cards (collection_id, card_id, quantity) - VALUES (${id}, ${cardId}, ${quantity}) - RETURNING * - `; - - await logCollectionActivity(id, req.user.userId, 'card_added', { - cardId, - quantity - }); - - res.status(201).json({ - message: 'Card added to collection', - card: result.rows[0] - }); - } - - } catch (error) { - console.error('Error adding card to collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'PUT') { - // Update card quantity in collection - try { - const { cardId, quantity } = req.body; - - if (!cardId || quantity === undefined) { - return res.status(400).json({ error: 'Card ID and quantity are required' }); - } - - if (quantity <= 0) { - // Remove card if quantity is 0 or negative - await sql` - DELETE FROM collection_cards - WHERE collection_id = ${id} AND card_id = ${cardId} - `; - - await logCollectionActivity(id, req.user.userId, 'card_removed', { - cardId, - reason: 'quantity_zero' - }); - - res.status(200).json({ message: 'Card removed from collection' }); - } else { - // Update quantity - const result = await sql` - UPDATE collection_cards - SET quantity = ${quantity} - WHERE collection_id = ${id} AND card_id = ${cardId} - RETURNING * - `; - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Card not found in collection' }); - } - - await logCollectionActivity(id, req.user.userId, 'card_quantity_updated', { - cardId, - newQuantity: quantity - }); - - res.status(200).json({ - message: 'Card quantity updated', - card: result.rows[0] - }); - } - - } catch (error) { - console.error('Error updating card in collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'DELETE') { - // Remove card from collection - try { - const { cardId } = req.body; - - if (!cardId) { - return res.status(400).json({ error: 'Card ID is required' }); - } - - const result = await sql` - DELETE FROM collection_cards - WHERE collection_id = ${id} AND card_id = ${cardId} - RETURNING * - `; - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Card not found in collection' }); - } - - await logCollectionActivity(id, req.user.userId, 'card_removed', { - cardId, - reason: 'explicit_delete' - }); - - res.status(200).json({ message: 'Card removed from collection' }); - - } catch (error) { - console.error('Error removing card from collection:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else { - res.status(405).json({ error: 'Method not allowed' }); - } -} - -// Apply permission middleware - all card operations require editor permissions -export default withCollectionPermission('editor')(handler); \ No newline at end of file diff --git a/pages/api/collections/[id]/permissions.js b/pages/api/collections/[id]/permissions.js deleted file mode 100644 index f3dc2c8..0000000 --- a/pages/api/collections/[id]/permissions.js +++ /dev/null @@ -1,262 +0,0 @@ -import { sql } from '@vercel/postgres'; -import { Resend } from 'resend'; - -const resend = new Resend(process.env.RESEND_API_KEY); - -export default async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - const { id } = req.query; // collection id - - if (req.method === 'GET') { - // Get all permissions for a collection - try { - const result = await sql` - SELECT - cp.*, - u.email, - u.id as user_id, - u.is_pending - FROM collection_permissions cp - JOIN users u ON cp.user_id = u.id - WHERE cp.collection_id = ${id} - ORDER BY cp.role, cp.created_at - `; - - res.status(200).json(result.rows); - - } catch (error) { - console.error('Error fetching permissions:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'POST') { - // Invite user to collection - try { - const { email, role = 'viewer', message = '' } = req.body; - - if (!email || !['owner', 'editor', 'viewer'].includes(role)) { - return res.status(400).json({ error: 'Valid email and role are required' }); - } - - // Check if user exists - const userResult = await sql` - SELECT id, email FROM users WHERE email = ${email} - `; - - let userId; - if (userResult.rows.length === 0) { - // Create pending user record - const newUserResult = await sql` - INSERT INTO users (email, password, role, is_pending) - VALUES (${email}, '', 'user', true) - RETURNING id - `; - userId = newUserResult.rows[0].id; - } else { - userId = userResult.rows[0].id; - } - - // Check if permission already exists - const existingPermission = await sql` - SELECT * FROM collection_permissions - WHERE collection_id = ${id} AND user_id = ${userId} - `; - - if (existingPermission.rows.length > 0) { - return res.status(409).json({ error: 'User already has access to this collection' }); - } - - // Get collection details for email - const collectionResult = await sql` - SELECT c.name, u.email as owner_email - FROM collections c - JOIN users u ON c.user_id = u.id - WHERE c.id = ${id} - `; - - if (collectionResult.rows.length === 0) { - return res.status(404).json({ error: 'Collection not found' }); - } - - const collection = collectionResult.rows[0]; - - // Create permission record - const permissionResult = await sql` - INSERT INTO collection_permissions (collection_id, user_id, role, status, invited_by) - VALUES (${id}, ${userId}, ${role}, 'pending', 1) - RETURNING * - `; - - // Generate invitation token - const inviteToken = Buffer.from(`${id}:${userId}:${Date.now()}`).toString('base64'); - - await sql` - UPDATE collection_permissions - SET invite_token = ${inviteToken} - WHERE id = ${permissionResult.rows[0].id} - `; - - // Send invitation email - const acceptUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/invite/accept?token=${inviteToken}`; - const declineUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/invite/decline?token=${inviteToken}`; - - try { - await resend.emails.send({ - from: 'TCG Vault ', - to: email, - subject: `You've been invited to collaborate on "${collection.name}"`, - html: ` -
-
-

🃏 TCG Vault

-

Collection Collaboration Invite

-
- -
-

You've been invited to collaborate!

- -

- ${collection.owner_email} has invited you to collaborate on the collection - "${collection.name}" with ${role} permissions. -

- - ${message ? ` -
-

Personal message:

-

"${message}"

-
- ` : ''} - -
-

What you can do as a ${role === 'editor' ? 'collaborator' : role}:

-
    - ${role === 'editor' ? ` -
  • Add and remove cards from the collection
  • -
  • Edit collection details and description
  • -
  • View and search all collection content
  • -
  • Help build and organize the collection
  • - ` : ` -
  • View all collection content
  • -
  • Browse and search cards
  • -
  • See collection statistics and details
  • - `} -
-
- -
- - Accept Invitation - - - Decline - -
- -
-

This invitation will expire in 7 days. If you have any questions, please contact ${collection.owner_email}.

-

If you didn't expect this invitation, you can safely ignore this email.

-
-
-
- ` - }); - } catch (emailError) { - console.error('Email sending failed:', emailError); - // Continue anyway - the invitation is still created - } - - // Log activity - await sql` - INSERT INTO collection_activity (collection_id, user_id, action, details) - VALUES (${id}, 1, 'user_invited', ${JSON.stringify({ email, role, inviteToken })}) - `; - - res.status(201).json({ - message: 'Invitation sent successfully', - permission: { - ...permissionResult.rows[0], - email, - invite_token: inviteToken - } - }); - - } catch (error) { - console.error('Error inviting user:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'PUT') { - // Update user permission - try { - const { userId, role, status } = req.body; - - if (!userId || !['owner', 'editor', 'viewer'].includes(role)) { - return res.status(400).json({ error: 'Valid user ID and role are required' }); - } - - const result = await sql` - UPDATE collection_permissions - SET role = ${role}, status = ${status || 'active'}, updated_at = NOW() - WHERE collection_id = ${id} AND user_id = ${userId} - RETURNING * - `; - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Permission not found' }); - } - - // Log activity - await sql` - INSERT INTO collection_activity (collection_id, user_id, action, details) - VALUES (${id}, 1, 'permission_updated', ${JSON.stringify({ userId, role, status })}) - `; - - res.status(200).json(result.rows[0]); - - } catch (error) { - console.error('Error updating permission:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else if (req.method === 'DELETE') { - // Remove user permission - try { - const { userId } = req.body; - - if (!userId) { - return res.status(400).json({ error: 'User ID is required' }); - } - - const result = await sql` - DELETE FROM collection_permissions - WHERE collection_id = ${id} AND user_id = ${userId} - RETURNING * - `; - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Permission not found' }); - } - - // Log activity - await sql` - INSERT INTO collection_activity (collection_id, user_id, action, details) - VALUES (${id}, 1, 'user_removed', ${JSON.stringify({ userId })}) - `; - - res.status(200).json({ message: 'Permission removed successfully' }); - - } catch (error) { - console.error('Error removing permission:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } else { - res.status(405).json({ error: 'Method not allowed' }); - } -} diff --git a/pages/api/collections/[identifier]/activity.js b/pages/api/collections/[identifier]/activity.js new file mode 100644 index 0000000..98e8fe2 --- /dev/null +++ b/pages/api/collections/[identifier]/activity.js @@ -0,0 +1,90 @@ +import { sql } from '@vercel/postgres'; +import { getUserFromRequest } from '../../../../lib/permission-middleware'; +import { isValidSlug } from '../../../../lib/slug-utils'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Get authenticated user + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { identifier } = req.query; + + if (!identifier) { + return res.status(400).json({ error: 'Collection identifier is required' }); + } + + // Determine if identifier is a slug or numeric ID + const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier)); + + // Verify user has access to this collection + let collectionResult; + if (isSlug) { + collectionResult = await sql` + SELECT c.*, cp.role as user_role + FROM collections c + LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' + WHERE c.slug = ${identifier} + AND ( + c.user_id = ${user.userId} OR + cp.id IS NOT NULL OR + c.is_public = true + ) + `; + } else { + const numericId = parseInt(identifier); + collectionResult = await sql` + SELECT c.*, cp.role as user_role + FROM collections c + LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' + WHERE c.id = ${numericId} + AND ( + c.user_id = ${user.userId} OR + cp.id IS NOT NULL OR + c.is_public = true + ) + `; + } + + if (collectionResult.length === 0) { + return res.status(404).json({ error: 'Collection not found or access denied' }); + } + + const collection = collectionResult[0]; + + // Get collection activity (this would typically come from an activity log table) + // For now, we'll return a simple mock response + const activities = [ + { + id: 1, + type: 'card_added', + description: 'Added Lightning Bolt to collection', + timestamp: new Date().toISOString(), + user: user.email + } + ]; + + res.status(200).json({ activities }); + + } catch (error) { + console.error('Collection activity API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js new file mode 100644 index 0000000..583d161 --- /dev/null +++ b/pages/api/collections/[identifier]/cards.js @@ -0,0 +1,258 @@ +import { sql } from '@vercel/postgres'; +import { getUserFromRequest } from '../../../../lib/permission-middleware'; +import { isValidSlug } from '../../../../lib/slug-utils'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + try { + // Get authenticated user + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { identifier } = req.query; + + if (!identifier) { + return res.status(400).json({ error: 'Collection identifier is required' }); + } + + // Determine if identifier is a slug or numeric ID + const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier)); + + // Get collection ID from identifier + let collectionResult; + if (isSlug) { + collectionResult = await sql` + SELECT c.*, cp.role as user_role + FROM collections c + LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' + WHERE c.slug = ${identifier} + AND ( + c.user_id = ${user.userId} OR + cp.id IS NOT NULL OR + c.is_public = true + ) + `; + } else { + const numericId = parseInt(identifier); + collectionResult = await sql` + SELECT c.*, cp.role as user_role + FROM collections c + LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' + WHERE c.id = ${numericId} + AND ( + c.user_id = ${user.userId} OR + cp.id IS NOT NULL OR + c.is_public = true + ) + `; + } + + if (collectionResult.length === 0) { + return res.status(404).json({ error: 'Collection not found or access denied' }); + } + + const collection = collectionResult[0]; + + if (req.method === 'GET') { + // Get all cards in the collection + const cardsResult = await sql` + SELECT + cards.*, + cc.quantity, + cc.created_at as added_at + FROM collection_cards cc + JOIN cards ON cc.card_id = cards.id + WHERE cc.collection_id = ${collection.id} + ORDER BY cc.created_at DESC + `; + + const cards = cardsResult.map(card => ({ + id: card.id, + name: card.name, + set_name: card.set_name, + set_code: card.set_code, + card_number: card.card_number, + rarity: card.rarity, + game: card.game, + mana_cost: card.mana_cost, + cmc: card.cmc, + card_type: card.card_type, + colors: card.colors, + oracle_text: card.oracle_text, + power: card.power, + toughness: card.toughness, + image_url: card.image_url, + stock_image_url: card.stock_image_url, + current_price: parseFloat(card.current_price) || 0, + market_price: parseFloat(card.market_price) || 0, + quantity: parseInt(card.quantity) || 1, + added_at: card.added_at + })); + + res.status(200).json({ cards }); + + } else if (req.method === 'POST') { + // Add card to collection - only allow if user has write access + const canWrite = collection.user_id === user.userId || + ['owner', 'editor'].includes(collection.user_role); + + if (!canWrite) { + return res.status(403).json({ error: 'You do not have permission to add cards to this collection' }); + } + + const { cardId, quantity = 1 } = req.body; + + if (!cardId) { + return res.status(400).json({ error: 'Card ID is required' }); + } + + // Check if card exists + const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`; + if (cardCheck.length === 0) { + return res.status(404).json({ error: 'Card not found' }); + } + + // Check if card already exists in collection + const existingResult = await sql` + SELECT * FROM collection_cards + WHERE collection_id = ${collection.id} AND card_id = ${cardId} + `; + + if (existingResult.length > 0) { + // Update quantity if card already exists + const result = await sql` + UPDATE collection_cards + SET quantity = quantity + ${quantity}, updated_at = CURRENT_TIMESTAMP + WHERE collection_id = ${collection.id} AND card_id = ${cardId} + RETURNING * + `; + + res.status(200).json({ + message: 'Card quantity updated in collection', + card: result[0] + }); + } else { + // Add new card to collection + const result = await sql` + INSERT INTO collection_cards (collection_id, card_id, quantity) + VALUES (${collection.id}, ${cardId}, ${quantity}) + RETURNING * + `; + + res.status(201).json({ + message: 'Card added to collection', + card: result[0] + }); + } + + // Update collection's updated_at timestamp + await sql` + UPDATE collections + SET updated_at = CURRENT_TIMESTAMP + WHERE id = ${collection.id} + `; + + } else if (req.method === 'PUT') { + // Update card quantity in collection + const canWrite = collection.user_id === user.userId || + ['owner', 'editor'].includes(collection.user_role); + + if (!canWrite) { + return res.status(403).json({ error: 'You do not have permission to modify this collection' }); + } + + const { cardId, quantity } = req.body; + + if (!cardId || quantity === undefined) { + return res.status(400).json({ error: 'Card ID and quantity are required' }); + } + + if (quantity <= 0) { + // Remove card from collection if quantity is 0 or negative + await sql` + DELETE FROM collection_cards + WHERE collection_id = ${collection.id} AND card_id = ${cardId} + `; + + res.status(200).json({ message: 'Card removed from collection' }); + } else { + // Update quantity + const result = await sql` + UPDATE collection_cards + SET quantity = ${quantity}, updated_at = CURRENT_TIMESTAMP + WHERE collection_id = ${collection.id} AND card_id = ${cardId} + RETURNING * + `; + + if (result.length === 0) { + return res.status(404).json({ error: 'Card not found in collection' }); + } + + res.status(200).json({ + message: 'Card quantity updated', + card: result[0] + }); + } + + // Update collection's updated_at timestamp + await sql` + UPDATE collections + SET updated_at = CURRENT_TIMESTAMP + WHERE id = ${collection.id} + `; + + } else if (req.method === 'DELETE') { + // Remove card from collection + const canWrite = collection.user_id === user.userId || + ['owner', 'editor'].includes(collection.user_role); + + if (!canWrite) { + return res.status(403).json({ error: 'You do not have permission to modify this collection' }); + } + + const { cardId } = req.body; + + if (!cardId) { + return res.status(400).json({ error: 'Card ID is required' }); + } + + const result = await sql` + DELETE FROM collection_cards + WHERE collection_id = ${collection.id} AND card_id = ${cardId} + RETURNING * + `; + + if (result.length === 0) { + return res.status(404).json({ error: 'Card not found in collection' }); + } + + // Update collection's updated_at timestamp + await sql` + UPDATE collections + SET updated_at = CURRENT_TIMESTAMP + WHERE id = ${collection.id} + `; + + res.status(200).json({ message: 'Card removed from collection' }); + + } else { + res.status(405).json({ error: 'Method not allowed' }); + } + + } catch (error) { + console.error('Collection cards API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/api/collections/[identifier]/permissions.js b/pages/api/collections/[identifier]/permissions.js new file mode 100644 index 0000000..5bfc233 --- /dev/null +++ b/pages/api/collections/[identifier]/permissions.js @@ -0,0 +1,210 @@ +import { sql } from '@vercel/postgres'; +import { getUserFromRequest } from '../../../../lib/permission-middleware'; +import { isValidSlug } from '../../../../lib/slug-utils'; + +export default async function handler(req, res) { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + try { + // Get authenticated user + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { identifier } = req.query; + + if (!identifier) { + return res.status(400).json({ error: 'Collection identifier is required' }); + } + + // Determine if identifier is a slug or numeric ID + const isSlug = isValidSlug(identifier) || isNaN(parseInt(identifier)); + + // Get collection and verify ownership + let collectionResult; + if (isSlug) { + collectionResult = await sql` + SELECT * FROM collections + WHERE slug = ${identifier} AND user_id = ${user.userId} + `; + } else { + const numericId = parseInt(identifier); + collectionResult = await sql` + SELECT * FROM collections + WHERE id = ${numericId} AND user_id = ${user.userId} + `; + } + + if (collectionResult.length === 0) { + return res.status(404).json({ error: 'Collection not found or you do not have permission to manage permissions' }); + } + + const collection = collectionResult[0]; + + if (req.method === 'GET') { + // Get all permissions for this collection + const permissionsResult = await sql` + SELECT + cp.*, + u.email, + u.first_name, + u.last_name + FROM collection_permissions cp + JOIN users u ON cp.user_id = u.id + WHERE cp.collection_id = ${collection.id} + ORDER BY cp.created_at DESC + `; + + const permissions = permissionsResult.map(perm => ({ + id: perm.id, + userId: perm.user_id, + email: perm.email, + firstName: perm.first_name, + lastName: perm.last_name, + role: perm.role, + status: perm.status, + createdAt: perm.created_at, + updatedAt: perm.updated_at + })); + + res.status(200).json({ permissions }); + + } else if (req.method === 'POST') { + // Add new permission + const { email, role = 'viewer' } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + if (!['viewer', 'editor', 'owner'].includes(role)) { + return res.status(400).json({ error: 'Invalid role. Must be viewer, editor, or owner' }); + } + + // Find user by email + const userResult = await sql` + SELECT id FROM users WHERE email = ${email} + `; + + if (userResult.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const targetUserId = userResult[0].id; + + // Check if permission already exists + const existingResult = await sql` + SELECT id FROM collection_permissions + WHERE collection_id = ${collection.id} AND user_id = ${targetUserId} + `; + + if (existingResult.length > 0) { + return res.status(400).json({ error: 'User already has permissions for this collection' }); + } + + // Create new permission + const result = await sql` + INSERT INTO collection_permissions (collection_id, user_id, role, status) + VALUES (${collection.id}, ${targetUserId}, ${role}, 'active') + RETURNING * + `; + + res.status(201).json({ + message: 'Permission added successfully', + permission: result[0] + }); + + } else if (req.method === 'PUT') { + // Update existing permission + const { permissionId, role, status } = req.body; + + if (!permissionId) { + return res.status(400).json({ error: 'Permission ID is required' }); + } + + const updateFields = []; + const updateValues = []; + let paramIndex = 1; + + if (role !== undefined) { + if (!['viewer', 'editor', 'owner'].includes(role)) { + return res.status(400).json({ error: 'Invalid role' }); + } + updateFields.push(`role = $${paramIndex}`); + updateValues.push(role); + paramIndex++; + } + + if (status !== undefined) { + if (!['active', 'pending', 'revoked'].includes(status)) { + return res.status(400).json({ error: 'Invalid status' }); + } + updateFields.push(`status = $${paramIndex}`); + updateValues.push(status); + paramIndex++; + } + + if (updateFields.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + updateFields.push('updated_at = CURRENT_TIMESTAMP'); + updateValues.push(permissionId, collection.id); + + const updateQuery = ` + UPDATE collection_permissions + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} AND collection_id = $${paramIndex + 1} + RETURNING * + `; + + const result = await sql.query(updateQuery, updateValues); + + if (result.length === 0) { + return res.status(404).json({ error: 'Permission not found' }); + } + + res.status(200).json({ + message: 'Permission updated successfully', + permission: result[0] + }); + + } else if (req.method === 'DELETE') { + // Remove permission + const { permissionId } = req.body; + + if (!permissionId) { + return res.status(400).json({ error: 'Permission ID is required' }); + } + + const result = await sql` + DELETE FROM collection_permissions + WHERE id = ${permissionId} AND collection_id = ${collection.id} + RETURNING * + `; + + if (result.length === 0) { + return res.status(404).json({ error: 'Permission not found' }); + } + + res.status(200).json({ message: 'Permission removed successfully' }); + + } else { + res.status(405).json({ error: 'Method not allowed' }); + } + + } catch (error) { + console.error('Collection permissions API error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/pages/api/collections/[id]/thumbnails.js b/pages/api/collections/[identifier]/thumbnails.js similarity index 94% rename from pages/api/collections/[id]/thumbnails.js rename to pages/api/collections/[identifier]/thumbnails.js index 04f1c86..5aa2fe3 100644 --- a/pages/api/collections/[id]/thumbnails.js +++ b/pages/api/collections/[identifier]/thumbnails.js @@ -25,7 +25,7 @@ export default async function handler(req, res) { return res.status(401).json({ error: 'Authentication required' }); } - const { id: identifier } = req.query; + const { identifier } = req.query; if (!identifier) { return res.status(400).json({ error: 'Collection identifier is required' }); @@ -63,11 +63,11 @@ export default async function handler(req, res) { `; } - if (collectionResult.rows.length === 0) { + if (collectionResult.length === 0) { return res.status(404).json({ error: 'Collection not found or access denied' }); } - const collection = collectionResult.rows[0]; + const collection = collectionResult[0]; // Get the top 5 rarest cards from the collection const thumbnailsResult = await sql` @@ -102,7 +102,7 @@ export default async function handler(req, res) { LIMIT 5 `; - const thumbnails = thumbnailsResult.rows.map(card => ({ + const thumbnails = thumbnailsResult.map(card => ({ id: card.id, name: card.name, rarity: card.rarity, diff --git a/pages/collection/[id].js b/pages/collection/[identifier].js similarity index 68% rename from pages/collection/[id].js rename to pages/collection/[identifier].js index f5b8638..a8b8850 100644 --- a/pages/collection/[id].js +++ b/pages/collection/[identifier].js @@ -8,7 +8,7 @@ import Layout from '../../components/Layout'; export default function CollectionView() { const router = useRouter(); - const { id } = router.query; + const { identifier } = router.query; // Get user from auth context - for now using admin user const user = { @@ -21,9 +21,20 @@ export default function CollectionView() { const [loading, setLoading] = useState(true); const [isFavorited, setIsFavorited] = useState(false); const [showShareModal, setShowShareModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const [selectedTCG, setSelectedTCG] = useState('MTG'); + // Edit form state + const [editForm, setEditForm] = useState({ + name: '', + description: '', + isPublic: false, + image: '', + tags: [] + }); + // Filter states const [searchQuery, setSearchQuery] = useState(''); const [selectedRarity, setSelectedRarity] = useState('All Rarities'); @@ -37,18 +48,39 @@ export default function CollectionView() { const [showUploadModal, setShowUploadModal] = useState(false); useEffect(() => { - if (id) { + if (identifier) { fetchCollectionData(); } - }, [id]); + }, [identifier]); const fetchCollectionData = async () => { try { - const response = await fetch(`/api/collections/${id}`); + const response = await fetch(`/api/collections/${identifier}`); if (response.ok) { const data = await response.json(); - setCollection(data.collection); - setCards(data.cards || []); + + // Check if we accessed via numeric ID and need to redirect to slug + if (data.slug && identifier !== data.slug && !isNaN(parseInt(identifier))) { + // Redirect to slug URL + router.replace(`/collection/${data.slug}`, undefined, { shallow: false }); + return; + } + + setCollection(data); + setEditForm({ + name: data.name || '', + description: data.description || '', + isPublic: data.isPublic || false, + image: data.image || '', + tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : []) + }); + + // Fetch collection cards + const cardsResponse = await fetch(`/api/collections/${identifier}/cards`); + if (cardsResponse.ok) { + const cardsData = await cardsResponse.json(); + setCards(cardsData.cards || []); + } // Check if collection is favorited checkIfFavorited(); @@ -75,15 +107,73 @@ export default function CollectionView() { }); if (response.ok) { const data = await response.json(); - const isFav = data.favorites.some(fav => fav.item_id === parseInt(id)); + const isFav = data.favorites.some(fav => fav.item_id === collection?.id); setIsFavorited(isFav); } else { console.error('Failed to check favorites:', response.status); - // Keep default false state } } catch (error) { console.error('Error checking favorites:', error); - // Keep default false state + } + }; + + const handleEditCollection = async () => { + try { + const response = await fetch(`/api/collections/${identifier}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + }, + body: JSON.stringify({ + name: editForm.name, + description: editForm.description, + isPublic: editForm.isPublic, + image: editForm.image, + tags: editForm.tags + }) + }); + + if (response.ok) { + const updatedCollection = await response.json(); + + // If the name changed and we got a new slug, redirect + if (updatedCollection.slug && updatedCollection.slug !== identifier) { + router.push(`/collection/${updatedCollection.slug}`); + } else { + // Just refresh the data + fetchCollectionData(); + } + + setShowEditModal(false); + } else { + const error = await response.json(); + alert(error.error || 'Failed to update collection'); + } + } catch (error) { + console.error('Error updating collection:', error); + alert('Network error. Please try again.'); + } + }; + + const handleDeleteCollection = async () => { + try { + const response = await fetch(`/api/collections/${identifier}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` + } + }); + + if (response.ok) { + router.push('/collections'); + } else { + const error = await response.json(); + alert(error.error || 'Failed to delete collection'); + } + } catch (error) { + console.error('Error deleting collection:', error); + alert('Network error. Please try again.'); } }; @@ -108,7 +198,7 @@ export default function CollectionView() { const handleAddCard = async (card) => { try { - const response = await fetch(`/api/collections/${id}/cards`, { + const response = await fetch(`/api/collections/${identifier}/cards`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -130,8 +220,6 @@ export default function CollectionView() { } }; - - const toggleFavorite = async () => { try { if (isFavorited) { @@ -144,7 +232,7 @@ export default function CollectionView() { }, body: JSON.stringify({ itemType: 'collection', - itemId: parseInt(id) + itemId: collection.id }) }); @@ -163,7 +251,7 @@ export default function CollectionView() { }, body: JSON.stringify({ itemType: 'collection', - itemId: parseInt(id) + itemId: collection.id }) }); @@ -180,21 +268,21 @@ export default function CollectionView() { const togglePublic = async () => { try { - const response = await fetch(`/api/collections/${id}`, { + const response = await fetch(`/api/collections/${identifier}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` }, body: JSON.stringify({ - is_public: !collection.is_public + isPublic: !collection.isPublic }) }); if (response.ok) { setCollection(prev => ({ ...prev, - is_public: !prev.is_public + isPublic: !prev.isPublic })); } } catch (error) { @@ -204,7 +292,7 @@ export default function CollectionView() { const handleImageUpload = async (imageUrl) => { try { - const response = await fetch(`/api/collections/${id}`, { + const response = await fetch(`/api/collections/${identifier}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -299,9 +387,7 @@ export default function CollectionView() { return acc; }, {}); - // Debug: Log removed - functionality working - - // Get game display names and counts - make it more flexible + // Get game display names and counts const gameStats = {}; Object.keys(groupedCards).forEach(game => { if (game && game !== 'Other') { @@ -313,7 +399,7 @@ export default function CollectionView() { return (
-
+
); @@ -328,7 +414,7 @@ export default function CollectionView() { Collection not found - @@ -358,6 +444,32 @@ export default function CollectionView() { {/* Action buttons */}
+ {/* Edit and Delete buttons - only show for owner */} + {collection.userRole === 'owner' && ( + <> + + + + )} +
@@ -561,7 +672,8 @@ export default function CollectionView() { @@ -658,7 +770,132 @@ export default function CollectionView() { )} + {/* Edit Collection Modal */} + {showEditModal && ( +
+
+

+ Edit Collection +

+
+
+ + setEditForm({...editForm, name: e.target.value})} + placeholder="Enter collection name" + /> +
+
+ +