feat: reset password + page mon-compte (RGPD) + facettes recherche (prix max, équipements)
All checks were successful
CI / test (pull_request) Successful in 2m19s

This commit is contained in:
Claude Integration 2026-06-01 10:16:37 +00:00
parent 0b5e5408e8
commit a6df96db7e
19 changed files with 922 additions and 0 deletions

View file

@ -16,6 +16,8 @@ export type CarbetSearchFilters = {
// Filtre plugin access-type : si "river-only" exclu, on garde uniquement
// ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe.
accessibility?: "road-only" | "all";
priceMax?: number;
amenities?: string[];
};
export type RawSearchParams = {
@ -69,6 +71,24 @@ export function parseSearchFilters(
filters.accessibility = accessibility;
}
const priceMaxRaw = pickString(searchParams.priceMax);
if (priceMaxRaw) {
const priceMax = Number(priceMaxRaw);
if (Number.isFinite(priceMax) && priceMax > 0 && priceMax <= 10000) {
filters.priceMax = priceMax;
}
}
const amenitiesRaw = searchParams.amenities;
if (amenitiesRaw) {
const arr = Array.isArray(amenitiesRaw) ? amenitiesRaw : [amenitiesRaw];
const keys = arr
.flatMap((s) => s.split(","))
.map((s) => s.trim())
.filter((s) => /^[a-z0-9-]{1,40}$/.test(s));
if (keys.length > 0) filters.amenities = keys.slice(0, 10);
}
return filters;
}
@ -112,6 +132,16 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
where.accessType = AccessType.ROAD_AND_RIVER;
}
if (filters.priceMax !== undefined) {
where.nightlyPrice = { lte: filters.priceMax };
}
if (filters.amenities && filters.amenities.length > 0) {
where.AND = filters.amenities.map((key) => ({
amenities: { some: { amenity: { key } } },
}));
}
if (filters.startDate && filters.endDate) {
where.availabilities = {
some: {

View file

@ -186,6 +186,23 @@ export async function sendBookingConfirmed(
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,
): Promise<void> {
await sendEmail({
to,
subject: "Réinitialisation de votre mot de passe Karbé",
html: wrap(
"Réinitialiser votre mot de passe",
`<p>Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :</p>
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email votre mot de passe ne change pas.</p>`,
),
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

51
src/lib/password-reset.ts Normal file
View file

@ -0,0 +1,51 @@
import "server-only";
import crypto from "node:crypto";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/password";
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/** Crée un token (renvoie la version *plain* à mettre dans l'URL email). */
export async function createPasswordResetToken(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
await prisma.passwordResetToken.create({
data: { tokenHash, userId, expiresAt },
});
return token;
}
/** Vérifie un token plain → renvoie userId si valide & non expiré. */
export async function consumePasswordResetToken(
plainToken: string,
newPassword: string,
): Promise<{ ok: true; userId: string } | { ok: false; reason: string }> {
const tokenHash = hashToken(plainToken);
const row = await prisma.passwordResetToken.findUnique({ where: { tokenHash } });
if (!row) return { ok: false, reason: "Lien invalide." };
if (row.expiresAt < new Date()) {
await prisma.passwordResetToken.delete({ where: { tokenHash } }).catch(() => {});
return { ok: false, reason: "Lien expiré." };
}
const passwordHash = await hashPassword(newPassword);
await prisma.$transaction([
prisma.user.update({ where: { id: row.userId }, data: { passwordHash } }),
prisma.passwordResetToken.deleteMany({ where: { userId: row.userId } }),
]);
return { ok: true, userId: row.userId };
}
/** Cleanup utility — peut être lancé par un cron. */
export async function purgeExpiredResetTokens(): Promise<number> {
const result = await prisma.passwordResetToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}