feat(api): tokens on all routes; items expose claims/claimedQuantity/remainingQuantity

This commit is contained in:
belisards
2026-05-03 16:25:19 -03:00
parent 844951c832
commit e518e28957
12 changed files with 139 additions and 214 deletions

View File

@@ -1,12 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ slug: string }> } { params }: { params: Promise<{ slug: string }> }
) { ) {
try { try {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
const { slug } = await params; const { slug } = await params;
const wishlist = await db const wishlist = await db
@@ -22,8 +29,8 @@ export async function GET(
); );
} }
// Only return public wishlists // Only return public wishlists (admin can see all)
if (!wishlist[0].isPublic) { if (!wishlist[0].isPublic && !isAdmin) {
return NextResponse.json( return NextResponse.json(
{ error: 'Wishlist not found' }, { error: 'Wishlist not found' },
{ status: 404 } { status: 404 }

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, wishlistItems } from '@/lib/db'; import { db, wishlistItems } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db'; import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
import { attachClaimsToItems } from '@/lib/items-with-claims';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -10,10 +11,11 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
// Check for auth token const isAdmin = verifyAdminToken(request);
const token = request.cookies.get('access_token')?.value; const guest = await getGuestFromRequest(request);
const payload = token ? verifyAccessToken(token) : null; if (!isAdmin && !guest) {
const isAuthenticated = payload !== null; return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Get item // Get item
const item = await db const item = await db
@@ -43,17 +45,18 @@ export async function GET(
); );
} }
// Check permissions if (!wishlist[0].isPublic && !isAdmin) {
if (!wishlist[0].isPublic && !isAuthenticated) {
return NextResponse.json( return NextResponse.json(
{ error: 'This item is private' }, { error: 'This item is private' },
{ status: 403 } { status: 403 }
); );
} }
const [withClaims] = await attachClaimsToItems(item);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
item: item[0], item: withClaims,
}); });
} catch (error) { } catch (error) {
console.error('Error fetching item:', error); console.error('Error fetching item:', error);
@@ -69,21 +72,8 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;
@@ -114,7 +104,7 @@ export async function PATCH(
} }
// Build update object (only include provided fields) // Build update object (only include provided fields)
const updateData: any = { const updateData: Record<string, unknown> = {
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -152,21 +142,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;

View File

@@ -1,9 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const isAdmin = verifyAdminToken(request);
const guest = await getGuestFromRequest(request);
if (!isAdmin && !guest) {
return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Fetch only public wishlists // Fetch only public wishlists
const publicWishlists = await db const publicWishlists = await db
.select() .select()

View File

@@ -1,24 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
import { scrapeUrl } from '@/lib/scraping/service'; import { scrapeUrl } from '@/lib/scraping/service';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 body = await request.json();

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, settings } from '@/lib/db'; import { db, settings } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
import crypto from 'crypto'; import crypto from 'crypto';
// GET /api/settings - Get all settings (public endpoint for reading only) // GET /api/settings - Get all settings (public endpoint for reading only)
@@ -42,21 +42,8 @@ export async function GET(request: NextRequest) {
// PUT /api/settings - Update settings (admin only) // PUT /api/settings - Update settings (admin only)
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 body = await request.json();

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db'; import { db, wishlistItems, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken, getGuestFromRequest } from '@/lib/auth/tokens';
import { attachClaimsToItems } from '@/lib/items-with-claims';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -10,10 +11,11 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
// Check for auth token const isAdmin = verifyAdminToken(request);
const token = request.cookies.get('access_token')?.value; const guest = await getGuestFromRequest(request);
const payload = token ? verifyAccessToken(token) : null; if (!isAdmin && !guest) {
const isAuthenticated = payload !== null; return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
}
// Check if wishlist exists // Check if wishlist exists
const wishlist = await db const wishlist = await db
@@ -29,20 +31,20 @@ export async function GET(
); );
} }
// Check permissions // Permissions: guest can only see public wishlists; admin sees all
if (!wishlist[0].isPublic && !isAuthenticated) { if (!wishlist[0].isPublic && !isAdmin) {
return NextResponse.json( return NextResponse.json(
{ error: 'This wishlist is private' }, { error: 'This wishlist is private' },
{ status: 403 } { status: 403 }
); );
} }
// Get all items (exclude archived unless authenticated) // Get all items (exclude archived unless admin)
const items = await db const raw = await db
.select() .select()
.from(wishlistItems) .from(wishlistItems)
.where( .where(
isAuthenticated isAdmin
? eq(wishlistItems.wishlistId, id) ? eq(wishlistItems.wishlistId, id)
: and( : and(
eq(wishlistItems.wishlistId, id), eq(wishlistItems.wishlistId, id),
@@ -51,12 +53,11 @@ export async function GET(
) )
.orderBy(wishlistItems.sortOrder); .orderBy(wishlistItems.sortOrder);
// Return items const items = await attachClaimsToItems(raw);
const responseItems = items;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
items: responseItems, items,
}); });
} catch (error) { } catch (error) {
console.error('Error fetching items:', error); console.error('Error fetching items:', error);
@@ -72,21 +73,8 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;

View File

@@ -1,28 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;
@@ -58,21 +45,8 @@ export async function PATCH(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;
@@ -145,21 +119,8 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 { id } = await params;

View File

@@ -1,25 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { db, wishlists } from '@/lib/db'; import { db, wishlists } from '@/lib/db';
import { verifyAccessToken } from '@/lib/auth/utils'; import { verifyAdminToken } from '@/lib/auth/tokens';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 const allWishlists = await db
@@ -42,21 +29,8 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const token = request.cookies.get('access_token')?.value; if (!verifyAdminToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
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 body = await request.json();

View File

@@ -71,6 +71,15 @@ export interface Wishlist {
updatedAt: string; updatedAt: string;
} }
export interface ItemClaim {
id: string;
quantity: number;
note: string | null;
isPurchased: boolean;
claimedAt: string;
guest: { id: string; name: string };
}
export interface Item { export interface Item {
id: string; id: string;
wishlistId: string; wishlistId: string;
@@ -82,10 +91,9 @@ export interface Item {
imageUrl: string | null; imageUrl: string | null;
purchaseUrls: Array<{ label: string; url: string }> | null; purchaseUrls: Array<{ label: string; url: string }> | null;
isArchived: boolean; isArchived: boolean;
claimedByName: string | null; claims: ItemClaim[];
claimedByNote: string | null; claimedQuantity: number;
claimedAt: string | null; remainingQuantity: number;
isPurchased: boolean;
sortOrder: number; sortOrder: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

55
lib/items-with-claims.ts Normal file
View File

@@ -0,0 +1,55 @@
import { eq, inArray } from 'drizzle-orm';
import { db, wishlistItems, itemClaims, guests } from '@/lib/db';
type RawItem = typeof wishlistItems.$inferSelect;
export async function attachClaimsToItems(items: RawItem[]) {
if (items.length === 0) return [];
const itemIds = items.map((i) => i.id);
const rows = await db
.select({
id: itemClaims.id,
itemId: itemClaims.itemId,
quantity: itemClaims.quantity,
note: itemClaims.note,
isPurchased: itemClaims.isPurchased,
claimedAt: itemClaims.claimedAt,
guestId: guests.id,
guestName: guests.name,
})
.from(itemClaims)
.innerJoin(guests, eq(itemClaims.guestId, guests.id))
.where(inArray(itemClaims.itemId, itemIds));
const byItem = new Map<string, ReturnType<typeof shapeClaim>[]>();
for (const r of rows) {
const arr = byItem.get(r.itemId) ?? [];
arr.push(shapeClaim(r));
byItem.set(r.itemId, arr);
}
return items.map((it) => {
const claims = byItem.get(it.id) ?? [];
const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0);
return {
...it,
claims,
claimedQuantity,
remainingQuantity: Math.max(0, it.quantity - claimedQuantity),
};
});
}
function shapeClaim(r: {
id: string; quantity: number; note: string | null; isPurchased: boolean;
claimedAt: Date; guestId: string; guestName: string;
}) {
return {
id: r.id,
quantity: r.quantity,
note: r.note,
isPurchased: r.isPurchased,
claimedAt: r.claimedAt,
guest: { id: r.guestId, name: r.guestName },
};
}