TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1import { and, eq, or, desc, sql, asc } from "drizzle-orm";2import { db } from "@/db/client";3import { inquiries, messages, viewingRequests, listings, properties, users, listingMedia } from "@/db/schema";4import { AuthError } from "@/lib/auth/session";5import { audit } from "@/lib/auth/audit";6import { sendMail, notifyMail } from "@/lib/auth/email";7import { mediaUrl } from "@/lib/listings/media";89const 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). */11export 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}14async 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}1920export 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}34export 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}38export 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'existence41 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}48export 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}56export 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}6263export 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}73export 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}76export 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}84