feat: sessão #3 — lib (db/auth/email/validations), API routes, NextAuth v5, middleware, páginas account/shelters/shelter-dashboard, Prisma v7 fix

This commit is contained in:
2026-05-21 09:01:59 +01:00
parent e6ebc0909c
commit e62dc9d6e6
44 changed files with 5341 additions and 273 deletions

View File

@@ -0,0 +1,35 @@
/**
* Validação de +18 anos — sempre executada no servidor.
* Nunca confiar no cliente para esta verificação.
*/
export function isAdult(birthdate: Date): boolean {
const today = new Date();
const age18 = new Date(
birthdate.getFullYear() + 18,
birthdate.getMonth(),
birthdate.getDate()
);
return today >= age18;
}
export function validateAge(birthdateStr: string): {
valid: boolean;
error?: string;
} {
const birthdate = new Date(birthdateStr);
if (isNaN(birthdate.getTime())) {
return { valid: false, error: 'Data de nascimento inválida.' };
}
const now = new Date();
if (birthdate > now) {
return { valid: false, error: 'Data de nascimento não pode ser no futuro.' };
}
if (!isAdult(birthdate)) {
return { valid: false, error: 'Tens de ter pelo menos 18 anos para criar conta.' };
}
return { valid: true };
}

60
lib/auth/config.ts Normal file
View File

@@ -0,0 +1,60 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from '@/lib/db/prisma';
import { verifyPassword } from '@/lib/auth/password';
import { loginSchema } from '@/lib/validations/auth';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Palavra-passe', type: 'password' },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await verifyPassword(password, user.password);
if (!valid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
(session.user as { role?: string }).role = token.role as string;
}
return session;
},
},
pages: {
signIn: '/auth/login',
error: '/auth/login',
},
session: { strategy: 'jwt' },
});

14
lib/auth/password.ts Normal file
View File

@@ -0,0 +1,14 @@
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}

11
lib/db/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

