import { and, eq, sql, desc, inArray } from "drizzle-orm"; import { customAlphabet } from "nanoid"; import { db } from "@/db/client"; import { listings, properties, listingMedia, listingVersions } from "@/db/schema"; import { ListingInput, publishProblems, spamFlags, type ListingInputT } from "./types"; import { normRegion, displayCity } from "@/lib/normalize"; import { slugify } from "@/lib/format"; import { audit } from "@/lib/auth/audit"; import { AuthError } from "@/lib/auth/session"; const nano = customAlphabet("abcdefghjkmnpqrstuvwxyz23456789", 8); export class ConflictError extends Error { constructor(msg = "Version périmée : rechargez l'annonce") { super(msg); } } export type ListingFull = typeof listings.$inferSelect & { property: typeof properties.$inferSelect; media: (typeof listingMedia.$inferSelect)[] }; export async function createListing(ownerId: string, input: ListingInputT): Promise { const d = db(); const i = ListingInput.parse(input); 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(); 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(); await audit("listing.create", { actorId: ownerId, objectType: "listing", objectId: l.id, meta: { listingType: l.listingType } }); return { ...l, property: p, media: [] }; } export async function getListingFull(id: string): Promise { const d = db(); 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); if (!row) return null; const media = await d.select().from(listingMedia).where(and(eq(listingMedia.listingId, id), eq(listingMedia.status, "READY"))).orderBy(listingMedia.position); return { ...row.l, property: row.p, media }; } export 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; } export 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"); } export async function updateListing(id: string, actorId: string, roles: string[], input: ListingInputT): Promise { 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); if (["ARCHIVED", "SOLD", "RENTED"].includes(cur.status)) throw new AuthError(409, "Annonce clôturée : réactivez-la d'abord"); if (i.version != null && i.version !== cur.version) throw new ConflictError(); return d.transaction(async (tx) => { await tx.insert(listingVersions).values({ listingId: id, version: cur.version, actorId, snapshot: { listing: { ...cur, property: undefined, media: undefined }, property: cur.property } }); const pset: Partial = {}; if (i.propertyType !== undefined) pset.propertyType = i.propertyType; if (i.address !== undefined) pset.canonicalAddress = i.address; if (i.unit !== undefined) pset.unit = i.unit; if (i.city !== undefined) { pset.city = i.city ? displayCity(i.city) : null; pset.region = i.region ?? normRegion(null, i.city); } if (i.postalCode !== undefined) pset.postalCode = i.postalCode?.toUpperCase().replace(/\s/g, "") ?? null; if (i.lat !== undefined) pset.lat = i.lat; if (i.lng !== undefined) pset.lng = i.lng; if (i.roleUnitId !== undefined) { pset.roleUnitId = i.roleUnitId; pset.geoSource = i.roleUnitId ? `role_unit:${i.roleUnitId}` : "manual"; } if (i.privacyLevel !== undefined) pset.privacyLevel = i.privacyLevel; 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; if (i.areaSqft !== undefined) pset.areaSqft = i.areaSqft; if (i.lotSqft !== undefined) pset.lotSqft = i.lotSqft; if (i.yearBuilt !== undefined) pset.yearBuilt = i.yearBuilt; if (Object.keys(pset).length) await tx.update(properties).set(pset).where(eq(properties.id, cur.propertyId)); const lset: Partial = { version: cur.version + 1, updatedAt: new Date() }; if (i.listingType !== undefined) { lset.listingType = i.listingType; lset.rentalPeriod = i.listingType === "RENT" ? "MONTH" : null; } if (i.sourceType !== undefined) lset.sourceType = i.sourceType; for (const k of ["title", "description", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (i[k] !== undefined) (lset as Record)[k] = i[k]; if (i.priceAmount !== undefined) lset.priceAmount = i.priceAmount != null ? String(i.priceAmount) : null; // Toute modification substantielle d'une annonce publiée repasse par la vérification automatique 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(" ; "); } } await tx.update(listings).set(lset).where(eq(listings.id, id)); await audit("listing.update", { actorId, objectType: "listing", objectId: id, meta: { fields: Object.keys(i) } }); return (await getListingFull(id))!; }); } export 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 }); } export type Transition = "publish" | "pause" | "resume" | "archive" | "mark-sold" | "mark-rented" | "reopen"; const ALLOWED: Record = { 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"] }; export async function transition(id: string, actorId: string, roles: string[], t: Transition): Promise<{ listing: ListingFull; problems: string[] }> { const cur = await getListingFull(id); if (!cur) throw new AuthError(404, "Annonce introuvable"); assertOwner(cur, actorId, roles); if (!ALLOWED[t].includes(cur.status)) throw new AuthError(409, `Transition « ${t} » impossible depuis l'état ${cur.status}`); const set: Partial = { updatedAt: new Date(), version: cur.version + 1 }; let problems: string[] = []; if (t === "publish") { 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 }); if (problems.length) return { listing: cur, problems }; const flags = spamFlags(cur); if (flags.length || cur.moderationState === "FLAGGED") { set.status = "PENDING_REVIEW"; set.moderationState = "FLAGGED"; set.moderationNote = flags.join(" ; ") || cur.moderationNote; } 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); } } if (t === "pause") set.status = "PAUSED"; if (t === "resume") { set.status = "PUBLISHED"; set.expiresAt = new Date(Date.now() + 90 * 86400000); } if (t === "archive") set.status = "ARCHIVED"; if (t === "mark-sold") set.status = "SOLD"; if (t === "mark-rented") set.status = "RENTED"; if (t === "reopen") set.status = "DRAFT"; await db().update(listings).set(set).where(eq(listings.id, id)); await audit(`listing.${t}`, { actorId, objectType: "listing", objectId: id, meta: { from: cur.status, to: set.status } }); return { listing: (await getListingFull(id))!, problems }; } export async function myListings(ownerId: string) { const d = db(); const rows = await d.select({ l: listings, p: properties, mediaCount: sql`(select count(*) from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY')::int`, cover: sql`(select m.storage_key from ${listingMedia} m where m.listing_id = ${listings.id} and m.status = 'READY' order by m.position limit 1)` }) .from(listings).innerJoin(properties, eq(properties.id, listings.propertyId)).where(eq(listings.ownerUserId, ownerId)).orderBy(desc(listings.updatedAt)); return rows; } export 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 }); } export async function incrementViews(ids: string[]) { if (ids.length) await db().update(listings).set({ viewsCount: sql`${listings.viewsCount} + 1` }).where(inArray(listings.id, ids)); }