SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
1 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%

Identité unique (OTP courriel Resend/mock, sessions), publications directes (wizard 8 étapes, médias sharp/HEIC, modération), messagerie, visites, favoris serveur, alertes, admin TOTP, recherche unifiée, fiches enrichies, PostgreSQL/Drizzle, tests d'intégration

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Oct 17, 2026) parent 95daa1d

93 changed files +1,913 −137

added Dockerfile +9 −0
@@ -0,0 +1,9 @@
1 +FROM node:24-bookworm-slim
2 +RUN corepack enable && apt-get update && apt-get install -y --no-install-recommends python3 make g++ sqlite3 && rm -rf /var/lib/apt/lists/*
3 +WORKDIR /app
4 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
5 +RUN pnpm install --frozen-lockfile
6 +COPY . .
7 +RUN pnpm build
8 +EXPOSE 3000
9 +CMD ["pnpm", "start"]
added docker-compose.yml +35 −0
@@ -0,0 +1,35 @@
1 +# Développement local / exécution sur la machine du propriétaire. PostgreSQL n'est PAS exposé hors de la machine (bind 127.0.0.1).
2 +# Production MacLustr : PostgreSQL 17 Homebrew sur M3U96b (localhost, scram-sha-256), app via PM2 — voir README.
3 +services:
4 + postgres:
5 + image: postgres:17-alpine
6 + environment:
7 + POSTGRES_DB: toveli
8 + POSTGRES_USER: toveli_app
9 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-toveli_dev}
10 + ports: ["127.0.0.1:5432:5432"]
11 + volumes: ["pgdata:/var/lib/postgresql/data"]
12 + healthcheck: { test: ["CMD-SHELL", "pg_isready -U toveli_app -d toveli"], interval: 5s, timeout: 3s, retries: 20 }
13 + app:
14 + build: .
15 + depends_on: { postgres: { condition: service_healthy } }
16 + env_file: [.env.local]
17 + environment:
18 + DATABASE_URL: postgresql://toveli_app:${POSTGRES_PASSWORD:-toveli_dev}@postgres:5432/toveli
19 + STORAGE_DIR: /app/data/media
20 + TOVELI_DB: /app/data/toveli.db
21 + PORT: "3000"
22 + ports: ["127.0.0.1:3000:3000"]
23 + volumes: ["./data:/app/data"]
24 + command: sh -c "pnpm db:migrate && pnpm start"
25 + worker:
26 + build: .
27 + depends_on: { postgres: { condition: service_healthy } }
28 + env_file: [.env.local]
29 + environment:
30 + DATABASE_URL: postgresql://toveli_app:${POSTGRES_PASSWORD:-toveli_dev}@postgres:5432/toveli
31 + STORAGE_DIR: /app/data/media
32 + TOVELI_DB: /app/data/toveli.db
33 + volumes: ["./data:/app/data"]
34 + command: sh -c "while true; do pnpm tsx scripts/jobs.ts; sleep 3600; done"
35 +volumes: { pgdata: {} }
modified package.json +4 −1
@@ -13,11 +13,14 @@
13 13 "db:generate": "drizzle-kit generate",
14 14 "db:migrate": "tsx scripts/migrate.ts",
15 15 "db:studio": "drizzle-kit studio",
16 − "test:db": "TOVELI_TEST_DB=1 vitest run tests/db"
16 + "test:db": "vitest run tests/db",
17 + "jobs": "tsx scripts/jobs.ts",
18 + "test:all": "vitest run"
17 19 },
18 20 "dependencies": {
19 21 "@anthropic-ai/sdk": "^0.135.0",
20 22 "better-sqlite3": "^13.0.3",
23 + "dotenv": "^18.0.8",
21 24 "drizzle-orm": "^0.45.4",
22 25 "heic-convert": "^2.1.0",
23 26 "maplibre-gl": "^6.14.0",
modified pnpm-lock.yaml +10 −0
@@ -14,6 +14,9 @@ importers:
14 14 better-sqlite3:
15 15 specifier: ^13.0.3
16 16 version: 13.0.3
17 + dotenv:
18 + specifier: ^18.0.8
19 + version: 18.0.8
17 20 drizzle-orm:
18 21 specifier: ^0.45.4
19 22 version: 0.45.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@9.6.0)(@types/pg@8.23.1)(better-sqlite3@13.0.3)(postgres@3.4.9)
@@ -1785,6 +1788,11 @@ packages:
1785 1788 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
1786 1789 engines: {node: '>=0.10.0'}
1787 1790
1791 + dotenv@18.0.8:
1792 + resolution: {integrity: sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==}
1793 + engines: {node: '>=12'}
1794 + hasBin: true
1795 +
1788 1796 drizzle-kit@0.31.11:
1789 1797 resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==}
1790 1798 hasBin: true
@@ -4688,6 +4696,8 @@ snapshots:
4688 4696 dependencies:
4689 4697 esutils: 2.0.3
4690 4698
4699 + dotenv@18.0.8: {}
4700 +
4691 4701 drizzle-kit@0.31.11:
4692 4702 dependencies:
4693 4703 '@drizzle-team/brocli': 0.10.2
added scripts/backup.sh +13 −0
@@ -0,0 +1,13 @@
1 +#!/bin/zsh
2 +# Sauvegarde chiffrée de la base PostgreSQL Toveli (compte applicatif, lecture) → data/backups/toveli-YYYYmmdd-HHMM.sql.gz.age
3 +# Restauration testée : age -d -i key.txt fichier.age | gunzip | psql $DATABASE_URL
4 +set -euo pipefail
5 +cd "$(dirname "$0")/.."
6 +export PATH=/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH
7 +: "${DATABASE_URL:?DATABASE_URL requise}"
8 +mkdir -p data/backups
9 +out="data/backups/toveli-$(date +%Y%m%d-%H%M).sql.gz"
10 +pg_dump --no-owner --no-privileges "$DATABASE_URL" | gzip -9 > "$out"
11 +if [[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] && command -v age >/dev/null; then age -r "$BACKUP_AGE_RECIPIENT" -o "$out.age" "$out" && rm -f "$out"; out="$out.age"; fi
12 +ls -t data/backups/toveli-* | tail -n +15 | xargs -r rm -f
13 +echo "sauvegarde OK : $out ($(du -h "$out" | cut -f1))"
added scripts/grant-role.ts +9 −0
@@ -0,0 +1,9 @@
1 +// Attribution d'un rôle depuis le serveur (jamais depuis le navigateur) : DATABASE_URL=… pnpm tsx scripts/grant-role.ts <email> <ADMIN|MODERATOR|AGENT_VERIFIED>
2 +import postgres from "postgres";
3 +const [email, role] = process.argv.slice(2);
4 +if (!email || !["ADMIN", "MODERATOR", "AGENT_VERIFIED"].includes(role ?? "")) { console.error("usage: grant-role.ts <email> <ADMIN|MODERATOR|AGENT_VERIFIED>"); process.exit(1); }
5 +const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
6 +const [u] = await sql`select id from users where email = ${email.toLowerCase()}`; if (!u) { console.error("utilisateur introuvable (il doit s'être connecté une première fois)"); process.exit(1); }
7 +await sql`insert into user_roles (user_id, role, note) values (${u.id}, ${role}, 'CLI serveur') on conflict do nothing`;
8 +await sql`insert into audit_events (actor_id, action, object_type, object_id, metadata_redacted) values (null, 'cli.grant_role', 'user', ${u.id}, ${sql.json({ role })})`;
9 +console.log(`rôle ${role} attribué à ${email}`); await sql.end();
added scripts/jobs.ts +54 −0
@@ -0,0 +1,54 @@
1 +// Tâches périodiques Toveli (PM2 cron, toutes les heures) : alertes de recherches sauvegardées, rappels d'expiration,
2 +// expiration automatique, purge des défis OTP et jetons d'upload orphelins, nettoyage des médias en attente.
3 +import "dotenv/config";
4 +import { and, eq, lt, sql, isNull } from "drizzle-orm";
5 +import { db } from "../src/db/client";
6 +import { savedSearches, users, listings, otpChallenges, listingMedia, emailDeliveries } from "../src/db/schema";
7 +import { unifiedSearch } from "../src/lib/search";
8 +import { fromUrl } from "../src/lib/search-params";
9 +import { sendMail, notifyMail } from "../src/lib/auth/email";
10 +import { fmtPrice } from "../src/lib/format";
11 +import { removeFiles } from "../src/lib/listings/media";
12 +
13 +const BASE = (process.env.APP_BASE_URL ?? "https://www.toveli.ca").replace(/\/$/, "");
14 +const log = (m: string) => console.log(`[jobs ${new Date().toISOString().slice(11, 19)}] ${m}`);
15 +
16 +async function alerts() {
17 + const d = db(); const now = Date.now();
18 + const rows = await d.select({ s: savedSearches, email: users.email, prefs: users.prefs, status: users.status }).from(savedSearches).innerJoin(users, eq(users.id, savedSearches.userId)).where(and(eq(savedSearches.alertEnabled, true), sql`${savedSearches.alertFrequency} <> 'OFF'`));
19 + let sent = 0;
20 + for (const r of rows) {
21 + if (r.status !== "ACTIVE" || (r.prefs as { notifications?: { alerts?: boolean } }).notifications?.alerts === false) continue;
22 + const period = r.s.alertFrequency === "INSTANT" ? 3600000 : r.s.alertFrequency === "DAILY" ? 86400000 : 7 * 86400000;
23 + const last = r.s.lastAlertAt?.getTime() ?? r.s.createdAt.getTime(); if (now - last < period) continue;
24 + const p = fromUrl(r.s.query as Record<string, string>, 50); const res = await unifiedSearch({ ...p, sort: "recent" });
25 + const fresh = res.items.filter((i) => i.firstSeen && Date.parse(i.firstSeen) > last).slice(0, 10);
26 + await d.update(savedSearches).set({ lastAlertAt: new Date() }).where(eq(savedSearches.id, r.s.id));
27 + if (!fresh.length) continue;
28 + const lines = fresh.map((i) => `• ${fmtPrice(i.price, { rent: i.market === "rent" })} — ${i.address ?? i.typeLabel}, ${i.city} (${BASE}/annonce/${encodeURIComponent(i.uid)})`).join("<br/>");
29 + try { await sendMail(notifyMail(r.email, `${fresh.length} nouvelle${fresh.length > 1 ? "s" : ""} annonce${fresh.length > 1 ? "s" : ""} : ${r.s.label}`, lines, `${BASE}/recherche?${new URLSearchParams(r.s.query as Record<string, string>)}`, "Voir les résultats", "alert_digest")); sent++; } catch { /* journalisé */ }
30 + }
31 + log(`alertes : ${rows.length} recherches, ${sent} courriels`);
32 +}
33 +async function expiry() {
34 + const d = db(); const soon = new Date(Date.now() + 7 * 86400000);
35 + const rows = await d.select({ l: listings, email: users.email }).from(listings).innerJoin(users, eq(users.id, listings.ownerUserId)).where(and(eq(listings.status, "PUBLISHED"), lt(listings.expiresAt, soon)));
36 + for (const r of rows) {
37 + if (r.l.expiresAt && r.l.expiresAt.getTime() < Date.now()) { await d.update(listings).set({ status: "PAUSED", moderationNote: "Expirée après 90 jours sans renouvellement", updatedAt: new Date() }).where(eq(listings.id, r.l.id)); try { await sendMail(notifyMail(r.email, "Votre annonce a expiré", `« ${r.l.title ?? "Votre annonce"} » a été mise en pause après 90 jours. Réactivez-la en un clic si elle est toujours d'actualité.`, `${BASE}/membre/annonces`, "Mes annonces", "listing_expired")); } catch { /* */ } continue; }
38 + const [already] = await d.select({ id: emailDeliveries.id }).from(emailDeliveries).where(and(eq(emailDeliveries.template, `expiry_soon:${r.l.id}`), sql`created_at > now() - interval '8 days'`)).limit(1); if (already) continue;
39 + try { await sendMail({ ...notifyMail(r.email, "Votre annonce expire bientôt", `« ${r.l.title ?? "Votre annonce"} » expirera le ${r.l.expiresAt?.toLocaleDateString("fr-CA")}. Mettez-la à jour pour la prolonger de 90 jours, ou marquez-la vendue/louée.`, `${BASE}/membre/annonces/${r.l.id}`, "Mettre à jour", "expiry_soon"), template: `expiry_soon:${r.l.id}` }); } catch { /* */ }
40 + }
41 + log(`expiration : ${rows.length} annonces traitées`);
42 +}
43 +async function purge() {
44 + const d = db();
45 + const otp = await d.delete(otpChallenges).where(lt(otpChallenges.expiresAt, new Date(Date.now() - 86400000))).returning({ id: otpChallenges.id });
46 + const stale = await d.select().from(listingMedia).where(and(eq(listingMedia.status, "UPLOADING"), lt(listingMedia.uploadedAt, new Date(Date.now() - 3600000))));
47 + for (const m of stale) { await d.update(listingMedia).set({ status: "DELETED", uploadToken: null }).where(eq(listingMedia.id, m.id)); if (!m.storageKey.startsWith("pending/")) removeFiles(m.storageKey); }
48 + await d.execute(sql`delete from rate_limits where window_start < now() - interval '2 days'`);
49 + await d.execute(sql`delete from email_deliveries where created_at < now() - interval '90 days'`);
50 + await d.execute(sql`update sessions set revoked_at = now() where revoked_at is null and expires_at < now()`);
51 + log(`purge : ${otp.length} défis OTP, ${stale.length} uploads orphelins`);
52 + void isNull;
53 +}
54 +(async () => { try { await purge(); await expiry(); await alerts(); } catch (e) { console.error("[jobs] échec", e); process.exitCode = 1; } finally { const { rawSql } = await import("../src/db/client"); await rawSql().end({ timeout: 5 }); } })();
added scripts/smoke.sh +34 −0
@@ -0,0 +1,34 @@
1 +#!/bin/zsh
2 +# Test de fumée API bout en bout (serveur local déjà démarré) : usage scripts/smoke.sh http://127.0.0.1:8460 [db]
3 +set -u
4 +B=${1:-http://127.0.0.1:8460}; DB=${2:-toveli}; ORIGIN=${3:-http://localhost:3000}; HOSTH=${ORIGIN#*://}
5 +H=(-H "content-type: application/json" -H "Origin: $ORIGIN" -H "Host: $HOSTH")
6 +G=(-H "Host: $HOSTH")
7 +CJ=$(mktemp)
8 +echo "--- request-code"; curl -s -X POST $B/api/auth/request-code "${H[@]}" -d '{"email":"Simon.Test@exemple.ca"}'; echo
9 +CODE=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from otp_challenges order by created_at desc limit 1"); echo "code=$CODE challenge=$CID"
10 +echo "--- verify sans conditions (attendu 409)"; curl -s -o /dev/null -w "%{http_code}\n" -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":false}"
11 +echo "--- verify avec conditions"; curl -s -c $CJ -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":true}"; echo
12 +echo "--- réutilisation (attendu 400)"; curl -s -o /dev/null -w "%{http_code}\n" -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":true}"
13 +echo "--- me"; curl -s -b $CJ "${G[@]}" $B/api/me | cut -c1-160; echo
14 +echo "--- create listing"; LID=$(curl -s -b $CJ -X POST $B/api/me/listings "${H[@]}" -d '{"listingType":"RENT","propertyType":"appartement","address":"1225 Rue Saint-Marc","city":"Montréal","lat":45.4922,"lng":-73.5789,"bedrooms":1,"bathrooms":1,"areaSqft":650,"title":"Grand 3½ lumineux près du métro","description":"Logement rénové, très lumineux, planchers de bois, à deux pas du métro Guy-Concordia et des commerces. Chauffage inclus.","priceAmount":1450,"availabilityDate":"now"}' | python3 -c "import json,sys;print(json.load(sys.stdin)['listing']['id'])"); echo "listing=$LID"
15 +python3 -c "
16 +import struct,zlib
17 +def png(w,h):
18 + raw=b''.join(b'\x00'+bytes([200,30,60]*w) for _ in range(h))
19 + def chunk(t,d): return struct.pack('>I',len(d))+t+d+struct.pack('>I',zlib.crc32(t+d)&0xffffffff)
20 + return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw))+chunk(b'IEND',b'')
21 +open('/tmp/t.png','wb').write(png(400,300))"
22 +echo "--- upload png"; UP=$(curl -s -b $CJ -X POST $B/api/listings/$LID/media/presign "${H[@]}" -d '{"fileName":"t.png","mimeType":"image/png"}' | python3 -c "import json,sys;print(json.load(sys.stdin)['uploadUrl'])"); curl -s -b $CJ -X PUT "$B$UP" -H "content-type: image/png" "${H[@]:1}" --data-binary @/tmp/t.png; echo
23 +echo "--- upload fichier non image (attendu 422)"; UP=$(curl -s -b $CJ -X POST $B/api/listings/$LID/media/presign "${H[@]}" -d '{}' | python3 -c "import json,sys;print(json.load(sys.stdin)['uploadUrl'])"); printf 'MZ not an image at all........' | curl -s -b $CJ -X PUT "$B$UP" -H "content-type: image/png" "${H[@]:1}" --data-binary @-; echo
24 +echo "--- publish sans attestation (attendu 422)"; curl -s -b $CJ -X POST $B/api/listings/$LID/publish "${H[@]}" | cut -c1-200; echo
25 +echo "--- attest + publish"; curl -s -b $CJ -X POST $B/api/listings/$LID/attest "${H[@]}" >/dev/null; curl -s -b $CJ -X POST $B/api/listings/$LID/publish "${H[@]}"; echo
26 +SLUG=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select slug from listings where id='$LID'"); echo "--- page publique toveli:$SLUG"; curl -s -o /tmp/dp.html -w "%{http_code} %{size_download}o\n" "$B/annonce/toveli%3A$SLUG"; grep -o "Publiée par le propriétaire\|Contacter\|Demander une visite\|En bref\|Budget mensuel" /tmp/dp.html | sort | uniq -c
27 +echo "--- recherche unifiée"; curl -s "$B/api/search?marche=location&ville=Montr%C3%A9al&limit=3" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['total'], [i['uid'] for i in d['items']][:3])"
28 +echo "--- second utilisateur : contact + visite"; curl -s -X POST $B/api/auth/request-code "${H[@]}" -d '{"email":"acheteur@exemple.ca"}' >/dev/null; CODE2=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID2=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from otp_challenges order by created_at desc limit 1"); CJ2=$(mktemp); curl -s -c $CJ2 -o /dev/null -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID2\",\"code\":\"$CODE2\",\"acceptTerms\":true}"
29 +curl -s -b $CJ2 -X POST $B/api/listings/$LID/inquiries "${H[@]}" -d '{"body":"Bonjour, le logement est-il encore disponible ? Mon numéro : 514 555 0100"}'; echo
30 +SLOT=$(python3 -c "import datetime;print((datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(days=2)).isoformat())"); curl -s -b $CJ2 -X POST $B/api/listings/$LID/viewings "${H[@]}" -d "{\"slots\":[\"$SLOT\"],\"note\":\"Je peux passer en soirée.\"}"; echo
31 +echo "--- propriétaire voit la demande (téléphone masqué ?)"; curl -s -b $CJ "${G[@]}" $B/api/me/inquiries | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['inquiries']),'conversations,',len(d['viewings']),'visites;', d['inquiries'][0]['lastBody'] if d['inquiries'] else '')"
32 +echo "--- intrus (attendu 404)"; IQ=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from inquiries order by created_at desc limit 1"); curl -s -o /dev/null -w "%{http_code}\n" "${G[@]}" $B/api/inquiries/$IQ/messages
33 +echo "--- pages"; for u in /membre /membre/annonces /membre/annonces/$LID /membre/messages /membre/visites /membre/profil /membre/securite /membre/alertes /membre/favoris /connexion /admin "/recherche?marche=location&ville=Montr%C3%A9al"; do printf "%s → " "$u"; curl -s -b $CJ "${G[@]}" -o /dev/null -w "%{http_code}\n" "$B$u"; done
34 +echo "--- courriels simulés"; psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select template, status, count(*) from email_deliveries group by 1,2 order by 1"
added src/app/admin/annonces/admin-listings.tsx +17 −0
@@ -0,0 +1,17 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import { fmtPrice, fmtDate } from "@/lib/format";
4 +import { STATUS_LABEL } from "@/lib/listings/types";
5 +type Row = { l: { id: string; slug: string; title: string | null; status: string; listingType: string; priceAmount: string | null; moderationNote: string | null; updatedAt: string; sourceType: string }; p: { city: string | null; canonicalAddress: string | null; propertyType: string }; ownerEmail: string | null };
6 +export function AdminListings() {
7 + const [status, setStatus] = useState("PENDING_REVIEW"); const [rows, setRows] = useState<Row[]>([]); const load = () => fetch(`/api/admin/listings?status=${status}`).then((r) => r.json()).then((j) => setRows(j.items ?? []));
8 + useEffect(() => { load(); }, [status]); // eslint-disable-line react-hooks/exhaustive-deps
9 + const act = async (id: string, action: string) => { const note = action !== "approve" ? prompt("Motif communiqué à l'annonceur :") ?? undefined : undefined; if (action !== "approve" && note == null) return; await fetch("/api/admin/listings", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, action, note }) }); load(); };
10 + return (
11 + <div>
12 + <div className="flex items-center gap-2 mb-3"><h1 className="text-2xl font-extrabold tracking-tight">Annonces</h1><select value={status} onChange={(e) => setStatus(e.target.value)} className="tv-chip appearance-none ml-auto">{["PENDING_REVIEW", "PUBLISHED", "REJECTED", "PAUSED", "DRAFT", "all"].map((s) => <option key={s} value={s}>{STATUS_LABEL[s] ?? "Toutes"}</option>)}</select></div>
13 + <ul className="tv-card divide-y divide-line text-sm">{rows.map(({ l, p, ownerEmail }) => <li key={l.id} className="p-3 flex flex-wrap gap-3 items-center"><div className="min-w-0 flex-1"><p className="font-semibold truncate">{l.title ?? "(sans titre)"} <span className="text-ink-3 font-normal">· {l.listingType === "RENT" ? "location" : "vente"} · {p.propertyType} · {fmtPrice(l.priceAmount ? Number(l.priceAmount) : null, { rent: l.listingType === "RENT" })}</span></p><p className="text-xs text-ink-2 truncate">{p.canonicalAddress}, {p.city} · {ownerEmail ?? "—"} · {l.sourceType} · maj {fmtDate(l.updatedAt)}</p>{l.moderationNote && <p className="text-xs text-warn">{l.moderationNote}</p>}</div>
14 + <div className="flex gap-2 text-xs"><a href={`/annonce/${encodeURIComponent("toveli:" + l.slug)}`} target="_blank" rel="noreferrer" className="underline">Voir</a><a href={`/membre/annonces/${l.id}`} className="underline">Éditer</a>{l.status !== "PUBLISHED" && <button type="button" onClick={() => act(l.id, "approve")} className="tv-btn tv-btn-primary !py-1 !px-3">Approuver</button>}{l.status === "PUBLISHED" && <button type="button" onClick={() => act(l.id, "unpublish")} className="tv-btn tv-btn-ghost !py-1 !px-3">Retirer</button>}{l.status !== "REJECTED" && <button type="button" onClick={() => act(l.id, "reject")} className="tv-btn tv-btn-ghost !py-1 !px-3 !text-brand-dark">Refuser</button>}</div></li>)}{rows.length === 0 && <li className="p-6 text-center text-ink-3">Rien à afficher.</li>}</ul>
15 + </div>
16 + );
17 +}
added src/app/admin/annonces/page.tsx +3 −0
@@ -0,0 +1,3 @@
1 +import { AdminListings } from "./admin-listings";
2 +export const dynamic = "force-dynamic";
3 +export default function Page() { return <AdminListings />; }
added src/app/admin/journal/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import { desc } from "drizzle-orm";
2 +import { db } from "@/db/client";
3 +import { auditEvents } from "@/db/schema";
4 +export const dynamic = "force-dynamic";
5 +export default async function Journal() {
6 + const rows = await db().select().from(auditEvents).orderBy(desc(auditEvents.createdAt)).limit(300);
7 + return (<div><h1 className="text-2xl font-extrabold tracking-tight mb-3">Journal d'audit (300 derniers)</h1><div className="tv-card overflow-x-auto"><table className="w-full text-xs"><thead className="text-left text-ink-3"><tr><th className="p-2 font-medium">Date</th><th className="p-2 font-medium">Action</th><th className="p-2 font-medium">Acteur</th><th className="p-2 font-medium">Objet</th><th className="p-2 font-medium">Méta (minimisées)</th></tr></thead><tbody>{rows.map((r) => <tr key={r.id} className="border-t border-line"><td className="p-2 whitespace-nowrap">{r.createdAt.toLocaleString("fr-CA", { timeZone: "America/Toronto" })}</td><td className="p-2 font-medium">{r.action}</td><td className="p-2 font-mono">{r.actorId?.slice(0, 8) ?? "—"}</td><td className="p-2">{r.objectType ? `${r.objectType} ${String(r.objectId ?? "").slice(0, 12)}` : "—"}</td><td className="p-2 font-mono text-ink-2">{JSON.stringify(r.metadataRedacted).slice(0, 80)}</td></tr>)}</tbody></table></div><p className="text-xs text-ink-3 mt-2">Append-only : aucune route ne modifie ni ne supprime ces lignes ; adresses IP hachées, aucun code ni jeton.</p></div>);
8 +}
added src/app/admin/layout.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import { redirect } from "next/navigation";
2 +import Link from "next/link";
3 +import { headers } from "next/headers";
4 +import { currentUser } from "@/lib/auth/session";
5 +import { pgAvailable } from "@/db/client";
6 +import { Empty } from "@/components/ui";
7 +import { isFreshMfa } from "@/lib/admin/mfa";
8 +export const dynamic = "force-dynamic";
9 +/** Console d'administration applicative : rôle ADMIN/MODERATOR + TOTP récent. Non indexée ; à exposer de préférence via VPN/Tailscale. */
10 +export default async function AdminLayout({ children }: { children: React.ReactNode }) {
11 + if (!pgAvailable()) return <div className="mx-auto max-w-3xl px-4 py-10"><Empty title="Indisponible" /></div>;
12 + const u = await currentUser(); const path = (await headers()).get("x-pathname") ?? "/admin";
13 + if (!u) redirect(`/connexion?next=${encodeURIComponent(path)}`);
14 + if (!u.roles.includes("ADMIN") && !u.roles.includes("MODERATOR")) redirect("/membre");
15 + const fresh = isFreshMfa(u.mfaAt);
16 + if (!fresh && !path.startsWith("/admin/mfa")) redirect(`/admin/mfa?next=${encodeURIComponent(path)}`);
17 + const nav = [["/admin", "Tableau de bord"], ["/admin/annonces", "Annonces à vérifier"], ["/admin/signalements", "Signalements"], ...(u.roles.includes("ADMIN") ? [["/admin/utilisateurs", "Utilisateurs"]] : []), ["/admin/journal", "Journal d'audit"]];
18 + return (
19 + <div className="mx-auto max-w-7xl px-4 py-6">
20 + <div className="flex flex-wrap items-center gap-3 mb-5"><span className="tv-badge bg-ink text-white">Administration</span><nav className="flex gap-1 overflow-x-auto no-scrollbar">{nav.map(([h, l]) => <Link key={h} href={h} className="tv-chip">{l}</Link>)}</nav><span className="ml-auto text-xs text-ink-3">{u.email} · {u.roles.join(", ")}{fresh ? " · TOTP ✓" : ""}</span></div>
21 + {children}
22 + </div>
23 + );
24 +}
added src/app/admin/mfa/mfa-view.tsx +18 −0
@@ -0,0 +1,18 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import { useRouter, useSearchParams } from "next/navigation";
4 +import { Notice } from "@/components/ui";
5 +export function MfaView() {
6 + const [st, setSt] = useState<{ enrolled: boolean; fresh: boolean; setup: { base32: string; uri: string } | null } | null>(null); const [code, setCode] = useState(""); const [err, setErr] = useState<string | null>(null); const router = useRouter(); const sp = useSearchParams();
7 + useEffect(() => { fetch("/api/admin/mfa").then((r) => r.json()).then(setSt); }, []);
8 + const submit = async () => { setErr(null); const r = await fetch("/api/admin/mfa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, base32: st?.setup?.base32 }) }); if (!r.ok) { setErr("Code invalide"); return; } const next = sp.get("next") && sp.get("next")!.startsWith("/admin") ? sp.get("next")! : "/admin"; router.push(next); router.refresh(); };
9 + if (!st) return <p className="text-sm text-ink-3">Chargement…</p>;
10 + return (
11 + <div className="max-w-md space-y-4">
12 + <h1 className="text-2xl font-extrabold tracking-tight">Vérification forte</h1>
13 + {!st.enrolled && st.setup && (<div className="tv-card p-4 space-y-2 text-sm"><p className="font-semibold">Activer l'application d'authentification</p><p className="text-ink-2">Ajoutez cette clé dans votre application TOTP (1Password, Google Authenticator, Authy…), puis entrez le code généré.</p><p className="font-mono text-xs break-all bg-surface-2 p-2 rounded-sm">{st.setup.base32}</p><a href={st.setup.uri} className="text-xs underline text-brand-dark">Ouvrir dans l'application (otpauth://)</a></div>)}
14 + <div className="tv-card p-4 space-y-3"><label className="block text-sm"><span className="tv-label">Code à 6 chiffres</span><input inputMode="numeric" maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} className="tv-input !pl-4 mt-1 text-center text-2xl tracking-[.4em] font-bold" /></label>{err && <Notice tone="warn">{err}</Notice>}<button type="button" onClick={submit} disabled={code.length !== 6} className="tv-btn tv-btn-primary w-full">{st.enrolled ? "Vérifier" : "Activer et vérifier"}</button></div>
15 + <Notice>Le code courriel seul ne suffit pas pour les actions d'administration : un second facteur TOTP est exigé et renouvelé toutes les 30 minutes. Chaque action est journalisée (append-only).</Notice>
16 + </div>
17 + );
18 +}
added src/app/admin/mfa/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import { Suspense } from "react";
2 +import { MfaView } from "./mfa-view";
3 +export const dynamic = "force-dynamic";
4 +export default function Mfa() { return <Suspense><MfaView /></Suspense>; }
added src/app/admin/page.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import Link from "next/link";
2 +import { sql, eq } from "drizzle-orm";
3 +import { db } from "@/db/client";
4 +import { listings, reports, users, emailDeliveries, auditEvents } from "@/db/schema";
5 +import { Stat } from "@/components/ui";
6 +import { fmtInt } from "@/lib/format";
7 +import { meta } from "@/lib/db";
8 +export const dynamic = "force-dynamic";
9 +export default async function AdminHome() {
10 + const d = db();
11 + const [[l], [r], [u], [m], [a]] = await Promise.all([
12 + d.select({ pending: sql<number>`sum(case when status='PENDING_REVIEW' then 1 else 0 end)::int`, published: sql<number>`sum(case when status='PUBLISHED' then 1 else 0 end)::int`, total: sql<number>`count(*)::int` }).from(listings),
13 + d.select({ open: sql<number>`count(*)::int` }).from(reports).where(eq(reports.status, "OPEN")),
14 + d.select({ n: sql<number>`count(*)::int`, d7: sql<number>`sum(case when created_at > now() - interval '7 days' then 1 else 0 end)::int` }).from(users),
15 + d.select({ n: sql<number>`count(*)::int`, failed: sql<number>`sum(case when status='FAILED' then 1 else 0 end)::int`, mocked: sql<number>`sum(case when status='MOCKED' then 1 else 0 end)::int` }).from(emailDeliveries).where(sql`created_at > now() - interval '7 days'`),
16 + d.select({ n: sql<number>`count(*)::int` }).from(auditEvents).where(sql`created_at > now() - interval '24 hours'`),
17 + ]);
18 + return (
19 + <div className="space-y-5">
20 + <h1 className="text-2xl font-extrabold tracking-tight">Tableau de bord</h1>
21 + <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
22 + <Link href="/admin/annonces"><Stat label="Annonces à vérifier" value={fmtInt(l.pending)} tone={l.pending ? "warn" : undefined} sub={`${l.published} publiées · ${l.total} au total`} /></Link>
23 + <Link href="/admin/signalements"><Stat label="Signalements ouverts" value={fmtInt(r.open)} tone={r.open ? "warn" : undefined} /></Link>
24 + <Stat label="Comptes" value={fmtInt(u.n)} sub={`${u.d7} cette semaine`} />
25 + <Stat label="Courriels (7 j)" value={fmtInt(m.n)} sub={`${m.failed} échecs · ${m.mocked} simulés${m.mocked ? " (transport mock)" : ""}`} tone={m.failed ? "warn" : undefined} />
26 + <Stat label="Événements d'audit (24 h)" value={fmtInt(a.n)} />
27 + </div>
28 + <div className="tv-card p-4 text-sm"><p className="font-semibold">Sources et synchronisation</p><p className="text-ink-2">Base canonique construite le {meta("built_at") ?? "—"} · {fmtInt(Number(meta("listings_sale")))} à vendre · {fmtInt(Number(meta("listings_rent")))} à louer · {fmtInt(Number(meta("properties")))} propriétés · {fmtInt(Number(meta("valuations")))} valuations. Journal : <code>data/sync.log</code> sur le nœud.</p>{!process.env.RESEND_API_KEY && <p className="text-warn mt-2">RESEND_API_KEY absente : les courriels sont simulés (data/outbox/mails.jsonl). Configurer une clé et vérifier le domaine d'envoi avant l'ouverture publique des comptes.</p>}</div>
29 + </div>
30 + );
31 +}
added src/app/admin/signalements/admin-reports.tsx +15 −0
@@ -0,0 +1,15 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import { fmtDate } from "@/lib/format";
4 +type R = { id: string; targetType: string; targetId: string; reason: string; details: string | null; status: string; createdAt: string };
5 +export function AdminReports() {
6 + const [status, setStatus] = useState("OPEN"); const [rows, setRows] = useState<R[]>([]); const load = () => fetch(`/api/admin/reports?status=${status}`).then((r) => r.json()).then((j) => setRows(j.items ?? []));
7 + useEffect(() => { load(); }, [status]); // eslint-disable-line react-hooks/exhaustive-deps
8 + const act = async (id: string, s: string) => { await fetch("/api/admin/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, status: s }) }); load(); };
9 + const link = (r: R) => r.targetType === "AGGREGATED_LISTING" ? `/annonce/${encodeURIComponent(r.targetId)}` : r.targetType === "LISTING" ? `/admin/annonces?id=${r.targetId}` : null;
10 + return (
11 + <div><div className="flex items-center gap-2 mb-3"><h1 className="text-2xl font-extrabold tracking-tight">Signalements</h1><select value={status} onChange={(e) => setStatus(e.target.value)} className="tv-chip appearance-none ml-auto"><option value="OPEN">Ouverts</option><option value="RESOLVED">Résolus</option><option value="DISMISSED">Rejetés</option></select></div>
12 + <ul className="tv-card divide-y divide-line text-sm">{rows.map((r) => <li key={r.id} className="p-3 flex flex-wrap gap-3 items-center"><div className="min-w-0 flex-1"><p className="font-semibold">{r.reason.replace(/_/g, " ")} <span className="text-ink-3 font-normal">· {r.targetType} · {fmtDate(r.createdAt)}</span></p><p className="text-xs text-ink-2 break-all">{r.targetId}</p>{r.details && <p className="text-xs text-ink-2 mt-1">« {r.details} »</p>}</div><div className="flex gap-2 text-xs">{link(r) && <a href={link(r)!} target="_blank" rel="noreferrer" className="underline">Voir la cible</a>}{r.status === "OPEN" && <><button type="button" onClick={() => act(r.id, "RESOLVED")} className="tv-btn tv-btn-primary !py-1 !px-3">Résolu</button><button type="button" onClick={() => act(r.id, "DISMISSED")} className="tv-btn tv-btn-ghost !py-1 !px-3">Sans suite</button></>}</div></li>)}{rows.length === 0 && <li className="p-6 text-center text-ink-3">Aucun signalement.</li>}</ul>
13 + <p className="text-xs text-ink-3 mt-2">Pour une annonce agrégée, « Résolu » signifie que la demande a été transmise/traitée côté source ; l'annonce disparaîtra à la prochaine synchronisation si la source la retire.</p></div>
14 + );
15 +}
added src/app/admin/signalements/page.tsx +3 −0
@@ -0,0 +1,3 @@
1 +import { AdminReports } from "./admin-reports";
2 +export const dynamic = "force-dynamic";
3 +export default function Page() { return <AdminReports />; }
added src/app/admin/utilisateurs/admin-users.tsx +15 −0
@@ -0,0 +1,15 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import { fmtDate } from "@/lib/format";
4 +type U = { id: string; email: string; displayName: string | null; status: string; createdAt: string; roles: string[]; nListings: number };
5 +export function AdminUsers() {
6 + const [q, setQ] = useState(""); const [rows, setRows] = useState<U[]>([]); const load = () => fetch(`/api/admin/users?q=${encodeURIComponent(q)}`).then((r) => r.json()).then((j) => setRows(j.items ?? []));
7 + useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [q]); // eslint-disable-line react-hooks/exhaustive-deps
8 + const act = async (id: string, action: string, role?: string) => { await fetch("/api/admin/users", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, action, role }) }); load(); };
9 + return (
10 + <div><div className="flex items-center gap-2 mb-3"><h1 className="text-2xl font-extrabold tracking-tight">Utilisateurs</h1><input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher un courriel" className="tv-chip ml-auto !pl-3" /></div>
11 + <ul className="tv-card divide-y divide-line text-sm">{rows.map((u) => <li key={u.id} className="p-3 flex flex-wrap gap-3 items-center"><div className="min-w-0 flex-1"><p className="font-semibold truncate">{u.email} {u.status !== "ACTIVE" && <span className="tv-badge bg-warn-soft text-warn">{u.status}</span>}</p><p className="text-xs text-ink-2">{u.displayName ?? "—"} · inscrit le {fmtDate(u.createdAt)} · {u.nListings} annonce(s) · rôles : {u.roles.length ? u.roles.join(", ") : "aucun"}</p></div>
12 + <div className="flex flex-wrap gap-2 text-xs">{u.status === "ACTIVE" ? <button type="button" onClick={() => confirm("Suspendre ce compte ?") && act(u.id, "suspend")} className="tv-btn tv-btn-ghost !py-1 !px-3">Suspendre</button> : <button type="button" onClick={() => act(u.id, "activate")} className="tv-btn tv-btn-ghost !py-1 !px-3">Réactiver</button>}{["MODERATOR", "AGENT_VERIFIED", "ADMIN"].map((r) => u.roles.includes(r) ? <button key={r} type="button" onClick={() => act(u.id, "revoke", r)} className="underline text-ink-3">− {r}</button> : <button key={r} type="button" onClick={() => confirm(`Attribuer le rôle ${r} ?`) && act(u.id, "grant", r)} className="underline">+ {r}</button>)}</div></li>)}</ul>
13 + <p className="text-xs text-ink-3 mt-2">AGENT_VERIFIED n'est attribué qu'après vérification hors ligne (permis OACIQ, mandat) ; ADMIN exige un TOTP actif.</p></div>
14 + );
15 +}
added src/app/admin/utilisateurs/page.tsx +3 −0
@@ -0,0 +1,3 @@
1 +import { AdminUsers } from "./admin-users";
2 +export const dynamic = "force-dynamic";
3 +export default function Page() { return <AdminUsers />; }
modified src/app/annonce/[uid]/listing-tabs.tsx +25 −3
@@ -9,15 +9,19 @@ import { PriceHistoryChart } from "@/components/price-chart";
9 9 import { MapView } from "@/components/map-view";
10 10 import { MortgageWidget } from "@/components/mortgage-widget";
11 11 import { EstimateBreakdown } from "@/components/estimate-breakdown";
12 +import type { Insight, MarketPosition } from "@/lib/insights";
13 +import Link2 from "next/link";
12 14
13 −type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record<string, unknown>; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null };
15 +type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record<string, unknown>; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null; direct?: boolean };
16 +type Fin = { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null };
17 +type Budget = { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null;
14 18 type Score = { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | null;
15 19 type Sale = { id: string; date: string; amount: number; street: string; city: string; property_type: string | null; year_built: number | null; floor_area: number | null };
16 20 type Prop = { id: string; role_value: number | null; role_land: number | null; role_building: number | null; key_kind: string; dedup_confidence: number | null; n_listings: number } | null;
17 21
18 22 const SKIP_KEYS = new Set(["pieces", "img_audited", "transaction", "prix_pi2", "prix_m2", "inclusions", "appliances", "parking"]);
19 23
20 −export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop }) {
24 +export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property, insights = [], position, financials, budget }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop; insights?: Insight[]; position?: MarketPosition; financials?: Fin; budget?: Budget }) {
21 25 const rent = l.market === "rent";
22 26 const items = [{ id: "apercu", label: "Aperçu" }, { id: "prix", label: rent ? "Loyer et marché" : "Prix et marché" }, ...(!rent && live ? [{ id: "comparables", label: "Comparables" }] : []), ...(!rent ? [{ id: "budget", label: "Budget" }] : []), { id: "quartier", label: "Quartier" }];
23 27 const [tab, setTab] = useState("apercu");
@@ -30,6 +34,8 @@ export function ListingTabs({ listing: l, valuation, live, priceEvents, score, s
30 34 <Tabs items={items} current={tab} onChange={setTab} />
31 35 <div className="pt-5 space-y-6">
32 36 {tab === "apercu" && (<>
37 + {insights.length > 0 && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">En bref</h2><ul className="space-y-1.5 text-sm">{insights.map((i) => <li key={i.text} className="flex gap-2"><span className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${i.tone === "good" ? "bg-good" : i.tone === "warn" ? "bg-warn" : i.tone === "info" ? "bg-info" : "bg-ink-3"}`} aria-hidden /><span className="text-ink-2">{i.text}</span></li>)}</ul><p className="text-[11px] text-ink-3 mt-2">Constats calculés à partir des données de l'annonce et du marché Toveli, sans interprétation éditoriale.</p></section>}
38 + {budget && <section className="tv-card p-4"><h2 className="font-bold text-lg mb-2">Budget mensuel estimé</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3"><Stat label="Loyer" value={fmtPrice(budget.rent)} /><Stat label="Électricité et chauffage" value={budget.electricityIncluded ? "Inclus" : fmtPrice(budget.electricity)} sub={budget.electricityIncluded ? "électricité incluse" : budget.heatingIncluded ? "chauffage inclus, électricité estimée" : "estimation, chauffage électrique"} /><Stat label="Internet" value={fmtPrice(budget.internet)} sub="forfait moyen" /><Stat label="Total indicatif" value={fmtPrice(budget.total)} tone="info" /></div><ul className="text-[11px] text-ink-3 mt-2 list-disc pl-4">{budget.notes.map((n) => <li key={n}>{n}</li>)}</ul></section>}
33 39 <section><h2 className="font-bold text-lg mb-2">Description</h2>{l.description ? <p className="text-sm text-ink-2 whitespace-pre-line leading-relaxed">{l.description}</p> : <p className="text-sm text-ink-3">Aucune description fournie par la source.</p>}</section>
34 40 {l.features.length > 0 && <section><h2 className="font-bold text-lg mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</h2><ul className="grid sm:grid-cols-2 gap-1.5 text-sm text-ink-2">{l.features.slice(0, 40).map((f) => <li key={f} className="flex gap-2"><span className="text-brand">•</span>{f}</li>)}</ul></section>}
35 41 {detailEntries.length > 0 && <section><h2 className="font-bold text-lg mb-2">Détails pratiques</h2><dl className="grid sm:grid-cols-2 gap-x-6 gap-y-1.5 text-sm">{detailEntries.map(([k, v]) => <div key={k} className="flex justify-between gap-3 border-b border-line py-1"><dt className="text-ink-3 capitalize">{k.replace(/_/g, " ")}</dt><dd className="text-right text-ink-2 truncate max-w-[60%]" title={String(v)}>{String(v)}</dd></div>)}</dl></section>}
@@ -66,6 +72,22 @@ export function ListingTabs({ listing: l, valuation, live, priceEvents, score, s
66 72 </section>
67 73 ) : <Notice>Aucune estimation disponible pour cette annonce ({l.category === "terrain" || l.category === "commercial" ? "catégorie non couverte par le modèle" : "données insuffisantes"}).</Notice>}
68 74
75 + {position?.metric && (
76 + <section>
77 + <h2 className="font-bold text-lg mb-2">Position dans le marché {position.metric.scope === "province" ? "québécois" : `· ${position.metric.scope_value}`}</h2>
78 + <div className="tv-card p-4">
79 + <div className="relative h-2 rounded-full bg-surface-2 mt-5 mb-6">
80 + {[["P25", position.metric.p25_price], ["Médiane", position.metric.median_price], ["P75", position.metric.p75_price]].map(([k, v], i) => <span key={String(k)} className="absolute -bottom-5 text-[10px] text-ink-3 -translate-x-1/2" style={{ left: `${[25, 50, 75][i]}%` }}>{k} {fmtPrice(v as number, { compact: true, rent })}</span>)}
81 + {position.pricePercentile != null && <span className="absolute -top-5 text-[11px] font-semibold text-brand-dark -translate-x-1/2 whitespace-nowrap" style={{ left: `${Math.min(97, Math.max(3, position.pricePercentile))}%` }}>Cette annonce · {position.pricePercentile}e centile<span className="block mx-auto mt-0.5 w-2.5 h-2.5 rounded-full bg-brand" /></span>}
82 + </div>
83 + <p className="text-xs text-ink-2">{fmtInt(position.metric.n)} {l.category === "logement" ? "logements" : `${l.category}s`} {rent ? "à louer" : "à vendre"} comparés · {position.metric.new_30d ?? 0} nouvelles en 30 jours · {position.metric.median_days != null ? `${position.metric.median_days} jours médians en ligne` : ""}{position.ppsfVsMedian != null ? ` · prix/pi² ${fmtPct(position.ppsfVsMedian)} vs médiane` : ""}.</p>
84 + {l.city && <Link2 href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="text-sm font-semibold text-brand-dark hover:underline">Toutes les données de {l.city} →</Link2>}
85 + </div>
86 + </section>
87 + )}
88 + {financials && (financials.municipalTax || financials.schoolTax || financials.assessment || financials.condoFees) && (
89 + <section><h2 className="font-bold text-lg mb-2">Taxes, évaluation et charges</h2><div className="grid grid-cols-2 sm:grid-cols-4 gap-3">{financials.assessment ? <Stat label="Évaluation municipale" value={fmtPrice(financials.assessment)} sub={l.price && financials.assessment ? `prix = ${(l.price / financials.assessment).toFixed(2)} × évaluation` : undefined} /> : null}{financials.municipalTax ? <Stat label="Taxes municipales" value={fmtPrice(financials.municipalTax)} sub="par année" /> : null}{financials.schoolTax ? <Stat label="Taxes scolaires" value={fmtPrice(financials.schoolTax)} sub="par année" /> : null}{financials.condoFees ? <Stat label="Frais de copropriété" value={fmtPrice(financials.condoFees)} sub="par mois" /> : null}</div><p className="text-xs text-ink-3 mt-1">Montants déclarés par la source ou issus du rôle d'évaluation ; vérifiez auprès de la municipalité.</p></section>
90 + )}
69 91 <section>
70 92 <h2 className="font-bold text-lg mb-2">Historique du prix demandé</h2>
71 93 {priceEvents.length >= 2 ? <PriceHistoryChart events={priceEvents} rent={rent} /> : <p className="text-sm text-ink-3">Aucun changement de prix observé depuis la première collecte{priceEvents[0] ? ` (${fmtDate(priceEvents[0].ts)})` : ""}.</p>}
@@ -97,7 +119,7 @@ export function ListingTabs({ listing: l, valuation, live, priceEvents, score, s
97 119 )}
98 120
99 121 {tab === "budget" && !rent && l.price && (
100 − <section><h2 className="font-bold text-lg mb-3">Budget et financement</h2><MortgageWidget price={l.price} city={l.city} compact /></section>
122 + <section><h2 className="font-bold text-lg mb-3">Budget et financement</h2><MortgageWidget price={l.price} city={l.city} compact taxes={financials?.municipalTax ?? undefined} schoolTaxes={financials?.schoolTax ?? undefined} condoFees={financials?.condoFees ?? undefined} /></section>
101 123 )}
102 124
103 125 {tab === "quartier" && (
modified src/app/annonce/[uid]/page.tsx +101 −78
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
2 2 import Link from "next/link";
3 3 import { notFound } from "next/navigation";
4 4 import { getListing, getProperty, nearbyListings, neighbourhoodScore, recentTransactions } from "@/lib/queries";
5 −import { estimateForListing } from "@/lib/estimate";
5 +import { estimateForListing, estimateManual } from "@/lib/estimate";
6 6 import { dbAvailable } from "@/lib/db";
7 7 import { fmtPrice, fmtArea, fmtDate, fmtInt, daysSince } from "@/lib/format";
8 8 import { CATEGORY_LABEL } from "@/lib/normalize";
@@ -13,113 +13,136 @@ import { Notice } from "@/components/ui";
13 13 import { IconExternal, IconBed, IconBath, IconArea, IconPin } from "@/components/icons";
14 14 import { ListingTabs } from "./listing-tabs";
15 15 import { ListingActions } from "./listing-actions";
16 +import { ContactPanel } from "@/components/contact-panel";
17 +import { ReportButton } from "@/components/report-button";
18 +import { insights, marketPosition, extractFinancials, rentalBudget } from "@/lib/insights";
19 +import { DIRECT_PREFIX, getDirectBySlug, publicAddress } from "@/lib/listings/public";
20 +import { incrementViews } from "@/lib/listings/service";
21 +import { mediaUrl } from "@/lib/listings/media";
22 +import { PROPERTY_TYPE_LABEL } from "@/lib/listings/types";
16 23
17 24 export const dynamic = "force-dynamic";
18 25 type Props = { params: Promise<{ uid: string }> };
26 +const parseJson = <T,>(s: string | null | undefined, fb: T): T => { try { const v = JSON.parse(s ?? ""); return (v ?? fb) as T; } catch { return fb; } };
19 27
20 28 export async function generateMetadata({ params }: Props): Promise<Metadata> {
21 − const { uid } = await params; if (!dbAvailable()) return {};
22 − const l = getListing(decodeURIComponent(uid)); if (!l) return { title: "Annonce introuvable" };
23 − const rent = l.market === "rent";
24 − const title = `${l.address ?? CATEGORY_LABEL[l.category]}, ${l.city} — ${fmtPrice(l.price, { rent })}`;
25 − const images = JSON.parse(l.images ?? "[]") as string[];
26 − return { title, description: (l.description ?? "").slice(0, 160) || `${CATEGORY_LABEL[l.category]} ${rent ? "à louer" : "à vendre"} à ${l.city}.`, openGraph: { title, images: images.slice(0, 1) }, alternates: { canonical: `/annonce/${encodeURIComponent(l.uid)}` } };
29 + const { uid: raw } = await params; const uid = decodeURIComponent(raw);
30 + if (uid.startsWith(DIRECT_PREFIX)) { const d = await getDirectBySlug(uid.slice(DIRECT_PREFIX.length)).catch(() => null); if (!d || d.l.status !== "PUBLISHED") return { title: "Annonce introuvable" }; const rent = d.l.listingType === "RENT"; const title = `${d.l.title ?? PROPERTY_TYPE_LABEL[d.p.propertyType]}, ${d.p.city} — ${fmtPrice(d.card.price, { rent })}`; return { title, description: (d.l.description ?? "").slice(0, 160), openGraph: { title, images: d.media.slice(0, 1).map((m) => `https://www.toveli.ca${mediaUrl(m.storageKey, 1600)}`) }, alternates: { canonical: `/annonce/${encodeURIComponent(uid)}` } }; }
31 + if (!dbAvailable()) return {};
32 + const l = getListing(uid); if (!l) return { title: "Annonce introuvable" };
33 + const rent = l.market === "rent"; const title = `${l.address ?? CATEGORY_LABEL[l.category]}, ${l.city} — ${fmtPrice(l.price, { rent })}`;
34 + return { title, description: (l.description ?? "").slice(0, 160) || `${CATEGORY_LABEL[l.category]} ${rent ? "à louer" : "à vendre"} à ${l.city}.`, openGraph: { title, images: parseJson<string[]>(l.images, []).slice(0, 1) }, alternates: { canonical: `/annonce/${encodeURIComponent(l.uid)}` } };
27 35 }
28 36
37 +function Crumbs({ rent, region, city }: { rent: boolean; region: string | null; city: string | null }) {
38 + const m = rent ? "location" : "achat";
39 + return <nav className="text-xs text-ink-3 mb-3 flex gap-1.5 flex-wrap" aria-label="Fil d'Ariane"><Link href="/" className="hover:underline">Accueil</Link> › <Link href={`/recherche?marche=${m}`} className="hover:underline">{rent ? "Louer" : "Acheter"}</Link>{region && <> › <Link href={`/recherche?marche=${m}&region=${encodeURIComponent(region)}`} className="hover:underline">{region}</Link></>}{city && <> › <Link href={`/recherche?marche=${m}&ville=${encodeURIComponent(city)}`} className="hover:underline">{city}</Link></>}</nav>;
40 +}
41 +function Chips({ typeLabel, bedrooms, bathrooms, powder, area, lot, year, availability, rent, extra = [] }: { typeLabel: string; bedrooms: number | null; bathrooms: number | null; powder?: number | null; area: number | null; lot: number | null; year: number | null; availability?: string | null; rent: boolean; extra?: string[] }) {
42 + return <ul className="mt-3 flex flex-wrap gap-2 text-sm">
43 + <li className="tv-chip">{typeLabel}</li>
44 + {bedrooms != null && <li className="tv-chip"><IconBed width={15} height={15} /> {fmtInt(bedrooms)} chambre{bedrooms > 1 ? "s" : ""}</li>}
45 + {bathrooms != null && <li className="tv-chip"><IconBath width={15} height={15} /> {fmtInt(bathrooms)} salle{bathrooms > 1 ? "s" : ""} de bain{powder ? ` + ${powder} s. d'eau` : ""}</li>}
46 + {area != null && area > 0 && <li className="tv-chip"><IconArea width={15} height={15} /> {fmtArea(area)} <span className="text-ink-3">({fmtArea(area, "m2")})</span></li>}
47 + {lot != null && lot > 0 && <li className="tv-chip">Terrain {fmtArea(lot)}</li>}
48 + {year != null && <li className="tv-chip">Construite en {year}</li>}
49 + {availability && rent && <li className="tv-chip">Disponible : {availability === "now" ? "immédiatement" : availability}</li>}
50 + {extra.map((e) => <li key={e} className="tv-chip">{e}</li>)}
51 + </ul>;
52 +}
53 +
54 +/* ───────────── Annonce directe Toveli ───────────── */
55 +async function DirectPage({ slug }: { slug: string }) {
56 + const d = await getDirectBySlug(slug).catch(() => null); if (!d || d.l.status !== "PUBLISHED") notFound();
57 + await incrementViews([d.l.id]).catch(() => {});
58 + const rent = d.l.listingType === "RENT"; const c = d.card; const p = d.p; const l = d.l;
59 + const images = d.media.map((m) => mediaUrl(m.storageKey, 1600));
60 + const features = (l.inclusions as string[]) ?? []; const details: Record<string, unknown> = { ...(l.features as Record<string, unknown>), ...(l.parking ? { Stationnement: l.parking } : {}), ...(l.furnished != null ? { Meublé: l.furnished ? "Oui" : "Non" } : {}), ...(l.petsPolicy ? { Animaux: { allowed: "Acceptés", not_allowed: "Refusés", ask: "À discuter" }[l.petsPolicy] } : {}), ...(l.smokingPolicy ? { Fumeur: l.smokingPolicy === "allowed" ? "Permis" : "Non-fumeur" } : {}), ...(l.leaseTerms ? { Bail: l.leaseTerms } : {}) };
61 + const live = !rent && p.lat != null && p.lng != null && !["terrain", "commercial"].includes(p.propertyType) ? estimateManual({ lat: p.lat, lng: p.lng, category: c.category, floorAreaSqft: p.areaSqft, yearBuilt: p.yearBuilt, landAreaSqft: p.lotSqft, label: c.address ?? "Propriété", city: p.city }) : null;
62 + const score = p.lat != null && p.lng != null ? neighbourhoodScore(p.lat, p.lng) : null;
63 + const nearby = p.lat != null && p.lng != null ? nearbyListings(p.lat, p.lng, c.market, "", 6) : [];
64 + const sales = !rent && p.lat != null && p.lng != null ? recentTransactions(p.lat, p.lng, 8) : [];
65 + const pos = marketPosition({ market: c.market, category: c.category, city: p.city, region: p.region, price: c.price, area_sqft: p.areaSqft, first_seen: c.firstSeen });
66 + const ins = insights({ market: c.market, category: c.category, city: p.city, price: c.price, area_sqft: p.areaSqft, lot_sqft: p.lotSqft, year_built: p.yearBuilt, bedrooms: c.bedrooms, first_seen: c.firstSeen, image_count: images.length, type_label: c.typeLabel }, live ? { estimate: live.result.estimate, ratio: c.price ? live.result.estimate / c.price : null, verdict: null, confidence: live.result.confidenceLevel } : null, pos, 0, null, score);
67 + const budget = rent ? rentalBudget({ price: c.price, area_sqft: p.areaSqft, features, details }) : null;
68 + const publisher = l.sourceType === "DIRECT_AGENT" ? "Publiée par un représentant" : "Publiée par le propriétaire";
69 + const showName = (d.owner?.prefs as { privacy?: { showNameOnListings?: boolean } } | null)?.privacy?.showNameOnListings !== false;
70 + const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.title ?? undefined, description: (l.description ?? "").slice(0, 500), address: { "@type": "PostalAddress", streetAddress: publicAddress(p) ?? undefined, addressLocality: p.city ?? undefined, addressRegion: "QC", postalCode: p.privacyLevel === "EXACT" ? p.postalCode ?? undefined : undefined, addressCountry: "CA" }, geo: c.lat != null ? { "@type": "GeoCoordinates", latitude: c.lat, longitude: c.lng } : undefined, numberOfRooms: c.bedrooms ?? undefined, floorSize: p.areaSqft ? { "@type": "QuantitativeValue", value: p.areaSqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: c.price ?? undefined, priceCurrency: "CAD", availability: "https://schema.org/InStock" }, image: images.slice(0, 3).map((i) => `https://www.toveli.ca${i}`) };
71 + return (
72 + <article className="mx-auto max-w-7xl px-4 py-4 md:py-6">
73 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
74 + <Crumbs rent={rent} region={p.region} city={p.city} />
75 + <Gallery images={images} credit={`${publisher.toLowerCase()} sur Toveli`} />
76 + <div className="mt-5 grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6">
77 + <div className="min-w-0">
78 + <header>
79 + <div className="flex flex-wrap items-center gap-2"><p className="tv-price text-3xl md:text-4xl">{fmtPrice(c.price, { rent })}</p><span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span><span className="tv-badge bg-good-soft text-good">{publisher}</span></div>
80 + <h1 className="mt-2 text-xl md:text-2xl font-bold tracking-tight">{l.title ?? c.typeLabel}</h1>
81 + <p className="text-ink-2 flex items-center gap-1"><IconPin width={16} height={16} /> {c.address ?? "Adresse communiquée après contact"}{p.unit && p.privacyLevel === "EXACT" ? `, app. ${p.unit}` : ""} · {p.city}{p.region ? `, ${p.region}` : ""}</p>
82 + <Chips typeLabel={c.typeLabel ?? ""} bedrooms={c.bedrooms} bathrooms={c.bathrooms} area={p.areaSqft} lot={p.lotSqft} year={p.yearBuilt} availability={l.availabilityDate} rent={rent} extra={[...(l.furnished ? ["Meublé"] : []), ...(l.petsPolicy === "allowed" ? ["Animaux acceptés"] : [])]} />
83 + <div className="lg:hidden mt-4"><ContactPanel listingId={l.id} uid={c.uid} publisherLabel={publisher} publisherName={showName ? d.publicName ?? null : null} /></div>
84 + <p className="mt-3 text-xs text-ink-3">Annonce n° {l.slug} · publiée le {fmtDate(l.publicationAt?.toISOString())} · mise à jour le {fmtDate(l.updatedAt.toISOString())} · {fmtInt(l.viewsCount)} vues{p.privacyLevel !== "EXACT" ? " · adresse exacte masquée à la demande de l'annonceur" : ""}.</p>
85 + </header>
86 + <ListingTabs listing={{ uid: c.uid, market: c.market, price: c.price, description: l.description, features, details, lat: c.lat, lng: c.lng, category: c.category, city: p.city, address: c.address, area_sqft: p.areaSqft, year_built: p.yearBuilt, bedrooms: c.bedrooms, bathrooms: c.bathrooms, direct: true }} valuation={null} live={live} priceEvents={[]} score={score} sales={sales} property={null} insights={ins} position={pos} financials={{ municipalTax: Number((l.features as Record<string, unknown>).taxes_municipales) || null, schoolTax: Number((l.features as Record<string, unknown>).taxes_scolaires) || null, assessment: null, condoFees: Number((l.features as Record<string, unknown>).frais_condo) || null }} budget={budget} />
87 + </div>
88 + <aside className="space-y-4">
89 + <div className="hidden lg:block"><ContactPanel listingId={l.id} uid={c.uid} publisherLabel={publisher} publisherName={showName ? d.publicName ?? null : null} /></div>
90 + {live && <div className="tv-card p-4"><p className="tv-label">Estimation Toveli (indicative)</p><p className="tv-price text-2xl mt-1">{fmtPrice(live.result.estimate)}</p><p className="text-xs text-ink-2">{fmtPrice(live.result.low)} – {fmtPrice(live.result.high)} · confiance {live.result.confidenceLevel} ({live.result.confidencePct} %)</p><Notice>Calculée à partir de ventes comparables autour de la position déclarée ; l'annonceur n'intervient pas dans ce calcul. Pas une évaluation agréée.</Notice></div>}
91 + <div className="tv-card p-4"><p className="tv-label">Provenance</p><dl className="mt-2 text-xs text-ink-2 space-y-1"><div className="flex justify-between gap-2"><dt>Source</dt><dd>Publication directe sur Toveli</dd></div><div className="flex justify-between gap-2"><dt>Annonceur</dt><dd>{l.sourceType === "DIRECT_AGENT" ? "représentant (non vérifié)" : "propriétaire (courriel vérifié)"}</dd></div><div className="flex justify-between gap-2"><dt>Photos</dt><dd>droits déclarés par l'annonceur, EXIF retirés</dd></div><div className="flex justify-between gap-2"><dt>Modération</dt><dd>{l.moderationState === "HUMAN_OK" ? "vérifiée par Toveli" : "contrôle automatique"}</dd></div></dl></div>
92 + </aside>
93 + </div>
94 + {nearby.length > 0 && <section className="mt-10"><h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2><div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((n) => <ListingCardView key={n.uid} c={n} />)}</div></section>}
95 + </article>
96 + );
97 +}
98 +
99 +/* ───────────── Annonce agrégée ───────────── */
29 100 export default async function Annonce({ params }: Props) {
30 − const { uid } = await params; if (!dbAvailable()) notFound();
31 − const l = getListing(decodeURIComponent(uid)); if (!l) notFound();
32 − const rent = l.market === "rent";
33 − const prop = getProperty(l.property_id);
34 − const images = JSON.parse(l.images ?? "[]") as string[];
35 − const features = (() => { try { const f = JSON.parse(l.features ?? "[]"); return Array.isArray(f) ? f.map(String) : []; } catch { return []; } })();
36 − const details = (() => { try { const d = JSON.parse(l.details ?? "{}"); return d && typeof d === "object" && !Array.isArray(d) ? d as Record<string, unknown> : {}; } catch { return {}; } })();
101 + const { uid: raw } = await params; const uid = decodeURIComponent(raw);
102 + if (uid.startsWith(DIRECT_PREFIX)) return <DirectPage slug={uid.slice(DIRECT_PREFIX.length)} />;
103 + if (!dbAvailable()) notFound();
104 + const l = getListing(uid); if (!l) notFound();
105 + const rent = l.market === "rent"; const prop = getProperty(l.property_id);
106 + const images = parseJson<string[]>(l.images, []); const features = parseJson<unknown[]>(l.features, []).map(String); const details = (() => { const d = parseJson<unknown>(l.details, {}); return d && typeof d === "object" && !Array.isArray(d) ? d as Record<string, unknown> : {}; })();
37 107 const live = !rent && l.lat != null ? estimateForListing(l, prop?.role_unit_id ?? null) : null;
38 108 const score = l.lat != null && l.lng != null ? neighbourhoodScore(l.lat, l.lng) : null;
39 109 const nearby = l.lat != null && l.lng != null ? nearbyListings(l.lat, l.lng, l.market, l.uid, 6) : [];
40 110 const sales = !rent && l.lat != null && l.lng != null ? recentTransactions(l.lat, l.lng, 8) : [];
41 111 const siblings = (prop?.listings ?? []).filter((x) => x.uid !== l.uid);
42 − const days = daysSince(l.first_seen);
43 − const credit = l.connector.replace(/_/g, " ");
112 + const days = daysSince(l.first_seen); const credit = l.connector.replace(/_/g, " ");
113 + const pos = marketPosition({ market: l.market, category: l.category, city: l.city, region: l.region, price: l.price, area_sqft: l.area_sqft, first_seen: l.first_seen });
114 + const drops = l.priceEvents.filter((e, i, a) => i > 0 && e.price < a[i - 1].price).length;
115 + const val = l.valuation ? { estimate: l.valuation.estimate, ratio: l.valuation.ratio, verdict: l.valuation.verdict, confidence: l.valuation.confidence } : live ? { estimate: live.result.estimate, ratio: l.price ? live.result.estimate / l.price : null, verdict: null, confidence: live.result.confidenceLevel } : null;
116 + const ins = insights(l, val, pos, drops, prop?.role_value ?? null, score);
117 + const fin = extractFinancials(details); if (!fin.assessment && prop?.role_value) fin.assessment = prop.role_value;
118 + const budget = rent ? rentalBudget({ price: l.price, area_sqft: l.area_sqft, features, details }) : null;
44 119 const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.address ?? undefined, address: { "@type": "PostalAddress", streetAddress: l.address ?? undefined, addressLocality: l.city ?? undefined, addressRegion: "QC", addressCountry: "CA" }, geo: l.lat != null ? { "@type": "GeoCoordinates", latitude: l.lat, longitude: l.lng } : undefined, numberOfRooms: l.bedrooms ?? undefined, floorSize: l.area_sqft ? { "@type": "QuantitativeValue", value: l.area_sqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: l.price ?? undefined, priceCurrency: "CAD", url: l.url ?? undefined, availability: "https://schema.org/InStock" }, image: images.slice(0, 3) };
45 −
46 120 return (
47 121 <article className="mx-auto max-w-7xl px-4 py-4 md:py-6">
48 122 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
49 − <nav className="text-xs text-ink-3 mb-3 flex gap-1.5 flex-wrap" aria-label="Fil d'Ariane"><Link href="/" className="hover:underline">Accueil</Link> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}`} className="hover:underline">{rent ? "Louer" : "Acheter"}</Link>{l.region && <> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}&region=${encodeURIComponent(l.region)}`} className="hover:underline">{l.region}</Link></>}{l.city && <> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}&ville=${encodeURIComponent(l.city)}`} className="hover:underline">{l.city}</Link></>}</nav>
50 −
51 − {/* 1. Galerie */}
123 + <Crumbs rent={rent} region={l.region} city={l.city} />
52 124 <Gallery images={images} credit={credit} />
53 −
54 125 <div className="mt-5 grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6">
55 126 <div className="min-w-0">
56 − {/* 2. Prix + badge + adresse + caractéristiques */}
57 127 <header>
58 − <div className="flex flex-wrap items-center gap-2">
59 − <p className="tv-price text-3xl md:text-4xl">{fmtPrice(l.price, { rent })}</p>
60 − <span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span>
61 − {l.valuation && <VerdictPill verdict={l.valuation.verdict} ratio={l.valuation.ratio} confidence={l.valuation.confidence} />}
62 − </div>
128 + <div className="flex flex-wrap items-center gap-2"><p className="tv-price text-3xl md:text-4xl">{fmtPrice(l.price, { rent })}</p><span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span>{l.valuation && <VerdictPill verdict={l.valuation.verdict} ratio={l.valuation.ratio} confidence={l.valuation.confidence} />}</div>
63 129 <h1 className="mt-2 text-xl md:text-2xl font-bold tracking-tight">{l.address ?? CATEGORY_LABEL[l.category]}{l.unit ? `, app. ${l.unit}` : ""}</h1>
64 130 <p className="text-ink-2 flex items-center gap-1"><IconPin width={16} height={16} /> {l.city}{l.sector ? ` (${l.sector})` : ""}{l.region ? `, ${l.region}` : ""}</p>
65 − <ul className="mt-3 flex flex-wrap gap-2 text-sm">
66 − <li className="tv-chip">{l.type_label ?? CATEGORY_LABEL[l.category]}</li>
67 − {l.bedrooms != null && <li className="tv-chip"><IconBed width={15} height={15} /> {fmtInt(l.bedrooms)} chambre{l.bedrooms > 1 ? "s" : ""}</li>}
68 − {l.bathrooms != null && <li className="tv-chip"><IconBath width={15} height={15} /> {fmtInt(l.bathrooms)} salle{l.bathrooms > 1 ? "s" : ""} de bain{l.powder_rooms ? ` + ${l.powder_rooms} s. d'eau` : ""}</li>}
69 − {l.area_sqft != null && l.area_sqft > 0 && <li className="tv-chip"><IconArea width={15} height={15} /> {fmtArea(l.area_sqft)} <span className="text-ink-3">({fmtArea(l.area_sqft, "m2")})</span></li>}
70 − {l.lot_sqft != null && l.lot_sqft > 0 && <li className="tv-chip">Terrain {fmtArea(l.lot_sqft)}</li>}
71 − {l.year_built != null && <li className="tv-chip">Construite en {l.year_built}</li>}
72 − {l.availability && rent && <li className="tv-chip">Disponible : {l.availability === "now" ? "immédiatement" : l.availability}</li>}
73 − </ul>
131 + <Chips typeLabel={l.type_label ?? CATEGORY_LABEL[l.category]} bedrooms={l.bedrooms} bathrooms={l.bathrooms} powder={l.powder_rooms} area={l.area_sqft} lot={l.lot_sqft} year={l.year_built} availability={l.availability} rent={rent} extra={[...(l.furnished ? ["Meublé"] : []), ...(l.pets && /oui|accept|allowed|yes/i.test(l.pets) ? ["Animaux acceptés"] : [])]} />
74 132 <ListingActions uid={l.uid} url={l.url} connector={credit} />
75 133 <p className="mt-3 text-xs text-ink-3">Source : <strong className="text-ink-2">{credit}</strong>{l.agency ? ` · ${l.agency}` : ""}{l.mls ? ` · n° ${l.mls}` : ""} · vue pour la première fois le {fmtDate(l.first_seen)}{days != null ? ` (il y a ${days} j)` : ""} · dernière vérification le {fmtDate(l.last_seen)}.</p>
76 134 </header>
77 −
78 − {/* 3→9 : onglets Aperçu / Prix et marché / Comparables / Budget / Quartier */}
79 − <ListingTabs listing={{ uid: l.uid, market: l.market, price: l.price, description: l.description, features, details, lat: l.lat, lng: l.lng, category: l.category, city: l.city, address: l.address, area_sqft: l.area_sqft, year_built: l.year_built, bedrooms: l.bedrooms, bathrooms: l.bathrooms }}
80 − valuation={l.valuation} live={live} priceEvents={l.priceEvents} score={score} sales={sales} property={prop ? { id: prop.id, role_value: prop.role_value, role_land: prop.role_land, role_building: prop.role_building, key_kind: prop.key_kind, dedup_confidence: prop.dedup_confidence, n_listings: prop.n_listings } : null} />
135 + <ListingTabs listing={{ uid: l.uid, market: l.market, price: l.price, description: l.description, features, details, lat: l.lat, lng: l.lng, category: l.category, city: l.city, address: l.address, area_sqft: l.area_sqft, year_built: l.year_built, bedrooms: l.bedrooms, bathrooms: l.bathrooms, direct: false }} valuation={l.valuation} live={live} priceEvents={l.priceEvents} score={score} sales={sales} property={prop ? { id: prop.id, role_value: prop.role_value, role_land: prop.role_land, role_building: prop.role_building, key_kind: prop.key_kind, dedup_confidence: prop.dedup_confidence, n_listings: prop.n_listings } : null} insights={ins} position={pos} financials={fin} budget={budget} />
81 136 </div>
82 −
83 137 <aside className="space-y-4">
84 − <div className="tv-card p-4">
85 − <p className="tv-label">Annonce d'origine</p>
86 − {l.url ? <a href={l.url} target="_blank" rel="noopener nofollow" className="tv-btn tv-btn-primary w-full mt-2">Voir sur {credit} <IconExternal width={16} height={16} /></a> : <p className="text-sm text-ink-3 mt-1">Lien non disponible</p>}
87 − <p className="mt-2 text-xs text-ink-3">Toveli ne gère ni visite ni transaction : contactez le vendeur, le courtier ou le propriétaire via l'annonce d'origine.</p>
88 − </div>
89 − {siblings.length > 0 && (
90 − <div className="tv-card p-4">
91 − <p className="tv-label">Même propriété · {siblings.length} autre{siblings.length > 1 ? "s" : ""} annonce{siblings.length > 1 ? "s" : ""}</p>
92 − <p className="text-xs text-ink-3 mt-1">Rapprochement par {prop?.key_kind === "role" ? "unité du rôle d'évaluation" : prop?.key_kind === "address" ? "adresse normalisée" : "coordonnées"} (confiance {Math.round((prop?.dedup_confidence ?? 0) * 100)} %).</p>
93 − <div className="mt-3 space-y-2">{siblings.slice(0, 4).map((s) => <ListingCardView key={s.uid} c={s} compact />)}</div>
94 − {prop && <Link href={`/propriete/${prop.id}`} className="block mt-3 text-sm font-semibold text-brand-dark hover:underline">Voir la fiche de la propriété →</Link>}
95 − </div>
96 − )}
97 − {!rent && (live || l.valuation) && (
98 − <div className="tv-card p-4">
99 − <p className="tv-label">Valeur estimée Toveli</p>
100 − <p className="tv-price text-2xl mt-1">{fmtPrice(live?.result.estimate ?? l.valuation?.estimate)}</p>
101 − <p className="text-xs text-ink-2">Fourchette {fmtPrice(live?.result.low ?? l.valuation?.low)} – {fmtPrice(live?.result.high ?? l.valuation?.high)} · confiance {live?.result.confidenceLevel ?? l.valuation?.confidence ?? "—"}{live ? ` (${live.result.confidencePct} %)` : ""}</p>
102 − <Notice>Estimation statistique (hédonique + comparables), pas une évaluation agréée. Méthode, données et limites dans l'onglet « Prix et marché ».</Notice>
103 − </div>
104 − )}
105 − <div className="tv-card p-4">
106 − <p className="tv-label">Provenance</p>
107 − <dl className="mt-2 text-xs text-ink-2 space-y-1">
108 − <div className="flex justify-between gap-2"><dt>Collecte</dt><dd>site d'origine → Toveli</dd></div>
109 − <div className="flex justify-between gap-2"><dt>Connecteur</dt><dd>{l.connector}</dd></div>
110 − <div className="flex justify-between gap-2"><dt>Photos</dt><dd>hotlink, droits du site source</dd></div>
111 − <div className="flex justify-between gap-2"><dt>Qualité des attributs</dt><dd>{l.quality_score != null ? `${Math.round(l.quality_score)} / 100` : "—"}</dd></div>
112 − </dl>
113 − </div>
138 + <div className="tv-card p-4"><p className="tv-label">Annonce d'origine</p>{l.url ? <a href={l.url} target="_blank" rel="noopener nofollow" className="tv-btn tv-btn-primary w-full mt-2">Voir sur {credit} <IconExternal width={16} height={16} /></a> : <p className="text-sm text-ink-3 mt-1">Lien non disponible</p>}<p className="mt-2 text-xs text-ink-3">Toveli ne gère ni visite ni transaction pour les annonces agrégées : contactez le vendeur, le courtier ou le propriétaire via l'annonce d'origine.</p><div className="mt-2"><ReportButton uid={l.uid} /></div></div>
139 + {siblings.length > 0 && <div className="tv-card p-4"><p className="tv-label">Même propriété · {siblings.length} autre{siblings.length > 1 ? "s" : ""} annonce{siblings.length > 1 ? "s" : ""}</p><p className="text-xs text-ink-3 mt-1">Rapprochement par {prop?.key_kind === "role" ? "unité du rôle d'évaluation" : prop?.key_kind === "address" ? "adresse normalisée" : "coordonnées"} (confiance {Math.round((prop?.dedup_confidence ?? 0) * 100)} %).</p><div className="mt-3 space-y-2">{siblings.slice(0, 4).map((s) => <ListingCardView key={s.uid} c={s} compact />)}</div>{prop && <Link href={`/propriete/${prop.id}`} className="block mt-3 text-sm font-semibold text-brand-dark hover:underline">Voir la fiche de la propriété →</Link>}</div>}
140 + {!rent && (live || l.valuation) && <div className="tv-card p-4"><p className="tv-label">Valeur estimée Toveli</p><p className="tv-price text-2xl mt-1">{fmtPrice(live?.result.estimate ?? l.valuation?.estimate)}</p><p className="text-xs text-ink-2">Fourchette {fmtPrice(live?.result.low ?? l.valuation?.low)} – {fmtPrice(live?.result.high ?? l.valuation?.high)} · confiance {live?.result.confidenceLevel ?? l.valuation?.confidence ?? "—"}{live ? ` (${live.result.confidencePct} %)` : ""}</p><Notice>Estimation statistique (hédonique + comparables), pas une évaluation agréée. Méthode, données et limites dans l'onglet « Prix et marché ».</Notice></div>}
141 + {rent && l.valuation && <div className="tv-card p-4"><p className="tv-label">Loyer de référence Toveli</p><p className="tv-price text-2xl mt-1">{fmtPrice(l.valuation.estimate, { rent: true })}</p><p className="text-xs text-ink-2">{fmtPrice(l.valuation.low)} – {fmtPrice(l.valuation.high)} · confiance {l.valuation.confidence ?? "—"}</p><Notice>Médianes de segment (ville/quartier × chambres) ajustées, voisins géographiques. Indicatif.</Notice></div>}
142 + <div className="tv-card p-4"><p className="tv-label">Provenance</p><dl className="mt-2 text-xs text-ink-2 space-y-1"><div className="flex justify-between gap-2"><dt>Collecte</dt><dd>site d'origine → Toveli</dd></div><div className="flex justify-between gap-2"><dt>Connecteur</dt><dd>{l.connector}</dd></div><div className="flex justify-between gap-2"><dt>Photos</dt><dd>hotlink, droits du site source</dd></div><div className="flex justify-between gap-2"><dt>Qualité des attributs</dt><dd>{l.quality_score != null ? `${Math.round(l.quality_score)} / 100` : "—"}</dd></div></dl></div>
114 143 </aside>
115 144 </div>
116 −
117 − {nearby.length > 0 && (
118 − <section className="mt-10">
119 − <h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2>
120 − <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
121 − </section>
122 − )}
145 + {nearby.length > 0 && <section className="mt-10"><h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2><div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((c) => <ListingCardView key={c.uid} c={c} />)}</div></section>}
123 146 </article>
124 147 );
125 148 }
added src/app/api/admin/listings/route.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq, desc, inArray } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { listings, properties, moderationActions, users } from "@/db/schema";
6 +import { assertSameOrigin, requireRole } from "@/lib/auth/session";
7 +import { requireFreshMfa } from "@/lib/admin/mfa";
8 +import { audit } from "@/lib/auth/audit";
9 +import { sendMail, notifyMail } from "@/lib/auth/email";
10 +import { handle, parseBody, apiError } from "@/lib/api";
11 +export const dynamic = "force-dynamic";
12 +export async function GET(req: NextRequest) { try { const u = await requireRole("MODERATOR"); requireFreshMfa(u); const status = req.nextUrl.searchParams.get("status") ?? "PENDING_REVIEW"; const rows = await db().select({ l: listings, p: properties, ownerEmail: users.email }).from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).leftJoin(users, eq(users.id, listings.ownerUserId)).where(inArray(listings.status, status === "all" ? ["DRAFT", "PENDING_REVIEW", "PUBLISHED", "PAUSED", "REJECTED", "ARCHIVED", "SOLD", "RENTED"] : [status])).orderBy(desc(listings.updatedAt)).limit(200); return NextResponse.json({ items: rows }); } catch (e) { return handle(e); } }
13 +const Body = z.object({ id: z.string().uuid(), action: z.enum(["approve", "reject", "unpublish"]), note: z.string().max(500).optional() });
14 +export async function POST(req: NextRequest) {
15 + try { await assertSameOrigin(); const u = await requireRole("MODERATOR"); requireFreshMfa(u); const b = await parseBody(req, Body); const d = db();
16 + const [l] = await d.select().from(listings).where(eq(listings.id, b.id)); if (!l) return apiError(404, "not_found", "Annonce introuvable");
17 + const set = b.action === "approve" ? { status: "PUBLISHED", moderationState: "HUMAN_OK", moderationNote: null, publicationAt: l.publicationAt ?? new Date(), expiresAt: new Date(Date.now() + 90 * 86400000) } : b.action === "reject" ? { status: "REJECTED", moderationState: "REJECTED", moderationNote: b.note ?? "Refusée par la modération" } : { status: "PAUSED", moderationState: "FLAGGED", moderationNote: b.note ?? "Retirée par la modération" };
18 + await d.update(listings).set({ ...set, updatedAt: new Date() }).where(eq(listings.id, b.id));
19 + await d.insert(moderationActions).values({ actorId: u.id, targetType: "LISTING", targetId: b.id, action: b.action, note: b.note ?? null });
20 + await audit(`admin.listing.${b.action}`, { actorId: u.id, objectType: "listing", objectId: b.id, meta: { note: b.note ?? null } });
21 + if (l.ownerUserId) { const [o] = await d.select({ email: users.email }).from(users).where(eq(users.id, l.ownerUserId)); if (o) { const base = (process.env.APP_BASE_URL ?? "https://www.toveli.ca").replace(/\/$/, ""); try { await sendMail(notifyMail(o.email, b.action === "approve" ? "Votre annonce est publiée" : b.action === "reject" ? "Votre annonce a été refusée" : "Votre annonce a été retirée", b.action === "approve" ? `« ${l.title ?? "Votre annonce"} » est maintenant visible sur Toveli.` : `Motif : ${b.note ?? "non conforme aux conditions"}. Vous pouvez corriger l'annonce et la soumettre de nouveau, ou nous écrire pour contester.`, `${base}/membre/annonces/${l.id}`, "Voir mon annonce", `moderation_${b.action}`)); } catch { /* */ } } }
22 + return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
23 +}
added src/app/api/admin/mfa/route.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { assertSameOrigin, requireRole } from "@/lib/auth/session";
4 +import { enroll, newSecret, totpStatus, verify } from "@/lib/admin/mfa";
5 +import { handle, parseBody, apiError } from "@/lib/api";
6 +import { hit } from "@/lib/auth/ratelimit";
7 +export const dynamic = "force-dynamic";
8 +export async function GET() { try { const u = await requireRole("MODERATOR"); const enrolled = await totpStatus(u.id); return NextResponse.json({ enrolled, fresh: !!u.mfaAt && Date.now() - u.mfaAt.getTime() < 30 * 60000, setup: enrolled ? null : newSecret(u.email) }); } catch (e) { return handle(e); } }
9 +const Body = z.object({ code: z.string().regex(/^\d{6}$/), base32: z.string().min(16).max(64).optional() });
10 +export async function POST(req: NextRequest) { try { await assertSameOrigin(); const u = await requireRole("MODERATOR"); const b = await parseBody(req, Body); const rl = await hit(`mfa:${u.id}`, 10, 600); if (!rl.ok) return apiError(429, "rate_limited", "Trop de tentatives"); const ok = b.base32 ? await enroll(u.id, b.base32, b.code) && await verify(u, b.code) : await verify(u, b.code); return ok ? NextResponse.json({ ok: true }) : apiError(400, "invalid_code", "Code invalide"); } catch (e) { return handle(e); } }
added src/app/api/admin/reports/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq, desc } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { reports, moderationActions } from "@/db/schema";
6 +import { assertSameOrigin, requireRole } from "@/lib/auth/session";
7 +import { requireFreshMfa } from "@/lib/admin/mfa";
8 +import { audit } from "@/lib/auth/audit";
9 +import { handle, parseBody } from "@/lib/api";
10 +export const dynamic = "force-dynamic";
11 +export async function GET(req: NextRequest) { try { const u = await requireRole("MODERATOR"); requireFreshMfa(u); const status = req.nextUrl.searchParams.get("status") ?? "OPEN"; return NextResponse.json({ items: await db().select().from(reports).where(eq(reports.status, status)).orderBy(desc(reports.createdAt)).limit(300) }); } catch (e) { return handle(e); } }
12 +const Body = z.object({ id: z.string().uuid(), status: z.enum(["RESOLVED", "DISMISSED"]), note: z.string().max(500).optional() });
13 +export async function POST(req: NextRequest) { try { await assertSameOrigin(); const u = await requireRole("MODERATOR"); requireFreshMfa(u); const b = await parseBody(req, Body); await db().update(reports).set({ status: b.status }).where(eq(reports.id, b.id)); await db().insert(moderationActions).values({ actorId: u.id, targetType: "REPORT", targetId: b.id, action: b.status, note: b.note ?? null }); await audit(`admin.report.${b.status.toLowerCase()}`, { actorId: u.id, objectType: "report", objectId: b.id }); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
added src/app/api/admin/users/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq, desc, sql, ilike } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { users, userRoles, listings } from "@/db/schema";
6 +import { assertSameOrigin, requireRole } from "@/lib/auth/session";
7 +import { requireFreshMfa } from "@/lib/admin/mfa";
8 +import { audit } from "@/lib/auth/audit";
9 +import { handle, parseBody, apiError } from "@/lib/api";
10 +export const dynamic = "force-dynamic";
11 +export async function GET(req: NextRequest) { try { const u = await requireRole("ADMIN"); requireFreshMfa(u); const q = req.nextUrl.searchParams.get("q") ?? ""; const rows = await db().select({ id: users.id, email: users.email, displayName: users.displayName, status: users.status, createdAt: users.createdAt, roles: sql<string[]>`coalesce((select array_agg(role) from ${userRoles} r where r.user_id = ${users.id}), '{}')`, nListings: sql<number>`(select count(*) from ${listings} l where l.owner_user_id = ${users.id})::int` }).from(users).where(q ? ilike(users.email, `%${q}%`) : sql`true`).orderBy(desc(users.createdAt)).limit(100); return NextResponse.json({ items: rows }); } catch (e) { return handle(e); } }
12 +const Body = z.object({ id: z.string().uuid(), action: z.enum(["suspend", "activate", "grant", "revoke"]), role: z.enum(["ADMIN", "MODERATOR", "AGENT_VERIFIED"]).optional(), note: z.string().max(300).optional() });
13 +export async function POST(req: NextRequest) {
14 + try { await assertSameOrigin(); const u = await requireRole("ADMIN"); requireFreshMfa(u); const b = await parseBody(req, Body); const d = db();
15 + if (b.id === u.id && (b.action === "suspend" || (b.action === "revoke" && b.role === "ADMIN"))) return apiError(409, "self", "Impossible sur votre propre compte");
16 + if (b.action === "suspend") await d.update(users).set({ status: "SUSPENDED", updatedAt: new Date() }).where(eq(users.id, b.id));
17 + if (b.action === "activate") await d.update(users).set({ status: "ACTIVE", updatedAt: new Date() }).where(eq(users.id, b.id));
18 + if (b.action === "grant" && b.role) await d.insert(userRoles).values({ userId: b.id, role: b.role, grantedBy: u.id, note: b.note ?? null }).onConflictDoNothing();
19 + if (b.action === "revoke" && b.role) await d.delete(userRoles).where(sql`${userRoles.userId} = ${b.id} and ${userRoles.role} = ${b.role}`);
20 + await audit(`admin.user.${b.action}`, { actorId: u.id, objectType: "user", objectId: b.id, meta: { role: b.role ?? null } });
21 + return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
22 +}
modified src/app/api/auth/verify-code/route.ts +2 −2
@@ -8,7 +8,7 @@ import { hit } from "@/lib/auth/ratelimit";
8 8 import { ipHash } from "@/lib/auth/crypto";
9 9 export const dynamic = "force-dynamic";
10 10 const Body = z.object({ challengeId: z.string().uuid(), code: z.string().regex(/^\d{6}$/), acceptTerms: z.boolean().optional().default(false) });
11 −const MSG: Record<string, string> = { invalid: "Code invalide.", expired: "Ce code a expiré. Demandez-en un nouveau.", too_many_attempts: "Trop d'essais. Demandez un nouveau code.", not_found: "Demande introuvable. Recommencez." };
11 +const MSG: Record<string, string> = { invalid: "Code invalide.", expired: "Ce code a expiré. Demandez-en un nouveau.", too_many_attempts: "Trop d'essais. Demandez un nouveau code.", not_found: "Demande introuvable. Recommencez.", terms_required: "Nouveau compte : acceptez les conditions puis confirmez le code." };
12 12 export async function POST(req: NextRequest) {
13 13 try {
14 14 await assertSameOrigin();
@@ -16,7 +16,7 @@ export async function POST(req: NextRequest) {
16 16 const ip = await clientIp();
17 17 const rl = await hit(`otp:verify:${ipHash(ip)}`, 30, 600); if (!rl.ok) return NextResponse.json({ error: { code: "rate_limited", message: "Trop de tentatives. Réessayez plus tard." } }, { status: 429 });
18 18 const r = await verifyCode(challengeId, code, ip, acceptTerms);
19 − if (!r.ok) return NextResponse.json({ error: { code: r.reason, message: MSG[r.reason] } }, { status: r.reason === "invalid" && !acceptTerms ? 409 : 400 });
19 + if (!r.ok) return NextResponse.json({ error: { code: r.reason, message: MSG[r.reason] } }, { status: r.reason === "terms_required" ? 409 : 400 });
20 20 const s = await createSession(r.userId, { ip, device: await userAgent() });
21 21 (await cookies()).set(COOKIE, s.token, cookieOptions(s.expiresAt));
22 22 return NextResponse.json({ ok: true, created: r.created, user: { id: r.userId, email: r.email } });
added src/app/api/inquiries/[id]/messages/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
4 +import { getThread, reply, setInquiryStatus } from "@/lib/inquiries/service";
5 +import { handle, parseBody } from "@/lib/api";
6 +export const dynamic = "force-dynamic";
7 +type P = { params: Promise<{ id: string }> };
8 +export async function GET(_req: NextRequest, { params }: P) { try { const u = await requireUser(); const { id } = await params; return NextResponse.json(await getThread(id, u.id)); } catch (e) { return handle(e); } }
9 +const Body = z.object({ body: z.string().trim().min(1).max(3000), idempotencyKey: z.string().max(80).optional() });
10 +export async function POST(req: NextRequest, { params }: P) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body); const m = await reply(id, u.id, b.body, b.idempotencyKey); return NextResponse.json({ ok: true, message: m }, { status: 201 }); } catch (e) { return handle(e); } }
11 +const Patch = z.object({ status: z.enum(["OPEN", "ARCHIVED", "BLOCKED"]) });
12 +export async function PATCH(req: NextRequest, { params }: P) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Patch); await setInquiryStatus(id, u.id, b.status); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
added src/app/api/listings/[id]/[action]/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
3 +import { attest, transition, type Transition } from "@/lib/listings/service";
4 +import { apiError, handle } from "@/lib/api";
5 +export const dynamic = "force-dynamic";
6 +const ACTIONS = new Set(["publish", "pause", "resume", "archive", "mark-sold", "mark-rented", "reopen", "attest"]);
7 +export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string; action: string }> }) {
8 + try {
9 + await assertSameOrigin(); const u = await requireUser(); const { id, action } = await params;
10 + if (!ACTIONS.has(action)) return apiError(404, "not_found", "Action inconnue");
11 + if (action === "attest") { await attest(id, u.id, u.roles); return NextResponse.json({ ok: true }); }
12 + const r = await transition(id, u.id, u.roles, action as Transition);
13 + if (r.problems.length) return NextResponse.json({ ok: false, problems: r.problems, status: r.listing.status }, { status: 422 });
14 + return NextResponse.json({ ok: true, status: r.listing.status, listing: { id: r.listing.id, slug: r.listing.slug, status: r.listing.status, moderationNote: r.listing.moderationNote } });
15 + } catch (e) { return handle(e); }
16 +}
added src/app/api/listings/[id]/inquiries/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { assertSameOrigin, requireUser, clientIp } from "@/lib/auth/session";
4 +import { openInquiry } from "@/lib/inquiries/service";
5 +import { hit } from "@/lib/auth/ratelimit";
6 +import { ipHash } from "@/lib/auth/crypto";
7 +import { apiError, handle, parseBody } from "@/lib/api";
8 +export const dynamic = "force-dynamic";
9 +const Body = z.object({ body: z.string().trim().min(10).max(3000), category: z.enum(["INFO", "VISIT", "OFFER"]).default("INFO"), idempotencyKey: z.string().max(80).optional() });
10 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
11 + try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body);
12 + const rl = await hit(`inq:${u.id}`, 20, 3600); const rl2 = await hit(`inq:ip:${ipHash(await clientIp())}`, 40, 3600); if (!rl.ok || !rl2.ok) return apiError(429, "rate_limited", "Trop de demandes envoyées. Réessayez plus tard.");
13 + const inq = await openInquiry(id, u.id, b.category, b.body, b.idempotencyKey); return NextResponse.json({ ok: true, inquiryId: inq.id }, { status: 201 }); } catch (e) { return handle(e); }
14 +}
added src/app/api/listings/[id]/media/[mediaId]/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { and, eq } from "drizzle-orm";
3 +import { db } from "@/db/client";
4 +import { listingMedia } from "@/db/schema";
5 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
6 +import { getListingFull, assertOwner } from "@/lib/listings/service";
7 +import { removeFiles } from "@/lib/listings/media";
8 +import { apiError, handle } from "@/lib/api";
9 +import { audit } from "@/lib/auth/audit";
10 +export const dynamic = "force-dynamic";
11 +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string; mediaId: string }> }) {
12 + try {
13 + await assertSameOrigin(); const u = await requireUser(); const { id, mediaId } = await params;
14 + const l = await getListingFull(id); if (!l) return apiError(404, "not_found", "Annonce introuvable"); assertOwner(l, u.id, u.roles);
15 + const [m] = await db().select().from(listingMedia).where(and(eq(listingMedia.id, mediaId), eq(listingMedia.listingId, id))).limit(1); if (!m) return apiError(404, "not_found", "Média introuvable");
16 + await db().update(listingMedia).set({ status: "DELETED" }).where(eq(listingMedia.id, mediaId)); if (!m.storageKey.startsWith("pending/")) removeFiles(m.storageKey);
17 + await audit("media.delete", { actorId: u.id, objectType: "listing_media", objectId: mediaId });
18 + return NextResponse.json({ ok: true });
19 + } catch (e) { return handle(e); }
20 +}
added src/app/api/listings/[id]/media/confirm/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { and, eq } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { listingMedia } from "@/db/schema";
6 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
7 +import { getListingFull, assertOwner } from "@/lib/listings/service";
8 +import { apiError, handle, parseBody } from "@/lib/api";
9 +export const dynamic = "force-dynamic";
10 +const Body = z.object({ order: z.array(z.string().uuid()).max(40).optional(), captions: z.record(z.string().uuid(), z.string().max(140)).optional() });
11 +/** Confirme l'état des médias d'une annonce : ordre et légendes (les fichiers sont déjà traités à l'upload). */
12 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13 + try {
14 + await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body);
15 + const l = await getListingFull(id); if (!l) return apiError(404, "not_found", "Annonce introuvable"); assertOwner(l, u.id, u.roles);
16 + const d = db();
17 + if (b.order) for (const [i, mid] of b.order.entries()) await d.update(listingMedia).set({ position: i }).where(and(eq(listingMedia.id, mid), eq(listingMedia.listingId, id)));
18 + if (b.captions) for (const [mid, alt] of Object.entries(b.captions)) await d.update(listingMedia).set({ altText: alt }).where(and(eq(listingMedia.id, mid), eq(listingMedia.listingId, id)));
19 + return NextResponse.json({ ok: true, media: (await getListingFull(id))!.media.map((m) => ({ id: m.id, position: m.position, altText: m.altText })) });
20 + } catch (e) { return handle(e); }
21 +}
added src/app/api/listings/[id]/media/presign/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { db } from "@/db/client";
4 +import { listingMedia } from "@/db/schema";
5 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
6 +import { getListingFull, assertOwner } from "@/lib/listings/service";
7 +import { countMedia, MAX_PER_LISTING, MAX_BYTES, newUploadToken } from "@/lib/listings/media";
8 +import { apiError, handle, parseBody } from "@/lib/api";
9 +export const dynamic = "force-dynamic";
10 +const Body = z.object({ fileName: z.string().max(200).optional(), mimeType: z.string().max(80).optional(), bytes: z.number().int().positive().max(MAX_BYTES).optional() });
11 +/** Équivalent local d'une URL pré-signée : jeton à usage unique, lié à l'annonce et au propriétaire ; l'upload se fait ensuite en PUT sur /api/uploads/<token>. */
12 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
13 + try {
14 + await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body);
15 + const l = await getListingFull(id); if (!l) return apiError(404, "not_found", "Annonce introuvable"); assertOwner(l, u.id, u.roles);
16 + if ((await countMedia(id)) >= MAX_PER_LISTING) return apiError(422, "too_many", `Maximum ${MAX_PER_LISTING} photos par annonce`);
17 + const token = newUploadToken();
18 + const [m] = await db().insert(listingMedia).values({ listingId: id, storageKey: `pending/${token}`, mimeType: b.mimeType ?? null, position: l.media.length, status: "UPLOADING", uploadToken: token }).returning({ id: listingMedia.id });
19 + return NextResponse.json({ mediaId: m.id, uploadUrl: `/api/uploads/${token}`, method: "PUT", maxBytes: MAX_BYTES, expiresInSec: 900 });
20 + } catch (e) { return handle(e); }
21 +}
added src/app/api/listings/[id]/route.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { assertSameOrigin, currentUser, requireUser } from "@/lib/auth/session";
3 +import { getListingFull, updateListing, deleteDraft, ConflictError, assertOwner } from "@/lib/listings/service";
4 +import { ListingInput, publishProblems } from "@/lib/listings/types";
5 +import { mediaUrl } from "@/lib/listings/media";
6 +import { apiError, handle, parseBody } from "@/lib/api";
7 +export const dynamic = "force-dynamic";
8 +type P = { params: Promise<{ id: string }> };
9 +export async function GET(_req: NextRequest, { params }: P) {
10 + try {
11 + const { id } = await params; const l = await getListingFull(id); if (!l) return apiError(404, "not_found", "Annonce introuvable");
12 + const u = await currentUser(); const isOwner = !!u && (l.ownerUserId === u.id || u.roles.includes("ADMIN") || u.roles.includes("MODERATOR"));
13 + if (l.status !== "PUBLISHED" && !isOwner) return apiError(404, "not_found", "Annonce introuvable");
14 + const media = l.media.map((m) => ({ id: m.id, url: mediaUrl(m.storageKey, 800), large: mediaUrl(m.storageKey, 1600), thumb: mediaUrl(m.storageKey, 320), position: m.position, altText: m.altText, width: m.width, height: m.height }));
15 + const problems = isOwner ? publishProblems({ listingType: l.listingType, propertyType: l.property.propertyType, priceAmount: l.priceAmount, title: l.title, description: l.description, city: l.property.city, lat: l.property.lat, mediaCount: l.media.length, attestedAt: l.attestedAt, bedrooms: l.property.bedrooms, areaSqft: l.property.areaSqft }) : undefined;
16 + return NextResponse.json({ listing: { ...l, media, ownerUserId: isOwner ? l.ownerUserId : undefined, problems } });
17 + } catch (e) { return handle(e); }
18 +}
19 +export async function PATCH(req: NextRequest, { params }: P) {
20 + try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const input = await parseBody(req, ListingInput); const l = await updateListing(id, u.id, u.roles, input); return NextResponse.json({ listing: l }); }
21 + catch (e) { if (e instanceof ConflictError) return apiError(409, "conflict", e.message); return handle(e); }
22 +}
23 +export async function DELETE(_req: NextRequest, { params }: P) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const l = await getListingFull(id); if (l) assertOwner(l, u.id, u.roles); await deleteDraft(id, u.id, u.roles); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
added src/app/api/listings/[id]/viewings/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
4 +import { requestViewing } from "@/lib/inquiries/service";
5 +import { hit } from "@/lib/auth/ratelimit";
6 +import { apiError, handle, parseBody } from "@/lib/api";
7 +export const dynamic = "force-dynamic";
8 +const Body = z.object({ slots: z.array(z.string().datetime({ offset: true })).min(1).max(3), note: z.string().trim().max(500).optional() });
9 +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
10 + try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body);
11 + if (b.slots.some((s) => Date.parse(s) < Date.now() - 3600000)) return apiError(400, "invalid_slot", "Les créneaux doivent être à venir");
12 + const rl = await hit(`view:${u.id}`, 10, 86400); if (!rl.ok) return apiError(429, "rate_limited", "Trop de demandes de visite aujourd'hui.");
13 + const v = await requestViewing(id, u.id, b.slots, b.note); return NextResponse.json({ ok: true, viewingId: v.id }, { status: 201 }); } catch (e) { return handle(e); }
14 +}
modified src/app/api/listings/route.ts +6 −2
@@ -1,13 +1,17 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 2 import { getDb } from "@/lib/db";
3 3 import type { ListingRow, ListingCard } from "@/lib/types";
4 +import { directCardsByUids } from "@/lib/listings/public";
5 +type Row = ListingRow & { estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null };
4 6 export const dynamic = "force-dynamic";
5 −export function GET(req: NextRequest) {
7 +export async function GET(req: NextRequest) {
6 8 const uids = (req.nextUrl.searchParams.get("uids") ?? "").split(",").filter(Boolean).slice(0, 50);
7 9 if (!uids.length) return NextResponse.json({ items: [] });
8 10 try {
9 − const rows = getDb().prepare(`SELECT l.*, v.estimate, v.ratio, v.verdict, v.confidence FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid WHERE l.uid IN (${uids.map(() => "?").join(",")})`).all(...uids) as (ListingRow & { estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null })[];
11 + const agg = uids.filter((u) => !u.startsWith("toveli:"));
12 + const rows = agg.length ? getDb().prepare(`SELECT l.*, v.estimate, v.ratio, v.verdict, v.confidence FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid WHERE l.uid IN (${agg.map(() => "?").join(",")})`).all(...agg) as Row[] : [];
10 13 const items: ListingCard[] = rows.map((r) => { let image: string | null = null; try { const a = JSON.parse(r.images ?? "[]"); image = a[0] ?? null; } catch { /* */ } return { uid: r.uid, propertyId: r.property_id, market: r.market, price: r.price, address: r.address, city: r.city, region: r.region, category: r.category, typeLabel: r.type_label, bedrooms: r.bedrooms, bathrooms: r.bathrooms, areaSqft: r.area_sqft, lat: r.lat, lng: r.lng, image, imageCount: r.image_count ?? 0, connector: r.connector, sourceId: r.source_id, url: r.url, firstSeen: r.first_seen, estimate: r.estimate, ratio: r.ratio, verdict: r.verdict, confidence: r.confidence }; });
14 + items.push(...(await directCardsByUids(uids)));
11 15 items.sort((a, b) => uids.indexOf(a.uid) - uids.indexOf(b.uid));
12 16 return NextResponse.json({ items });
13 17 } catch (e) { return NextResponse.json({ items: [], error: (e as Error).message }, { status: 503 }); }
added src/app/api/me/email-change/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { users } from "@/db/schema";
6 +import { assertSameOrigin, requireUser, clientIp, revokeAllSessions } from "@/lib/auth/session";
7 +import { requestCode, verifyCode } from "@/lib/auth/otp";
8 +import { normalizeEmail } from "@/lib/auth/crypto";
9 +import { audit } from "@/lib/auth/audit";
10 +import { apiError, handle, parseBody } from "@/lib/api";
11 +export const dynamic = "force-dynamic";
12 +/** Étape 1 : code envoyé à la NOUVELLE adresse (l'utilisateur est déjà authentifié sur l'ancienne). */
13 +export async function POST(req: NextRequest) {
14 + try { await assertSameOrigin(); const u = await requireUser(); const { newEmail } = await parseBody(req, z.object({ newEmail: z.string().max(254) }));
15 + const e = normalizeEmail(newEmail); if (!e) return apiError(400, "invalid_email", "Adresse invalide"); if (e === u.email) return apiError(400, "same_email", "C'est déjà votre adresse");
16 + const r = await requestCode(e, await clientIp(), "EMAIL_CHANGE", { userId: u.id });
17 + if (!r.ok) return apiError(429, "rate_limited", "Trop de demandes, réessayez plus tard");
18 + return NextResponse.json({ ok: true, challengeId: r.challengeId }); } catch (e) { return handle(e); }
19 +}
20 +/** Étape 2 : vérification du code puis changement, si l'adresse est libre ; déconnexion des autres sessions. */
21 +export async function PATCH(req: NextRequest) {
22 + try { await assertSameOrigin(); const u = await requireUser(); const b = await parseBody(req, z.object({ challengeId: z.string().uuid(), code: z.string().regex(/^\d{6}$/) }));
23 + const r = await verifyCode(b.challengeId, b.code, await clientIp(), true);
24 + if (!r.ok) return apiError(400, r.reason, "Code invalide ou expiré"); if (r.userId !== u.id) return apiError(403, "forbidden", "Ce code ne correspond pas à votre compte");
25 + const [taken] = await db().select({ id: users.id }).from(users).where(eq(users.email, r.email)).limit(1); if (taken) return apiError(409, "email_taken", "Cette adresse est déjà utilisée");
26 + await db().update(users).set({ email: r.email, emailVerifiedAt: new Date(), updatedAt: new Date() }).where(eq(users.id, u.id)); await revokeAllSessions(u.id, u.sessionId); await audit("me.email_change", { actorId: u.id });
27 + return NextResponse.json({ ok: true }); } catch (e) { return handle(e); }
28 +}
added src/app/api/me/export/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +import { NextResponse } from "next/server";
2 +import { eq, or } from "drizzle-orm";
3 +import { db } from "@/db/client";
4 +import { users, userProfiles, listings, properties, favorites, savedSearches, inquiries, messages, viewingRequests } from "@/db/schema";
5 +import { requireUser } from "@/lib/auth/session";
6 +import { decrypt } from "@/lib/auth/crypto";
7 +import { audit } from "@/lib/auth/audit";
8 +import { handle } from "@/lib/api";
9 +export const dynamic = "force-dynamic";
10 +/** Portabilité (Loi 25) : export JSON des données de la personne connectée. */
11 +export async function GET() {
12 + try { const u = await requireUser(); const d = db();
13 + const [user] = await d.select().from(users).where(eq(users.id, u.id)); const [profile] = await d.select().from(userProfiles).where(eq(userProfiles.userId, u.id));
14 + const myListings = await d.select({ l: listings, p: properties }).from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.ownerUserId, u.id));
15 + const favs = await d.select().from(favorites).where(eq(favorites.userId, u.id)); const searches = await d.select().from(savedSearches).where(eq(savedSearches.userId, u.id));
16 + const inqs = await d.select().from(inquiries).where(or(eq(inquiries.senderUserId, u.id), eq(inquiries.recipientUserId, u.id))); const msgs = await d.select().from(messages).where(eq(messages.senderId, u.id)); const views = await d.select().from(viewingRequests).where(or(eq(viewingRequests.requesterId, u.id), eq(viewingRequests.ownerId, u.id)));
17 + let phone: string | null = null; try { phone = user.phoneEncrypted ? decrypt(user.phoneEncrypted) : null; } catch { phone = null; }
18 + await audit("me.export", { actorId: u.id });
19 + const body = { exportedAt: new Date().toISOString(), user: { id: user.id, email: user.email, displayName: user.displayName, phone, locale: user.locale, prefs: user.prefs, createdAt: user.createdAt }, profile, listings: myListings, favorites: favs, savedSearches: searches, inquiries: inqs, messagesSent: msgs, viewings: views };
20 + return new NextResponse(JSON.stringify(body, null, 2), { headers: { "content-type": "application/json; charset=utf-8", "content-disposition": "attachment; filename=toveli-mes-donnees.json" } }); } catch (e) { return handle(e); }
21 +}
added src/app/api/me/inquiries/route.ts +6 −0
@@ -0,0 +1,6 @@
1 +import { NextResponse } from "next/server";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { myInquiries, myViewings } from "@/lib/inquiries/service";
4 +import { handle } from "@/lib/api";
5 +export const dynamic = "force-dynamic";
6 +export async function GET() { try { const u = await requireUser(); return NextResponse.json({ inquiries: await myInquiries(u.id), viewings: await myViewings(u.id) }); } catch (e) { return handle(e); } }
added src/app/api/me/listings/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
3 +import { createListing, myListings } from "@/lib/listings/service";
4 +import { ListingInput } from "@/lib/listings/types";
5 +import { mediaUrl } from "@/lib/listings/media";
6 +import { handle, parseBody } from "@/lib/api";
7 +export const dynamic = "force-dynamic";
8 +export async function GET() { try { const u = await requireUser(); const rows = await myListings(u.id); return NextResponse.json({ items: rows.map((r) => ({ ...r.l, property: r.p, mediaCount: r.mediaCount, cover: r.cover ? mediaUrl(r.cover, 320) : null })) }); } catch (e) { return handle(e); } }
9 +export async function POST(req: NextRequest) { try { await assertSameOrigin(); const u = await requireUser(); const input = await parseBody(req, ListingInput); const l = await createListing(u.id, input); return NextResponse.json({ listing: l }, { status: 201 }); } catch (e) { return handle(e); } }
modified src/app/api/me/route.ts +20 −1
@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
2 2 import { z } from "zod";
3 3 import { eq } from "drizzle-orm";
4 4 import { db } from "@/db/client";
5 −import { users, userProfiles } from "@/db/schema";
5 +import { users, userProfiles, listings, messages, sessions } from "@/db/schema";
6 +import { cookies } from "next/headers";
7 +import { COOKIE } from "@/lib/auth/session";
6 8 import { assertSameOrigin, currentUser, requireUser } from "@/lib/auth/session";
7 9 import { encrypt } from "@/lib/auth/crypto";
8 10 import { audit } from "@/lib/auth/audit";
@@ -23,3 +25,20 @@ export async function PATCH(req: NextRequest) {
23 25 return NextResponse.json({ ok: true });
24 26 } catch (e) { return handle(e); }
25 27 }
28 +
29 +/** Droit à l'effacement : annonces archivées, messages anonymisés, sessions révoquées, compte anonymisé et marqué DELETED. */
30 +export async function DELETE() {
31 + try {
32 + await assertSameOrigin(); const u = await requireUser(); const d = db();
33 + await d.transaction(async (tx) => {
34 + await tx.update(listings).set({ status: "ARCHIVED", updatedAt: new Date() }).where(eq(listings.ownerUserId, u.id));
35 + await tx.update(messages).set({ body: "[message supprimé par son auteur]" }).where(eq(messages.senderId, u.id));
36 + await tx.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.userId, u.id));
37 + await tx.delete(userProfiles).where(eq(userProfiles.userId, u.id));
38 + await tx.update(users).set({ email: `deleted+${u.id}@toveli.invalid`, displayName: null, phoneEncrypted: null, totpSecretEncrypted: null, prefs: {}, status: "DELETED", deletedAt: new Date(), updatedAt: new Date() }).where(eq(users.id, u.id));
39 + });
40 + await audit("me.delete", { actorId: u.id });
41 + (await cookies()).delete(COOKIE);
42 + return NextResponse.json({ ok: true });
43 + } catch (e) { return handle(e); }
44 +}
added src/app/api/me/saved-searches/[id]/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { and, eq } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { savedSearches } from "@/db/schema";
6 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
7 +import { handle, parseBody } from "@/lib/api";
8 +export const dynamic = "force-dynamic";
9 +type P = { params: Promise<{ id: string }> };
10 +const Patch = z.object({ label: z.string().trim().min(1).max(120).optional(), alertEnabled: z.boolean().optional(), alertFrequency: z.enum(["INSTANT", "DAILY", "WEEKLY", "OFF"]).optional() });
11 +export async function PATCH(req: NextRequest, { params }: P) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Patch); await db().update(savedSearches).set(b).where(and(eq(savedSearches.id, id), eq(savedSearches.userId, u.id))); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
12 +export async function DELETE(_req: NextRequest, { params }: P) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; await db().delete(savedSearches).where(and(eq(savedSearches.id, id), eq(savedSearches.userId, u.id))); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
added src/app/api/me/saved-searches/route.ts +11 −0
@@ -0,0 +1,11 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { eq, desc } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { savedSearches } from "@/db/schema";
6 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
7 +import { handle, parseBody } from "@/lib/api";
8 +export const dynamic = "force-dynamic";
9 +export async function GET() { try { const u = await requireUser(); return NextResponse.json({ items: await db().select().from(savedSearches).where(eq(savedSearches.userId, u.id)).orderBy(desc(savedSearches.createdAt)) }); } catch (e) { return handle(e); } }
10 +const Body = z.object({ label: z.string().trim().min(1).max(120), query: z.record(z.string(), z.string().max(200)).refine((q) => Object.keys(q).length <= 20), alertEnabled: z.boolean().default(false), alertFrequency: z.enum(["INSTANT", "DAILY", "WEEKLY", "OFF"]).default("DAILY") });
11 +export async function POST(req: NextRequest) { try { await assertSameOrigin(); const u = await requireUser(); const b = await parseBody(req, Body); const [r] = await db().insert(savedSearches).values({ userId: u.id, label: b.label, query: b.query, alertEnabled: b.alertEnabled, alertFrequency: b.alertFrequency }).returning(); return NextResponse.json({ item: r }, { status: 201 }); } catch (e) { return handle(e); } }
added src/app/api/media/[...key]/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { createReadStream, existsSync, statSync } from "node:fs";
3 +import path from "node:path";
4 +import { STORAGE_DIR } from "@/lib/listings/media";
5 +export const dynamic = "force-dynamic";
6 +/** Sert les variantes WebP depuis le stockage local (clé validée, pas de traversée de répertoire). */
7 +export async function GET(_req: NextRequest, { params }: { params: Promise<{ key: string[] }> }) {
8 + const { key } = await params;
9 + if (key.length !== 3 || !/^\d{4}-\d{2}$/.test(key[0]) || !/^[a-f0-9]{24}$/.test(key[1]) || !/^(1600|800|320)\.webp$/.test(key[2])) return new NextResponse("Not found", { status: 404 });
10 + const p = path.join(STORAGE_DIR, key[0], key[1], key[2]); if (!p.startsWith(STORAGE_DIR) || !existsSync(/*turbopackIgnore: true*/ p)) return new NextResponse("Not found", { status: 404 });
11 + const size = statSync(p).size;
12 + const stream = createReadStream(p);
13 + return new NextResponse(stream as unknown as ReadableStream, { headers: { "content-type": "image/webp", "content-length": String(size), "cache-control": "public, max-age=31536000, immutable" } });
14 +}
added src/app/api/reports/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { db } from "@/db/client";
4 +import { reports } from "@/db/schema";
5 +import { assertSameOrigin, currentUser, clientIp } from "@/lib/auth/session";
6 +import { hit } from "@/lib/auth/ratelimit";
7 +import { ipHash } from "@/lib/auth/crypto";
8 +import { audit } from "@/lib/auth/audit";
9 +import { apiError, handle, parseBody } from "@/lib/api";
10 +export const dynamic = "force-dynamic";
11 +const Body = z.object({ targetType: z.enum(["LISTING", "AGGREGATED_LISTING", "USER", "MESSAGE"]), targetId: z.string().min(1).max(200), reason: z.enum(["arnaque", "doublon", "contenu_inapproprie", "informations_fausses", "deja_vendu_loue", "droits_photos", "autre"]), details: z.string().trim().max(1000).optional() });
12 +export async function POST(req: NextRequest) {
13 + try { await assertSameOrigin(); const b = await parseBody(req, Body); const u = await currentUser(); const iph = ipHash(await clientIp());
14 + const rl = await hit(`report:${u?.id ?? iph}`, 10, 86400); if (!rl.ok) return apiError(429, "rate_limited", "Trop de signalements aujourd'hui.");
15 + const [r] = await db().insert(reports).values({ targetType: b.targetType, targetId: b.targetId, reporterId: u?.id ?? null, reason: b.reason, details: b.details ?? null }).returning({ id: reports.id });
16 + await audit("report.create", { actorId: u?.id ?? null, objectType: b.targetType, objectId: b.targetId, ipHash: iph, meta: { reason: b.reason } });
17 + return NextResponse.json({ ok: true, id: r.id }, { status: 201 }); } catch (e) { return handle(e); }
18 +}
modified src/app/api/search/route.ts +4 −4
@@ -1,12 +1,12 @@
1 1 import { NextRequest, NextResponse } from "next/server";
2 −import { mapPoints, searchListings } from "@/lib/queries";
2 +import { unifiedSearch, unifiedPoints } from "@/lib/search";
3 3 import { fromUrl } from "@/lib/search-params";
4 4 export const dynamic = "force-dynamic";
5 −export function GET(req: NextRequest) {
5 +export async function GET(req: NextRequest) {
6 6 const sp = Object.fromEntries(req.nextUrl.searchParams.entries());
7 7 const p = fromUrl(sp, Math.min(Number(sp.limit ?? 24) || 24, 200));
8 8 try {
9 − if (sp.format === "points") return NextResponse.json({ points: mapPoints({ ...p, limit: 2000 }) }, { headers: { "Cache-Control": "public, max-age=120" } });
10 − return NextResponse.json(searchListings(p), { headers: { "Cache-Control": "public, max-age=120" } });
9 + if (sp.format === "points") return NextResponse.json({ points: await unifiedPoints({ ...p, limit: 2000 }) }, { headers: { "Cache-Control": "public, max-age=120" } });
10 + return NextResponse.json(await unifiedSearch(p), { headers: { "Cache-Control": "public, max-age=120" } });
11 11 } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 503 }); }
12 12 }
added src/app/api/uploads/[token]/route.ts +30 −0
@@ -0,0 +1,30 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { and, eq, sql } from "drizzle-orm";
3 +import { db } from "@/db/client";
4 +import { listingMedia } from "@/db/schema";
5 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
6 +import { getListingFull, assertOwner } from "@/lib/listings/service";
7 +import { processUpload, MediaError, MAX_BYTES } from "@/lib/listings/media";
8 +import { apiError, handle } from "@/lib/api";
9 +import { audit } from "@/lib/auth/audit";
10 +export const dynamic = "force-dynamic";
11 +export async function PUT(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
12 + try {
13 + await assertSameOrigin(); const u = await requireUser(); const { token } = await params; const d = db();
14 + const [m] = await d.select().from(listingMedia).where(and(eq(listingMedia.uploadToken, token), eq(listingMedia.status, "UPLOADING"), sql`${listingMedia.uploadedAt} > now() - interval '15 minutes'`)).limit(1);
15 + if (!m) return apiError(404, "not_found", "Jeton d'envoi invalide ou expiré");
16 + const l = await getListingFull(m.listingId); if (!l) return apiError(404, "not_found", "Annonce introuvable"); assertOwner(l, u.id, u.roles);
17 + const len = Number(req.headers.get("content-length") ?? 0); if (len > MAX_BYTES) return apiError(413, "too_large", "Image trop volumineuse (25 Mo max)");
18 + const buf = Buffer.from(await req.arrayBuffer()); if (buf.length > MAX_BYTES) return apiError(413, "too_large", "Image trop volumineuse (25 Mo max)");
19 + try {
20 + const r = await processUpload(buf, req.headers.get("content-type"));
21 + await d.update(listingMedia).set({ storageKey: r.key, mimeType: "image/webp", width: r.width, height: r.height, bytes: r.bytes, scanStatus: "CLEAN", status: "READY", uploadToken: null }).where(eq(listingMedia.id, m.id));
22 + await audit("media.upload", { actorId: u.id, objectType: "listing_media", objectId: m.id, meta: { bytes: buf.length, mime: r.mime } });
23 + return NextResponse.json({ ok: true, mediaId: m.id, url: `/api/media/${r.key}/800.webp`, width: r.width, height: r.height });
24 + } catch (e) {
25 + await d.update(listingMedia).set({ status: "DELETED", scanStatus: "REJECTED", uploadToken: null }).where(eq(listingMedia.id, m.id));
26 + if (e instanceof MediaError) { await audit("media.rejected", { actorId: u.id, objectId: m.id, meta: { code: e.code } }); return apiError(422, e.code, e.message); }
27 + throw e;
28 + }
29 + } catch (e) { return handle(e); }
30 +}
added src/app/api/viewings/[id]/route.ts +8 −0
@@ -0,0 +1,8 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { assertSameOrigin, requireUser } from "@/lib/auth/session";
4 +import { updateViewing } from "@/lib/inquiries/service";
5 +import { handle, parseBody } from "@/lib/api";
6 +export const dynamic = "force-dynamic";
7 +const Body = z.object({ action: z.enum(["confirm", "cancel", "done"]), slot: z.string().datetime({ offset: true }).optional() });
8 +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { await assertSameOrigin(); const u = await requireUser(); const { id } = await params; const b = await parseBody(req, Body); await updateViewing(id, u.id, b.action, b.slot); return NextResponse.json({ ok: true }); } catch (e) { return handle(e); } }
deleted src/app/compte/account-view.tsx +0 −20
@@ -1,20 +0,0 @@
1 −"use client";
2 −import Link from "next/link";
3 −import { useFavorites, useSavedSearches, useCompare } from "@/lib/favorites";
4 −import { Notice } from "@/components/ui";
5 −export function AccountView() {
6 − const fav = useFavorites(); const saved = useSavedSearches(); const cmp = useCompare();
7 − return (
8 − <div className="mx-auto max-w-3xl px-4 py-8">
9 − <h1 className="text-3xl font-extrabold tracking-tight">Mon profil</h1>
10 − <p className="mt-1 text-ink-2">Toveli fonctionne sans compte. Vos favoris, comparaisons et recherches sont stockés dans ce navigateur et ne sont jamais envoyés à nos serveurs.</p>
11 − <ul className="mt-5 tv-card divide-y divide-line text-sm">
12 − <li className="p-3 flex justify-between"><span>Favoris</span><Link href="/favoris" className="font-semibold">{fav.items.length}</Link></li>
13 − <li className="p-3 flex justify-between"><span>Comparateur</span><Link href="/comparer" className="font-semibold">{cmp.items.length}</Link></li>
14 − <li className="p-3 flex justify-between"><span>Recherches sauvegardées</span><Link href="/favoris" className="font-semibold">{saved.items.length}</Link></li>
15 − </ul>
16 − <div className="mt-4 flex gap-2"><button type="button" onClick={() => { if (confirm("Supprimer toutes les données locales Toveli ?")) saved.clearAll(); }} className="tv-btn tv-btn-ghost">Supprimer mes données locales</button></div>
17 − <div className="mt-6"><Notice>Comptes, alertes par courriel et synchronisation entre appareils arriveront dans une version ultérieure, avec consentement explicite et politique de confidentialité conforme à la Loi 25. <Link href="/confidentialite" className="underline">Politique de confidentialité</Link>.</Notice></div>
18 − </div>
19 − );
20 −}
modified src/app/compte/page.tsx +2 −4
@@ -1,4 +1,2 @@
1 −import type { Metadata } from "next";
2 −import { AccountView } from "./account-view";
3 −export const metadata: Metadata = { title: "Mon profil", robots: { index: false } };
4 −export default function Compte() { return <AccountView />; }
1 +import { redirect } from "next/navigation";
2 +export default function Compte() { redirect("/membre"); }
modified src/app/connexion/login-view.tsx +1 −1
@@ -24,7 +24,7 @@ export function LoginView() {
24 24 if (!challengeId || c.length !== 6) return;
25 25 setBusy(true); setErr(null);
26 26 const r = await fetch("/api/auth/verify-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId, code: c, acceptTerms: accept }) }); const d = await r.json(); setBusy(false);
27 − if (r.status === 409 && !accept) { setNeedTerms(true); setErr("Nouveau compte : acceptez les conditions puis confirmez le code."); return; }
27 + if (r.status === 409) { setNeedTerms(true); setErr(d.error?.message ?? "Acceptez les conditions puis confirmez le code."); return; }
28 28 if (!r.ok) { setErr(d.error?.message ?? "Erreur"); if (["expired", "too_many_attempts", "not_found"].includes(d.error?.code)) { setStep("email"); setChallengeId(null); setCode(""); } return; }
29 29 router.push(next); router.refresh();
30 30 };
modified src/app/favoris/favorites-view.tsx +2 −2
@@ -10,13 +10,13 @@ export function useCards(uids: string[]) {
10 10 useEffect(() => { if (!uids.length) { setCards([]); return; } fetch(`/api/listings?uids=${encodeURIComponent(uids.join(","))}`).then((r) => r.json()).then((d) => setCards(d.items ?? [])).catch(() => setCards([])); }, [uids.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
11 11 return cards;
12 12 }
13 −export function FavoritesView() {
13 +export function FavoritesView({ member = false }: { member?: boolean } = {}) {
14 14 const fav = useFavorites(); const saved = useSavedSearches(); const cmp = useCompare(); const cards = useCards(fav.items);
15 15 const gone = fav.items.filter((u) => cards.length && !cards.some((c) => c.uid === u));
16 16 return (
17 17 <div className="mx-auto max-w-7xl px-4 py-8">
18 18 <div className="flex items-end justify-between gap-3"><h1 className="text-3xl font-extrabold tracking-tight">Mes favoris</h1>{cmp.items.length >= 2 && <Link href="/comparer" className="tv-btn tv-btn-ghost">Comparateur ({cmp.items.length})</Link>}</div>
19 − <p className="mt-1 text-sm text-ink-2">Enregistrés sur cet appareil uniquement (aucun compte requis). <Link href="/compte" className="underline">Gérer mes données</Link>.</p>
19 + <p className="mt-1 text-sm text-ink-2">{member ? "Favoris synchronisés avec votre compte Toveli, disponibles sur tous vos appareils." : <>Sans compte, vos favoris restent sur cet appareil. <Link href="/connexion?next=/membre/favoris" className="underline">Connectez-vous</Link> pour les retrouver partout et créer des alertes.</>}</p>
20 20 {fav.items.length === 0 ? <div className="mt-6"><Empty title="Aucun favori pour l'instant" hint="Touchez le cœur sur une annonce pour la retrouver ici." action={{ href: "/recherche", label: "Explorer les annonces" }} /></div> : (
21 21 <div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3">{cards.map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
22 22 )}
added src/app/membre/alertes/alerts-view.tsx +22 −0
@@ -0,0 +1,22 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useRouter } from "next/navigation";
4 +import { Empty, Notice } from "@/components/ui";
5 +type S = { id: string; label: string; query: Record<string, string>; alertEnabled: boolean; alertFrequency: string; lastAlertAt: string | null; createdAt: string };
6 +export function AlertsView({ items }: { items: S[] }) {
7 + const router = useRouter();
8 + const patch = async (id: string, b: Record<string, unknown>) => { await fetch(`/api/me/saved-searches/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); router.refresh(); };
9 + const del = async (id: string) => { await fetch(`/api/me/saved-searches/${id}`, { method: "DELETE" }); router.refresh(); };
10 + return (
11 + <div>
12 + <h1 className="text-2xl md:text-3xl font-extrabold tracking-tight mb-1">Alertes</h1><p className="text-sm text-ink-2 mb-4">Recevez par courriel les nouvelles annonces correspondant à vos recherches sauvegardées (compte requis). Fréquence au choix, désactivable en un clic.</p>
13 + {items.length === 0 ? <Empty title="Aucune recherche sauvegardée" hint="Sur la page des résultats, cliquez « Sauvegarder » : connecté, la recherche est enregistrée dans votre compte et peut devenir une alerte." action={{ href: "/recherche", label: "Lancer une recherche" }} /> : (
14 + <ul className="tv-card divide-y divide-line text-sm">{items.map((s) => <li key={s.id} className="p-3 flex flex-wrap items-center gap-3"><div className="min-w-0 flex-1"><Link href={`/recherche?${new URLSearchParams(s.query)}`} className="font-semibold hover:underline">{s.label}</Link><p className="text-xs text-ink-3">{s.lastAlertAt ? `Dernière alerte ${new Date(s.lastAlertAt).toLocaleDateString("fr-CA")}` : "Aucune alerte envoyée encore"}</p></div>
15 + <label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={s.alertEnabled} onChange={(e) => patch(s.id, { alertEnabled: e.target.checked })} className="accent-[#e0263c]" /> Alerte</label>
16 + <select value={s.alertFrequency} onChange={(e) => patch(s.id, { alertFrequency: e.target.value })} className="tv-chip appearance-none text-xs"><option value="INSTANT">Dès publication</option><option value="DAILY">Quotidienne</option><option value="WEEKLY">Hebdomadaire</option><option value="OFF">Désactivée</option></select>
17 + <button type="button" onClick={() => del(s.id)} className="text-xs underline text-ink-3">Supprimer</button></li>)}</ul>
18 + )}
19 + <div className="mt-4"><Notice>Les alertes sont envoyées par le service de courriel Toveli à l'adresse vérifiée de votre compte, selon la fréquence choisie, uniquement pour les nouvelles annonces (agrégées ou directes) correspondant à vos critères.</Notice></div>
20 + </div>
21 + );
22 +}
added src/app/membre/alertes/page.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { requireUser } from "@/lib/auth/session";
2 +import { db } from "@/db/client";
3 +import { savedSearches } from "@/db/schema";
4 +import { eq, desc } from "drizzle-orm";
5 +import { AlertsView } from "./alerts-view";
6 +export const dynamic = "force-dynamic";
7 +export default async function Alertes() {
8 + const u = await requireUser(); const rows = await db().select().from(savedSearches).where(eq(savedSearches.userId, u.id)).orderBy(desc(savedSearches.createdAt));
9 + return <AlertsView items={rows.map((r) => ({ id: r.id, label: r.label, query: r.query as Record<string, string>, alertEnabled: r.alertEnabled, alertFrequency: r.alertFrequency, lastAlertAt: r.lastAlertAt?.toISOString() ?? null, createdAt: r.createdAt.toISOString() }))} />;
10 +}
added src/app/membre/annonces/[id]/page.tsx +16 −0
@@ -0,0 +1,16 @@
1 +import { notFound } from "next/navigation";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { getListingFull } from "@/lib/listings/service";
4 +import { mediaUrl } from "@/lib/listings/media";
5 +import { Wizard } from "./wizard";
6 +export const dynamic = "force-dynamic";
7 +export default async function EditAnnonce({ params }: { params: Promise<{ id: string }> }) {
8 + const u = await requireUser(); const { id } = await params; const l = await getListingFull(id);
9 + if (!l || (l.ownerUserId !== u.id && !u.roles.includes("ADMIN") && !u.roles.includes("MODERATOR"))) notFound();
10 + const initial = { id: l.id, slug: l.slug, status: l.status, version: l.version, wizardStep: l.wizardStep, attestedAt: l.attestedAt?.toISOString() ?? null, moderationNote: l.moderationNote,
11 + listingType: l.listingType as "SALE" | "RENT", sourceType: l.sourceType as "DIRECT_OWNER" | "DIRECT_AGENT", propertyType: l.property.propertyType, address: l.property.canonicalAddress, unit: l.property.unit, city: l.property.city, region: l.property.region, postalCode: l.property.postalCode, lat: l.property.lat, lng: l.property.lng, roleUnitId: l.property.roleUnitId, privacyLevel: l.property.privacyLevel,
12 + bedrooms: l.property.bedrooms != null ? Number(l.property.bedrooms) : null, bathrooms: l.property.bathrooms != null ? Number(l.property.bathrooms) : null, areaSqft: l.property.areaSqft, lotSqft: l.property.lotSqft, yearBuilt: l.property.yearBuilt,
13 + title: l.title, description: l.description, priceAmount: l.priceAmount != null ? Number(l.priceAmount) : null, availabilityDate: l.availabilityDate, furnished: l.furnished, petsPolicy: l.petsPolicy, smokingPolicy: l.smokingPolicy, parking: l.parking, leaseTerms: l.leaseTerms, inclusions: (l.inclusions as string[]) ?? [], features: (l.features as Record<string, string | number | boolean>) ?? {},
14 + media: l.media.map((m) => ({ id: m.id, url: mediaUrl(m.storageKey, 320), large: mediaUrl(m.storageKey, 800), altText: m.altText ?? "", position: m.position })) };
15 + return <Wizard initial={initial} />;
16 +}
added src/app/membre/annonces/[id]/wizard.tsx +169 −0
@@ -0,0 +1,169 @@
1 +"use client";
2 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3 +import Link from "next/link";
4 +import { useRouter } from "next/navigation";
5 +import { PROPERTY_TYPES, PROPERTY_TYPE_LABEL, STATUS_LABEL } from "@/lib/listings/types";
6 +import { REGIONS } from "@/lib/normalize";
7 +import { fmtPrice } from "@/lib/format";
8 +import { Notice } from "@/components/ui";
9 +import { MapView } from "@/components/map-view";
10 +import { IconClose } from "@/components/icons";
11 +
12 +type Media = { id: string; url: string; large: string; altText: string; position: number };
13 +export type WizardData = { id: string; slug: string; status: string; version: number; wizardStep: number; attestedAt: string | null; moderationNote: string | null; listingType: "SALE" | "RENT"; sourceType: "DIRECT_OWNER" | "DIRECT_AGENT"; propertyType: string; address: string | null; unit: string | null; city: string | null; region: string | null; postalCode: string | null; lat: number | null; lng: number | null; roleUnitId: string | null; privacyLevel: string; bedrooms: number | null; bathrooms: number | null; areaSqft: number | null; lotSqft: number | null; yearBuilt: number | null; title: string | null; description: string | null; priceAmount: number | null; availabilityDate: string | null; furnished: boolean | null; petsPolicy: string | null; smokingPolicy: string | null; parking: string | null; leaseTerms: string | null; inclusions: string[]; features: Record<string, string | number | boolean>; media: Media[] };
14 +
15 +const STEPS = ["Type", "Adresse", "Caractéristiques", "Conditions", "Photos", "Description", "Vérification", "Aperçu"];
16 +const INCLUSIONS_RENT = ["Chauffage", "Électricité", "Eau chaude", "Internet", "Stationnement", "Laveuse-sécheuse", "Réfrigérateur", "Cuisinière", "Lave-vaisselle", "Climatisation", "Meublé", "Balcon", "Rangement", "Ascenseur", "Gym", "Piscine", "Accessible (mobilité réduite)"];
17 +const INCLUSIONS_SALE = ["Garage", "Piscine", "Foyer", "Climatisation centrale", "Thermopompe", "Sous-sol aménagé", "Bord de l'eau", "Vue", "Terrain boisé", "Rénové récemment", "Accessible (mobilité réduite)", "Électroménagers inclus"];
18 +
19 +export function Wizard({ initial }: { initial: WizardData }) {
20 + const [d, setD] = useState<WizardData>(initial); const [step, setStep] = useState(Math.min(initial.wizardStep, STEPS.length - 1));
21 + const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [err, setErr] = useState<string | null>(null); const [problems, setProblems] = useState<string[]>([]);
22 + const router = useRouter(); const dirty = useRef<Partial<WizardData>>({}); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
23 + const rent = d.listingType === "RENT";
24 + const set = (patch: Partial<WizardData>) => { setD((x) => ({ ...x, ...patch })); Object.assign(dirty.current, patch); setSaving("saving"); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(save, 700); };
25 + const save = useCallback(async (extra: Partial<WizardData> = {}) => {
26 + const patch = { ...dirty.current, ...extra }; dirty.current = {}; if (!Object.keys(patch).length) { setSaving("saved"); return true; }
27 + const body: Record<string, unknown> = { version: d.version };
28 + for (const k of ["listingType", "sourceType", "propertyType", "address", "unit", "city", "region", "postalCode", "lat", "lng", "roleUnitId", "privacyLevel", "bedrooms", "bathrooms", "areaSqft", "lotSqft", "yearBuilt", "title", "description", "priceAmount", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (k in patch) body[k] = (patch as Record<string, unknown>)[k];
29 + const r = await fetch(`/api/listings/${d.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const j = await r.json();
30 + if (!r.ok) { setSaving("error"); setErr(j.error?.code === "conflict" ? "Cette annonce a été modifiée ailleurs : rechargez la page." : j.error?.issues?.map((i: { path: string; message: string }) => `${i.path} : ${i.message}`).join(" · ") ?? j.error?.message ?? "Erreur d'enregistrement"); return false; }
31 + setD((x) => ({ ...x, version: j.listing.version, status: j.listing.status })); setSaving("saved"); setErr(null); return true;
32 + }, [d.id, d.version]);
33 + useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
34 + const go = async (n: number) => { if (timer.current) clearTimeout(timer.current); const ok = await save({ wizardStep: n }); if (ok) { setStep(n); window.scrollTo({ top: 0, behavior: "smooth" }); } };
35 +
36 + // ── Adresse : recherche dans le rôle d'évaluation (3,7 M d'unités) pour géocoder sans service externe
37 + const [adjust, setAdjust] = useState(false);
38 + const [addrQ, setAddrQ] = useState(""); const [addrSug, setAddrSug] = useState<{ id: string; address: string; unit: string | null; city: string; type_prop: string; year_built: number | null; floor_area_m2: number | null }[]>([]);
39 + useEffect(() => { if (addrQ.trim().length < 4) { setAddrSug([]); return; } const t = setTimeout(() => fetch(`/api/estimate?q=${encodeURIComponent(addrQ)}`).then((r) => r.json()).then((j) => setAddrSug(j.units ?? [])).catch(() => {}), 200); return () => clearTimeout(t); }, [addrQ]);
40 + const pickUnit = async (u: (typeof addrSug)[number]) => { const r = await fetch(`/api/estimate?id=${encodeURIComponent(u.id)}`).then((r) => r.json()).catch(() => null); const lat = r?.subject?.lat ?? null, lng = r?.subject?.lng ?? null; set({ address: u.address, unit: u.unit, city: u.city, lat, lng, roleUnitId: u.id, yearBuilt: d.yearBuilt ?? u.year_built ?? null, areaSqft: d.areaSqft ?? (u.floor_area_m2 ? Math.round(u.floor_area_m2 * 10.7639) : null) }); setAddrSug([]); setAddrQ(""); };
41 +
42 + // ── Photos
43 + const [uploading, setUploading] = useState<string[]>([]); const fileRef = useRef<HTMLInputElement>(null);
44 + const upload = async (files: FileList | File[]) => {
45 + for (const f of Array.from(files)) {
46 + setUploading((u) => [...u, f.name]);
47 + try {
48 + const pre = await fetch(`/api/listings/${d.id}/media/presign`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fileName: f.name, mimeType: f.type || "application/octet-stream", bytes: f.size }) }); const pj = await pre.json(); if (!pre.ok) throw new Error(pj.error?.message ?? "Erreur");
49 + const up = await fetch(pj.uploadUrl, { method: "PUT", headers: { "content-type": f.type || "application/octet-stream" }, body: f }); const uj = await up.json(); if (!up.ok) throw new Error(uj.error?.message ?? "Photo refusée");
50 + setD((x) => ({ ...x, media: [...x.media, { id: uj.mediaId, url: uj.url.replace("/800.webp", "/320.webp"), large: uj.url, altText: "", position: x.media.length }] }));
51 + } catch (e) { setErr(`${f.name} : ${(e as Error).message}`); }
52 + finally { setUploading((u) => u.filter((n) => n !== f.name)); }
53 + }
54 + };
55 + const removeMedia = async (id: string) => { await fetch(`/api/listings/${d.id}/media/${id}`, { method: "DELETE" }); setD((x) => ({ ...x, media: x.media.filter((m) => m.id !== id) })); };
56 + const moveMedia = async (i: number, dir: -1 | 1) => { const arr = [...d.media]; const j = i + dir; if (j < 0 || j >= arr.length) return; [arr[i], arr[j]] = [arr[j], arr[i]]; setD((x) => ({ ...x, media: arr })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order: arr.map((m) => m.id) }) }); };
57 + const caption = async (id: string, alt: string) => { setD((x) => ({ ...x, media: x.media.map((m) => m.id === id ? { ...m, altText: alt } : m) })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ captions: { [id]: alt } }) }); };
58 +
59 + const attest = async () => { const r = await fetch(`/api/listings/${d.id}/attest`, { method: "POST" }); if (r.ok) setD((x) => ({ ...x, attestedAt: new Date().toISOString() })); };
60 + const publish = async () => { if (timer.current) clearTimeout(timer.current); if (!(await save())) return; setProblems([]); const r = await fetch(`/api/listings/${d.id}/publish`, { method: "POST" }); const j = await r.json(); if (r.status === 422) { setProblems(j.problems); return; } if (!r.ok) { setErr(j.error?.message ?? "Erreur"); return; } setD((x) => ({ ...x, status: j.status })); router.refresh(); };
61 +
62 + const F = useMemo(() => ({
63 + num: (k: keyof WizardData, label: string, opts: { step?: number; min?: number; max?: number; suffix?: string; help?: string } = {}) => (
64 + <label className="block text-sm"><span className="tv-label">{label}</span><div className="relative mt-1"><input type="number" inputMode="decimal" step={opts.step ?? 1} min={opts.min} max={opts.max} value={(d[k] as number | null) ?? ""} onChange={(e) => set({ [k]: e.target.value === "" ? null : Number(e.target.value) } as Partial<WizardData>)} className="tv-chip w-full !pl-3 !pr-10 tabular-nums" />{opts.suffix && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-ink-3">{opts.suffix}</span>}</div>{opts.help && <span className="text-[11px] text-ink-3">{opts.help}</span>}</label>),
65 + text: (k: keyof WizardData, label: string, opts: { placeholder?: string; max?: number; help?: string } = {}) => (
66 + <label className="block text-sm"><span className="tv-label">{label}</span><input type="text" maxLength={opts.max} placeholder={opts.placeholder} value={(d[k] as string | null) ?? ""} onChange={(e) => set({ [k]: e.target.value || null } as Partial<WizardData>)} className="tv-chip w-full !pl-3 mt-1" />{opts.help && <span className="text-[11px] text-ink-3">{opts.help}</span>}</label>),
67 + // eslint-disable-next-line react-hooks/exhaustive-deps
68 + }), [d]);
69 +
70 + const cardIndicator = <span className={`text-xs ${saving === "error" ? "text-brand" : "text-ink-3"}`}>{saving === "saving" ? "Enregistrement…" : saving === "saved" ? "Brouillon enregistré" : saving === "error" ? "Non enregistré" : ""}</span>;
71 + const incl = rent ? INCLUSIONS_RENT : INCLUSIONS_SALE;
72 + const isLand = d.propertyType === "terrain", isCom = d.propertyType === "commercial";
73 +
74 + return (
75 + <div>
76 + <div className="flex flex-wrap items-center justify-between gap-2 mb-3">
77 + <div><Link href="/membre/annonces" className="text-xs text-ink-3 hover:underline">← Mes annonces</Link><h1 className="text-2xl font-extrabold tracking-tight">{d.status === "DRAFT" ? "Publier une propriété" : `Modifier l'annonce · ${STATUS_LABEL[d.status]}`}</h1></div>
78 + {cardIndicator}
79 + </div>
80 + <ol className="flex gap-1 overflow-x-auto no-scrollbar mb-5 text-xs" aria-label="Étapes">{STEPS.map((s, i) => <li key={s}><button type="button" onClick={() => go(i)} className={`tv-chip !py-1 ${i === step ? "!bg-brand !text-white !border-brand" : i < step ? "!border-brand/40" : ""}`} aria-current={i === step ? "step" : undefined}>{i + 1}. {s}</button></li>)}</ol>
81 + {err && <div className="mb-3"><Notice tone="warn">{err}</Notice></div>}
82 + {d.moderationNote && ["PENDING_REVIEW", "REJECTED"].includes(d.status) && <div className="mb-3"><Notice tone="warn">Vérification : {d.moderationNote}. {d.status === "REJECTED" ? "Corrigez l'annonce puis republiez ; vous pouvez contester via Signaler un problème → « autre »." : "Un modérateur examinera l'annonce ; vous serez avisé par courriel."}</Notice></div>}
83 +
84 + <div className="tv-card p-4 md:p-6">
85 + {step === 0 && (<div className="space-y-5">
86 + <div><p className="tv-label mb-2">Je souhaite</p><div className="grid grid-cols-2 gap-2">{(["RENT", "SALE"] as const).map((t) => <button key={t} type="button" onClick={() => set({ listingType: t })} className={`tv-card p-4 text-left ${d.listingType === t ? "!border-brand ring-2 ring-brand-soft" : ""}`}><span className="font-bold">{t === "RENT" ? "Louer" : "Vendre"}</span><span className="block text-xs text-ink-2">{t === "RENT" ? "Logement, condo, maison, chambre…" : "Maison, condo, plex, chalet, terrain…"}</span></button>)}</div></div>
87 + <div><p className="tv-label mb-2">Type de bien</p><div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{PROPERTY_TYPES.map((t) => <button key={t} type="button" onClick={() => set({ propertyType: t })} className={`tv-chip justify-center ${d.propertyType === t ? "!bg-brand-soft !border-brand !text-brand-dark" : ""}`}>{PROPERTY_TYPE_LABEL[t]}</button>)}</div></div>
88 + <div><p className="tv-label mb-2">Je publie en tant que</p><div className="grid grid-cols-2 gap-2">{([["DIRECT_OWNER", "Propriétaire", "Je suis propriétaire (ou mandaté par écrit) du bien."], ["DIRECT_AGENT", "Représentant", "Je représente le propriétaire (courtier, gestionnaire). La mention « professionnel vérifié » n'apparaît qu'après vérification par Toveli."]] as const).map(([v, l, h]) => <button key={v} type="button" onClick={() => set({ sourceType: v })} className={`tv-card p-3 text-left ${d.sourceType === v ? "!border-brand ring-2 ring-brand-soft" : ""}`}><span className="font-semibold text-sm">{l}</span><span className="block text-xs text-ink-2">{h}</span></button>)}</div></div>
89 + </div>)}
90 +
91 + {step === 1 && (<div className="space-y-4">
92 + <div className="relative"><label className="block text-sm"><span className="tv-label">Rechercher l'adresse (rôle d'évaluation du Québec)</span><input value={addrQ} onChange={(e) => setAddrQ(e.target.value)} placeholder="ex. 1234 rue des Érables Québec" className="tv-input !pl-4 mt-1" /></label>
93 + {addrSug.length > 0 && <ul className="absolute z-20 mt-1 w-full tv-card p-1 max-h-72 overflow-auto">{addrSug.map((u) => <li key={u.id}><button type="button" onMouseDown={() => pickUnit(u)} className="w-full text-left px-3 py-2 rounded-sm hover:bg-surface-2 text-sm"><span className="font-medium">{u.address}{u.unit ? `, app. ${u.unit}` : ""}</span><span className="block text-xs text-ink-3">{u.city} · {u.type_prop}{u.year_built ? ` · ${u.year_built}` : ""}</span></button></li>)}</ul>}</div>
94 + <div className="grid sm:grid-cols-2 gap-3">{F.text("address", "Adresse civique", { placeholder: "1234 rue des Érables", max: 200 })}{F.text("unit", "Appartement / unité", { placeholder: "4", max: 20 })}{F.text("city", "Municipalité", { max: 100 })}
95 + <label className="block text-sm"><span className="tv-label">Région</span><select value={d.region ?? ""} onChange={(e) => set({ region: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option>{REGIONS.map((r) => <option key={r} value={r}>{r}</option>)}</select></label>
96 + {F.text("postalCode", "Code postal", { placeholder: "G1R 2L3", max: 7 })}
97 + <label className="block text-sm"><span className="tv-label">Confidentialité de l'adresse</span><select value={d.privacyLevel} onChange={(e) => set({ privacyLevel: e.target.value })} className="tv-chip w-full mt-1 appearance-none"><option value="EXACT">Adresse exacte publiée</option><option value="STREET_ONLY">Rue et municipalité seulement (recommandé en location)</option><option value="AREA_ONLY">Secteur seulement (position approximative)</option></select></label></div>
98 + <div><div className="flex flex-wrap items-center justify-between gap-2 mb-1"><p className="tv-label">Position sur la carte {d.lat == null && <span className="text-warn">— requise : choisissez une adresse ci-dessus ou activez l'ajustement</span>}</p><label className="flex items-center gap-2 text-xs text-ink-2"><input type="checkbox" checked={adjust} onChange={(e) => { setAdjust(e.target.checked); if (e.target.checked && d.lat == null) set({ lat: 46.81, lng: -71.21 }); }} className="accent-[#e0263c]" /> Ajuster en déplaçant la carte</label></div>
99 + <div className="h-64 rounded-card overflow-hidden border border-line relative"><MapView points={d.lat != null && d.lng != null && !adjust ? [{ uid: d.id, lat: d.lat, lng: d.lng, price: d.priceAmount ?? 0 }] : []} center={d.lat != null && d.lng != null ? [d.lat, d.lng] : [46.81, -71.21]} zoom={d.lat != null ? 15 : 6} rent={rent} onMove={(b) => { if (adjust) set({ lat: Math.round(((b[1] + b[3]) / 2) * 1e5) / 1e5, lng: Math.round(((b[0] + b[2]) / 2) * 1e5) / 1e5 }); }} />{adjust && <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-full text-brand text-2xl pointer-events-none" aria-hidden>📍</span>}</div>
100 + <div className="grid grid-cols-2 gap-3 mt-2">{F.num("lat", "Latitude", { step: 0.00001, min: 44, max: 63 })}{F.num("lng", "Longitude", { step: 0.00001, min: -80, max: -57 })}</div></div>
101 + </div>)}
102 +
103 + {step === 2 && (<div className="grid sm:grid-cols-2 gap-3">
104 + {!isLand && !isCom && F.num("bedrooms", "Chambres", { step: 1, min: 0, max: 30 })}{!isLand && !isCom && F.num("bathrooms", "Salles de bain", { step: 0.5, min: 0, max: 20 })}
105 + {!isLand && F.num("areaSqft", "Superficie habitable", { suffix: "pi²", min: 50, help: "Mesure intérieure ; 1 m² = 10,76 pi²" })}
106 + {(!rent || isLand) && F.num("lotSqft", "Superficie du terrain", { suffix: "pi²", min: 100 })}
107 + {!isLand && F.num("yearBuilt", "Année de construction", { min: 1600, max: 2030 })}
108 + {!isLand && F.text("parking", "Stationnement", { placeholder: "Garage simple + 2 places extérieures", max: 120 })}
109 + {!isLand && <label className="block text-sm"><span className="tv-label">Étage(s)</span><input type="text" maxLength={40} value={String(d.features.etages ?? "")} onChange={(e) => set({ features: { ...d.features, etages: e.target.value } })} className="tv-chip w-full !pl-3 mt-1" placeholder={rent ? "3e étage, ascenseur" : "2 étages + sous-sol"} /></label>}
110 + {!isLand && <label className="block text-sm"><span className="tv-label">Chauffage / énergie</span><input type="text" maxLength={80} value={String(d.features.chauffage ?? "")} onChange={(e) => set({ features: { ...d.features, chauffage: e.target.value } })} className="tv-chip w-full !pl-3 mt-1" placeholder="Électrique, plinthes · thermopompe" /></label>}
111 + <div className="sm:col-span-2"><p className="tv-label mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</p><div className="flex flex-wrap gap-2">{incl.map((x) => <button key={x} type="button" onClick={() => set({ inclusions: d.inclusions.includes(x) ? d.inclusions.filter((y) => y !== x) : [...d.inclusions, x] })} className="tv-chip" data-active={d.inclusions.includes(x)} aria-pressed={d.inclusions.includes(x)}>{x}</button>)}</div></div>
112 + </div>)}
113 +
114 + {step === 3 && (<div className="space-y-4">
115 + <div className="grid sm:grid-cols-2 gap-3">
116 + {F.num("priceAmount", rent ? "Loyer mensuel demandé" : "Prix demandé", { suffix: "$ CAD", step: rent ? 5 : 1000, min: 0, help: rent ? "Loyer de base par mois, avant frais facultatifs." : "Prix affiché publiquement ; l'estimation Toveli sera montrée à titre indicatif." })}
117 + {rent && <label className="block text-sm"><span className="tv-label">Disponible</span><input type="date" value={d.availabilityDate && d.availabilityDate !== "now" ? d.availabilityDate : ""} onChange={(e) => set({ availabilityDate: e.target.value || "now" })} className="tv-chip w-full !pl-3 mt-1" /><label className="flex items-center gap-2 text-xs mt-1 text-ink-2"><input type="checkbox" checked={d.availabilityDate === "now" || !d.availabilityDate} onChange={(e) => set({ availabilityDate: e.target.checked ? "now" : null })} className="accent-[#e0263c]" /> Immédiatement</label></label>}
118 + {rent && <label className="block text-sm"><span className="tv-label">Meublé</span><select value={d.furnished == null ? "" : d.furnished ? "1" : "0"} onChange={(e) => set({ furnished: e.target.value === "" ? null : e.target.value === "1" })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="1">Oui</option><option value="0">Non</option></select></label>}
119 + {rent && <label className="block text-sm"><span className="tv-label">Animaux</span><select value={d.petsPolicy ?? ""} onChange={(e) => set({ petsPolicy: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="allowed">Acceptés</option><option value="ask">À discuter</option><option value="not_allowed">Refusés</option></select></label>}
120 + {rent && <label className="block text-sm"><span className="tv-label">Fumeur</span><select value={d.smokingPolicy ?? ""} onChange={(e) => set({ smokingPolicy: e.target.value || null })} className="tv-chip w-full mt-1 appearance-none"><option value="">—</option><option value="not_allowed">Non-fumeur</option><option value="allowed">Permis</option></select></label>}
121 + {rent && <label className="block text-sm sm:col-span-2"><span className="tv-label">Durée et conditions du bail</span><input type="text" maxLength={400} value={d.leaseTerms ?? ""} onChange={(e) => set({ leaseTerms: e.target.value || null })} className="tv-chip w-full !pl-3 mt-1" placeholder="Bail de 12 mois, formulaire obligatoire du TAL, aucun dépôt exigé" /></label>}
122 + {!rent && <label className="block text-sm"><span className="tv-label">Taxes municipales annuelles (facultatif)</span><input type="number" inputMode="numeric" value={String(d.features.taxes_municipales ?? "")} onChange={(e) => set({ features: { ...d.features, taxes_municipales: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}
123 + {!rent && <label className="block text-sm"><span className="tv-label">Taxes scolaires annuelles (facultatif)</span><input type="number" inputMode="numeric" value={String(d.features.taxes_scolaires ?? "")} onChange={(e) => set({ features: { ...d.features, taxes_scolaires: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}
124 + {!rent && d.propertyType === "condo" && <label className="block text-sm"><span className="tv-label">Frais de condo mensuels</span><input type="number" inputMode="numeric" value={String(d.features.frais_condo ?? "")} onChange={(e) => set({ features: { ...d.features, frais_condo: e.target.value ? Number(e.target.value) : "" } })} className="tv-chip w-full !pl-3 mt-1" /></label>}
125 + </div>
126 + {rent ? <Notice>Au Québec, le locateur ne peut exiger aucun dépôt de garantie ni paiement d'avance autre que le premier mois de loyer, et doit utiliser le bail obligatoire du Tribunal administratif du logement (TAL). La clause G du bail indique le loyer le plus bas payé au cours des 12 derniers mois. Les critères discriminatoires sont interdits (Charte québécoise).</Notice>
127 + : <Notice>Le vendeur doit déclarer les vices et défauts connus (déclaration du vendeur). Toveli n'est pas un courtier : la transaction se conclut entre les parties, idéalement avec notaire et inspection. Les droits de mutation sont à la charge de l'acheteur.</Notice>}
128 + </div>)}
129 +
130 + {step === 4 && (<div className="space-y-4">
131 + <div onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }} className="border-2 border-dashed border-line rounded-card p-6 text-center">
132 + <p className="font-semibold">Glissez vos photos ici</p><p className="text-xs text-ink-3 mt-1">JPEG, PNG, WebP ou HEIC · 25 Mo max par photo · 30 photos max · les données EXIF/GPS sont retirées automatiquement</p>
133 + <input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic" multiple capture="environment" className="hidden" onChange={(e) => e.target.files && upload(e.target.files)} />
134 + <div className="mt-3 flex justify-center gap-2"><button type="button" onClick={() => fileRef.current?.click()} className="tv-btn tv-btn-primary">Choisir des photos</button></div>
135 + {uploading.length > 0 && <p className="text-xs text-ink-2 mt-2">Traitement : {uploading.join(", ")}…</p>}
136 + </div>
137 + {d.media.length > 0 && <ul className="grid grid-cols-2 sm:grid-cols-3 gap-3">{d.media.map((m, i) => <li key={m.id} className="tv-card overflow-hidden"><div className="relative aspect-[4/3] bg-surface-2"><img src={m.url} alt={m.altText} className="absolute inset-0 w-full h-full object-cover" />{i === 0 && <span className="absolute top-1.5 left-1.5 tv-badge bg-brand text-white">Photo principale</span>}<button type="button" onClick={() => removeMedia(m.id)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-white/90" aria-label="Supprimer"><IconClose width={14} height={14} /></button></div><div className="p-2 flex items-center gap-1"><button type="button" onClick={() => moveMedia(i, -1)} className="tv-chip !px-2 !py-0.5" aria-label="Avancer">←</button><button type="button" onClick={() => moveMedia(i, 1)} className="tv-chip !px-2 !py-0.5" aria-label="Reculer">→</button><input value={m.altText} onChange={(e) => caption(m.id, e.target.value)} placeholder="Légende" maxLength={140} className="flex-1 min-w-0 text-xs border-b border-line outline-none py-0.5" /></div></li>)}</ul>}
138 + <Notice>En téléversant des photos, vous confirmez en détenir les droits et consentez à leur publication sur Toveli. Pas de personnes identifiables ni de documents personnels sur les photos.</Notice>
139 + </div>)}
140 +
141 + {step === 5 && (<div className="space-y-4">
142 + {F.text("title", "Titre de l'annonce", { placeholder: rent ? "Grand 4½ lumineux près du métro, chauffé" : "Maison familiale rénovée, grand terrain, quartier paisible", max: 120, help: "8 à 120 caractères. Pas de majuscules excessives." })}
143 + <label className="block text-sm"><span className="tv-label">Description</span><textarea value={d.description ?? ""} onChange={(e) => set({ description: e.target.value || null })} rows={10} maxLength={6000} className="w-full mt-1 border border-line rounded-sm p-3 text-sm outline-none focus:border-brand" placeholder="Décrivez le bien, ses atouts, le quartier, les conditions. Ne mentionnez ni téléphone ni courriel : les échanges passent par la messagerie Toveli." /><span className="text-[11px] text-ink-3">{(d.description ?? "").length} / 6000 · 40 caractères minimum</span></label>
144 + <Notice>Pas d'aide à la rédaction par IA pour l'instant : une description écrite par vous, exacte et sans caractéristique inventée, est ce qui inspire le plus confiance.</Notice>
145 + </div>)}
146 +
147 + {step === 6 && (<div className="space-y-4">
148 + <h2 className="font-bold">Vérification avant publication</h2>
149 + <label className="flex gap-3 items-start text-sm tv-card p-3"><input type="checkbox" checked={!!d.attestedAt} onChange={(e) => e.target.checked && attest()} disabled={!!d.attestedAt} className="mt-1 accent-[#e0263c]" /><span>J'atteste être le propriétaire du bien ou être dûment autorisé(e) à le publier, que les informations sont exactes, que je détiens les droits sur les photos et que l'annonce respecte les lois applicables au Québec (Code civil, TAL, Charte des droits). Je comprends que Toveli peut suspendre une annonce signalée et que toute usurpation entraîne la fermeture du compte.{d.attestedAt && <span className="block text-xs text-ink-3 mt-1">Attestation enregistrée le {new Date(d.attestedAt).toLocaleString("fr-CA")}.</span>}</span></label>
150 + <Notice>Votre adresse courriel est vérifiée par le code de connexion. Toveli applique une détection automatique de spam et de doublons ; en cas de doute l'annonce passe en vérification humaine (sous 48 h ouvrables) et vous êtes avisé par courriel. Aucune pièce d'identité n'est demandée par défaut.</Notice>
151 + </div>)}
152 +
153 + {step === 7 && (<div className="space-y-4">
154 + <h2 className="font-bold">Aperçu</h2>
155 + <div className="tv-card overflow-hidden"><div className="aspect-[16/9] bg-surface-2 relative">{d.media[0] && <img src={d.media[0].large} alt="" className="absolute inset-0 w-full h-full object-cover" />}<span className="absolute top-2 left-2 tv-badge bg-white/90">{rent ? "À louer" : "À vendre"} · {PROPERTY_TYPE_LABEL[d.propertyType]}</span></div>
156 + <div className="p-4"><p className="tv-price text-2xl">{fmtPrice(d.priceAmount, { rent })}</p><p className="font-semibold">{d.title ?? "Sans titre"}</p><p className="text-sm text-ink-2">{d.privacyLevel === "EXACT" ? d.address : d.privacyLevel === "STREET_ONLY" ? (d.address ?? "").replace(/^\d+\S*\s+/, "") : "Secteur"}{d.city ? `, ${d.city}` : ""}</p><p className="text-xs text-ink-3 mt-1">{[d.bedrooms != null ? `${d.bedrooms} ch.` : null, d.bathrooms != null ? `${d.bathrooms} sdb` : null, d.areaSqft ? `${d.areaSqft} pi²` : null].filter(Boolean).join(" · ")}</p><p className="text-sm mt-3 whitespace-pre-line line-clamp-6">{d.description}</p><p className="mt-3 text-[11px] text-ink-3">Publiée par {d.sourceType === "DIRECT_AGENT" ? "un représentant" : "le propriétaire"} sur Toveli</p></div></div>
157 + {problems.length > 0 && <div className="tv-card p-3 border-warn/40"><p className="font-semibold text-warn text-sm">À compléter avant publication</p><ul className="list-disc pl-5 text-sm text-ink-2 mt-1">{problems.map((p) => <li key={p}>{p}</li>)}</ul></div>}
158 + <div className="flex flex-wrap gap-2">
159 + {["DRAFT", "REJECTED", "PENDING_REVIEW"].includes(d.status) && <button type="button" onClick={publish} className="tv-btn tv-btn-primary">Publier maintenant</button>}
160 + {d.status === "PUBLISHED" && <><span className="tv-badge bg-good-soft text-good">Publiée</span><a href={`/annonce/${encodeURIComponent("toveli:" + d.slug)}`} target="_blank" rel="noreferrer" className="tv-btn tv-btn-ghost">Voir la page publique</a></>}
161 + {d.status === "PENDING_REVIEW" && <span className="tv-badge bg-warn-soft text-warn">En vérification</span>}
162 + <Link href="/membre/annonces" className="tv-btn tv-btn-ghost">Terminer plus tard</Link>
163 + </div>
164 + </div>)}
165 + </div>
166 + <div className="mt-4 flex justify-between"><button type="button" disabled={step === 0} onClick={() => go(step - 1)} className="tv-btn tv-btn-ghost disabled:opacity-40">Précédent</button>{step < STEPS.length - 1 && <button type="button" onClick={() => go(step + 1)} className="tv-btn tv-btn-primary">Suivant</button>}</div>
167 + </div>
168 + );
169 +}
added src/app/membre/annonces/nouvelle/page.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { redirect } from "next/navigation";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { createListing } from "@/lib/listings/service";
4 +export const dynamic = "force-dynamic";
5 +/** Crée immédiatement un brouillon puis ouvre le wizard : le brouillon est sauvegardé à chaque étape. */
6 +export default async function Nouvelle({ searchParams }: { searchParams: Promise<{ type?: string; bien?: string }> }) {
7 + const u = await requireUser(); const sp = await searchParams;
8 + const l = await createListing(u.id, { listingType: sp.type === "RENT" ? "RENT" : sp.type === "SALE" ? "SALE" : undefined, propertyType: (["appartement", "condo", "maison", "jumele", "plex", "chalet", "terrain", "commercial", "autre"] as const).find((x) => x === sp.bien), wizardStep: sp.type ? 1 : 0 });
9 + redirect(`/membre/annonces/${l.id}`);
10 +}
added src/app/membre/annonces/page.tsx +32 −0
@@ -0,0 +1,32 @@
1 +import Link from "next/link";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { myListings } from "@/lib/listings/service";
4 +import { STATUS_LABEL, PROPERTY_TYPE_LABEL } from "@/lib/listings/types";
5 +import { mediaUrl } from "@/lib/listings/media";
6 +import { fmtPrice, fmtDate, fmtInt } from "@/lib/format";
7 +import { Empty } from "@/components/ui";
8 +import { ListingRowActions } from "./row-actions";
9 +export const dynamic = "force-dynamic";
10 +const TONE: Record<string, string> = { PUBLISHED: "bg-good-soft text-good", DRAFT: "bg-surface-2 text-ink-2", PENDING_REVIEW: "bg-warn-soft text-warn", PAUSED: "bg-info-soft text-info", REJECTED: "bg-brand-soft text-brand-dark", ARCHIVED: "bg-surface-2 text-ink-3", SOLD: "bg-surface-2 text-ink-3", RENTED: "bg-surface-2 text-ink-3" };
11 +export default async function MesAnnonces() {
12 + const u = await requireUser(); const rows = await myListings(u.id);
13 + return (
14 + <div>
15 + <div className="flex items-end justify-between gap-3 mb-4"><h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">Mes annonces</h1><Link href="/membre/annonces/nouvelle" className="tv-btn tv-btn-primary">+ Nouvelle annonce</Link></div>
16 + {rows.length === 0 ? <Empty title="Aucune annonce pour l'instant" hint="Publiez un logement à louer ou une propriété à vendre en quelques étapes. Les brouillons sont sauvegardés automatiquement." action={{ href: "/membre/annonces/nouvelle", label: "Publier une propriété" }} /> : (
17 + <ul className="space-y-3">{rows.map(({ l, p, mediaCount, cover }) => (
18 + <li key={l.id} className="tv-card p-3 flex gap-3">
19 + <Link href={`/membre/annonces/${l.id}`} className="w-28 h-20 shrink-0 rounded-sm overflow-hidden bg-surface-2 relative">{cover ? <img src={mediaUrl(cover, 320)} alt="" className="absolute inset-0 w-full h-full object-cover" /> : <span className="absolute inset-0 grid place-items-center text-[11px] text-ink-3">Sans photo</span>}</Link>
20 + <div className="min-w-0 flex-1">
21 + <div className="flex flex-wrap items-center gap-2"><span className={`tv-badge ${TONE[l.status]}`}>{STATUS_LABEL[l.status]}</span><span className="text-xs text-ink-3">{l.listingType === "RENT" ? "À louer" : "À vendre"} · {PROPERTY_TYPE_LABEL[p.propertyType]}</span></div>
22 + <Link href={`/membre/annonces/${l.id}`} className="block font-semibold truncate mt-1 hover:underline">{l.title ?? p.canonicalAddress ?? "Annonce sans titre"}</Link>
23 + <p className="text-xs text-ink-2 truncate">{p.canonicalAddress ? `${p.canonicalAddress}, ` : ""}{p.city ?? "Municipalité à préciser"} · {fmtPrice(l.priceAmount != null ? Number(l.priceAmount) : null, { rent: l.listingType === "RENT" })} · {mediaCount} photo{mediaCount > 1 ? "s" : ""} · {fmtInt(l.viewsCount)} vues</p>
24 + {l.moderationNote && l.status !== "PUBLISHED" && <p className="text-xs text-warn mt-1">Vérification : {l.moderationNote}</p>}
25 + <p className="text-[11px] text-ink-3 mt-1">Modifiée le {fmtDate(l.updatedAt.toISOString())}{l.publicationAt ? ` · publiée le ${fmtDate(l.publicationAt.toISOString())}` : ""}{l.expiresAt && l.status === "PUBLISHED" ? ` · expire le ${fmtDate(l.expiresAt.toISOString())}` : ""}</p>
26 + </div>
27 + <ListingRowActions id={l.id} slug={l.slug} status={l.status} listingType={l.listingType} />
28 + </li>))}</ul>
29 + )}
30 + </div>
31 + );
32 +}
added src/app/membre/annonces/row-actions.tsx +22 −0
@@ -0,0 +1,22 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useRouter } from "next/navigation";
4 +import { useState } from "react";
5 +export function ListingRowActions({ id, slug, status, listingType }: { id: string; slug: string; status: string; listingType: string }) {
6 + const router = useRouter(); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<string | null>(null);
7 + const act = async (a: string) => { setBusy(true); setMsg(null); const r = await fetch(`/api/listings/${id}/${a}`, { method: "POST" }); const d = await r.json(); setBusy(false); if (!r.ok) setMsg(d.problems ? d.problems.join(" · ") : d.error?.message ?? "Erreur"); else router.refresh(); };
8 + const del = async () => { if (!confirm("Supprimer ce brouillon ?")) return; await fetch(`/api/listings/${id}`, { method: "DELETE" }); router.refresh(); };
9 + return (
10 + <div className="flex flex-col gap-1 items-end text-xs shrink-0">
11 + <Link href={`/membre/annonces/${id}`} className="tv-btn tv-btn-ghost !py-1 !px-3">Modifier</Link>
12 + {status === "PUBLISHED" && <a href={`/annonce/${encodeURIComponent("toveli:" + slug)}`} className="underline text-ink-2" target="_blank" rel="noreferrer">Voir en ligne</a>}
13 + {["DRAFT", "PAUSED", "REJECTED"].includes(status) && <button type="button" disabled={busy} onClick={() => act(status === "PAUSED" ? "resume" : "publish")} className="underline text-brand-dark">{status === "PAUSED" ? "Réactiver" : "Publier"}</button>}
14 + {status === "PUBLISHED" && <button type="button" disabled={busy} onClick={() => act("pause")} className="underline text-ink-2">Mettre en pause</button>}
15 + {["PUBLISHED", "PAUSED"].includes(status) && <button type="button" disabled={busy} onClick={() => act(listingType === "RENT" ? "mark-rented" : "mark-sold")} className="underline text-ink-2">{listingType === "RENT" ? "Marquer louée" : "Marquer vendue"}</button>}
16 + {["SOLD", "RENTED", "ARCHIVED"].includes(status) && <button type="button" disabled={busy} onClick={() => act("reopen")} className="underline text-ink-2">Rouvrir</button>}
17 + {!["ARCHIVED", "DRAFT"].includes(status) && <button type="button" disabled={busy} onClick={() => act("archive")} className="underline text-ink-3">Retirer</button>}
18 + {status === "DRAFT" && <button type="button" onClick={del} className="underline text-ink-3">Supprimer</button>}
19 + {msg && <p className="text-warn max-w-[220px] text-right">{msg}</p>}
20 + </div>
21 + );
22 +}
added src/app/membre/favoris/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { FavoritesView } from "../../favoris/favorites-view";
3 +export const metadata: Metadata = { title: "Mes favoris", robots: { index: false } };
4 +export default function MembreFavoris() { return <FavoritesView member />; }
added src/app/membre/layout.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import { redirect } from "next/navigation";
2 +import { headers } from "next/headers";
3 +import { currentUser } from "@/lib/auth/session";
4 +import { pgAvailable } from "@/db/client";
5 +import { MemberNav } from "./member-nav";
6 +import { Empty } from "@/components/ui";
7 +export const dynamic = "force-dynamic";
8 +export default async function MembreLayout({ children }: { children: React.ReactNode }) {
9 + if (!pgAvailable()) return <div className="mx-auto max-w-3xl px-4 py-10"><Empty title="Espace membre indisponible" hint="Le service des comptes n'est pas configuré sur ce serveur." /></div>;
10 + const u = await currentUser();
11 + if (!u) { const h = await headers(); const next = h.get("x-pathname") ?? "/membre"; redirect(`/connexion?next=${encodeURIComponent(next)}`); }
12 + return (
13 + <div className="mx-auto max-w-7xl px-4 py-5 md:py-8 grid md:grid-cols-[220px_minmax(0,1fr)] gap-6">
14 + <MemberNav email={u.email} name={u.publicName ?? u.displayName} roles={u.roles} />
15 + <div className="min-w-0">{children}</div>
16 + </div>
17 + );
18 +}
added src/app/membre/member-nav.tsx +29 −0
@@ -0,0 +1,29 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { usePathname, useRouter } from "next/navigation";
4 +import { IconHome, IconList, IconUser, IconHeart, IconChart, IconPin, IconSpark } from "@/components/icons";
5 +const ITEMS = [
6 + { href: "/membre", label: "Tableau de bord", Icon: IconHome, exact: true },
7 + { href: "/membre/annonces", label: "Mes annonces", Icon: IconList },
8 + { href: "/membre/messages", label: "Messages", Icon: IconSpark },
9 + { href: "/membre/visites", label: "Visites", Icon: IconPin },
10 + { href: "/membre/favoris", label: "Favoris et recherches", Icon: IconHeart },
11 + { href: "/membre/alertes", label: "Alertes", Icon: IconChart },
12 + { href: "/membre/profil", label: "Profil et préférences", Icon: IconUser },
13 + { href: "/membre/securite", label: "Sécurité et sessions", Icon: IconUser },
14 +];
15 +export function MemberNav({ email, name, roles }: { email: string; name: string | null; roles: string[] }) {
16 + const path = usePathname(); const router = useRouter();
17 + const logout = async () => { await fetch("/api/auth/logout", { method: "POST" }); router.push("/"); router.refresh(); };
18 + return (
19 + <aside className="md:sticky md:top-20 self-start">
20 + <div className="tv-card p-3 mb-3"><p className="font-semibold truncate">{name ?? "Mon compte"}</p><p className="text-xs text-ink-3 truncate">{email}</p>{roles.length > 0 && <p className="text-[11px] text-brand-dark mt-1">{roles.join(" · ")}</p>}</div>
21 + <Link href="/membre/annonces/nouvelle" className="tv-btn tv-btn-primary w-full mb-3">+ Publier une propriété</Link>
22 + <nav className="flex md:flex-col gap-1 overflow-x-auto no-scrollbar -mx-4 px-4 md:mx-0 md:px-0" aria-label="Espace membre">
23 + {ITEMS.map(({ href, label, Icon, exact }) => { const active = exact ? path === href : path.startsWith(href); return <Link key={href} href={href} className={`flex items-center gap-2 px-3 py-2 rounded-full md:rounded-sm text-sm whitespace-nowrap ${active ? "bg-brand-soft text-brand-dark font-semibold" : "text-ink-2 hover:bg-surface-2"}`} aria-current={active ? "page" : undefined}><Icon width={16} height={16} /> {label}</Link>; })}
24 + {roles.includes("ADMIN") || roles.includes("MODERATOR") ? <Link href="/admin" className="flex items-center gap-2 px-3 py-2 rounded-full md:rounded-sm text-sm text-ink-2 hover:bg-surface-2 whitespace-nowrap">Administration</Link> : null}
25 + <button type="button" onClick={logout} className="flex items-center gap-2 px-3 py-2 rounded-full md:rounded-sm text-sm text-ink-3 hover:bg-surface-2 whitespace-nowrap text-left">Se déconnecter</button>
26 + </nav>
27 + </aside>
28 + );
29 +}
added src/app/membre/messages/[id]/page.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { notFound } from "next/navigation";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { getThread } from "@/lib/inquiries/service";
4 +import { ThreadView } from "./thread-view";
5 +export const dynamic = "force-dynamic";
6 +export default async function Thread({ params }: { params: Promise<{ id: string }> }) {
7 + const u = await requireUser(); const { id } = await params;
8 + let t; try { t = await getThread(id, u.id); } catch { notFound(); }
9 + return <ThreadView t={{ inquiry: { id: t.inquiry.id, status: t.inquiry.status, category: t.inquiry.category, recipientUserId: t.inquiry.recipientUserId }, listing: t.listing ? { slug: t.listing.slug, title: t.listing.title, status: t.listing.status } : null, other: t.other, me: t.me, messages: t.messages.map((m) => ({ id: m.id, senderId: m.senderId, body: m.body, createdAt: m.createdAt.toISOString(), readAt: m.readAt?.toISOString() ?? null })) }} />;
10 +}
added src/app/membre/messages/[id]/thread-view.tsx +24 −0
@@ -0,0 +1,24 @@
1 +"use client";
2 +import { useEffect, useRef, useState } from "react";
3 +import Link from "next/link";
4 +import { useRouter } from "next/navigation";
5 +import { Notice } from "@/components/ui";
6 +type T = { inquiry: { id: string; status: string; category: string; recipientUserId: string }; listing: { slug: string; title: string | null; status: string } | null; other: { id: string; name: string }; me: string; messages: { id: string; senderId: string; body: string; createdAt: string; readAt: string | null }[] };
7 +export function ThreadView({ t }: { t: T }) {
8 + const [msgs, setMsgs] = useState(t.messages); const [body, setBody] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState<string | null>(null); const end = useRef<HTMLDivElement>(null); const router = useRouter();
9 + useEffect(() => { end.current?.scrollIntoView({ block: "end" }); }, [msgs.length]);
10 + useEffect(() => { const i = setInterval(async () => { const r = await fetch(`/api/inquiries/${t.inquiry.id}/messages`); if (r.ok) { const j = await r.json(); setMsgs(j.messages.map((m: { id: string; senderId: string; body: string; createdAt: string; readAt: string | null }) => ({ ...m }))); } }, 15000); return () => clearInterval(i); }, [t.inquiry.id]);
11 + const send = async () => { if (!body.trim()) return; setBusy(true); setErr(null); const key = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const r = await fetch(`/api/inquiries/${t.inquiry.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body, idempotencyKey: key }) }); const j = await r.json(); setBusy(false); if (!r.ok) { setErr(j.error?.message ?? "Erreur"); return; } if (j.message) setMsgs((m) => [...m, { ...j.message }]); setBody(""); };
12 + const setStatus = async (status: string) => { await fetch(`/api/inquiries/${t.inquiry.id}/messages`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ status }) }); router.refresh(); };
13 + const report = async () => { const details = prompt("Décrivez le problème (arnaque, harcèlement, contenu inapproprié…)"); if (details == null) return; await fetch("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetType: "USER", targetId: t.other.id, reason: "autre", details }) }); alert("Signalement transmis. Merci."); };
14 + const iAmOwner = t.inquiry.recipientUserId === t.me;
15 + return (
16 + <div className="flex flex-col h-[calc(100dvh-170px)] md:h-[calc(100dvh-140px)]">
17 + <div className="flex items-start justify-between gap-3 mb-2"><div><Link href="/membre/messages" className="text-xs text-ink-3 hover:underline">← Messages</Link><h1 className="text-xl font-extrabold tracking-tight">{t.other.name}</h1>{t.listing && <p className="text-xs text-ink-2">Au sujet de <Link href={`/annonce/${encodeURIComponent("toveli:" + t.listing.slug)}`} className="underline">{t.listing.title ?? "l'annonce"}</Link>{t.listing.status !== "PUBLISHED" ? " (plus en ligne)" : ""}</p>}</div>
18 + <div className="flex gap-2 text-xs">{t.inquiry.status === "OPEN" ? <button type="button" onClick={() => setStatus("ARCHIVED")} className="underline text-ink-3">Archiver</button> : <button type="button" onClick={() => setStatus("OPEN")} className="underline text-ink-3">Rouvrir</button>}{iAmOwner && t.inquiry.status !== "BLOCKED" && <button type="button" onClick={() => confirm("Bloquer cette personne ? Elle ne pourra plus vous écrire.") && setStatus("BLOCKED")} className="underline text-ink-3">Bloquer</button>}<button type="button" onClick={report} className="underline text-brand-dark">Signaler</button></div></div>
19 + <div className="flex-1 overflow-auto space-y-2 pr-1">{msgs.map((m) => <div key={m.id} className={`flex ${m.senderId === t.me ? "justify-end" : "justify-start"}`}><div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-sm whitespace-pre-wrap ${m.senderId === t.me ? "bg-brand text-white rounded-br-sm" : "bg-surface-2 rounded-bl-sm"}`}>{m.body}<span className={`block text-[10px] mt-1 ${m.senderId === t.me ? "text-white/70" : "text-ink-3"}`}>{new Date(m.createdAt).toLocaleString("fr-CA", { timeZone: "America/Toronto", dateStyle: "short", timeStyle: "short" })}{m.senderId === t.me && m.readAt ? " · lu" : ""}</span></div></div>)}<div ref={end} /></div>
20 + {t.inquiry.status === "BLOCKED" ? <Notice tone="warn">Conversation bloquée.</Notice> : (<form onSubmit={(e) => { e.preventDefault(); send(); }} className="mt-2 flex gap-2"><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={2} maxLength={3000} placeholder="Votre message… (les coordonnées sont masquées ; proposez une visite via le bouton de la fiche)" className="flex-1 border border-line rounded-sm p-2 text-sm outline-none focus:border-brand" onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send(); }} /><button type="submit" disabled={busy || !body.trim()} className="tv-btn tv-btn-primary self-end">Envoyer</button></form>)}
21 + {err && <Notice tone="warn">{err}</Notice>}
22 + </div>
23 + );
24 +}
added src/app/membre/messages/page.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import Link from "next/link";
2 +import { requireUser } from "@/lib/auth/session";
3 +import { myInquiries } from "@/lib/inquiries/service";
4 +import { fmtDate } from "@/lib/format";
5 +import { Empty } from "@/components/ui";
6 +export const dynamic = "force-dynamic";
7 +export default async function Messages() {
8 + const u = await requireUser(); const rows = await myInquiries(u.id);
9 + return (
10 + <div>
11 + <h1 className="text-2xl md:text-3xl font-extrabold tracking-tight mb-1">Messages</h1><p className="text-sm text-ink-2 mb-4">Demandes d'information, de visite et conversations liées à vos annonces ou à celles que vous avez contactées. Courriels et téléphones sont masqués automatiquement.</p>
12 + {rows.length === 0 ? <Empty title="Aucune conversation" hint="Contactez un annonceur depuis une fiche, ou attendez les demandes sur vos annonces." action={{ href: "/recherche", label: "Explorer" }} /> : (
13 + <ul className="tv-card divide-y divide-line">{rows.map((r) => (
14 + <li key={r.inq.id}><Link href={`/membre/messages/${r.inq.id}`} className="flex gap-3 p-3 hover:bg-surface-2">
15 + <span className="w-16 h-12 rounded-sm bg-surface-2 overflow-hidden shrink-0 relative">{r.cover && <img src={r.cover} alt="" className="absolute inset-0 w-full h-full object-cover" />}</span>
16 + <span className="min-w-0 flex-1"><span className="flex items-center gap-2"><span className="font-semibold truncate">{r.otherName ?? "Membre Toveli"}</span>{r.unread > 0 && <span className="tv-badge bg-brand text-white">{r.unread}</span>}<span className="tv-badge bg-surface-2 text-ink-3">{r.inq.category === "VISIT" ? "Visite" : r.inq.category === "OFFER" ? "Offre" : "Info"}</span>{r.inq.status !== "OPEN" && <span className="tv-badge bg-surface-2 text-ink-3">{r.inq.status === "ARCHIVED" ? "Archivée" : "Bloquée"}</span>}</span>
17 + <span className="block text-xs text-ink-2 truncate">{r.listingTitle ?? "Annonce"} · {r.city} · {r.listingType === "RENT" ? "location" : "vente"}{r.inq.recipientUserId === u.id ? " · mon annonce" : ""}</span>
18 + <span className="block text-xs text-ink-3 truncate mt-0.5">{r.lastBody}</span></span>
19 + <span className="text-[11px] text-ink-3 shrink-0">{fmtDate(r.inq.lastMessageAt.toISOString())}</span>
20 + </Link></li>))}</ul>
21 + )}
22 + </div>
23 + );
24 +}
added src/app/membre/page.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import Link from "next/link";
2 +import { eq, or, sql, and } from "drizzle-orm";
3 +import { requireUser } from "@/lib/auth/session";
4 +import { db } from "@/db/client";
5 +import { listings, inquiries, messages, viewingRequests, favorites } from "@/db/schema";
6 +import { Stat, Notice } from "@/components/ui";
7 +import { fmtInt } from "@/lib/format";
8 +export const dynamic = "force-dynamic";
9 +export default async function Dashboard() {
10 + const u = await requireUser(); const d = db();
11 + const [[l], [m], [v], [f]] = await Promise.all([
12 + d.select({ total: sql<number>`count(*)::int`, published: sql<number>`sum(case when status='PUBLISHED' then 1 else 0 end)::int`, views: sql<number>`coalesce(sum(views_count),0)::int`, drafts: sql<number>`sum(case when status='DRAFT' then 1 else 0 end)::int` }).from(listings).where(eq(listings.ownerUserId, u.id)),
13 + d.select({ unread: sql<number>`count(*)::int` }).from(messages).innerJoin(inquiries, eq(inquiries.id, messages.inquiryId)).where(and(or(eq(inquiries.senderUserId, u.id), eq(inquiries.recipientUserId, u.id)), sql`${messages.senderId} <> ${u.id}`, sql`${messages.readAt} is null`)),
14 + d.select({ pending: sql<number>`count(*)::int` }).from(viewingRequests).where(and(eq(viewingRequests.ownerId, u.id), eq(viewingRequests.status, "REQUESTED"))),
15 + d.select({ n: sql<number>`count(*)::int` }).from(favorites).where(eq(favorites.userId, u.id)),
16 + ]);
17 + return (
18 + <div className="space-y-6">
19 + <div><h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">Tableau de bord</h1><p className="text-ink-2 text-sm mt-1">Une seule identité pour chercher, publier et échanger. Vous êtes à la fois visiteur, acheteur ou locataire potentiel et, si vous publiez, propriétaire : aucun profil à choisir.</p></div>
20 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
21 + <Link href="/membre/annonces"><Stat label="Mes annonces" value={fmtInt(l.total)} sub={`${l.published} publiée${l.published > 1 ? "s" : ""} · ${l.drafts} brouillon${l.drafts > 1 ? "s" : ""}`} /></Link>
22 + <Link href="/membre/messages"><Stat label="Messages non lus" value={fmtInt(m.unread)} tone={m.unread ? "warn" : undefined} /></Link>
23 + <Link href="/membre/visites"><Stat label="Visites à confirmer" value={fmtInt(v.pending)} tone={v.pending ? "info" : undefined} /></Link>
24 + <Link href="/membre/favoris"><Stat label="Favoris" value={fmtInt(f.n)} /></Link>
25 + </div>
26 + <div className="grid md:grid-cols-2 gap-3">
27 + <Link href="/membre/annonces/nouvelle?type=RENT" className="tv-card p-5 hover:border-brand/40"><p className="tv-label">Publier</p><p className="font-bold text-lg mt-1">Un logement à louer</p><p className="text-sm text-ink-2">Appartement, condo, maison, chambre… Loyer, inclusions, disponibilité, photos.</p></Link>
28 + <Link href="/membre/annonces/nouvelle?type=SALE" className="tv-card p-5 hover:border-brand/40"><p className="tv-label">Publier</p><p className="font-bold text-lg mt-1">Une propriété à vendre</p><p className="text-sm text-ink-2">Maison, condo, plex, chalet, terrain, local commercial. Estimation Toveli affichée à titre indicatif.</p></Link>
29 + </div>
30 + <Notice>Vues totales de vos annonces : {fmtInt(l.views)} (comptage de pages, sans suivi des visiteurs). Les demandes et visites passent par la messagerie Toveli : votre courriel et votre téléphone ne sont jamais affichés publiquement.</Notice>
31 + </div>
32 + );
33 +}
added src/app/membre/profil/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import { requireUser } from "@/lib/auth/session";
2 +import { db } from "@/db/client";
3 +import { users, userProfiles } from "@/db/schema";
4 +import { eq } from "drizzle-orm";
5 +import { decrypt } from "@/lib/auth/crypto";
6 +import { ProfileForm } from "./profile-form";
7 +export const dynamic = "force-dynamic";
8 +export default async function Profil() {
9 + const u = await requireUser(); const d = db();
10 + const [row] = await d.select({ u: users, p: userProfiles }).from(users).leftJoin(userProfiles, eq(userProfiles.userId, users.id)).where(eq(users.id, u.id)).limit(1);
11 + let phone = ""; try { phone = row.u.phoneEncrypted ? decrypt(row.u.phoneEncrypted) : ""; } catch { phone = ""; }
12 + return <ProfileForm initial={{ email: row.u.email, displayName: row.u.displayName ?? "", publicName: row.p?.publicName ?? "", phone, sellerType: (row.p?.sellerType as "OWNER" | "AGENT") ?? "OWNER", bio: row.p?.bio ?? "", prefs: (row.u.prefs ?? {}) as { notifications?: Record<string, boolean>; privacy?: Record<string, boolean>; termsAcceptedAt?: string } }} />;
13 +}
added src/app/membre/profil/profile-form.tsx +32 −0
@@ -0,0 +1,32 @@
1 +"use client";
2 +import { useState } from "react";
3 +import Link from "next/link";
4 +import { Notice } from "@/components/ui";
5 +type P = { email: string; displayName: string; publicName: string; phone: string; sellerType: "OWNER" | "AGENT"; bio: string; prefs: { notifications?: Record<string, boolean>; privacy?: Record<string, boolean>; termsAcceptedAt?: string } };
6 +export function ProfileForm({ initial }: { initial: P }) {
7 + const [f, setF] = useState(initial); const [msg, setMsg] = useState<string | null>(null); const [busy, setBusy] = useState(false);
8 + const n = { inquiries: true, viewings: true, alerts: true, marketing: false, ...(f.prefs.notifications ?? {}) }; const pv = { showPhone: false, showNameOnListings: true, ...(f.prefs.privacy ?? {}) };
9 + const save = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); setMsg(null); const r = await fetch("/api/me", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ displayName: f.displayName || null, publicName: f.publicName || null, phone: f.phone || null, sellerType: f.sellerType, bio: f.bio || null, prefs: { notifications: n, privacy: pv } }) }); setBusy(false); setMsg(r.ok ? "Profil enregistré." : "Erreur d'enregistrement."); };
10 + const setN = (k: string, v: boolean) => setF({ ...f, prefs: { ...f.prefs, notifications: { ...n, [k]: v } } }); const setP = (k: string, v: boolean) => setF({ ...f, prefs: { ...f.prefs, privacy: { ...pv, [k]: v } } });
11 + return (
12 + <form onSubmit={save} className="space-y-5 max-w-2xl">
13 + <div><h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">Profil et préférences</h1><p className="text-sm text-ink-2 mt-1">Identifiant : <strong>{f.email}</strong> (vérifié). <Link href="/membre/securite" className="underline">Changer d'adresse ou gérer les sessions</Link>.</p></div>
14 + <div className="tv-card p-4 grid sm:grid-cols-2 gap-3 text-sm">
15 + <label className="block"><span className="tv-label">Nom complet (privé)</span><input value={f.displayName} onChange={(e) => setF({ ...f, displayName: e.target.value })} maxLength={80} className="tv-chip w-full !pl-3 mt-1" /></label>
16 + <label className="block"><span className="tv-label">Nom public (sur vos annonces et messages)</span><input value={f.publicName} onChange={(e) => setF({ ...f, publicName: e.target.value })} maxLength={80} className="tv-chip w-full !pl-3 mt-1" placeholder="ex. Marie T." /></label>
17 + <label className="block"><span className="tv-label">Téléphone (chiffré, jamais public par défaut)</span><input value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} maxLength={30} inputMode="tel" className="tv-chip w-full !pl-3 mt-1" placeholder="418 555-0199" /></label>
18 + <label className="block"><span className="tv-label">Je publie habituellement comme</span><select value={f.sellerType} onChange={(e) => setF({ ...f, sellerType: e.target.value as "OWNER" | "AGENT" })} className="tv-chip w-full mt-1 appearance-none"><option value="OWNER">Propriétaire</option><option value="AGENT">Représentant / professionnel</option></select></label>
19 + <label className="block sm:col-span-2"><span className="tv-label">Présentation (facultatif)</span><textarea value={f.bio} onChange={(e) => setF({ ...f, bio: e.target.value })} rows={3} maxLength={600} className="w-full mt-1 border border-line rounded-sm p-2 text-sm" /></label>
20 + </div>
21 + <div className="tv-card p-4 text-sm space-y-2"><p className="font-semibold">Notifications par courriel</p>
22 + {([["inquiries", "Nouvelles demandes et messages"], ["viewings", "Demandes et confirmations de visite"], ["alerts", "Alertes de recherches sauvegardées"], ["marketing", "Nouveautés Toveli (facultatif, désactivé par défaut)"]] as const).map(([k, l]) => <label key={k} className="flex items-center gap-2"><input type="checkbox" checked={!!n[k]} onChange={(e) => setN(k, e.target.checked)} className="accent-[#e0263c]" /> {l}</label>)}
23 + </div>
24 + <div className="tv-card p-4 text-sm space-y-2"><p className="font-semibold">Confidentialité</p>
25 + <label className="flex items-center gap-2"><input type="checkbox" checked={pv.showNameOnListings} onChange={(e) => setP("showNameOnListings", e.target.checked)} className="accent-[#e0263c]" /> Afficher mon nom public sur mes annonces</label>
26 + <label className="flex items-center gap-2"><input type="checkbox" checked={pv.showPhone} onChange={(e) => setP("showPhone", e.target.checked)} className="accent-[#e0263c]" /> Montrer mon téléphone aux membres connectés qui m'écrivent</label>
27 + <Notice>Conformément à la Loi 25, vous pouvez demander l'accès, la rectification ou la suppression de vos données : <Link href="/membre/securite" className="underline">Sécurité → Exporter / supprimer mon compte</Link>. Conditions acceptées le {f.prefs.termsAcceptedAt ? new Date(f.prefs.termsAcceptedAt).toLocaleDateString("fr-CA") : "—"}.</Notice>
28 + </div>
29 + <div className="flex items-center gap-3"><button type="submit" disabled={busy} className="tv-btn tv-btn-primary">{busy ? "Enregistrement…" : "Enregistrer"}</button>{msg && <span className="text-sm text-ink-2">{msg}</span>}</div>
30 + </form>
31 + );
32 +}
added src/app/membre/securite/page.tsx +7 −0
@@ -0,0 +1,7 @@
1 +import { requireUser, listSessions } from "@/lib/auth/session";
2 +import { SecurityView } from "./security-view";
3 +export const dynamic = "force-dynamic";
4 +export default async function Securite() {
5 + const u = await requireUser(); const sessions = await listSessions(u.id);
6 + return <SecurityView email={u.email} isAdmin={u.roles.includes("ADMIN") || u.roles.includes("MODERATOR")} sessions={sessions.map((s) => ({ ...s, issuedAt: s.issuedAt.toISOString(), lastSeenAt: s.lastSeenAt.toISOString(), expiresAt: s.expiresAt.toISOString(), current: s.id === u.sessionId }))} />;
7 +}
added src/app/membre/securite/security-view.tsx +31 −0
@@ -0,0 +1,31 @@
1 +"use client";
2 +import { useState } from "react";
3 +import { useRouter } from "next/navigation";
4 +import { Notice } from "@/components/ui";
5 +import { fmtDate } from "@/lib/format";
6 +type S = { id: string; issuedAt: string; lastSeenAt: string; expiresAt: string; deviceLabel: string | null; current: boolean };
7 +function deviceName(ua: string | null) { if (!ua) return "Appareil inconnu"; const os = /iPhone/.test(ua) ? "iPhone" : /iPad/.test(ua) ? "iPad" : /Android/.test(ua) ? "Android" : /Mac OS/.test(ua) ? "Mac" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "Appareil"; const br = /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : /Firefox\//.test(ua) ? "Firefox" : "navigateur"; return `${os} · ${br}`; }
8 +export function SecurityView({ email, sessions: init, isAdmin }: { email: string; sessions: S[]; isAdmin: boolean }) {
9 + const [sessions, setSessions] = useState(init); const router = useRouter(); const [msg, setMsg] = useState<string | null>(null);
10 + const [newEmail, setNewEmail] = useState(""); const [chal, setChal] = useState<string | null>(null); const [code, setCode] = useState("");
11 + const revoke = async (id: string) => { await fetch(`/api/me/sessions/${id}`, { method: "DELETE" }); setSessions((s) => s.filter((x) => x.id !== id)); };
12 + const logoutAll = async () => { if (!confirm("Se déconnecter de tous les appareils, y compris celui-ci ?")) return; await fetch("/api/auth/logout-all", { method: "POST" }); router.push("/"); router.refresh(); };
13 + const askChange = async () => { setMsg(null); const r = await fetch("/api/me/email-change", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ newEmail }) }); const j = await r.json(); if (!r.ok) { setMsg(j.error?.message ?? "Erreur"); return; } setChal(j.challengeId); setMsg("Un code a été envoyé à la nouvelle adresse."); };
14 + const confirmChange = async () => { const r = await fetch("/api/me/email-change", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId: chal, code }) }); const j = await r.json(); if (!r.ok) { setMsg(j.error?.message ?? "Erreur"); return; } setMsg("Adresse modifiée. Les autres sessions ont été déconnectées."); setChal(null); router.refresh(); };
15 + const exportData = async () => { const r = await fetch("/api/me/export"); const b = await r.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = "toveli-mes-donnees.json"; a.click(); };
16 + const del = async () => { if (!confirm("Supprimer définitivement votre compte, vos annonces et vos messages ? Cette action est irréversible.")) return; const r = await fetch("/api/me", { method: "DELETE" }); if (r.ok) { router.push("/"); router.refresh(); } else setMsg("Suppression impossible pour l'instant."); };
17 + return (
18 + <div className="space-y-6 max-w-2xl">
19 + <div><h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">Sécurité et sessions</h1><p className="text-sm text-ink-2 mt-1">Connexion sans mot de passe par code courriel. Chaque appareil a sa propre session, révocable ici.</p></div>
20 + <section className="tv-card p-4"><div className="flex items-center justify-between"><h2 className="font-semibold">Mes sessions ({sessions.length})</h2><button type="button" onClick={logoutAll} className="text-xs underline text-brand-dark">Se déconnecter partout</button></div>
21 + <ul className="divide-y divide-line mt-2 text-sm">{sessions.map((s) => <li key={s.id} className="py-2 flex items-center justify-between gap-3"><span><span className="font-medium">{deviceName(s.deviceLabel)}</span>{s.current && <span className="tv-badge bg-good-soft text-good ml-2">cet appareil</span>}<span className="block text-xs text-ink-3">Dernière activité {fmtDate(s.lastSeenAt)} · ouverte le {fmtDate(s.issuedAt)} · expire le {fmtDate(s.expiresAt)}</span></span>{!s.current && <button type="button" onClick={() => revoke(s.id)} className="text-xs underline text-ink-3">Révoquer</button>}</li>)}</ul>
22 + <p className="text-xs text-ink-3 mt-2">Sessions valides 30 jours, fermées après 14 jours d'inactivité. Cookie HttpOnly, Secure, SameSite=Lax.</p></section>
23 + <section className="tv-card p-4 space-y-3"><h2 className="font-semibold">Changer d'adresse courriel</h2><p className="text-xs text-ink-2">Adresse actuelle : {email}. La nouvelle adresse doit être vérifiée par un code ; les autres sessions seront déconnectées.</p>
24 + {!chal ? <div className="flex gap-2"><input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="nouvelle@adresse.ca" className="tv-chip flex-1 !pl-3" /><button type="button" onClick={askChange} className="tv-btn tv-btn-ghost">Envoyer un code</button></div>
25 + : <div className="flex gap-2"><input inputMode="numeric" maxLength={6} value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))} placeholder="Code à 6 chiffres" className="tv-chip flex-1 !pl-3 tabular-nums" /><button type="button" onClick={confirmChange} className="tv-btn tv-btn-primary">Confirmer</button></div>}
26 + {msg && <Notice>{msg}</Notice>}</section>
27 + {isAdmin && <section className="tv-card p-4"><h2 className="font-semibold">Authentification forte (administration)</h2><p className="text-xs text-ink-2">Les actions d'administration exigent un second facteur TOTP (application d'authentification). <a href="/admin/mfa" className="underline">Configurer / vérifier</a>.</p></section>}
28 + <section className="tv-card p-4 space-y-2"><h2 className="font-semibold">Mes données (Loi 25)</h2><p className="text-xs text-ink-2">Exportez l'ensemble de vos données (profil, annonces, messages, favoris) ou supprimez votre compte. Les annonces sont retirées, les messages anonymisés ; les journaux de sécurité minimisés sont conservés 12 mois.</p><div className="flex gap-2"><button type="button" onClick={exportData} className="tv-btn tv-btn-ghost">Exporter (JSON)</button><button type="button" onClick={del} className="tv-btn tv-btn-ghost !text-brand-dark">Supprimer mon compte</button></div></section>
29 + </div>
30 + );
31 +}
added src/app/membre/visites/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import { requireUser } from "@/lib/auth/session";
2 +import { myViewings } from "@/lib/inquiries/service";
3 +import { ViewingsView } from "./viewings-view";
4 +export const dynamic = "force-dynamic";
5 +export default async function Visites() {
6 + const u = await requireUser(); const rows = await myViewings(u.id);
7 + return <ViewingsView me={u.id} items={rows.map((r) => ({ id: r.v.id, status: r.v.status, proposedSlots: r.v.proposedSlots as string[], confirmedSlot: r.v.confirmedSlot?.toISOString() ?? null, note: r.v.note, ownerId: r.v.ownerId, requesterId: r.v.requesterId, inquiryId: r.v.inquiryId, title: r.title, slug: r.slug, city: r.city, createdAt: r.v.createdAt.toISOString() }))} />;
8 +}
added src/app/membre/visites/viewings-view.tsx +28 −0
@@ -0,0 +1,28 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useRouter } from "next/navigation";
4 +import { Empty, Notice } from "@/components/ui";
5 +type V = { id: string; status: string; proposedSlots: string[]; confirmedSlot: string | null; note: string | null; ownerId: string; requesterId: string; inquiryId: string | null; title: string | null; slug: string; city: string | null; createdAt: string };
6 +const fmt = (iso: string) => new Date(iso).toLocaleString("fr-CA", { timeZone: "America/Toronto", weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
7 +const LABEL: Record<string, string> = { REQUESTED: "Demandée", CONFIRMED: "Confirmée", CANCELLED: "Annulée", DONE: "Effectuée" };
8 +export function ViewingsView({ me, items }: { me: string; items: V[] }) {
9 + const router = useRouter();
10 + const act = async (id: string, action: string, slot?: string) => { const r = await fetch(`/api/viewings/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, slot }) }); if (!r.ok) alert((await r.json()).error?.message ?? "Erreur"); router.refresh(); };
11 + return (
12 + <div>
13 + <h1 className="text-2xl md:text-3xl font-extrabold tracking-tight mb-1">Visites</h1><p className="text-sm text-ink-2 mb-4">Heure de l'Est (America/Toronto). Le propriétaire confirme un des créneaux proposés ; les conflits à moins de 30 minutes sont refusés.</p>
14 + {items.length === 0 ? <Empty title="Aucune demande de visite" hint="Demandez une visite depuis une fiche publiée sur Toveli, ou attendez les demandes sur vos annonces." /> : (
15 + <ul className="space-y-3">{items.map((v) => { const owner = v.ownerId === me; return (
16 + <li key={v.id} className="tv-card p-4">
17 + <div className="flex flex-wrap items-center gap-2"><span className={`tv-badge ${v.status === "CONFIRMED" ? "bg-good-soft text-good" : v.status === "REQUESTED" ? "bg-warn-soft text-warn" : "bg-surface-2 text-ink-3"}`}>{LABEL[v.status]}</span><span className="text-xs text-ink-3">{owner ? "Pour mon annonce" : "Ma demande"} · {new Date(v.createdAt).toLocaleDateString("fr-CA")}</span></div>
18 + <p className="font-semibold mt-1"><Link href={`/annonce/${encodeURIComponent("toveli:" + v.slug)}`} className="hover:underline">{v.title ?? "Annonce"}</Link> <span className="text-ink-3 font-normal text-sm">· {v.city}</span></p>
19 + {v.note && <p className="text-sm text-ink-2 mt-1">« {v.note} »</p>}
20 + {v.status === "CONFIRMED" && v.confirmedSlot && <p className="text-sm mt-2">📅 <strong>{fmt(v.confirmedSlot)}</strong></p>}
21 + {v.status === "REQUESTED" && <div className="mt-2 flex flex-wrap gap-2">{v.proposedSlots.map((s) => owner ? <button key={s} type="button" onClick={() => act(v.id, "confirm", s)} className="tv-chip">Confirmer {fmt(s)}</button> : <span key={s} className="tv-chip">{fmt(s)}</span>)}</div>}
22 + <div className="mt-2 flex gap-3 text-xs">{!["CANCELLED", "DONE"].includes(v.status) && <button type="button" onClick={() => confirm("Annuler cette visite ?") && act(v.id, "cancel")} className="underline text-ink-3">Annuler</button>}{owner && v.status === "CONFIRMED" && <button type="button" onClick={() => act(v.id, "done")} className="underline text-ink-3">Marquer effectuée</button>}{v.inquiryId && <Link href={`/membre/messages/${v.inquiryId}`} className="underline text-brand-dark">Conversation</Link>}</div>
23 + </li>); })}</ul>
24 + )}
25 + <div className="mt-4"><Notice>Par sécurité, l'adresse exacte d'une annonce « rue seulement » ou « secteur » n'est communiquée qu'après confirmation d'une visite, par le propriétaire, dans la conversation.</Notice></div>
26 + </div>
27 + );
28 +}
modified src/app/recherche/page.tsx +3 −3
@@ -1,6 +1,6 @@
1 1 import type { Metadata } from "next";
2 2 import { Suspense } from "react";
3 −import { searchListings, mapPoints } from "@/lib/queries";
3 +import { unifiedSearch, unifiedPoints } from "@/lib/search";
4 4 import { dbAvailable } from "@/lib/db";
5 5 import { fromUrl, describe, type SP } from "@/lib/search-params";
6 6 import { ResultsView } from "./results-view";
@@ -18,8 +18,8 @@ export default async function Recherche({ searchParams }: { searchParams: Promis
18 18 const sp = await searchParams;
19 19 if (!dbAvailable()) return <div className="mx-auto max-w-7xl px-4 py-10"><Empty title="Données en cours de chargement" hint="La base Toveli n'est pas encore disponible. Réessayez dans quelques minutes." /></div>;
20 20 const p = fromUrl(sp);
21 − const res = searchListings(p);
22 − const points = mapPoints({ ...p, limit: 1500, offset: 0 });
21 + const res = await unifiedSearch(p);
22 + const points = await unifiedPoints({ ...p, limit: 1500, offset: 0 });
23 23 return (
24 24 <Suspense>
25 25 <ResultsView initial={res} points={points} params={p} title={describe(p, res.total)} />
added src/components/contact-panel.tsx +49 −0
@@ -0,0 +1,49 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import Link from "next/link";
4 +import { Notice } from "./ui";
5 +import { IconHeart, IconCompare, IconShare } from "./icons";
6 +import { useFavorites, useCompare } from "@/lib/favorites";
7 +
8 +function minSlot() { return new Date(Date.now() + 3600000).toISOString().slice(0, 16); }
9 +function idemKey(listingId: string, body: string) { return `${listingId}-${body.length}-${Math.floor(Date.now() / 16384)}`; }
10 +/** Panneau d'action d'une annonce directe Toveli : contacter, demander une visite, signaler. Connexion requise pour écrire (retour automatique sur la fiche). */
11 +export function ContactPanel({ listingId, uid, publisherLabel, publisherName }: { listingId: string; uid: string; publisherLabel: string; publisherName: string | null }) {
12 + const [me, setMe] = useState<{ id: string } | null | undefined>(undefined);
13 + const [mode, setMode] = useState<"idle" | "contact" | "visit" | "report">("idle"); const [body, setBody] = useState(""); const [slots, setSlots] = useState<string[]>(["", "", ""]); const [note, setNote] = useState(""); const [reason, setReason] = useState("arnaque"); const [msg, setMsg] = useState<string | null>(null); const [busy, setBusy] = useState(false);
14 + const fav = useFavorites(); const cmp = useCompare();
15 + useEffect(() => { fetch("/api/me").then((r) => r.json()).then((d) => setMe(d.user ?? null)).catch(() => setMe(null)); }, []);
16 + const here = typeof window !== "undefined" ? window.location.pathname : `/annonce/${encodeURIComponent(uid)}`;
17 + const need = () => { if (me === null) { window.location.href = `/connexion?next=${encodeURIComponent(here)}`; return true; } return false; };
18 + const toISO = (v: string) => { const d = new Date(v); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
19 + const send = async () => {
20 + setBusy(true); setMsg(null);
21 + try {
22 + if (mode === "contact") { const r = await fetch(`/api/listings/${listingId}/inquiries`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body, category: "INFO", idempotencyKey: idemKey(listingId, body) }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Message envoyé. Suivez la conversation dans Mes messages."); setBody(""); }
23 + if (mode === "visit") { const iso = slots.map(toISO).filter((x): x is string => !!x); if (!iso.length) throw new Error("Proposez au moins un créneau"); const r = await fetch(`/api/listings/${listingId}/viewings`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slots: iso, note: note || undefined }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Demande de visite envoyée. Vous serez avisé par courriel de la confirmation."); }
24 + if (mode === "report") { const r = await fetch("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetType: "LISTING", targetId: listingId, reason, details: note || undefined }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error?.message ?? "Erreur"); setMsg("Signalement transmis à l'équipe Toveli. Merci."); }
25 + setMode("idle");
26 + } catch (e) { setMsg((e as Error).message); } finally { setBusy(false); }
27 + };
28 + const share = async () => { const u = window.location.href; if (navigator.share) { try { await navigator.share({ url: u, title: document.title }); } catch { /* */ } } else { await navigator.clipboard.writeText(u); setMsg("Lien copié"); } };
29 + return (
30 + <div className="tv-card p-4 space-y-3">
31 + <div><p className="tv-label">Publiée sur Toveli</p><p className="font-semibold">{publisherLabel}{publisherName ? ` · ${publisherName}` : ""}</p><p className="text-xs text-ink-3">Les échanges passent par la messagerie Toveli : aucune coordonnée n'est affichée publiquement.</p></div>
32 + <div className="grid grid-cols-2 gap-2">
33 + <button type="button" onClick={() => { if (!need()) setMode(mode === "contact" ? "idle" : "contact"); }} className="tv-btn tv-btn-primary">Contacter</button>
34 + <button type="button" onClick={() => { if (!need()) setMode(mode === "visit" ? "idle" : "visit"); }} className="tv-btn tv-btn-ghost">Demander une visite</button>
35 + </div>
36 + {me === null && <p className="text-xs text-ink-3">Connexion par code courriel requise pour écrire au propriétaire (30 secondes). <Link href={`/connexion?next=${encodeURIComponent(here)}`} className="underline">Se connecter</Link></p>}
37 + {mode === "contact" && <div className="space-y-2"><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} maxLength={3000} placeholder="Bonjour, je suis intéressé(e) par votre propriété. Est-elle toujours disponible ? …" className="w-full border border-line rounded-sm p-2 text-sm outline-none focus:border-brand" /><button type="button" disabled={busy || body.trim().length < 10} onClick={send} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Envoyer le message"}</button></div>}
38 + {mode === "visit" && <div className="space-y-2"><p className="text-xs text-ink-2">Proposez jusqu'à 3 créneaux (heure de l'Est) ; le propriétaire en confirme un.</p>{slots.map((s, i) => <input key={i} type="datetime-local" value={s} onChange={(e) => setSlots(slots.map((x, k) => k === i ? e.target.value : x))} min={minSlot()} className="tv-chip w-full !pl-3" />)}<input value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} placeholder="Note (facultatif)" className="tv-chip w-full !pl-3" /><button type="button" disabled={busy} onClick={send} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Envoyer la demande"}</button></div>}
39 + {mode === "report" && <div className="space-y-2"><select value={reason} onChange={(e) => setReason(e.target.value)} className="tv-chip w-full appearance-none"><option value="arnaque">Arnaque ou fraude présumée</option><option value="doublon">Doublon</option><option value="informations_fausses">Informations fausses</option><option value="deja_vendu_loue">Déjà vendu / loué</option><option value="droits_photos">Photos sans droits</option><option value="contenu_inapproprie">Contenu inapproprié</option><option value="autre">Autre</option></select><textarea value={note} onChange={(e) => setNote(e.target.value)} rows={3} maxLength={1000} placeholder="Précisions (facultatif)" className="w-full border border-line rounded-sm p-2 text-sm" /><button type="button" disabled={busy} onClick={send} className="tv-btn tv-btn-ghost w-full">Envoyer le signalement</button></div>}
40 + {msg && <Notice>{msg}</Notice>}
41 + <div className="flex flex-wrap gap-2 pt-1 border-t border-line">
42 + <button type="button" onClick={() => fav.toggle(uid)} className={`tv-chip ${fav.has(uid) ? "!text-brand-dark !border-brand" : ""}`}><IconHeart width={14} height={14} filled={fav.has(uid)} /> {fav.has(uid) ? "Favori" : "Ajouter aux favoris"}</button>
43 + <button type="button" onClick={() => cmp.toggle(uid)} className="tv-chip"><IconCompare width={14} height={14} /> Comparer</button>
44 + <button type="button" onClick={share} className="tv-chip"><IconShare width={14} height={14} /> Partager</button>
45 + <button type="button" onClick={() => setMode(mode === "report" ? "idle" : "report")} className="tv-chip !text-ink-3">Signaler</button>
46 + </div>
47 + </div>
48 + );
49 +}
modified src/components/mobile-nav.tsx +2 −2
@@ -6,9 +6,9 @@ import { IconHeart, IconMap, IconSearch, IconSpark, IconUser } from "./icons";
6 6 const TABS = [
7 7 { href: "/recherche", label: "Explorer", Icon: IconSearch, match: /^\/(recherche|propriete|annonce)/ },
8 8 { href: "/carte", label: "Carte", Icon: IconMap, match: /^\/carte/ },
9 − { href: "/assistant", label: "Assistant", Icon: IconSpark, match: /^\/assistant/ },
9 + { href: "/membre/annonces/nouvelle", label: "Publier", Icon: IconSpark, match: /^\/membre\/annonces/ },
10 10 { href: "/favoris", label: "Favoris", Icon: IconHeart, match: /^\/favoris/ },
11 − { href: "/compte", label: "Profil", Icon: IconUser, match: /^\/compte/ },
11 + { href: "/membre", label: "Compte", Icon: IconUser, match: /^\/(membre|compte|connexion)/ },
12 12 ];
13 13 export function MobileNav() {
14 14 const path = usePathname() ?? "/";
modified src/components/mortgage-widget.tsx +4 −4
@@ -11,16 +11,16 @@ function F({ label, value, set, step = 1, suffix = "" }: { label: string; value:
11 11 );
12 12 }
13 13
14 −export function MortgageWidget({ price: p0 = 500000, city, compact = false }: { price?: number; city?: string | null; compact?: boolean }) {
14 +export function MortgageWidget({ price: p0 = 500000, city, compact = false, taxes: t0, schoolTaxes: s0, condoFees: c0 }: { price?: number; city?: string | null; compact?: boolean; taxes?: number; schoolTaxes?: number; condoFees?: number }) {
15 15 const [price, setPrice] = useState(p0);
16 16 const [dpPct, setDpPct] = useState(20);
17 17 const [rate, setRate] = useState(DEFAULT_RATE);
18 18 const [amort, setAmort] = useState(25);
19 19 const [freq, setFreq] = useState<12 | 26 | 52>(12);
20 − const [taxes, setTaxes] = useState(Math.round(p0 * 0.009));
21 − const [school, setSchool] = useState(Math.round(p0 * 0.0007));
20 + const [taxes, setTaxes] = useState(t0 ?? Math.round(p0 * 0.009));
21 + const [school, setSchool] = useState(s0 ?? Math.round(p0 * 0.0007));
22 22 const [ins, setIns] = useState(90);
23 − const [condo, setCondo] = useState(0);
23 + const [condo, setCondo] = useState(c0 ?? 0);
24 24 const [heat, setHeat] = useState(150);
25 25 const dp = Math.round(price * dpPct / 100);
26 26 const r = useMemo(() => computeMortgage({ price, downPayment: dp, rateAnnualPct: rate, amortYears: amort, paymentsPerYear: freq, municipalTaxAnnual: taxes, schoolTaxAnnual: school, insuranceMonthly: ins, condoFeesMonthly: condo, heatingMonthly: heat }), [price, dp, rate, amort, freq, taxes, school, ins, condo, heat]);
added src/components/report-button.tsx +14 −0
@@ -0,0 +1,14 @@
1 +"use client";
2 +import { useState } from "react";
3 +/** Signalement d'une annonce agrégée (doublon, déjà vendue, droits photos…) — sans compte, limité par IP. */
4 +export function ReportButton({ uid }: { uid: string }) {
5 + const [open, setOpen] = useState(false); const [reason, setReason] = useState("deja_vendu_loue"); const [details, setDetails] = useState(""); const [done, setDone] = useState<string | null>(null);
6 + const send = async () => { const r = await fetch("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetType: "AGGREGATED_LISTING", targetId: uid, reason, details: details || undefined }) }); setDone(r.ok ? "Merci, signalement transmis." : "Impossible pour l'instant."); setOpen(false); };
7 + return (
8 + <div className="text-xs">
9 + {!open ? <button type="button" onClick={() => setOpen(true)} className="underline text-ink-3">Signaler un problème avec cette annonce</button> : (
10 + <div className="tv-card p-3 space-y-2 mt-1"><select value={reason} onChange={(e) => setReason(e.target.value)} className="tv-chip w-full appearance-none"><option value="deja_vendu_loue">Déjà vendue / louée</option><option value="doublon">Doublon</option><option value="informations_fausses">Informations erronées</option><option value="arnaque">Arnaque présumée</option><option value="droits_photos">Retrait demandé (droits / propriétaire)</option><option value="autre">Autre</option></select><textarea value={details} onChange={(e) => setDetails(e.target.value)} rows={2} maxLength={1000} placeholder="Précisions" className="w-full border border-line rounded-sm p-2" /><div className="flex gap-2"><button type="button" onClick={send} className="tv-btn tv-btn-ghost !py-1">Envoyer</button><button type="button" onClick={() => setOpen(false)} className="underline text-ink-3">Annuler</button></div></div>)}
11 + {done && <p className="text-ink-2 mt-1">{done}</p>}
12 + </div>
13 + );
14 +}
modified src/components/site-footer.tsx +2 −0
@@ -17,6 +17,8 @@ export function SiteFooter() {
17 17 <li><Link href="/estimer" className="hover:text-ink">Estimer une propriété</Link></li>
18 18 <li><Link href="/marche" className="hover:text-ink">Marché et données</Link></li>
19 19 <li><Link href="/financement" className="hover:text-ink">Calculateur hypothécaire</Link></li>
20 + <li><Link href="/assistant" className="hover:text-ink">Assistant IA</Link></li>
21 + <li><Link href="/membre/annonces/nouvelle" className="hover:text-ink">Publier une propriété</Link></li>
20 22 </ul>
21 23 </div>
22 24 <div>
modified src/components/site-header.tsx +3 −2
@@ -2,7 +2,7 @@
2 2 import Link from "next/link";
3 3 import { usePathname } from "next/navigation";
4 4 import { Logo } from "./logo";
5 −import { IconHeart, IconSearch } from "./icons";
5 +import { IconHeart, IconSearch, IconUser } from "./icons";
6 6
7 7 const NAV = [
8 8 { href: "/recherche?marche=achat", label: "Acheter", match: /^\/recherche(?!.*marche=location)/ },
@@ -27,7 +27,8 @@ export function SiteHeader() {
27 27 <div className="ml-auto flex items-center gap-2">
28 28 <Link href="/recherche" className="md:hidden p-2 rounded-full hover:bg-surface-2" aria-label="Rechercher"><IconSearch /></Link>
29 29 <Link href="/favoris" className="p-2 rounded-full hover:bg-surface-2 text-ink-2" aria-label="Mes favoris"><IconHeart /></Link>
30 − <Link href="/assistant" className="hidden sm:inline-flex tv-btn tv-btn-primary !py-2 text-sm">Assistant IA</Link>
30 + <Link href="/membre" className="hidden md:inline-flex p-2 rounded-full hover:bg-surface-2 text-ink-2" aria-label="Mon compte"><IconUser /></Link>
31 + <Link href="/membre/annonces/nouvelle" className="inline-flex tv-btn tv-btn-primary !py-2 text-sm">Publier une propriété</Link>
31 32 </div>
32 33 </div>
33 34 </header>
modified src/db/client.ts +2 −2
@@ -3,14 +3,14 @@ import postgres from "postgres";
3 3 import * as schema from "./schema";
4 4
5 5 // Compte applicatif à privilèges minimaux (toveli_app), connexion locale uniquement (DATABASE_URL jamais exposée au client).
6 −const url = process.env.DATABASE_URL;
7 6 declare global { var __toveliSql: ReturnType<typeof postgres> | undefined; }
8 7 function sqlClient() {
8 + const url = process.env.DATABASE_URL;
9 9 if (!url) throw new Error("DATABASE_URL manquante");
10 10 if (!globalThis.__toveliSql) globalThis.__toveliSql = postgres(url, { max: Number(process.env.PG_POOL_MAX ?? 8), idle_timeout: 30, connect_timeout: 10, prepare: true });
11 11 return globalThis.__toveliSql;
12 12 }
13 −export function pgAvailable(): boolean { return !!url; }
13 +export function pgAvailable(): boolean { return !!process.env.DATABASE_URL; }
14 14 export function db() { return drizzle(sqlClient(), { schema }); }
15 15 export function rawSql() { return sqlClient(); }
16 16 export { schema };
added src/lib/admin/mfa.ts +17 −0
@@ -0,0 +1,17 @@
1 +// MFA forte pour les rôles élevés : TOTP (RFC 6238) via otpauth ; secret chiffré en base ; vérification récente exigée (30 min) pour toute action admin.
2 +import * as OTPAuth from "otpauth";
3 +import { eq } from "drizzle-orm";
4 +import { db } from "@/db/client";
5 +import { users, sessions } from "@/db/schema";
6 +import { encrypt, decrypt } from "@/lib/auth/crypto";
7 +import { AuthError, type SessionUser } from "@/lib/auth/session";
8 +import { audit } from "@/lib/auth/audit";
9 +const MFA_WINDOW_MS = 30 * 60000;
10 +export async function totpStatus(userId: string): Promise<boolean> { const [u] = await db().select({ s: users.totpSecretEncrypted }).from(users).where(eq(users.id, userId)); return !!u?.s; }
11 +export function newSecret(email: string) { const secret = new OTPAuth.Secret({ size: 20 }); const totp = new OTPAuth.TOTP({ issuer: "Toveli", label: email, algorithm: "SHA1", digits: 6, period: 30, secret }); return { base32: secret.base32, uri: totp.toString() }; }
12 +export async function enroll(userId: string, base32: string, code: string): Promise<boolean> { const totp = new OTPAuth.TOTP({ issuer: "Toveli", digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(base32) }); if (totp.validate({ token: code, window: 1 }) === null) return false; await db().update(users).set({ totpSecretEncrypted: encrypt(base32), updatedAt: new Date() }).where(eq(users.id, userId)); await audit("mfa.enroll", { actorId: userId }); return true; }
13 +export async function verify(user: SessionUser, code: string): Promise<boolean> { const [u] = await db().select({ s: users.totpSecretEncrypted }).from(users).where(eq(users.id, user.id)); if (!u?.s) return false; const totp = new OTPAuth.TOTP({ issuer: "Toveli", digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(decrypt(u.s)) }); const ok = totp.validate({ token: code, window: 1 }) !== null; if (ok) await db().update(sessions).set({ mfaAt: new Date() }).where(eq(sessions.id, user.sessionId)); await audit(ok ? "mfa.ok" : "mfa.fail", { actorId: user.id }); return ok; }
14 +/** Garde des actions admin : rôle + TOTP vérifié dans la fenêtre. */
15 +export function requireFreshMfa(u: SessionUser) { if (!u.mfaAt || Date.now() - u.mfaAt.getTime() > MFA_WINDOW_MS) throw new AuthError(403, "Vérification TOTP requise"); }
16 +
17 +export function isFreshMfa(mfaAt: Date | null | undefined): boolean { return !!mfaAt && Date.now() - mfaAt.getTime() <= MFA_WINDOW_MS; }
modified src/lib/auth/otp.ts +6 −3
@@ -16,7 +16,7 @@ export async function requestCode(rawEmail: string, ip: string | null, purpose:
16 16 const [byIp, byEmailHour, byEmailDay, byIpDay] = await Promise.all([hit(`otp:ip:${iph}`, 10, 600), hit(`otp:email:${email}`, 5, 3600), hit(`otp:email:day:${email}`, 12, 86400), hit(`otp:ip:day:${iph}`, 60, 86400)]);
17 17 if (!byIp.ok || !byEmailHour.ok || !byEmailDay.ok || !byIpDay.ok) { await audit("otp.rate_limited", { ipHash: iph }); return { ok: false, reason: "rate_limited", retryAfterSec: Math.max(byIp.retryAfterSec, byEmailHour.retryAfterSec, 60) }; }
18 18 const d = db();
19 − const [recent] = await d.select({ createdAt: otpChallenges.createdAt }).from(otpChallenges).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), isNull(otpChallenges.consumedAt), sql`${otpChallenges.createdAt} > now() - interval '${sql.raw(String(RESEND_DELAY_SEC))} seconds'`)).limit(1);
19 + const [recent] = await d.select({ createdAt: otpChallenges.createdAt }).from(otpChallenges).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), sql`${otpChallenges.createdAt} > now() - interval '${sql.raw(String(RESEND_DELAY_SEC))} seconds'`)).orderBy(sql`${otpChallenges.createdAt} desc`).limit(1);
20 20 if (recent) return { ok: false, reason: "cooldown", retryAfterSec: Math.max(1, RESEND_DELAY_SEC - Math.floor((Date.now() - recent.createdAt.getTime()) / 1000)) };
21 21 // Invalide les défis précédents encore ouverts pour cette adresse/finalité
22 22 await d.update(otpChallenges).set({ consumedAt: new Date() }).where(and(eq(otpChallenges.emailNormalized, email), eq(otpChallenges.purpose, purpose), isNull(otpChallenges.consumedAt)));
@@ -29,7 +29,7 @@ export async function requestCode(rawEmail: string, ip: string | null, purpose:
29 29 return { ok: true, challengeId: ch.id, retryAfterSec: RESEND_DELAY_SEC };
30 30 }
31 31
32 −export type VerifyResult = { ok: true; userId: string; email: string; created: boolean } | { ok: false; reason: "invalid" | "expired" | "too_many_attempts" | "not_found" };
32 +export type VerifyResult = { ok: true; userId: string; email: string; created: boolean } | { ok: false; reason: "invalid" | "expired" | "too_many_attempts" | "not_found" | "terms_required" };
33 33
34 34 /** Vérification atomique : une seule transaction, verrou de ligne, compteur de tentatives, usage unique. */
35 35 export async function verifyCode(challengeId: string, code: string, ip: string | null, acceptTerms: boolean): Promise<VerifyResult> {
@@ -43,11 +43,14 @@ export async function verifyCode(challengeId: string, code: string, ip: string |
43 43 if (ch.attempts >= MAX_ATTEMPTS) { await tx.update(otpChallenges).set({ consumedAt: new Date() }).where(eq(otpChallenges.id, ch.id)); await audit("otp.too_many_attempts", { objectId: ch.id, ipHash: iph }); return { ok: false, reason: "too_many_attempts" as const }; }
44 44 const good = safeEqualHex(ch.secretDigest, otpDigest(code, ch.id));
45 45 if (!good) { const attempts = ch.attempts + 1; await tx.update(otpChallenges).set({ attempts, consumedAt: attempts >= MAX_ATTEMPTS ? new Date() : null }).where(eq(otpChallenges.id, ch.id)); await audit("otp.failed", { objectId: ch.id, ipHash: iph, meta: { attempts } }); return { ok: false, reason: attempts >= MAX_ATTEMPTS ? ("too_many_attempts" as const) : ("invalid" as const) }; }
46 + if (ch.purpose === "LOGIN") {
47 + const [exists] = await tx.select({ id: users.id }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1);
48 + if (!exists && !acceptTerms) return { ok: false, reason: "terms_required" as const }; // code correct mais non consommé : l'utilisateur doit accepter les conditions
49 + }
46 50 await tx.update(otpChallenges).set({ consumedAt: new Date(), attempts: ch.attempts + 1 }).where(eq(otpChallenges.id, ch.id));
47 51 if (ch.purpose !== "LOGIN") return { ok: true as const, userId: String(ch.meta && (ch.meta as { userId?: string }).userId), email: ch.emailNormalized, created: false };
48 52 const [existing] = await tx.select({ id: users.id, status: users.status }).from(users).where(eq(users.email, ch.emailNormalized)).limit(1);
49 53 if (existing) { if (existing.status !== "ACTIVE") return { ok: false, reason: "invalid" as const }; await tx.update(users).set({ emailVerifiedAt: sql`coalesce(${users.emailVerifiedAt}, now())`, updatedAt: new Date() }).where(eq(users.id, existing.id)); await audit("auth.login", { actorId: existing.id, ipHash: iph }); return { ok: true as const, userId: existing.id, email: ch.emailNormalized, created: false }; }
50 − if (!acceptTerms) return { ok: false, reason: "invalid" as const };
51 54 const [u] = await tx.insert(users).values({ email: ch.emailNormalized, emailVerifiedAt: new Date(), prefs: { termsAcceptedAt: new Date().toISOString(), notifications: { inquiries: true, viewings: true, alerts: true, marketing: false } } }).returning({ id: users.id });
52 55 await tx.insert(userProfiles).values({ userId: u.id });
53 56 await audit("auth.signup", { actorId: u.id, ipHash: iph });
modified src/lib/auth/ratelimit.ts +1 −1
@@ -8,7 +8,7 @@ export async function hit(key: string, limit: number, windowSec: number, slices
8 8 const now = Date.now(); const cur = new Date(Math.floor(now / sliceMs) * sliceMs); const since = new Date(now - windowSec * 1000);
9 9 const d = db();
10 10 await d.insert(rateLimits).values({ key, windowStart: cur, count: 1 }).onConflictDoUpdate({ target: [rateLimits.key, rateLimits.windowStart], set: { count: sql`${rateLimits.count} + 1` } });
11 − const [row] = await d.select({ total: sql<number>`coalesce(sum(${rateLimits.count}), 0)::int` }).from(rateLimits).where(sql`${rateLimits.key} = ${key} and ${rateLimits.windowStart} >= ${since}`);
11 + const [row] = await d.select({ total: sql<number>`coalesce(sum(${rateLimits.count}), 0)::int` }).from(rateLimits).where(sql`${rateLimits.key} = ${key} and ${rateLimits.windowStart} >= ${since.toISOString()}::timestamptz`);
12 12 const total = Number(row?.total ?? 0);
13 13 if (Math.random() < 0.02) await d.execute(sql`delete from ${rateLimits} where ${rateLimits.windowStart} < now() - interval '2 days'`);
14 14 return { ok: total <= limit, remaining: Math.max(0, limit - total), retryAfterSec: total <= limit ? 0 : Math.ceil(sliceMs / 1000) };
modified src/lib/favorites.ts +23 −1
@@ -17,7 +17,29 @@ function useLocalList(key: string, max: number) {
17 17 const clear = useCallback(() => { write(key, []); setItems([]); }, [key]);
18 18 return { items, toggle, has, clear };
19 19 }
20 −export function useFavorites() { return useLocalList(KEY_FAV, 200); }
20 +/** Favoris : locaux par défaut ; si l'utilisateur est connecté, synchronisés avec le serveur (les favoris locaux sont poussés une fois, puis le serveur fait foi). */
21 +export function useFavorites() {
22 + const local = useLocalList(KEY_FAV, 200);
23 + const [server, setServer] = useState<string[] | null>(null);
24 + useEffect(() => {
25 + let alive = true;
26 + fetch("/api/me").then((r) => r.json()).then(async (d) => {
27 + if (!alive || !d?.user) return;
28 + const cur = read<string[]>(KEY_FAV, []);
29 + if (cur.length) { await fetch("/api/me/favorites", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ uids: cur }) }).catch(() => {}); write(KEY_FAV, []); }
30 + const r = await fetch("/api/me/favorites").then((r) => r.json()).catch(() => null);
31 + if (alive && r?.items) setServer(r.items.map((i: { uid: string }) => i.uid));
32 + }).catch(() => {});
33 + return () => { alive = false; };
34 + }, []);
35 + if (server === null) return local;
36 + return {
37 + items: server,
38 + has: (uid: string) => server.includes(uid),
39 + toggle: (uid: string) => { const next = server.includes(uid) ? server.filter((x) => x !== uid) : [uid, ...server]; setServer(next); fetch("/api/me/favorites", { method: server.includes(uid) ? "DELETE" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ uids: [uid] }) }).catch(() => {}); },
40 + clear: () => { fetch("/api/me/favorites", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ uids: server }) }).catch(() => {}); setServer([]); },
41 + };
42 +}
21 43 export function useCompare() { return useLocalList(KEY_CMP, 4); }
22 44
23 45 export interface SavedSearch { id: string; label: string; href: string; createdAt: string; }
added src/lib/inquiries/service.ts +83 −0
@@ -0,0 +1,83 @@
1 +import { and, eq, or, desc, sql, asc } from "drizzle-orm";
2 +import { db } from "@/db/client";
3 +import { inquiries, messages, viewingRequests, listings, properties, users, listingMedia } from "@/db/schema";
4 +import { AuthError } from "@/lib/auth/session";
5 +import { audit } from "@/lib/auth/audit";
6 +import { sendMail, notifyMail } from "@/lib/auth/email";
7 +import { mediaUrl } from "@/lib/listings/media";
8 +
9 +const BASE = () => (process.env.APP_BASE_URL ?? "https://www.toveli.ca").replace(/\/$/, "");
10 +/** Masque courriels, téléphones et liens suspects dans les messages (les coordonnées passent par Toveli, pas par le texte). */
11 +export function sanitizeBody(s: string): string {
12 + return s.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[courriel masqué]").replace(/(\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}/g, "[téléphone masqué]").replace(/https?:\/\/[^\s]+/gi, (m) => /toveli\.ca/i.test(m) ? m : "[lien retiré]").slice(0, 4000);
13 +}
14 +async function notify(userId: string, key: "inquiries" | "viewings", title: string, body: string, path: string, template: string) {
15 + const [u] = await db().select({ email: users.email, prefs: users.prefs }).from(users).where(eq(users.id, userId)).limit(1); if (!u) return;
16 + const n = (u.prefs as { notifications?: Record<string, boolean> }).notifications ?? {}; if (n[key] === false) return;
17 + try { await sendMail(notifyMail(u.email, title, body, `${BASE()}${path}`, "Ouvrir sur Toveli", template)); } catch { /* journalisé */ }
18 +}
19 +
20 +export async function openInquiry(listingId: string, senderId: string, category: string, body: string, idem?: string) {
21 + const d = db();
22 + const [l] = await d.select({ id: listings.id, ownerUserId: listings.ownerUserId, status: listings.status, title: listings.title }).from(listings).where(eq(listings.id, listingId)).limit(1);
23 + if (!l || l.status !== "PUBLISHED" || !l.ownerUserId) throw new AuthError(404, "Annonce introuvable ou non publiée");
24 + if (l.ownerUserId === senderId) throw new AuthError(409, "Vous ne pouvez pas vous écrire à vous-même");
25 + return d.transaction(async (tx) => {
26 + let [inq] = await tx.select().from(inquiries).where(and(eq(inquiries.listingId, listingId), eq(inquiries.senderUserId, senderId))).limit(1);
27 + if (!inq) [inq] = await tx.insert(inquiries).values({ listingId, senderUserId: senderId, recipientUserId: l.ownerUserId!, category }).returning();
28 + if (inq.status === "BLOCKED") throw new AuthError(403, "Conversation bloquée");
29 + const [m] = await tx.insert(messages).values({ inquiryId: inq.id, senderId, body: sanitizeBody(body), idempotencyKey: idem ?? null }).onConflictDoNothing().returning();
30 + if (m) { await tx.update(inquiries).set({ lastMessageAt: new Date(), status: "OPEN" }).where(eq(inquiries.id, inq.id)); await audit("inquiry.message", { actorId: senderId, objectType: "inquiry", objectId: inq.id }); await notify(l.ownerUserId!, "inquiries", "Nouvelle demande pour votre annonce", `Un membre Toveli vous a écrit au sujet de « ${l.title ?? "votre annonce"} ». Le contenu complet est disponible dans votre messagerie Toveli.`, `/membre/messages/${inq.id}`, "inquiry_new"); }
31 + return inq;
32 + });
33 +}
34 +export async function myInquiries(userId: string) {
35 + return db().select({ inq: inquiries, listingTitle: listings.title, listingSlug: listings.slug, listingType: listings.listingType, city: properties.city, cover: sql<string | null>`(select m.storage_key from ${listingMedia} m where m.listing_id = ${listings.id} and m.status='READY' order by m.position limit 1)`, unread: sql<number>`(select count(*) from ${messages} mm where mm.inquiry_id = ${inquiries.id} and mm.read_at is null and mm.sender_id <> ${userId})::int`, lastBody: sql<string | null>`(select left(mm.body, 120) from ${messages} mm where mm.inquiry_id = ${inquiries.id} order by mm.created_at desc limit 1)`, otherName: sql<string | null>`(select coalesce(p.public_name, u.display_name) from ${users} u left join user_profiles p on p.user_id = u.id where u.id = case when ${inquiries.senderUserId} = ${userId} then ${inquiries.recipientUserId} else ${inquiries.senderUserId} end)` })
36 + .from(inquiries).innerJoin(listings, eq(listings.id, inquiries.listingId)).innerJoin(properties, eq(properties.id, listings.propertyId)).where(or(eq(inquiries.senderUserId, userId), eq(inquiries.recipientUserId, userId))).orderBy(desc(inquiries.lastMessageAt)).then((rows) => rows.map((r) => ({ ...r, cover: r.cover ? mediaUrl(r.cover, 320) : null })));
37 +}
38 +export async function getThread(inquiryId: string, userId: string) {
39 + const d = db(); const [inq] = await d.select().from(inquiries).where(eq(inquiries.id, inquiryId)).limit(1);
40 + if (!inq || (inq.senderUserId !== userId && inq.recipientUserId !== userId)) throw new AuthError(404, "Conversation introuvable"); // 404 volontaire : ne révèle pas l'existence
41 + const msgs = await d.select().from(messages).where(eq(messages.inquiryId, inquiryId)).orderBy(asc(messages.createdAt)).limit(500);
42 + await d.update(messages).set({ readAt: new Date() }).where(and(eq(messages.inquiryId, inquiryId), sql`${messages.senderId} <> ${userId}`, sql`${messages.readAt} is null`));
43 + const [l] = await d.select({ id: listings.id, slug: listings.slug, title: listings.title, status: listings.status, ownerUserId: listings.ownerUserId }).from(listings).where(eq(listings.id, inq.listingId)).limit(1);
44 + const otherId = inq.senderUserId === userId ? inq.recipientUserId : inq.senderUserId;
45 + const [other] = await d.select({ name: sql<string | null>`coalesce((select public_name from user_profiles where user_id = ${otherId}), ${users.displayName})` }).from(users).where(eq(users.id, otherId)).limit(1);
46 + return { inquiry: inq, messages: msgs, listing: l, other: { id: otherId, name: other?.name ?? "Membre Toveli" }, me: userId };
47 +}
48 +export async function reply(inquiryId: string, userId: string, body: string, idem?: string) {
49 + const d = db(); const [inq] = await d.select().from(inquiries).where(eq(inquiries.id, inquiryId)).limit(1);
50 + if (!inq || (inq.senderUserId !== userId && inq.recipientUserId !== userId)) throw new AuthError(404, "Conversation introuvable");
51 + if (inq.status === "BLOCKED") throw new AuthError(403, "Conversation bloquée");
52 + const [m] = await d.insert(messages).values({ inquiryId, senderId: userId, body: sanitizeBody(body), idempotencyKey: idem ?? null }).onConflictDoNothing().returning();
53 + if (m) { await d.update(inquiries).set({ lastMessageAt: new Date() }).where(eq(inquiries.id, inquiryId)); const to = inq.senderUserId === userId ? inq.recipientUserId : inq.senderUserId; await notify(to, "inquiries", "Nouveau message", "Vous avez reçu un nouveau message dans votre messagerie Toveli.", `/membre/messages/${inquiryId}`, "message_new"); }
54 + return m ?? null;
55 +}
56 +export async function setInquiryStatus(inquiryId: string, userId: string, status: "OPEN" | "ARCHIVED" | "BLOCKED") {
57 + const d = db(); const [inq] = await d.select().from(inquiries).where(eq(inquiries.id, inquiryId)).limit(1);
58 + if (!inq || (inq.senderUserId !== userId && inq.recipientUserId !== userId)) throw new AuthError(404, "Conversation introuvable");
59 + if (status === "BLOCKED" && inq.recipientUserId !== userId) throw new AuthError(403, "Seul le destinataire peut bloquer");
60 + await d.update(inquiries).set({ status }).where(eq(inquiries.id, inquiryId)); await audit(`inquiry.${status.toLowerCase()}`, { actorId: userId, objectId: inquiryId });
61 +}
62 +
63 +export async function requestViewing(listingId: string, requesterId: string, slots: string[], note?: string) {
64 + const d = db(); const [l] = await d.select({ ownerUserId: listings.ownerUserId, status: listings.status, title: listings.title }).from(listings).where(eq(listings.id, listingId)).limit(1);
65 + if (!l || l.status !== "PUBLISHED" || !l.ownerUserId) throw new AuthError(404, "Annonce introuvable ou non publiée");
66 + if (l.ownerUserId === requesterId) throw new AuthError(409, "Vous êtes le propriétaire de cette annonce");
67 + const inq = await openInquiry(listingId, requesterId, "VISIT", `Demande de visite · créneaux proposés : ${slots.map((s) => new Date(s).toLocaleString("fr-CA", { timeZone: "America/Toronto", dateStyle: "medium", timeStyle: "short" })).join(" ; ")}${note ? ` · ${note}` : ""}`, `visit:${slots.join("|")}`);
68 + const [v] = await d.insert(viewingRequests).values({ listingId, inquiryId: inq.id, requesterId, ownerId: l.ownerUserId, proposedSlots: slots, note: note ? sanitizeBody(note) : null }).returning();
69 + await audit("viewing.request", { actorId: requesterId, objectType: "viewing", objectId: v.id });
70 + await notify(l.ownerUserId, "viewings", "Demande de visite", `Un membre souhaite visiter « ${l.title ?? "votre propriété"} ». Confirmez un créneau dans Toveli.`, `/membre/visites`, "viewing_request");
71 + return v;
72 +}
73 +export async function myViewings(userId: string) {
74 + return db().select({ v: viewingRequests, title: listings.title, slug: listings.slug, city: properties.city }).from(viewingRequests).innerJoin(listings, eq(listings.id, viewingRequests.listingId)).innerJoin(properties, eq(properties.id, listings.propertyId)).where(or(eq(viewingRequests.requesterId, userId), eq(viewingRequests.ownerId, userId))).orderBy(desc(viewingRequests.createdAt));
75 +}
76 +export async function updateViewing(id: string, userId: string, action: "confirm" | "cancel" | "done", slot?: string) {
77 + const d = db(); const [v] = await d.select().from(viewingRequests).where(eq(viewingRequests.id, id)).limit(1);
78 + if (!v || (v.requesterId !== userId && v.ownerId !== userId)) throw new AuthError(404, "Demande introuvable");
79 + if (action === "confirm") { if (v.ownerId !== userId) throw new AuthError(403, "Seul le propriétaire confirme"); if (v.status !== "REQUESTED") throw new AuthError(409, "Déjà traitée"); if (!slot || !(v.proposedSlots as string[]).includes(slot)) throw new AuthError(400, "Créneau invalide"); const conflict = await d.select({ id: viewingRequests.id }).from(viewingRequests).where(and(eq(viewingRequests.ownerId, userId), eq(viewingRequests.status, "CONFIRMED"), sql`abs(extract(epoch from (${viewingRequests.confirmedSlot} - ${new Date(slot).toISOString()}::timestamptz))) < 1800`)).limit(1); if (conflict.length) throw new AuthError(409, "Conflit avec une visite déjà confirmée (30 min)"); await d.update(viewingRequests).set({ status: "CONFIRMED", confirmedSlot: new Date(slot), updatedAt: new Date() }).where(eq(viewingRequests.id, id)); await notify(v.requesterId, "viewings", "Visite confirmée", `Votre visite est confirmée le ${new Date(slot).toLocaleString("fr-CA", { timeZone: "America/Toronto", dateStyle: "full", timeStyle: "short" })}.`, "/membre/visites", "viewing_confirmed"); }
80 + if (action === "cancel") { if (["CANCELLED", "DONE"].includes(v.status)) throw new AuthError(409, "Déjà clôturée"); await d.update(viewingRequests).set({ status: "CANCELLED", updatedAt: new Date() }).where(eq(viewingRequests.id, id)); await notify(v.requesterId === userId ? v.ownerId : v.requesterId, "viewings", "Visite annulée", "Une demande de visite a été annulée.", "/membre/visites", "viewing_cancelled"); }
81 + if (action === "done") { if (v.ownerId !== userId || v.status !== "CONFIRMED") throw new AuthError(409, "Impossible"); await d.update(viewingRequests).set({ status: "DONE", updatedAt: new Date() }).where(eq(viewingRequests.id, id)); }
82 + await audit(`viewing.${action}`, { actorId: userId, objectId: id });
83 +}
added src/lib/insights.ts +63 −0
@@ -0,0 +1,63 @@
1 +// « En bref » : constats déterministes à partir des données (jamais d'invention), inspirés de la logique de synthèse des fiches sources, réécrits pour Toveli.
2 +import { getDb } from "./db";
3 +import type { MarketMetricRow } from "./types";
4 +import { fmtPrice, fmtPct, fmtInt } from "./format";
5 +
6 +export interface Insight { tone: "good" | "warn" | "info" | "neutral"; text: string }
7 +export interface MarketPosition { metric: MarketMetricRow | null; pricePercentile: number | null; ppsfVsMedian: number | null; daysVsMedian: number | null }
8 +
9 +export function marketPosition(l: { market: string; category: string; city: string | null; region: string | null; price: number | null; area_sqft: number | null; first_seen: string | null }): MarketPosition {
10 + const db = getDb();
11 + const pick = (scope: string, value: string | null) => value ? (db.prepare("SELECT * FROM market_metric WHERE scope=? AND scope_value=? AND market=? AND category=?").get(scope, value, l.market, l.category) as MarketMetricRow | undefined) ?? null : null;
12 + const metric = pick("city", l.city) ?? pick("region", l.region) ?? pick("province", "Québec");
13 + let pricePercentile: number | null = null;
14 + if (l.price && metric) {
15 + const scopeCol = metric.scope === "city" ? "city" : metric.scope === "region" ? "region" : null;
16 + const row = db.prepare(`SELECT sum(price < ?) * 1.0 / count(*) p FROM listing WHERE market=? AND category=? AND price>0 ${scopeCol ? `AND ${scopeCol} = ?` : ""}`).get(...(scopeCol ? [l.price, l.market, l.category, metric.scope_value] : [l.price, l.market, l.category])) as { p: number | null } | undefined;
17 + pricePercentile = row?.p != null ? Math.round(row.p * 100) : null;
18 + }
19 + const ppsf = l.price && l.area_sqft && l.area_sqft > 100 ? l.price / l.area_sqft : null;
20 + const ppsfVsMedian = ppsf && metric?.median_ppsf ? ppsf / metric.median_ppsf - 1 : null;
21 + const days = l.first_seen ? Math.floor((Date.now() - Date.parse(l.first_seen)) / 86400000) : null;
22 + const daysVsMedian = days != null && metric?.median_days != null ? days - metric.median_days : null;
23 + return { metric, pricePercentile, ppsfVsMedian, daysVsMedian };
24 +}
25 +
26 +export function insights(l: { market: string; category: string; city: string | null; price: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null; bedrooms: number | null; first_seen: string | null; image_count: number | null; type_label: string | null }, v: { estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null } | null, pos: MarketPosition, priceDrops: number, roleValue: number | null, score: { global: number | null; transit: number | null; walk: number | null } | null): Insight[] {
27 + const out: Insight[] = []; const rent = l.market === "rent"; const scopeLabel = pos.metric ? (pos.metric.scope === "province" ? "au Québec" : `à ${pos.metric.scope_value}`) : null;
28 + if (v?.estimate && v.ratio && l.price) {
29 + const gap = l.price / v.estimate - 1; const weak = v.confidence === "C" || v.confidence === "D";
30 + if (Math.abs(gap) < 0.05) out.push({ tone: "info", text: `${rent ? "Loyer" : "Prix"} aligné sur ${rent ? "le loyer de référence" : "l'estimation Toveli"} (${fmtPrice(v.estimate, { rent })}), écart ${fmtPct(gap)}${weak ? " — confiance faible" : ""}.` });
31 + else out.push({ tone: gap < 0 ? "good" : "warn", text: `${rent ? "Loyer" : "Prix demandé"} ${gap < 0 ? "inférieur" : "supérieur"} de ${fmtPct(Math.abs(gap), 1, false)} à ${rent ? "notre loyer de référence" : "notre estimation"} (${fmtPrice(v.estimate, { rent })})${v.confidence ? `, confiance ${v.confidence}` : ""}${weak ? " : à interpréter avec prudence" : ""}.` });
32 + } else if (!rent && l.category !== "terrain" && l.category !== "commercial") out.push({ tone: "neutral", text: "Pas d'estimation Toveli pour cette annonce (unité du rôle non appariée ou données insuffisantes)." });
33 + if (pos.pricePercentile != null && pos.metric && scopeLabel) out.push({ tone: "neutral", text: `${rent ? "Loyer" : "Prix"} plus élevé que ${pos.pricePercentile} % des ${l.category === "logement" ? "logements" : `${l.category}s`} ${rent ? "à louer" : "à vendre"} ${scopeLabel} (médiane ${fmtPrice(pos.metric.median_price, { rent })}, ${fmtInt(pos.metric.n)} annonces).` });
34 + if (pos.ppsfVsMedian != null && !rent) out.push({ tone: pos.ppsfVsMedian < -0.1 ? "good" : pos.ppsfVsMedian > 0.1 ? "warn" : "neutral", text: `${fmtInt(l.price! / l.area_sqft!)} $/pi², soit ${fmtPct(pos.ppsfVsMedian)} par rapport à la médiane ${scopeLabel} (${fmtInt(pos.metric!.median_ppsf)} $/pi²).` });
35 + if (pos.daysVsMedian != null && pos.metric?.median_days != null) { const d = Math.floor((Date.now() - Date.parse(l.first_seen!)) / 86400000); out.push({ tone: d <= 7 ? "good" : pos.daysVsMedian > 30 ? "warn" : "neutral", text: d <= 7 ? `Nouvelle annonce : en ligne depuis ${d} jour${d > 1 ? "s" : ""} (médiane ${pos.metric.median_days} j ${scopeLabel}).` : `En ligne depuis ${d} jours, ${pos.daysVsMedian > 0 ? `${pos.daysVsMedian} de plus` : `${-pos.daysVsMedian} de moins`} que la médiane ${scopeLabel} (${pos.metric.median_days} j).` }); }
36 + if (priceDrops > 0) out.push({ tone: "info", text: `${priceDrops} baisse${priceDrops > 1 ? "s" : ""} de ${rent ? "loyer" : "prix"} observée${priceDrops > 1 ? "s" : ""} depuis la première collecte.` });
37 + if (!rent && roleValue && l.price) out.push({ tone: "neutral", text: `Prix demandé = ${(l.price / roleValue).toFixed(2)} × la valeur au rôle (${fmtPrice(roleValue)}).` });
38 + if (l.year_built && l.year_built >= new Date().getFullYear() - 5) out.push({ tone: "good", text: `Construction récente (${l.year_built}).` });
39 + if (!rent && l.lot_sqft && l.lot_sqft >= 20000) out.push({ tone: "good", text: `Grand terrain : ${fmtInt(l.lot_sqft)} pi² (${(l.lot_sqft / 43560).toFixed(2)} acre).` });
40 + if (score?.global != null) out.push({ tone: score.global >= 7 ? "good" : "neutral", text: `Score de quartier ${score.global.toFixed(1)}/10${score.transit != null ? ` · transport ${score.transit.toFixed(1)}` : ""}${score.walk != null ? ` · marche ${score.walk.toFixed(1)}` : ""}.` });
41 + if ((l.image_count ?? 0) < 3) out.push({ tone: "warn", text: `Peu de photos (${l.image_count ?? 0}) : demandez-en davantage avant de vous déplacer.` });
42 + return out.slice(0, 7);
43 +}
44 +
45 +/** Taxes et évaluation extraites des détails source (libellés Centris/courtiers), sans interprétation. */
46 +export function extractFinancials(details: Record<string, unknown>): { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null } {
47 + const num = (v: unknown) => { if (v == null) return null; const n = Number(String(v).replace(/[^\d.,]/g, "").replace(/\s/g, "").replace(",", ".")); return Number.isFinite(n) && n > 0 ? n : null; };
48 + const find = (re: RegExp) => { for (const [k, v] of Object.entries(details)) if (re.test(k.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase())) { const n = num(v); if (n) return n; } return null; };
49 + return { municipalTax: find(/taxes? municipale/), schoolTax: find(/taxes? scolaire/), assessment: find(/evaluation municipale( \(total\)| totale)?$|^evaluation$/), condoFees: find(/frais de (condo|copropriete)|charges de copropriete/) };
50 +}
51 +
52 +/** Budget locatif indicatif : loyer + énergie estimée si non incluse (ordre de grandeur Hydro-Québec tarif D selon superficie) + internet. */
53 +export function rentalBudget(l: { price: number | null; area_sqft: number | null; features: string[]; details: Record<string, unknown> }): { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null {
54 + if (!l.price) return null;
55 + const f = l.features.map((x) => x.toLowerCase()); const d = JSON.stringify(l.details).toLowerCase();
56 + const electricityIncluded = f.some((x) => /lectricit/.test(x) && /inclu/.test(x)) || /"electricity":\s*true/.test(d) || f.some((x) => x === "électricité incluse" || x === "electricite incluse");
57 + const heatingIncluded = f.some((x) => /chauff/.test(x) && /inclu/.test(x)) || /"heating":\s*true/.test(d) || f.includes("chauffage inclus") || f.includes("chauffé");
58 + const area = l.area_sqft && l.area_sqft > 150 ? l.area_sqft : 750;
59 + const electricity = electricityIncluded ? 0 : Math.round((heatingIncluded ? 0.035 : 0.11) * area + 25); // ordre de grandeur mensuel moyen annualisé
60 + const internet = 65;
61 + const notes = ["Énergie : ordre de grandeur annualisé (tarif résidentiel Hydro-Québec, chauffage électrique), varie selon l'isolation et l'usage.", "Internet : forfait moyen. Assurance locataire (~25 $/mois) et stationnement non inclus."];
62 + return { rent: l.price, electricity: electricityIncluded ? 0 : electricity, heatingIncluded, electricityIncluded, internet, total: l.price + (electricityIncluded ? 0 : electricity) + internet, notes };
63 +}
added src/lib/listings/media.ts +57 −0
@@ -0,0 +1,57 @@
1 +// Médias d'annonces : stockage disque local (STORAGE_DIR, défaut data/media), traitement serveur avec sharp :
2 +// vérification du type réel (magic bytes), conversion HEIC, ré-encodage WebP (neutralise les charges utiles), suppression EXIF/GPS, variantes 1600/800/320.
3 +import sharp from "sharp";
4 +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
5 +import path from "node:path";
6 +import { randomBytes } from "node:crypto";
7 +import { and, eq, sql } from "drizzle-orm";
8 +import { db } from "@/db/client";
9 +import { listingMedia } from "@/db/schema";
10 +
11 +export const STORAGE_DIR = process.env.STORAGE_DIR ?? path.join(process.cwd(), "data", "media");
12 +export const MAX_BYTES = 25 * 1024 * 1024, MAX_PER_LISTING = 30;
13 +export const VARIANTS = [1600, 800, 320] as const;
14 +const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
15 +
16 +export function newUploadToken(): string { return randomBytes(24).toString("base64url"); }
17 +export function mediaUrl(key: string, w: (typeof VARIANTS)[number] = 800): string { return `/api/media/${key}/${w}.webp`; }
18 +export function mediaPath(key: string, w: number): string { return path.join(STORAGE_DIR, key, `${w}.webp`); }
19 +
20 +function sniff(buf: Buffer): string | null {
21 + if (buf.length < 12) return null;
22 + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
23 + if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
24 + if (buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
25 + if (buf.subarray(4, 8).toString("ascii") === "ftyp") { const brand = buf.subarray(8, 12).toString("ascii"); if (/^(heic|heix|hevc|hevx|mif1|msf1|heim|heis)$/.test(brand)) return "image/heic"; }
26 + return null;
27 +}
28 +
29 +export class MediaError extends Error { constructor(public code: string, msg: string) { super(msg); } }
30 +
31 +/** Traite un fichier téléversé : retourne la clé de stockage et les dimensions, ou lève MediaError (rejet propre). */
32 +export async function processUpload(buf: Buffer, declaredMime: string | null): Promise<{ key: string; width: number; height: number; bytes: number; mime: string }> {
33 + if (buf.length > MAX_BYTES) throw new MediaError("too_large", "Image trop volumineuse (25 Mo max)");
34 + const real = sniff(buf);
35 + if (!real || !ALLOWED_MIME.has(real)) throw new MediaError("bad_type", "Format non pris en charge : utilisez JPEG, PNG, WebP ou HEIC");
36 + if (declaredMime && declaredMime !== "application/octet-stream" && !declaredMime.startsWith("image/")) throw new MediaError("bad_type", "Type de fichier incohérent");
37 + let input = buf;
38 + if (real === "image/heic") {
39 + try { const mod = await import("heic-convert"); const conv = (mod.default ?? mod) as unknown as (o: { buffer: Buffer; format: "JPEG"; quality: number }) => Promise<ArrayBuffer | Buffer>; const outBuf = await conv({ buffer: buf, format: "JPEG", quality: 0.92 }); input = Buffer.isBuffer(outBuf) ? outBuf : Buffer.from(new Uint8Array(outBuf as ArrayBuffer)); }
40 + catch { try { await sharp(buf).metadata(); } catch { throw new MediaError("heic_unsupported", "Photo HEIC non convertible ici : exportez-la en JPEG depuis votre téléphone"); } }
41 + }
42 + const img = sharp(input, { failOn: "error", limitInputPixels: 80_000_000 }).rotate(); // .rotate() applique l'orientation EXIF puis les métadonnées sont retirées
43 + const m = await img.metadata().catch(() => { throw new MediaError("corrupt", "Image illisible ou corrompue"); });
44 + if (!m.width || !m.height || m.width < 200 || m.height < 200) throw new MediaError("too_small", "Image trop petite (200 px minimum)");
45 + const key = `${new Date().toISOString().slice(0, 7)}/${randomBytes(12).toString("hex")}`;
46 + mkdirSync(path.join(STORAGE_DIR, key), { recursive: true });
47 + let width = 0, height = 0, bytes = 0;
48 + for (const w of VARIANTS) {
49 + const out = await img.clone().resize({ width: w, withoutEnlargement: true }).webp({ quality: w >= 1600 ? 84 : 80 }).toBuffer({ resolveWithObject: true }); // pas de withMetadata() → EXIF/GPS/ICC retirés
50 + writeFileSync(mediaPath(key, w), out.data);
51 + if (w === 1600) { width = out.info.width; height = out.info.height; bytes = out.data.length; }
52 + }
53 + return { key, width, height, bytes, mime: real };
54 +}
55 +
56 +export async function countMedia(listingId: string): Promise<number> { const [r] = await db().select({ n: sql<number>`count(*)::int` }).from(listingMedia).where(and(eq(listingMedia.listingId, listingId), sql`${listingMedia.status} <> 'DELETED'`)); return Number(r?.n ?? 0); }
57 +export function removeFiles(key: string) { const dir = path.join(STORAGE_DIR, key); if (existsSync(/*turbopackIgnore: true*/ dir)) rmSync(dir, { recursive: true, force: true }); }
added src/lib/listings/public.ts +53 −0
@@ -0,0 +1,53 @@
1 +// Projection publique d'une annonce directe Toveli → ListingCard / fiche, avec respect du niveau de confidentialité de l'adresse.
2 +import { and, eq, desc, sql, gte, lte, inArray } from "drizzle-orm";
3 +import { db, pgAvailable } from "@/db/client";
4 +import { listings, properties, listingMedia, users, userProfiles } from "@/db/schema";
5 +import type { ListingCard, Market, SearchParams } from "@/lib/types";
6 +import { mediaUrl } from "./media";
7 +import { PROPERTY_TYPE_LABEL } from "./types";
8 +
9 +export const DIRECT_PREFIX = "toveli:";
10 +export function toCategory(pt: string): string { return ({ appartement: "logement", condo: "condo", maison: "maison", jumele: "maison", plex: "plex", chalet: "chalet", terrain: "terrain", commercial: "commercial", autre: "autre" } as Record<string, string>)[pt] ?? "autre"; }
11 +export function publicAddress(p: { canonicalAddress: string | null; privacyLevel: string; city: string | null }): string | null {
12 + if (!p.canonicalAddress) return null;
13 + if (p.privacyLevel === "AREA_ONLY") return null;
14 + if (p.privacyLevel === "STREET_ONLY") return p.canonicalAddress.replace(/^\s*\d+[a-zA-Z]?(\s*-\s*\d+[a-zA-Z]?)?\s+/, "");
15 + return p.canonicalAddress;
16 +}
17 +export function jitter(lat: number, lng: number, level: string): [number, number] { if (level === "EXACT") return [lat, lng]; const r = level === "STREET_ONLY" ? 0.0015 : 0.006; const h = (lat * 1e4 + lng * 1e4) % 1; return [lat + (h - 0.5) * r, lng + ((h * 7) % 1 - 0.5) * r * 1.4]; }
18 +
19 +type Row = { l: typeof listings.$inferSelect; p: typeof properties.$inferSelect; cover: string | null; mediaCount: number; sellerType: string | null; publicName: string | null };
20 +function toCard(r: Row): ListingCard {
21 + const [lat, lng] = r.p.lat != null && r.p.lng != null ? jitter(r.p.lat, r.p.lng, r.p.privacyLevel) : [null, null];
22 + const pro = r.l.sourceType === "DIRECT_AGENT";
23 + return { uid: DIRECT_PREFIX + r.l.slug, propertyId: `toveli:${r.p.id}`, market: r.l.listingType === "RENT" ? "rent" : "sale", price: r.l.priceAmount != null ? Number(r.l.priceAmount) : null, address: publicAddress(r.p) ?? (r.p.privacyLevel === "AREA_ONLY" ? `Secteur ${r.p.city ?? ""}`.trim() : null), city: r.p.city, region: r.p.region, category: toCategory(r.p.propertyType), typeLabel: PROPERTY_TYPE_LABEL[r.p.propertyType] ?? r.p.propertyType, bedrooms: r.p.bedrooms != null ? Number(r.p.bedrooms) : null, bathrooms: r.p.bathrooms != null ? Number(r.p.bathrooms) : null, areaSqft: r.p.areaSqft, lat, lng, image: r.cover ? mediaUrl(r.cover, 800) : null, imageCount: r.mediaCount, connector: pro ? "professionnel" : "proprietaire", sourceId: "toveli", url: null, firstSeen: r.l.publicationAt?.toISOString() ?? r.l.createdAt.toISOString(), estimate: null, ratio: null, verdict: null, confidence: null };
24 +}
25 +const baseSelect = () => db().select({ l: listings, p: properties, cover: sql<string | null>`(select m.storage_key from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY' order by m.position limit 1)`, mediaCount: sql<number>`(select count(*) from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY')::int`, sellerType: userProfiles.sellerType, publicName: userProfiles.publicName }).from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).leftJoin(userProfiles, eq(userProfiles.userId, listings.ownerUserId));
26 +
27 +/** Annonces directes publiées correspondant aux filtres (volume faible → fusionnées en mémoire avec la base canonique). */
28 +export async function searchDirect(p: SearchParams): Promise<ListingCard[]> {
29 + if (!pgAvailable()) return [];
30 + const market: Market = p.market ?? "sale"; const conds = [eq(listings.status, "PUBLISHED"), eq(listings.listingType, market === "rent" ? "RENT" : "SALE")];
31 + if (p.city) conds.push(sql`lower(${properties.city}) = lower(${p.city})`);
32 + if (p.region) conds.push(eq(properties.region, p.region));
33 + if (p.priceMin) conds.push(gte(listings.priceAmount, String(p.priceMin))); if (p.priceMax) conds.push(lte(listings.priceAmount, String(p.priceMax)));
34 + if (p.bedroomsMin) conds.push(gte(properties.bedrooms, String(p.bedroomsMin))); if (p.bathroomsMin) conds.push(gte(properties.bathrooms, String(p.bathroomsMin)));
35 + if (p.areaMin) conds.push(gte(properties.areaSqft, p.areaMin)); if (p.yearMin) conds.push(gte(properties.yearBuilt, p.yearMin));
36 + if (p.bbox) { const [w, s, e, n] = p.bbox; conds.push(sql`${properties.lng} between ${w} and ${e} and ${properties.lat} between ${s} and ${n}`); }
37 + if (p.q) conds.push(sql`(${listings.title} ilike ${"%" + p.q + "%"} or ${properties.canonicalAddress} ilike ${"%" + p.q + "%"} or ${properties.city} ilike ${"%" + p.q + "%"})`);
38 + let rows = await baseSelect().where(and(...conds)).orderBy(desc(listings.publicationAt)).limit(500);
39 + if (p.category?.length) rows = rows.filter((r) => p.category!.includes(toCategory(r.p.propertyType)));
40 + return rows.map(toCard);
41 +}
42 +export async function getDirectBySlug(slug: string) {
43 + if (!pgAvailable()) return null;
44 + const [r] = await baseSelect().where(eq(listings.slug, slug)).limit(1); if (!r) return null;
45 + const media = await db().select().from(listingMedia).where(and(eq(listingMedia.listingId, r.l.id), eq(listingMedia.status, "READY"))).orderBy(listingMedia.position);
46 + const [owner] = r.l.ownerUserId ? await db().select({ id: users.id, displayName: users.displayName, prefs: users.prefs }).from(users).where(eq(users.id, r.l.ownerUserId)).limit(1) : [];
47 + return { ...r, card: toCard(r), media, owner: owner ?? null };
48 +}
49 +export async function directCardsByUids(uids: string[]): Promise<ListingCard[]> {
50 + const slugs = uids.filter((u) => u.startsWith(DIRECT_PREFIX)).map((u) => u.slice(DIRECT_PREFIX.length)); if (!slugs.length || !pgAvailable()) return [];
51 + const rows = await baseSelect().where(and(inArray(listings.slug, slugs), eq(listings.status, "PUBLISHED"))); return rows.map(toCard);
52 +}
53 +export async function directCount(): Promise<{ sale: number; rent: number }> { if (!pgAvailable()) return { sale: 0, rent: 0 }; try { const r = await db().select({ t: listings.listingType, n: sql<number>`count(*)::int` }).from(listings).where(eq(listings.status, "PUBLISHED")).groupBy(listings.listingType); return { sale: r.find((x) => x.t === "SALE")?.n ?? 0, rent: r.find((x) => x.t === "RENT")?.n ?? 0 }; } catch { return { sale: 0, rent: 0 }; } }
added src/lib/search.ts +24 −0
@@ -0,0 +1,24 @@
1 +// Recherche unifiée : base canonique (annonces agrégées) + publications directes Toveli, un seul contrat de résultat.
2 +import { searchListings as searchAggregated, mapPoints as aggPoints } from "./queries";
3 +import { searchDirect } from "./listings/public";
4 +import type { SearchParams, SearchResult, ListingCard } from "./types";
5 +
6 +function sortCards(items: ListingCard[], sort: SearchParams["sort"]) {
7 + const k = sort ?? "recent";
8 + return items.sort((a, b) => k === "price_asc" ? (a.price ?? 0) - (b.price ?? 0) : k === "price_desc" ? (b.price ?? 0) - (a.price ?? 0) : k === "area_desc" ? (b.areaSqft ?? 0) - (a.areaSqft ?? 0) : k === "deal" ? ((b.ratio ?? -1) - (a.ratio ?? -1)) : (Date.parse(b.firstSeen ?? "") || 0) - (Date.parse(a.firstSeen ?? "") || 0));
9 +}
10 +export async function unifiedSearch(p: SearchParams): Promise<SearchResult> {
11 + const limit = Math.min(Math.max(p.limit ?? 24, 1), 500), offset = Math.max(p.offset ?? 0, 0);
12 + const direct = await searchDirect(p).catch(() => [] as ListingCard[]);
13 + if (!direct.length) return searchAggregated(p);
14 + // fusion : on récupère la fenêtre agrégée élargie puis on intercale les annonces directes selon le tri
15 + const agg = searchAggregated({ ...p, limit: Math.min(limit + direct.length, 500), offset: Math.max(0, offset - direct.length) });
16 + const merged = sortCards([...direct, ...agg.items], p.sort);
17 + const seen = new Set<string>(); const items = merged.filter((c) => (seen.has(c.uid) ? false : (seen.add(c.uid), true)));
18 + const start = offset < direct.length ? offset : 0;
19 + return { items: items.slice(start, start + limit), total: agg.total + direct.length, limit, offset };
20 +}
21 +export async function unifiedPoints(p: SearchParams) {
22 + const direct = await searchDirect({ ...p, limit: 3000 }).catch(() => [] as ListingCard[]);
23 + return [...direct.filter((i) => i.lat != null && i.lng != null && i.price != null).map((i) => ({ uid: i.uid, lat: i.lat!, lng: i.lng!, price: i.price!, category: i.category, propertyId: i.propertyId })), ...aggPoints(p)];
24 +}
added src/middleware.ts +7 −0
@@ -0,0 +1,7 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +/** Transmet le chemin demandé aux layouts serveur (redirection « next » après connexion) et pose des en-têtes de sécurité de base. */
3 +export function middleware(req: NextRequest) {
4 + const h = new Headers(req.headers); h.set("x-pathname", req.nextUrl.pathname + req.nextUrl.search);
5 + return NextResponse.next({ request: { headers: h } });
6 +}
7 +export const config = { matcher: ["/membre/:path*", "/admin/:path*"] };
added tests/db/auth.test.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
2 +import { resetDb, closeDb } from "./setup";
3 +import { requestCode, verifyCode } from "../../src/lib/auth/otp";
4 +import { lastMockMail, setTransportForTests, mockTransport } from "../../src/lib/auth/email";
5 +import { createSession } from "../../src/lib/auth/session";
6 +import { db } from "../../src/db/client";
7 +import { sessions, otpChallenges, users } from "../../src/db/schema";
8 +import { eq } from "drizzle-orm";
9 +import { normalizeEmail, generateOtp, otpDigest, sessionDigest } from "../../src/lib/auth/crypto";
10 +
11 +const codeFromMail = () => lastMockMail.set?.text.match(/\b(\d{6})\b/)?.[1] ?? "";
12 +describe("connexion par code courriel (Resend simulé)", () => {
13 + beforeAll(() => setTransportForTests(mockTransport)); afterAll(closeDb); beforeEach(resetDb);
14 + it("normalise les adresses sans supprimer points ni +", () => { expect(normalizeEmail(" Marie.T+toveli@Exemple.CA ")).toBe("marie.t+toveli@exemple.ca"); expect(normalizeEmail("pas une adresse")).toBeNull(); });
15 + it("génère un code à 6 chiffres et n'en stocke que l'empreinte HMAC", async () => {
16 + const r = await requestCode("test1@exemple.ca", "1.2.3.4"); expect(r.ok).toBe(true); if (!r.ok) return;
17 + const code = codeFromMail(); expect(code).toMatch(/^\d{6}$/);
18 + const [ch] = await db().select().from(otpChallenges).where(eq(otpChallenges.id, r.challengeId)); expect(ch.secretDigest).not.toContain(code); expect(ch.secretDigest).toBe(otpDigest(code, ch.id));
19 + expect(generateOtp()).toMatch(/^\d{6}$/);
20 + });
21 + it("crée le compte au premier succès, ouvre une session, refuse la réutilisation du code", async () => {
22 + const r = await requestCode("test2@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error();
23 + const code = codeFromMail();
24 + const bad = await verifyCode(r.challengeId, code, "1.2.3.4", false); expect(bad.ok).toBe(false); if (!bad.ok) expect(bad.reason).toBe("terms_required"); // nouveau compte sans acceptation : code non consommé
25 + const ok = await verifyCode(r.challengeId, code, "1.2.3.4", true); expect(ok.ok).toBe(true); if (!ok.ok) return; expect(ok.created).toBe(true);
26 + const s = await createSession(ok.userId, { ip: "1.2.3.4", device: "test" }); const [row] = await db().select().from(sessions).where(eq(sessions.tokenDigest, sessionDigest(s.token))); expect(row).toBeTruthy(); expect(row.tokenDigest).not.toBe(s.token);
27 + const again = await verifyCode(r.challengeId, code, "1.2.3.4", true); expect(again.ok).toBe(false); // usage unique
28 + });
29 + it("refuse code faux (×5 → verrou), code expiré et renvoi avant 60 s", async () => {
30 + const r = await requestCode("test3@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error();
31 + for (let i = 0; i < 5; i++) { const v = await verifyCode(r.challengeId, "000000", "1.2.3.4", true); expect(v.ok).toBe(false); }
32 + const sixth = await verifyCode(r.challengeId, codeFromMail(), "1.2.3.4", true); expect(sixth.ok).toBe(false); if (!sixth.ok) expect(["too_many_attempts", "invalid"]).toContain(sixth.reason);
33 + const cd = await requestCode("test3@exemple.ca", "1.2.3.4"); expect(cd.ok).toBe(false); if (!cd.ok) expect(cd.reason).toBe("cooldown");
34 + const r2 = await requestCode("test4@exemple.ca", "1.2.3.4"); if (!r2.ok) throw new Error();
35 + await db().update(otpChallenges).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(otpChallenges.id, r2.challengeId));
36 + const exp = await verifyCode(r2.challengeId, codeFromMail(), "1.2.3.4", true); expect(exp.ok).toBe(false); if (!exp.ok) expect(exp.reason).toBe("expired");
37 + });
38 + it("double soumission concurrente : une seule réussit", async () => {
39 + const r = await requestCode("test5@exemple.ca", "1.2.3.4"); if (!r.ok) throw new Error(); const code = codeFromMail();
40 + const results = await Promise.all([verifyCode(r.challengeId, code, "1.2.3.4", true), verifyCode(r.challengeId, code, "1.2.3.4", true), verifyCode(r.challengeId, code, "1.2.3.4", true)]);
41 + expect(results.filter((x) => x.ok).length).toBe(1);
42 + expect((await db().select().from(users)).length).toBe(1);
43 + });
44 + it("limite les demandes par adresse (5/h) sans révéler l'existence du compte", async () => {
45 + let limited = 0;
46 + for (let i = 0; i < 7; i++) { await db().update(otpChallenges).set({ createdAt: new Date(Date.now() - 120000) }); const r = await requestCode("spam@exemple.ca", `9.9.9.${i}`); if (!r.ok && r.reason === "rate_limited") limited++; }
47 + expect(limited).toBeGreaterThan(0);
48 + });
49 +});
added tests/db/listings.test.ts +57 −0
@@ -0,0 +1,57 @@
1 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
2 +import { resetDb, closeDb } from "./setup";
3 +import { setTransportForTests, mockTransport } from "../../src/lib/auth/email";
4 +import { db } from "../../src/db/client";
5 +import { users, userProfiles, listingMedia } from "../../src/db/schema";
6 +import { createListing, updateListing, transition, attest, getListingFull, ConflictError } from "../../src/lib/listings/service";
7 +import { openInquiry, getThread, reply, requestViewing, updateViewing } from "../../src/lib/inquiries/service";
8 +import { searchDirect } from "../../src/lib/listings/public";
9 +import { sanitizeBody } from "../../src/lib/inquiries/service";
10 +
11 +async function mkUser(email: string) { const [u] = await db().insert(users).values({ email, emailVerifiedAt: new Date() }).returning(); await db().insert(userProfiles).values({ userId: u.id }); return u; }
12 +async function fakeMedia(listingId: string) { await db().insert(listingMedia).values({ listingId, storageKey: "2026-10/abcdefabcdefabcdefabcdef", status: "READY", scanStatus: "CLEAN", position: 0 }); }
13 +const base = { address: "123 rue Test", city: "Québec", lat: 46.81, lng: -71.21, bedrooms: 3, bathrooms: 1, areaSqft: 1200, yearBuilt: 1990, title: "Beau logement lumineux près du parc", description: "Grand logement rénové, très lumineux, près des services, du parc et des transports. Disponible rapidement, idéal pour une famille." };
14 +
15 +describe("publications directes : un compte, plusieurs annonces, isolation", () => {
16 + beforeAll(() => setTransportForTests(mockTransport)); afterAll(closeDb); beforeEach(resetDb);
17 + it("publie un logement à louer et une maison à vendre depuis le même compte ; visibles dans la recherche", async () => {
18 + const u = await mkUser("owner@exemple.ca");
19 + const rent = await createListing(u.id, { listingType: "RENT", propertyType: "appartement", ...base, priceAmount: 1450 });
20 + const sale = await createListing(u.id, { listingType: "SALE", propertyType: "maison", ...base, title: "Maison familiale avec grand terrain boisé", priceAmount: 489000 });
21 + const r0 = await transition(rent.id, u.id, [], "publish"); expect(r0.problems).toContain("Au moins une photo requise"); expect(r0.problems).toContain("Attestation du droit de publier manquante");
22 + await fakeMedia(rent.id); await fakeMedia(sale.id); await attest(rent.id, u.id, []); await attest(sale.id, u.id, []);
23 + expect((await transition(rent.id, u.id, [], "publish")).listing.status).toBe("PUBLISHED");
24 + expect((await transition(sale.id, u.id, [], "publish")).listing.status).toBe("PUBLISHED");
25 + expect((await searchDirect({ market: "rent", city: "Québec" })).map((c) => c.uid)).toContain(`toveli:${rent.slug}`);
26 + expect((await searchDirect({ market: "sale", priceMax: 500000 })).map((c) => c.uid)).toContain(`toveli:${sale.slug}`);
27 + await transition(rent.id, u.id, [], "pause"); expect((await searchDirect({ market: "rent" })).length).toBe(0);
28 + await transition(rent.id, u.id, [], "resume"); await transition(rent.id, u.id, [], "mark-rented"); expect((await getListingFull(rent.id))!.status).toBe("RENTED");
29 + });
30 + it("un autre utilisateur ne peut ni modifier, ni publier, ni lire les messages", async () => {
31 + const a = await mkUser("a@exemple.ca"), b = await mkUser("b@exemple.ca");
32 + const l = await createListing(a.id, { listingType: "RENT", propertyType: "condo", ...base, priceAmount: 1800 });
33 + await expect(updateListing(l.id, b.id, [], { title: "pirate" })).rejects.toThrow(); await expect(transition(l.id, b.id, [], "publish")).rejects.toThrow();
34 + await fakeMedia(l.id); await attest(l.id, a.id, []); await transition(l.id, a.id, [], "publish");
35 + const inq = await openInquiry(l.id, b.id, "INFO", "Bonjour, est-ce encore disponible ? Mon courriel b@x.ca et mon numéro 418 555-0199.");
36 + const c = await mkUser("c@exemple.ca"); await expect(getThread(inq.id, c.id)).rejects.toThrow(); await expect(reply(inq.id, c.id, "intrus")).rejects.toThrow();
37 + const t = await getThread(inq.id, a.id); expect(t.messages[0].body).toContain("[courriel masqué]"); expect(t.messages[0].body).toContain("[téléphone masqué]");
38 + await expect(openInquiry(l.id, a.id, "INFO", "je m'écris à moi-même ?")).rejects.toThrow();
39 + });
40 + it("concurrence optimiste et spam → vérification humaine", async () => {
41 + const a = await mkUser("d@exemple.ca"); const l = await createListing(a.id, { listingType: "RENT", propertyType: "appartement", ...base, priceAmount: 900 });
42 + await updateListing(l.id, a.id, [], { title: "Titre un", version: 1 });
43 + await expect(updateListing(l.id, a.id, [], { title: "Titre périmé", version: 1 })).rejects.toThrow(ConflictError);
44 + await fakeMedia(l.id); await attest(l.id, a.id, []);
45 + await updateListing(l.id, a.id, [], { description: "Envoyez un dépôt par Western Union et les clés arrivent par la poste. Contact WhatsApp. Grand logement lumineux." });
46 + expect((await transition(l.id, a.id, [], "publish")).listing.status).toBe("PENDING_REVIEW");
47 + });
48 + it("visites : créneaux, confirmation par le propriétaire seulement, conflit à 30 min", async () => {
49 + const a = await mkUser("e@exemple.ca"), b = await mkUser("f@exemple.ca"), c = await mkUser("g@exemple.ca");
50 + const l = await createListing(a.id, { listingType: "SALE", propertyType: "maison", ...base, priceAmount: 400000 }); await fakeMedia(l.id); await attest(l.id, a.id, []); await transition(l.id, a.id, [], "publish");
51 + const slot = new Date(Date.now() + 2 * 86400000).toISOString();
52 + const v1 = await requestViewing(l.id, b.id, [slot]); const v2 = await requestViewing(l.id, c.id, [slot]);
53 + await expect(updateViewing(v1.id, b.id, "confirm", slot)).rejects.toThrow();
54 + await updateViewing(v1.id, a.id, "confirm", slot); await expect(updateViewing(v2.id, a.id, "confirm", slot)).rejects.toThrow(/Conflit/);
55 + });
56 + it("sanitize", () => { expect(sanitizeBody("voir https://evil.example/x et https://www.toveli.ca/annonce/1")).toBe("voir [lien retiré] et https://www.toveli.ca/annonce/1"); });
57 +});
added tests/db/setup.ts +12 −0
@@ -0,0 +1,12 @@
1 +// Base PostgreSQL éphémère pour les tests d'intégration : toveli_test (migrations appliquées par `pnpm db:migrate` avec DATABASE_URL=…/toveli_test).
2 +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgresql://toveli_app:toveli_dev@127.0.0.1:5432/toveli_test";
3 +process.env.OTP_HMAC_SECRET = "test-otp-secret-0123456789abcdef";
4 +process.env.SESSION_SECRET = "test-session-secret-0123456789abcdef";
5 +process.env.APP_BASE_URL = "http://localhost:3000";
6 +process.env.STORAGE_DIR = "/tmp/toveli-test-media";
7 +import { sql } from "drizzle-orm";
8 +import { db, rawSql } from "../../src/db/client";
9 +export async function resetDb() {
10 + await db().execute(sql`truncate users, user_profiles, otp_challenges, sessions, user_roles, properties, listings, listing_media, listing_versions, listing_claims, favorites, saved_searches, email_deliveries, inquiries, messages, viewing_requests, reports, moderation_actions, audit_events, rate_limits restart identity cascade`);
11 +}
12 +export async function closeDb() { await rawSql().end({ timeout: 2 }); }
modified vitest.config.ts +2 −1
@@ -1,2 +1,3 @@
1 1 import { defineConfig } from "vitest/config";
2 −export default defineConfig({ test: { include: ["tests/**/*.test.ts"], environment: "node" } });
2 +import path from "node:path";
3 +export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "src") } }, test: { include: ["tests/**/*.test.ts"], environment: "node", fileParallelism: false, testTimeout: 30000 } });
3 4