Initial commit

This commit is contained in:
michaeltieso
2025-12-01 14:49:17 +00:00
commit 3480888eaa
92 changed files with 16631 additions and 0 deletions

44
app/api/[slug]/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Only return public wishlists
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
wishlist: wishlist[0],
});
} catch (error) {
console.error('Error fetching wishlist by slug:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlist' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, validateAdminCredentials } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Validate credentials
if (!validateAdminCredentials(username, password)) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate tokens
const accessToken = generateAccessToken(username);
const refreshToken = generateRefreshToken(username);
// Create response
const response = NextResponse.json({
success: true,
user: { username },
accessToken,
refreshToken,
});
// Set cookies
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', accessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', refreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const response = NextResponse.json({
success: true,
message: 'Logged out successfully'
});
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
return response;
}

34
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
user: { username: payload.username },
});
} catch (error) {
console.error('Auth error:', error);
return NextResponse.json(
{ error: 'Authentication failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '@/lib/auth/utils';
export async function POST(request: NextRequest) {
try {
// Get refresh token from cookie or body
let refreshToken: string | undefined;
const cookieToken = request.cookies.get('refresh_token')?.value;
if (cookieToken) {
refreshToken = cookieToken;
} else {
try {
const body = await request.json();
refreshToken = body.refreshToken;
} catch {
// No body or invalid JSON, continue without it
refreshToken = undefined;
}
}
if (!refreshToken) {
return NextResponse.json(
{ error: 'No refresh token provided' },
{ status: 401 }
);
}
// Verify refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired refresh token' },
{ status: 401 }
);
}
// Generate new tokens
const newAccessToken = generateAccessToken(payload.username);
const newRefreshToken = generateRefreshToken(payload.username);
// Create response
const response = NextResponse.json({
success: true,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
// Set new cookies
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', newAccessToken, {
...cookieOptions,
maxAge: 72 * 60 * 60, // 72 hours
});
response.cookies.set('refresh_token', newRefreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error('Refresh error:', error);
return NextResponse.json(
{ error: 'Token refresh failed' },
{ status: 500 }
);
}
}

9
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
}

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { newSortOrder } = body;
if (newSortOrder === undefined || typeof newSortOrder !== 'number') {
return NextResponse.json(
{ error: 'newSortOrder is required and must be a number' },
{ status: 400 }
);
}
// Validate newSortOrder is not negative
if (newSortOrder < 0) {
return NextResponse.json(
{ error: 'newSortOrder must be a non-negative number' },
{ status: 400 }
);
}
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
const oldSortOrder = existingItem[0].sortOrder;
const wishlistId = existingItem[0].wishlistId;
// Get all items for this wishlist sorted by sortOrder
const allItems = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.wishlistId, wishlistId))
.orderBy(wishlistItems.sortOrder);
// Validate newSortOrder is within bounds
if (newSortOrder >= allItems.length) {
return NextResponse.json(
{ error: `newSortOrder must be less than ${allItems.length}` },
{ status: 400 }
);
}
// Skip if no change needed
if (oldSortOrder === newSortOrder) {
return NextResponse.json({
success: true,
item: existingItem[0],
});
}
// Remove the item being moved from the array
const movingItemIndex = allItems.findIndex(item => item.id === id);
// Safety check: ensure item was found in the array
if (movingItemIndex === -1) {
console.error(`Item ${id} not found in wishlist items array`);
return NextResponse.json(
{ error: 'Item not found in wishlist' },
{ status: 500 }
);
}
const movingItem = allItems[movingItemIndex];
allItems.splice(movingItemIndex, 1);
// Insert it at the new position
allItems.splice(newSortOrder, 0, movingItem);
// Update all sortOrders in a transaction for atomicity
const updatedItem = await db.transaction(async (tx) => {
// Update all sortOrders
for (let i = 0; i < allItems.length; i++) {
await tx
.update(wishlistItems)
.set({
sortOrder: i,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, allItems[i].id));
}
// Get the updated moving item
const result = await tx
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
return result[0];
});
return NextResponse.json({
success: true,
item: updatedItem,
});
} catch (error) {
console.error('Error reordering item:', error);
return NextResponse.json(
{ error: 'Failed to reorder item' },
{ status: 500 }
);
}
}