102
lib/email/index.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = process.env.RESEND_FROM_EMAIL ?? 'noreply@pawlink.pt';
export interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const { data, error } = await resend.emails.send({
from: `PawLink <${FROM}>`,
to,
subject,
html,
});
if (error) {
console.error('[email] Falha ao enviar:', error);
throw new Error(`Falha ao enviar email: ${error.message}`);
}
return data;
}
export function buildReservationConfirmationHtml(opts: {
userName: string;
animalName: string;
shelterName: string;
date: string;
}): string {
return `
<!DOCTYPE html>
<html lang="pt">
<head><meta charset="UTF-8"><title>Confirmação de Reserva — PawLink</title></head>
<body style="margin:0;padding:0;background:#F9F4ED;font-family:Georgia,serif;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:40px 16px;">
<table width="560" cellpadding="0" cellspacing="0" style="background:#EFE6D8;border-radius:16px;overflow:hidden;">
<tr><td style="background:#C4501A;padding:28px 36px;">
<span style="color:white;font-size:22px;font-style:italic;font-weight:700;">PawLink</span>
</td></tr>
<tr><td style="padding:36px;">
<h1 style="margin:0 0 12px;color:#231408;font-size:28px;">Reserva confirmada! 🐾</h1>
<p style="margin:0 0 20px;color:#5C4033;font-size:16px;line-height:1.6;">
Olá <strong>${opts.userName}</strong>, a tua reserva para conhecer <strong>${opts.animalName}</strong> foi confirmada.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F9F4ED;border-radius:10px;margin-bottom:24px;">
<tr><td style="padding:20px;">
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Animal</p>
<p style="margin:0 0 16px;color:#231408;font-size:18px;font-weight:700;">${opts.animalName}</p>
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Canil</p>
<p style="margin:0 0 16px;color:#231408;font-size:16px;">${opts.shelterName}</p>
<p style="margin:0 0 8px;color:#9C8070;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;">Data</p>
<p style="margin:0;color:#231408;font-size:16px;">${opts.date}</p>
</td></tr>
</table>
<p style="margin:0;color:#5C4033;font-size:14px;line-height:1.6;">
Lembra-te de levar um documento de identificação. Se precisares de alterar ou cancelar, acede à tua área de conta em <a href="https://pawlink.pt/main/account" style="color:#C4501A;">pawlink.pt</a>.
</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #E4D8C8;">
<p style="margin:0;color:#9C8070;font-size:12px;">© 2025 PawLink · Portugal · <a href="https://pawlink.pt/privacy" style="color:#9C8070;">Privacidade</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
export function buildWelcomeHtml(opts: { userName: string }): string {
return `
<!DOCTYPE html>
<html lang="pt">
<head><meta charset="UTF-8"><title>Bem-vindo ao PawLink</title></head>
<body style="margin:0;padding:0;background:#F9F4ED;font-family:Georgia,serif;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:40px 16px;">
<table width="560" cellpadding="0" cellspacing="0" style="background:#EFE6D8;border-radius:16px;overflow:hidden;">
<tr><td style="background:#C4501A;padding:28px 36px;">
<span style="color:white;font-size:22px;font-style:italic;font-weight:700;">PawLink</span>
</td></tr>
<tr><td style="padding:36px;">
<h1 style="margin:0 0 12px;color:#231408;font-size:28px;">Bem-vindo, ${opts.userName}! 🐾</h1>
<p style="margin:0 0 20px;color:#5C4033;font-size:16px;line-height:1.6;">
A tua conta PawLink está pronta. Começa a explorar animais à espera de uma família como a tua.
</p>
<a href="https://pawlink.pt/main/animals" style="display:inline-block;background:#C4501A;color:white;padding:14px 28px;border-radius:100px;text-decoration:none;font-size:14px;letter-spacing:0.08em;text-transform:uppercase;">Explorar animais</a>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #E4D8C8;">
<p style="margin:0;color:#9C8070;font-size:12px;">© 2025 PawLink · Portugal</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

253
lib/mock-data.ts Normal file
View File

@@ -0,0 +1,253 @@
// lib/mock-data.ts
// TODO: substituir por queries Prisma quando DATABASE_URL estiver configurada
export interface Animal {
id: string;
name: string;
species: 'DOG' | 'CAT' | 'OTHER';
breed: string;
ageMonths: number;
sex: 'MALE' | 'FEMALE';
sterilized: boolean;
status: 'AVAILABLE' | 'RESERVED' | 'ADOPTED';
urgent: boolean;
description: string;
photos: string[];
shelter: {
id: string;
name: string;
district: string;
address: string;
phone: string;
email: string;
openHours: string;
};
}
export const MOCK_ANIMALS: Animal[] = [
{
id: 'mock-1',
name: 'Bobi',
species: 'DOG',
breed: 'Labrador',
ageMonths: 24,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Bobi é um Labrador cheio de energia e amor para dar. Adora brincar ao ar livre e é excelente com crianças. Está vacinado, desparasitado e pronto para encontrar a sua família definitiva. Vive em canil há 8 meses e merece uma segunda oportunidade.',
photos: [
'https://images.unsplash.com/photo-1587300003388-59208cc962cb?w=600&q=80',
'https://images.unsplash.com/photo-1552053831-71594a27632d?w=600&q=80',
'https://images.unsplash.com/photo-1560807707-8cc77767d783?w=600&q=80',
],
shelter: {
id: 'shelter-1',
name: 'Canil Municipal de Lisboa',
district: 'Lisboa',
address: 'Rua do Canil, 12, 1500-001 Lisboa',
phone: '213 000 001',
email: 'canil@cm-lisboa.pt',
openHours: 'SegSex 9h18h · Sáb 10h14h',
},
},
{
id: 'mock-2',
name: 'Luna',
species: 'CAT',
breed: 'Siamês',
ageMonths: 18,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Luna é uma gata Siamesa elegante e carinhosa. Adora colo e ronrona constantemente. É ideal para apartamento e dá-se bem com outros gatos. Urgente — o canil está sobrelotado e ela precisa de lar.',
photos: [
'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=600&q=80',
'https://images.unsplash.com/photo-1573865526739-10659fec78a5?w=600&q=80',
],
shelter: {
id: 'shelter-2',
name: 'Associação Amigos dos Animais do Porto',
district: 'Porto',
address: 'Av. da Boavista, 450, 4100-100 Porto',
phone: '222 000 002',
email: 'adopcao@amigosanimais.pt',
openHours: 'SegDom 10h17h',
},
},
{
id: 'mock-3',
name: 'Rex',
species: 'DOG',
breed: 'Pastor Alemão',
ageMonths: 36,
sex: 'MALE',
sterilized: false,
status: 'AVAILABLE',
urgent: false,
description:
'O Rex é um Pastor Alemão inteligente e leal. Tem treino básico de obediência e adora aprender novos comandos. Precisa de espaço exterior e de uma pessoa experiente com a raça.',
photos: [
'https://images.unsplash.com/photo-1589941013453-ec89f33b5e95?w=600&q=80',
'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?w=600&q=80',
],
shelter: {
id: 'shelter-3',
name: 'Centro de Recolha de Sintra',
district: 'Sintra',
address: 'Estrada da Serra, 8, 2710-001 Sintra',
phone: '219 000 003',
email: 'sintra@recolha.pt',
openHours: 'TerSáb 9h17h',
},
},
{
id: 'mock-4',
name: 'Mel',
species: 'CAT',
breed: 'Europeu Comum',
ageMonths: 8,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Mel é uma gatinha de 8 meses cheia de curiosidade e energia. Brinca o dia todo e adormece no colo à noite. Socializada com crianças e outros animais.',
photos: [
'https://images.unsplash.com/photo-1533743983669-94fa5c4338ec?w=600&q=80',
'https://images.unsplash.com/photo-1495360010541-f48722b4f3c7?w=600&q=80',
],
shelter: {
id: 'shelter-1',
name: 'Canil Municipal de Lisboa',
district: 'Lisboa',
address: 'Rua do Canil, 12, 1500-001 Lisboa',
phone: '213 000 001',
email: 'canil@cm-lisboa.pt',
openHours: 'SegSex 9h18h · Sáb 10h14h',
},
},
{
id: 'mock-5',
name: 'Toto',
species: 'DOG',
breed: 'Beagle',
ageMonths: 12,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Toto é um Beagle jovem, curioso e muito sociável. Adora farejar tudo e brincar com outros cães. Excelente companheiro para famílias activas.',
photos: [
'https://images.unsplash.com/photo-1505628346881-b72b27e84530?w=600&q=80',
'https://images.unsplash.com/photo-1537151608828-ea2b11777ee8?w=600&q=80',
],
shelter: {
id: 'shelter-4',
name: 'Associação Zara Animal',
district: 'Braga',
address: 'Rua das Flores, 22, 4700-001 Braga',
phone: '253 000 004',
email: 'zara@zaraaanimal.pt',
openHours: 'SegSex 10h18h',
},
},
{
id: 'mock-6',
name: 'Nala',
species: 'DOG',
breed: 'Golden Retriever',
ageMonths: 60,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'A Nala é uma Golden Retriever de 5 anos, calma, carinhosa e treinada. Adora crianças e adapta-se facilmente a qualquer ambiente. Adoptada e devolvida por motivos de saúde do dono anterior — não tem qualquer problema.',
photos: [
'https://images.unsplash.com/photo-1601758125946-6ec2ef64daf8?w=600&q=80',
'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?w=600&q=80',
],
shelter: {
id: 'shelter-2',
name: 'Associação Amigos dos Animais do Porto',
district: 'Porto',
address: 'Av. da Boavista, 450, 4100-100 Porto',
phone: '222 000 002',
email: 'adopcao@amigosanimais.pt',
openHours: 'SegDom 10h17h',
},
},
{
id: 'mock-7',
name: 'Simba',
species: 'CAT',
breed: 'Maine Coon',
ageMonths: 30,
sex: 'MALE',
sterilized: true,
status: 'AVAILABLE',
urgent: false,
description:
'O Simba é um Maine Coon imponente e gentil. Tem um pelo magnífico e um temperamento sereno. Ideal para quem procura um gato tranquilo e sociável.',
photos: [
'https://images.unsplash.com/photo-1574158622682-e40e69881006?w=600&q=80',
'https://images.unsplash.com/photo-1533738363-b7f9aef128ce?w=600&q=80',
],
shelter: {
id: 'shelter-3',
name: 'Centro de Recolha de Sintra',
district: 'Sintra',
address: 'Estrada da Serra, 8, 2710-001 Sintra',
phone: '219 000 003',
email: 'sintra@recolha.pt',
openHours: 'TerSáb 9h17h',
},
},
{
id: 'mock-8',
name: 'Bica',
species: 'DOG',
breed: 'Rafeiro Alentejano',
ageMonths: 48,
sex: 'FEMALE',
sterilized: true,
status: 'AVAILABLE',
urgent: true,
description:
'A Bica é uma Rafeira Alentejana de 4 anos, leal e protectora. Cresceu na rua mas já está completamente socializada. Precisa de espaço exterior e de uma pessoa paciente.',
photos: [
'https://images.unsplash.com/photo-1583337130417-3346a1be7dee?w=600&q=80',
'https://images.unsplash.com/photo-1548199973-03cce0bbc87b?w=600&q=80',
],
shelter: {
id: 'shelter-4',
name: 'Associação Zara Animal',
district: 'Braga',
address: 'Rua das Flores, 22, 4700-001 Braga',
phone: '253 000 004',
email: 'zara@zaraaanimal.pt',
openHours: 'SegSex 10h18h',
},
},
];
export function formatAge(months: number): string {
if (months < 12) return `${months} ${months === 1 ? 'mês' : 'meses'}`;
const years = Math.floor(months / 12);
const rem = months % 12;
if (rem === 0) return `${years} ${years === 1 ? 'ano' : 'anos'}`;
return `${years} ${years === 1 ? 'ano' : 'anos'} e ${rem} ${rem === 1 ? 'mês' : 'meses'}`;
}
export function formatSex(sex: 'MALE' | 'FEMALE'): string {
return sex === 'MALE' ? '♂ Macho' : '♀ Fêmea';
}
export function formatSpecies(species: 'DOG' | 'CAT' | 'OTHER'): string {
return species === 'DOG' ? 'Cão' : species === 'CAT' ? 'Gato' : 'Outro';
}

32
lib/validations/animal.ts Normal file
View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const animalFilterSchema = z.object({
district: z.string().optional(),
species: z.enum(['DOG', 'CAT', 'OTHER']).optional(),
sex: z.enum(['MALE', 'FEMALE']).optional(),
sterilized: z.coerce.boolean().optional(),
urgent: z.coerce.boolean().optional(),
status: z.enum(['AVAILABLE', 'RESERVED', 'ADOPTED']).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(50).default(12),
});
export const createAnimalSchema = z.object({
name: z.string().min(1, 'Nome obrigatório.').max(80),
species: z.enum(['DOG', 'CAT', 'OTHER']),
breed: z.string().max(80).optional(),
ageMonths: z.number().int().min(0).max(300),
sex: z.enum(['MALE', 'FEMALE']),
sterilized: z.boolean(),
urgent: z.boolean().default(false),
description: z.string().max(2000).optional(),
shelterId: z.string().cuid(),
});
export const updateAnimalSchema = createAnimalSchema
.partial()
.extend({ status: z.enum(['AVAILABLE', 'RESERVED', 'ADOPTED']).optional() });
export type AnimalFilter = z.infer<typeof animalFilterSchema>;
export type CreateAnimalInput = z.infer<typeof createAnimalSchema>;
export type UpdateAnimalInput = z.infer<typeof updateAnimalSchema>;

55
lib/validations/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import { z } from 'zod';
const DISTRITOS_PT = [
'Aveiro', 'Beja', 'Braga', 'Bragança', 'Castelo Branco', 'Coimbra',
'Évora', 'Faro', 'Guarda', 'Leiria', 'Lisboa', 'Portalegre', 'Porto',
'Santarém', 'Setúbal', 'Viana do Castelo', 'Vila Real', 'Viseu',
'Açores', 'Madeira',
] as const;
export const loginSchema = z.object({
email: z.string().email('Email inválido.'),
password: z.string().min(1, 'Palavra-passe obrigatória.'),
});
export const registerSchema = z
.object({
name: z.string().min(2, 'Nome deve ter pelo menos 2 caracteres.').max(100),
email: z.string().email('Email inválido.'),
password: z.string()
.min(8, 'A palavra-passe deve ter pelo menos 8 caracteres.')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula.')
.regex(/[0-9]/, 'Deve conter pelo menos um número.'),
confirmPassword: z.string(),
birthdate: z.string().refine((v) => !isNaN(new Date(v).getTime()), 'Data inválida.'),
district: z.enum(DISTRITOS_PT, { error: 'Selecciona um distrito válido.' }),
terms: z.literal(true, { error: 'Tens de aceitar os termos.' }),
})
.refine((d) => d.password === d.confirmPassword, {
message: 'As palavras-passe não coincidem.',
path: ['confirmPassword'],
});
export const forgotPasswordSchema = z.object({
email: z.string().email('Email inválido.'),
});
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
password: z.string()
.min(8, 'A palavra-passe deve ter pelo menos 8 caracteres.')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula.')
.regex(/[0-9]/, 'Deve conter pelo menos um número.'),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: 'As palavras-passe não coincidem.',
path: ['confirmPassword'],
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export const DISTRITOS = DISTRITOS_PT;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const createDonationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('MONETARY'),
shelterId: z.string().cuid(),
details: z.object({
amount: z.number().min(1, 'Mínimo 1€.').max(10000),
currency: z.literal('EUR').default('EUR'),
deliveryMethod: z.literal('payment'),
}),
}),
z.object({
type: z.literal('FOOD'),
shelterId: z.string().cuid(),
details: z.object({
foodType: z.enum(['dry', 'wet']),
animalType: z.enum(['dog', 'cat']),
ageGroup: z.enum(['adult', 'puppy']),
deliveryMethod: z.enum(['pickup', 'home_delivery']),
address: z.string().max(200).optional(),
}),
}),
z.object({
type: z.literal('TOYS'),
shelterId: z.string().cuid(),
details: z.object({
category: z.enum(['chew', 'plush', 'interactive']),
deliveryMethod: z.enum(['pickup', 'home_delivery']),
address: z.string().max(200).optional(),
}),
}),
]);
export type CreateDonationInput = z.infer<typeof createDonationSchema>;

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createReservationSchema = z.object({
animalId: z.string().cuid('ID de animal inválido.'),
date: z
.string()
.refine((v) => !isNaN(new Date(v).getTime()), 'Data inválida.')
.refine((v) => new Date(v) > new Date(), 'A data tem de ser no futuro.'),
notes: z.string().max(500).optional(),
});
export const updateReservationSchema = z.object({
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
});
export type CreateReservationInput = z.infer<typeof createReservationSchema>;
export type UpdateReservationInput = z.infer<typeof updateReservationSchema>;