TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1import { and, eq, sql, desc, inArray } from "drizzle-orm";2import { customAlphabet } from "nanoid";3import { db } from "@/db/client";4import { listings, properties, listingMedia, listingVersions } from "@/db/schema";5import { ListingInput, publishProblems, spamFlags, type ListingInputT } from "./types";6import { normRegion, displayCity } from "@/lib/normalize";7import { slugify } from "@/lib/format";8import { audit } from "@/lib/auth/audit";9import { AuthError } from "@/lib/auth/session";1011const nano = customAlphabet("abcdefghjkmnpqrstuvwxyz23456789", 8);12export class ConflictError extends Error { constructor(msg = "Version périmée : rechargez l'annonce") { super(msg); } }1314export type ListingFull = typeof listings.$inferSelect & { property: typeof properties.$inferSelect; media: (typeof listingMedia.$inferSelect)[] };1516export async function createListing(ownerId: string, input: ListingInputT): Promise<ListingFull> {17 const d = db(); const i = ListingInput.parse(input);18 const [p] = await d.insert(properties).values({ propertyType: i.propertyType ?? "autre", canonicalAddress: i.address ?? null, unit: i.unit ?? null, city: i.city ? displayCity(i.city) : null, region: i.region ?? normRegion(null, i.city), postalCode: i.postalCode?.toUpperCase().replace(/\s/g, "") ?? null, lat: i.lat ?? null, lng: i.lng ?? null, roleUnitId: i.roleUnitId ?? null, geoSource: i.roleUnitId ? `role_unit:${i.roleUnitId}` : i.lat ? "manual" : null, privacyLevel: i.privacyLevel ?? "EXACT", bedrooms: i.bedrooms != null ? String(i.bedrooms) : null, bathrooms: i.bathrooms != null ? String(i.bathrooms) : null, areaSqft: i.areaSqft ?? null, lotSqft: i.lotSqft ?? null, yearBuilt: i.yearBuilt ?? null }).returning();19 const [l] = await d.insert(listings).values({ slug: nano(), propertyId: p.id, ownerUserId: ownerId, listingType: i.listingType ?? "SALE", sourceType: i.sourceType ?? "DIRECT_OWNER", title: i.title ?? null, description: i.description ?? null, priceAmount: i.priceAmount != null ? String(i.priceAmount) : null, rentalPeriod: (i.listingType ?? "SALE") === "RENT" ? "MONTH" : null, availabilityDate: i.availabilityDate ?? null, furnished: i.furnished ?? null, petsPolicy: i.petsPolicy ?? null, smokingPolicy: i.smokingPolicy ?? null, parking: i.parking ?? null, leaseTerms: i.leaseTerms ?? null, inclusions: i.inclusions ?? [], features: i.features ?? {}, wizardStep: i.wizardStep ?? 1 }).returning();20 await audit("listing.create", { actorId: ownerId, objectType: "listing", objectId: l.id, meta: { listingType: l.listingType } });21 return { ...l, property: p, media: [] };22}2324export async function getListingFull(id: string): Promise<ListingFull | null> {25 const d = db();26 const [row] = await d.select({ l: listings, p: properties }).from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.id, id)).limit(1);27 if (!row) return null;28 const media = await d.select().from(listingMedia).where(and(eq(listingMedia.listingId, id), eq(listingMedia.status, "READY"))).orderBy(listingMedia.position);29 return { ...row.l, property: row.p, media };30}31export async function getListingBySlug(slug: string) { const d = db(); const [r] = await d.select({ id: listings.id }).from(listings).where(eq(listings.slug, slug)).limit(1); return r ? getListingFull(r.id) : null; }3233export function assertOwner(l: ListingFull, userId: string, roles: string[] = []) { if (l.ownerUserId !== userId && !roles.includes("ADMIN") && !roles.includes("MODERATOR")) throw new AuthError(403, "Cette annonce ne vous appartient pas"); }3435export async function updateListing(id: string, actorId: string, roles: string[], input: ListingInputT): Promise<ListingFull> {36 const d = db(); const i = ListingInput.parse(input); const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles);37 if (["ARCHIVED", "SOLD", "RENTED"].includes(cur.status)) throw new AuthError(409, "Annonce clôturée : réactivez-la d'abord");38 if (i.version != null && i.version !== cur.version) throw new ConflictError();39 return d.transaction(async (tx) => {40 await tx.insert(listingVersions).values({ listingId: id, version: cur.version, actorId, snapshot: { listing: { ...cur, property: undefined, media: undefined }, property: cur.property } });41 const pset: Partial<typeof properties.$inferInsert> = {};42 if (i.propertyType !== undefined) pset.propertyType = i.propertyType;43 if (i.address !== undefined) pset.canonicalAddress = i.address; if (i.unit !== undefined) pset.unit = i.unit;44 if (i.city !== undefined) { pset.city = i.city ? displayCity(i.city) : null; pset.region = i.region ?? normRegion(null, i.city); }45 if (i.postalCode !== undefined) pset.postalCode = i.postalCode?.toUpperCase().replace(/\s/g, "") ?? null;46 if (i.lat !== undefined) pset.lat = i.lat; if (i.lng !== undefined) pset.lng = i.lng;47 if (i.roleUnitId !== undefined) { pset.roleUnitId = i.roleUnitId; pset.geoSource = i.roleUnitId ? `role_unit:${i.roleUnitId}` : "manual"; }48 if (i.privacyLevel !== undefined) pset.privacyLevel = i.privacyLevel;49 if (i.bedrooms !== undefined) pset.bedrooms = i.bedrooms != null ? String(i.bedrooms) : null; if (i.bathrooms !== undefined) pset.bathrooms = i.bathrooms != null ? String(i.bathrooms) : null;50 if (i.areaSqft !== undefined) pset.areaSqft = i.areaSqft; if (i.lotSqft !== undefined) pset.lotSqft = i.lotSqft; if (i.yearBuilt !== undefined) pset.yearBuilt = i.yearBuilt;51 if (Object.keys(pset).length) await tx.update(properties).set(pset).where(eq(properties.id, cur.propertyId));52 const lset: Partial<typeof listings.$inferInsert> = { version: cur.version + 1, updatedAt: new Date() };53 if (i.listingType !== undefined) { lset.listingType = i.listingType; lset.rentalPeriod = i.listingType === "RENT" ? "MONTH" : null; }54 if (i.sourceType !== undefined) lset.sourceType = i.sourceType;55 for (const k of ["title", "description", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (i[k] !== undefined) (lset as Record<string, unknown>)[k] = i[k];56 if (i.priceAmount !== undefined) lset.priceAmount = i.priceAmount != null ? String(i.priceAmount) : null;57 // Toute modification substantielle d'une annonce publiée repasse par la vérification automatique58 if (cur.status === "PUBLISHED" && (i.title !== undefined || i.description !== undefined || i.priceAmount !== undefined)) { const flags = spamFlags({ title: i.title ?? cur.title, description: i.description ?? cur.description, priceAmount: i.priceAmount ?? cur.priceAmount, listingType: i.listingType ?? cur.listingType }); if (flags.length) { lset.status = "PENDING_REVIEW"; lset.moderationState = "FLAGGED"; lset.moderationNote = flags.join(" ; "); } }59 await tx.update(listings).set(lset).where(eq(listings.id, id));60 await audit("listing.update", { actorId, objectType: "listing", objectId: id, meta: { fields: Object.keys(i) } });61 return (await getListingFull(id))!;62 });63}6465export async function attest(id: string, actorId: string, roles: string[]) { const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles); await db().update(listings).set({ attestedAt: new Date(), updatedAt: new Date() }).where(eq(listings.id, id)); await audit("listing.attest", { actorId, objectType: "listing", objectId: id }); }6667export type Transition = "publish" | "pause" | "resume" | "archive" | "mark-sold" | "mark-rented" | "reopen";68const ALLOWED: Record<Transition, string[]> = { publish: ["DRAFT", "PAUSED", "REJECTED", "PENDING_REVIEW"], pause: ["PUBLISHED"], resume: ["PAUSED"], archive: ["DRAFT", "PUBLISHED", "PAUSED", "REJECTED", "PENDING_REVIEW", "SOLD", "RENTED"], "mark-sold": ["PUBLISHED", "PAUSED"], "mark-rented": ["PUBLISHED", "PAUSED"], reopen: ["ARCHIVED", "SOLD", "RENTED"] };6970export async function transition(id: string, actorId: string, roles: string[], t: Transition): Promise<{ listing: ListingFull; problems: string[] }> {71 const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles);72 if (!ALLOWED[t].includes(cur.status)) throw new AuthError(409, `Transition « ${t} » impossible depuis l'état ${cur.status}`);73 const set: Partial<typeof listings.$inferInsert> = { updatedAt: new Date(), version: cur.version + 1 };74 let problems: string[] = [];75 if (t === "publish") {76 problems = publishProblems({ listingType: cur.listingType, propertyType: cur.property.propertyType, priceAmount: cur.priceAmount, title: cur.title, description: cur.description, city: cur.property.city, lat: cur.property.lat, mediaCount: cur.media.length, attestedAt: cur.attestedAt, bedrooms: cur.property.bedrooms, areaSqft: cur.property.areaSqft });77 if (problems.length) return { listing: cur, problems };78 const flags = spamFlags(cur);79 if (flags.length || cur.moderationState === "FLAGGED") { set.status = "PENDING_REVIEW"; set.moderationState = "FLAGGED"; set.moderationNote = flags.join(" ; ") || cur.moderationNote; }80 else { set.status = "PUBLISHED"; set.moderationState = cur.moderationState === "HUMAN_OK" ? "HUMAN_OK" : "AUTO_OK"; set.publicationAt = cur.publicationAt ?? new Date(); set.expiresAt = new Date(Date.now() + 90 * 86400000); }81 }82 if (t === "pause") set.status = "PAUSED";83 if (t === "resume") { set.status = "PUBLISHED"; set.expiresAt = new Date(Date.now() + 90 * 86400000); }84 if (t === "archive") set.status = "ARCHIVED";85 if (t === "mark-sold") set.status = "SOLD";86 if (t === "mark-rented") set.status = "RENTED";87 if (t === "reopen") set.status = "DRAFT";88 await db().update(listings).set(set).where(eq(listings.id, id));89 await audit(`listing.${t}`, { actorId, objectType: "listing", objectId: id, meta: { from: cur.status, to: set.status } });90 return { listing: (await getListingFull(id))!, problems };91}9293export async function myListings(ownerId: string) {94 const d = db();95 const rows = await d.select({ l: listings, p: properties, mediaCount: sql<number>`(select count(*) from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY')::int`, 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)` })96 .from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.ownerUserId, ownerId)).orderBy(desc(listings.updatedAt));97 return rows;98}99export async function deleteDraft(id: string, actorId: string, roles: string[]) { const cur = await getListingFull(id); if (!cur) return; assertOwner(cur, actorId, roles); if (cur.status !== "DRAFT") throw new AuthError(409, "Seuls les brouillons peuvent être supprimés ; archivez l'annonce sinon"); await db().delete(listings).where(eq(listings.id, id)); await db().delete(properties).where(eq(properties.id, cur.propertyId)); await audit("listing.delete_draft", { actorId, objectType: "listing", objectId: id }); }100export async function incrementViews(ids: string[]) { if (ids.length) await db().update(listings).set({ viewsCount: sql`${listings.viewsCount} + 1` }).where(inArray(listings.id, ids)); }101