204
app/api/items/[id]/route.ts Normal file
View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
// Get item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
return NextResponse.json(
{ error: 'This item is private' },
{ status: 403 }
);
}
return NextResponse.json({
success: true,
item: item[0],
});
} catch (error) {
console.error('Error fetching item:', error);
return NextResponse.json(
{ error: 'Failed to fetch item' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const {
name,
description,
price,
currency,
quantity,
imageUrl,
purchaseUrls,
isArchived,
} = body;
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Build update object (only include provided fields)
const updateData: any = {
updatedAt: new Date(),
};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (price !== undefined) updateData.price = price;
if (currency !== undefined) updateData.currency = currency;
if (quantity !== undefined) updateData.quantity = quantity;
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (purchaseUrls !== undefined) updateData.purchaseUrls = purchaseUrls;
if (isArchived !== undefined) updateData.isArchived = isArchived;
// Update item
const updatedItem = await db
.update(wishlistItems)
.set(updateData)
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json({
success: true,
item: updatedItem[0],
});
} catch (error) {
console.error('Error updating item:', error);
return NextResponse.json(
{ error: 'Failed to update item' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
// Check if item exists
const existingItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (existingItem.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Delete item
await db
.delete(wishlistItems)
.where(eq(wishlistItems.id, id));
return NextResponse.json({
success: true,
message: 'Item deleted successfully',
});
} catch (error) {
console.error('Error deleting item:', error);
return NextResponse.json(
{ error: 'Failed to delete item' },
{ status: 500 }
);
}
}

67
app/api/lock/route.ts Normal file
View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import crypto from 'crypto';
// POST /api/lock - Verify password
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { password } = body;
if (!password) {
return NextResponse.json(
{ error: 'Password is required' },
{ status: 400 }
);
}
// Get the stored password hash
const hashSetting = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (hashSetting.length === 0) {
return NextResponse.json(
{ error: 'Password lock not configured' },
{ status: 400 }
);
}
// Hash the provided password
const hash = crypto.createHash('sha256').update(password).digest('hex');
// Compare hashes
if (hash === hashSetting[0].value) {
// Password correct - set a cookie
const response = NextResponse.json({
success: true,
message: 'Password verified',
});
// Set an unlock cookie that expires in 24 hours
response.cookies.set('site_unlocked', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return response;
} else {
return NextResponse.json(
{ error: 'Incorrect password' },
{ status: 401 }
);
}
} catch (error) {
console.error('Error verifying password:', error);
return NextResponse.json(
{ error: 'Failed to verify password' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { createId } from '@paralleldrive/cuid2';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { name, note } = body;
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if item is already claimed
if (item[0].claimedByToken) {
return NextResponse.json(
{ error: 'Item is already claimed' },
{ status: 409 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Generate unique claim token
const claimToken = createId();
// Update item with claim information
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: name || null,
claimedByNote: note || null,
claimedByToken: claimToken,
claimedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json(
{
success: true,
claimToken,
item: updatedItem[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error claiming item:', error);
return NextResponse.json(
{ error: 'Failed to claim item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get the item
const item = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
}
// Check if item is actually claimed
if (!item[0].claimedByToken) {
return NextResponse.json(
{ error: 'Item is not claimed' },
{ status: 400 }
);
}
// Check if wishlist is public
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, item[0].wishlistId))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
if (!wishlist[0].isPublic) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Remove claim information (honor system - no verification)
const updatedItem = await db
.update(wishlistItems)
.set({
claimedByName: null,
claimedByNote: null,
claimedByToken: null,
claimedAt: null,
isPurchased: false,
updatedAt: new Date(),
})
.where(eq(wishlistItems.id, id))
.returning();
return NextResponse.json(
{
success: true,
message: 'Item unclaimed successfully',
item: updatedItem[0],
},
{ status: 200 }
);
} catch (error) {
console.error('Error unclaiming item:', error);
return NextResponse.json(
{ error: 'Failed to unclaim item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
// Fetch only public wishlists
const publicWishlists = await db
.select()
.from(wishlists)
.where(eq(wishlists.isPublic, true))
.orderBy(asc(wishlists.sortOrder));
return NextResponse.json({
success: true,
wishlists: publicWishlists,
});
} catch (error) {
console.error('Error fetching public wishlists:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlists' },
{ status: 500 }
);
}
}

63
app/api/scrape/route.ts Normal file
View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils';
import { scrapeUrl } from '@/lib/scraping/service';
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { url } = body;
// Validation
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
// Validate URL format
try {
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
new URL(normalizedUrl);
} catch (error) {
return NextResponse.json(
{ error: 'Invalid URL format' },
{ status: 400 }
);
}
// Scrape the URL
const scrapedData = await scrapeUrl(url);
return NextResponse.json({
success: true,
data: scrapedData,
});
} catch (error) {
console.error('Error scraping URL:', error);
return NextResponse.json(
{
error: 'Failed to scrape URL',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

164
app/api/settings/route.ts Normal file
View File

@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
import crypto from 'crypto';
// GET /api/settings - Get all settings (public endpoint for reading only)
export async function GET(request: NextRequest) {
try {
const allSettings = await db.select().from(settings);
// Convert to key-value object
const settingsObj = allSettings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string | boolean>);
// Set defaults if not found
if (!settingsObj.siteTitle) {
settingsObj.siteTitle = 'Wishlist';
}
if (!settingsObj.homepageSubtext) {
settingsObj.homepageSubtext = 'Browse and explore available wishlists';
}
// Convert passwordLockEnabled to boolean
(settingsObj as any).passwordLockEnabled = settingsObj.passwordLockEnabled === 'true';
return NextResponse.json({
success: true,
settings: settingsObj,
});
} catch (error) {
console.error('Error fetching settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
}
// PUT /api/settings - Update settings (admin only)
export async function PUT(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { siteTitle, homepageSubtext, passwordLockEnabled, passwordLock } = body;
// Update or insert siteTitle
if (siteTitle !== undefined) {
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'siteTitle'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: siteTitle, updatedAt: new Date() })
.where(eq(settings.key, 'siteTitle'));
} else {
await db.insert(settings).values({
key: 'siteTitle',
value: siteTitle,
});
}
}
// Update or insert homepageSubtext
if (homepageSubtext !== undefined) {
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'homepageSubtext'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: homepageSubtext, updatedAt: new Date() })
.where(eq(settings.key, 'homepageSubtext'));
} else {
await db.insert(settings).values({
key: 'homepageSubtext',
value: homepageSubtext,
});
}
}
// Update or insert passwordLockEnabled
if (passwordLockEnabled !== undefined) {
const value = passwordLockEnabled ? 'true' : 'false';
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockEnabled'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockEnabled'));
} else {
await db.insert(settings).values({
key: 'passwordLockEnabled',
value,
});
}
}
// Update password hash if provided
if (passwordLock && passwordLock.trim() !== '') {
// Hash the password using SHA-256
const hash = crypto.createHash('sha256').update(passwordLock).digest('hex');
const existing = await db
.select()
.from(settings)
.where(eq(settings.key, 'passwordLockHash'))
.limit(1);
if (existing.length > 0) {
await db
.update(settings)
.set({ value: hash, updatedAt: new Date() })
.where(eq(settings.key, 'passwordLockHash'));
} else {
await db.insert(settings).values({
key: 'passwordLockHash',
value: hash,
});
}
}
return NextResponse.json({
success: true,
message: 'Settings updated successfully',
});
} catch (error) {
console.error('Error updating settings:', error);
return NextResponse.json(
{ error: 'Failed to update settings' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, and, desc } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Check for auth token
const token = request.cookies.get('access_token')?.value;
const payload = token ? verifyAccessToken(token) : null;
const isAuthenticated = payload !== null;
// Check if wishlist exists
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Check permissions
if (!wishlist[0].isPublic && !isAuthenticated) {
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Get all items (exclude archived unless authenticated)
const items = await db
.select()
.from(wishlistItems)
.where(
isAuthenticated
? eq(wishlistItems.wishlistId, id)
: and(
eq(wishlistItems.wishlistId, id),
eq(wishlistItems.isArchived, false)
)
)
.orderBy(wishlistItems.sortOrder);
// Return items
const responseItems = items;
return NextResponse.json({
success: true,
items: responseItems,
});
} catch (error) {
console.error('Error fetching items:', error);
return NextResponse.json(
{ error: 'Failed to fetch items' },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const {
name,
description,
price,
currency,
quantity,
imageUrl,
purchaseUrls,
} = body;
// Validation
if (!name) {
return NextResponse.json(
{ error: 'Item name is required' },
{ status: 400 }
);
}
// Check if wishlist exists
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Get the highest sortOrder value to append the new item at the end
const lastItem = await db
.select()
.from(wishlistItems)
.where(eq(wishlistItems.wishlistId, id))
.orderBy(desc(wishlistItems.sortOrder))
.limit(1);
const nextSortOrder = lastItem.length > 0 ? lastItem[0].sortOrder + 1 : 0;
// Create item
const newItem = await db
.insert(wishlistItems)
.values({
wishlistId: id,
name,
description: description || null,
price: price || null,
currency: currency || 'USD',
quantity: quantity || 1,
imageUrl: imageUrl || null,
purchaseUrls: purchaseUrls || null,
sortOrder: nextSortOrder,
})
.returning();
return NextResponse.json(
{
success: true,
item: newItem[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating item:', error);
return NextResponse.json(
{ error: 'Failed to create item' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { newSortOrder } = body;
if (newSortOrder === undefined || typeof newSortOrder !== 'number') {
return NextResponse.json(
{ error: 'newSortOrder is required and must be a number' },
{ status: 400 }
);
}
// Validate newSortOrder is not negative
if (newSortOrder < 0) {
return NextResponse.json(
{ error: 'newSortOrder must be a non-negative number' },
{ status: 400 }
);
}
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
const oldSortOrder = existingWishlist[0].sortOrder;
// Get all wishlists sorted by sortOrder
const allWishlists = await db
.select()
.from(wishlists)
.orderBy(wishlists.sortOrder);
// Validate newSortOrder is within bounds
if (newSortOrder >= allWishlists.length) {
return NextResponse.json(
{ error: `newSortOrder must be less than ${allWishlists.length}` },
{ status: 400 }
);
}
// Skip if no change needed
if (oldSortOrder === newSortOrder) {
return NextResponse.json({
success: true,
wishlist: existingWishlist[0],
});
}
// Remove the wishlist being moved from the array
const movingWishlistIndex = allWishlists.findIndex(w => w.id === id);
// Safety check: ensure wishlist was found in the array
if (movingWishlistIndex === -1) {
console.error(`Wishlist ${id} not found in wishlists array`);
return NextResponse.json(
{ error: 'Wishlist not found in array' },
{ status: 500 }
);
}
const movingWishlist = allWishlists[movingWishlistIndex];
allWishlists.splice(movingWishlistIndex, 1);
// Insert it at the new position
allWishlists.splice(newSortOrder, 0, movingWishlist);
// Update all sortOrders in a transaction for atomicity
const updatedWishlist = await db.transaction(async (tx) => {
// Update all sortOrders
for (let i = 0; i < allWishlists.length; i++) {
await tx
.update(wishlists)
.set({
sortOrder: i,
updatedAt: new Date(),
})
.where(eq(wishlists.id, allWishlists[i].id));
}
// Get the updated wishlist
const result = await tx
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
return result[0];
});
return NextResponse.json({
success: true,
wishlist: updatedWishlist,
});
} catch (error) {
console.error('Error reordering wishlist:', error);
return NextResponse.json(
{ error: 'Failed to reorder wishlist' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,197 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const wishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (wishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
wishlist: wishlist[0],
});
} catch (error) {
console.error('Error fetching wishlist:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlist' },
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
const body = await request.json();
const { name, slug, description, imageUrl, isPublic } = body;
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// If slug is being changed, check if new slug is available
if (slug && slug !== existingWishlist[0].slug) {
const slugExists = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (slugExists.length > 0) {
return NextResponse.json(
{ error: 'A wishlist with this slug already exists' },
{ status: 409 }
);
}
}
// Build update object (only include provided fields)
const updateData: any = {
updatedAt: new Date(),
};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (description !== undefined) updateData.description = description;
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (isPublic !== undefined) updateData.isPublic = isPublic;
// Update wishlist
const updatedWishlist = await db
.update(wishlists)
.set(updateData)
.where(eq(wishlists.id, id))
.returning();
return NextResponse.json({
success: true,
wishlist: updatedWishlist[0],
});
} catch (error) {
console.error('Error updating wishlist:', error);
return NextResponse.json(
{ error: 'Failed to update wishlist' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const { id } = await params;
// Check if wishlist exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.id, id))
.limit(1);
if (existingWishlist.length === 0) {
return NextResponse.json(
{ error: 'Wishlist not found' },
{ status: 404 }
);
}
// Delete wishlist (cascade will delete items automatically)
await db
.delete(wishlists)
.where(eq(wishlists.id, id));
return NextResponse.json({
success: true,
message: 'Wishlist deleted successfully',
});
} catch (error) {
console.error('Error deleting wishlist:', error);
return NextResponse.json(
{ error: 'Failed to delete wishlist' },
{ status: 500 }
);
}
}

113
app/api/wishlists/route.ts Normal file
View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const allWishlists = await db
.select()
.from(wishlists)
.orderBy(asc(wishlists.sortOrder));
return NextResponse.json({
success: true,
wishlists: allWishlists,
});
} catch (error) {
console.error('Error fetching wishlists:', error);
return NextResponse.json(
{ error: 'Failed to fetch wishlists' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const token = request.cookies.get('access_token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const payload = verifyAccessToken(token);
if (!payload) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
const body = await request.json();
const { name, slug, description, imageUrl, isPublic } = body;
// Validation
if (!name || !slug) {
return NextResponse.json(
{ error: 'Name and slug are required' },
{ status: 400 }
);
}
// Check if slug already exists
const existingWishlist = await db
.select()
.from(wishlists)
.where(eq(wishlists.slug, slug))
.limit(1);
if (existingWishlist.length > 0) {
return NextResponse.json(
{ error: 'A wishlist with this slug already exists' },
{ status: 409 }
);
}
// Create wishlist
const newWishlist = await db
.insert(wishlists)
.values({
name,
slug,
description: description || null,
imageUrl: imageUrl || null,
isPublic: isPublic !== undefined ? isPublic : false,
})
.returning();
return NextResponse.json(
{
success: true,
wishlist: newWishlist[0],
},
{ status: 201 }
);
} catch (error) {
console.error('Error creating wishlist:', error);
return NextResponse.json(
{ error: 'Failed to create wishlist' },
{ status: 500 }
);
}
}