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:
35
lib/auth/age-validation.ts
Normal file
35
lib/auth/age-validation.ts
Normal 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
60
lib/auth/config.ts
Normal 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
14
lib/auth/password.ts
Normal 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
11
lib/db/prisma.ts
Normal 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
102
lib/email/index.ts
Normal 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
253
lib/mock-data.ts
Normal 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: 'Seg–Sex 9h–18h · Sáb 10h–14h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Seg–Dom 10h–17h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Ter–Sáb 9h–17h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Seg–Sex 9h–18h · Sáb 10h–14h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Seg–Sex 10h–18h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Seg–Dom 10h–17h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Ter–Sáb 9h–17h',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Seg–Sex 10h–18h',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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
32
lib/validations/animal.ts
Normal 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
55
lib/validations/auth.ts
Normal 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;
|
||||
35
lib/validations/donation.ts
Normal file
35
lib/validations/donation.ts
Normal 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>;
|
||||
17
lib/validations/reservation.ts
Normal file
17
lib/validations/reservation.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user