diff --git a/app/api/animals/[id]/route.ts b/app/api/animals/[id]/route.ts new file mode 100644 index 0000000..5a939a0 --- /dev/null +++ b/app/api/animals/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/prisma'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const animal = await prisma.animal.findUnique({ + where: { id }, + include: { + photos: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] }, + shelter: true, + }, + }); + + if (!animal) { + return NextResponse.json({ error: 'Animal não encontrado.' }, { status: 404 }); + } + + return NextResponse.json(animal); +} diff --git a/app/api/animals/route.ts b/app/api/animals/route.ts new file mode 100644 index 0000000..b651e45 --- /dev/null +++ b/app/api/animals/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/prisma'; +import { animalFilterSchema } from '@/lib/validations/animal'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + const parsed = animalFilterSchema.safeParse( + Object.fromEntries(searchParams.entries()) + ); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Parâmetros inválidos.', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { district, species, sex, sterilized, urgent, status, page, limit } = + parsed.data; + + const where = { + ...(species && { species }), + ...(sex && { sex }), + ...(sterilized !== undefined && { sterilized }), + ...(urgent !== undefined && { urgent }), + status: status ?? 'AVAILABLE', + ...(district && { + shelter: { district: { contains: district, mode: 'insensitive' as const } }, + }), + }; + + const [animals, total] = await Promise.all([ + prisma.animal.findMany({ + where, + include: { + photos: { where: { isPrimary: true }, take: 1 }, + shelter: { select: { id: true, name: true, district: true } }, + }, + orderBy: [{ urgent: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + prisma.animal.count({ where }), + ]); + + return NextResponse.json({ + animals, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..03b9fc6 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/lib/auth/config'; + +export const { GET, POST } = handlers; diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..a66b496 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/prisma'; +import { registerSchema } from '@/lib/validations/auth'; +import { hashPassword } from '@/lib/auth/password'; +import { validateAge } from '@/lib/auth/age-validation'; +import { sendEmail, buildWelcomeHtml } from '@/lib/email'; + +export async function POST(request: Request) { + const body = await request.json(); + const parsed = registerSchema.safeParse(body); + + if (!parsed.success) { + const errors = parsed.error.flatten().fieldErrors; + const firstError = Object.values(errors).flat()[0]; + return NextResponse.json({ error: firstError ?? 'Dados inválidos.' }, { status: 400 }); + } + + const { name, email, password, birthdate, district } = parsed.data; + + // Validação de +18 sempre no servidor + const ageCheck = validateAge(birthdate); + if (!ageCheck.valid) { + return NextResponse.json({ error: ageCheck.error }, { status: 400 }); + } + + // Verificar se email já existe + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json({ error: 'Este email já está registado.' }, { status: 409 }); + } + + const hashedPassword = await hashPassword(password); + + const user = await prisma.user.create({ + data: { + name, + email, + password: hashedPassword, + birthdate: new Date(birthdate), + district, + }, + select: { id: true, name: true, email: true }, + }); + + // Email de boas-vindas (não bloqueia) + sendEmail({ + to: user.email, + subject: 'Bem-vindo à PawLink 🐾', + html: buildWelcomeHtml({ userName: user.name }), + }).catch(console.error); + + return NextResponse.json( + { message: 'Conta criada com sucesso.', userId: user.id }, + { status: 201 } + ); +} diff --git a/app/api/reservations/[id]/route.ts b/app/api/reservations/[id]/route.ts new file mode 100644 index 0000000..343d0d7 --- /dev/null +++ b/app/api/reservations/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth/config'; +import { prisma } from '@/lib/db/prisma'; +import { updateReservationSchema } from '@/lib/validations/reservation'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const parsed = updateReservationSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Dados inválidos.', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const reservation = await prisma.reservation.findUnique({ + where: { id }, + include: { animal: true }, + }); + + if (!reservation) { + return NextResponse.json({ error: 'Reserva não encontrada.' }, { status: 404 }); + } + + const userRole = (session.user as { role?: string }).role; + const isOwner = reservation.userId === session.user.id; + const isShelterAdmin = userRole === 'SHELTER_ADMIN' || userRole === 'ADMIN'; + + if (!isOwner && !isShelterAdmin) { + return NextResponse.json({ error: 'Sem permissão.' }, { status: 403 }); + } + + const { status } = parsed.data; + + const updated = await prisma.$transaction(async (tx) => { + const res = await tx.reservation.update({ + where: { id }, + data: { status }, + }); + + // Se cancelada, devolver animal a AVAILABLE + if (status === 'CANCELLED' && reservation.animal.status === 'RESERVED') { + await tx.animal.update({ + where: { id: reservation.animalId }, + data: { status: 'AVAILABLE' }, + }); + } + // Se completada, marcar como ADOPTED + if (status === 'COMPLETED') { + await tx.animal.update({ + where: { id: reservation.animalId }, + data: { status: 'ADOPTED' }, + }); + } + + return res; + }); + + return NextResponse.json(updated); +} diff --git a/app/api/reservations/route.ts b/app/api/reservations/route.ts new file mode 100644 index 0000000..4b81b4d --- /dev/null +++ b/app/api/reservations/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth/config'; +import { prisma } from '@/lib/db/prisma'; +import { createReservationSchema } from '@/lib/validations/reservation'; +import { sendEmail, buildReservationConfirmationHtml } from '@/lib/email'; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 }); + } + + const body = await request.json(); + const parsed = createReservationSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Dados inválidos.', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { animalId, date, notes } = parsed.data; + + // Verificar que o animal existe e está disponível + const animal = await prisma.animal.findUnique({ + where: { id: animalId }, + include: { shelter: { select: { name: true } } }, + }); + + if (!animal) { + return NextResponse.json({ error: 'Animal não encontrado.' }, { status: 404 }); + } + if (animal.status !== 'AVAILABLE') { + return NextResponse.json({ error: 'Animal não disponível para reserva.' }, { status: 409 }); + } + + // Criar reserva + marcar animal como RESERVED em transacção + const reservation = await prisma.$transaction(async (tx) => { + const res = await tx.reservation.create({ + data: { + userId: session.user!.id!, + animalId, + date: new Date(date), + status: 'PENDING', + notes, + }, + include: { + user: { select: { name: true, email: true } }, + animal: { select: { name: true, shelter: { select: { name: true } } } }, + }, + }); + + await tx.animal.update({ + where: { id: animalId }, + data: { status: 'RESERVED' }, + }); + + return res; + }); + + // Enviar email de confirmação (não bloqueia a resposta) + sendEmail({ + to: reservation.user.email, + subject: `Reserva confirmada — ${reservation.animal.name} 🐾`, + html: buildReservationConfirmationHtml({ + userName: reservation.user.name, + animalName: reservation.animal.name, + shelterName: reservation.animal.shelter.name, + date: new Date(date).toLocaleDateString('pt-PT', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', + }), + }), + }).catch(console.error); + + return NextResponse.json(reservation, { status: 201 }); +} + +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 }); + } + + const reservations = await prisma.reservation.findMany({ + where: { userId: session.user.id }, + include: { + animal: { + include: { + photos: { where: { isPrimary: true }, take: 1 }, + shelter: { select: { name: true, district: true } }, + }, + }, + }, + orderBy: { date: 'desc' }, + }); + + return NextResponse.json(reservations); +} diff --git a/app/api/shelters/[id]/route.ts b/app/api/shelters/[id]/route.ts new file mode 100644 index 0000000..79aba49 --- /dev/null +++ b/app/api/shelters/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/prisma'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const shelter = await prisma.shelter.findUnique({ + where: { id, verified: true }, + include: { + animals: { + where: { status: 'AVAILABLE' }, + include: { photos: { where: { isPrimary: true }, take: 1 } }, + orderBy: [{ urgent: 'desc' }, { createdAt: 'desc' }], + }, + needs: { where: { active: true }, orderBy: { urgent: 'desc' } }, + }, + }); + + if (!shelter) { + return NextResponse.json({ error: 'Canil não encontrado.' }, { status: 404 }); + } + + return NextResponse.json(shelter); +} diff --git a/app/api/shelters/route.ts b/app/api/shelters/route.ts new file mode 100644 index 0000000..5cd1da8 --- /dev/null +++ b/app/api/shelters/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/prisma'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const district = searchParams.get('district'); + + const shelters = await prisma.shelter.findMany({ + where: { + verified: true, + ...(district && { + district: { contains: district, mode: 'insensitive' }, + }), + }, + select: { + id: true, + name: true, + district: true, + address: true, + phone: true, + email: true, + description: true, + website: true, + openHours: true, + _count: { select: { animals: { where: { status: 'AVAILABLE' } } } }, + }, + orderBy: { name: 'asc' }, + }); + + return NextResponse.json(shelters); +} diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts new file mode 100644 index 0000000..27d34e6 --- /dev/null +++ b/app/api/users/me/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/auth/config'; +import { prisma } from '@/lib/db/prisma'; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + name: true, + email: true, + district: true, + role: true, + emailVerified: true, + createdAt: true, + _count: { + select: { + reservations: true, + donations: true, + }, + }, + }, + }); + + if (!user) { + return NextResponse.json({ error: 'Utilizador não encontrado.' }, { status: 404 }); + } + + return NextResponse.json(user); +} + +export async function PATCH(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Autenticação necessária.' }, { status: 401 }); + } + + const body = await request.json(); + + // Apenas permitir actualizar campos seguros + const { name, district } = body as { name?: string; district?: string }; + + if (name && (typeof name !== 'string' || name.length < 2 || name.length > 100)) { + return NextResponse.json({ error: 'Nome inválido.' }, { status: 400 }); + } + + const user = await prisma.user.update({ + where: { id: session.user.id }, + data: { + ...(name && { name }), + ...(district && { district }), + }, + select: { id: true, name: true, email: true, district: true, role: true }, + }); + + return NextResponse.json(user); +} diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..d9e6fb7 --- /dev/null +++ b/app/auth/forgot-password/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { PawPrint, Mail, ArrowLeft } from 'lucide-react'; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + // TODO: substituir por chamada real à API quando o fluxo de reset estiver implementado + await new Promise((r) => setTimeout(r, 800)); + setSent(true); + } catch { + setError('Erro ao enviar email. Tenta de novo.'); + } finally { + setLoading(false); + } + }; + + if (sent) { + return ( +
+
📬
+

+ Email enviado! +

+

+ Se {email} estiver registado, receberás um email com instruções para repor a tua palavra-passe. +

+ + + Voltar ao login + +
+ ); + } + + return ( +
+
+ + PawLink +
+ +

+ Esqueceste a palavra-passe? +

+

+ Introduz o teu email e enviamos um link para repor a palavra-passe. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + setEmail(e.target.value)} + placeholder="o.teu@email.pt" + required + autoComplete="email" + style={{ width: '100%', padding: '12px 16px 12px 40px', background: 'var(--cream)', border: '1.5px solid var(--parchment)', borderRadius: '10px', fontFamily: 'var(--font-body)', fontSize: '15px', color: 'var(--soil)', outline: 'none', transition: 'border-color 180ms ease', minHeight: '48px' }} + onFocus={(e) => (e.target.style.borderColor = 'var(--terra)')} + onBlur={(e) => (e.target.style.borderColor = 'var(--parchment)')} + /> +
+
+ + +
+ +
+ + + Voltar ao login + +
+
+ ); +} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..db1ddf1 --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,14 @@ +import Header from '@/components/layout/Header'; +import Footer from '@/components/layout/Footer'; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + <> +
+
+ {children} +
+