From 360522f30a71f4a22a93f1943d250eb56ac8bc7c Mon Sep 17 00:00:00 2001 From: Bastien COIGNOUX Date: Mon, 4 May 2026 22:11:46 +0200 Subject: [PATCH] recherche --- .cursorrules | 4 +- AGENTS.md | 3 +- app/app/(tabs)/recherche.tsx | 4 +- app/components/recherche/VeilleAgentsTab.tsx | 433 ++++++++++++++++++ app/services/agentsApi.ts | 66 +++ app/types/collections.ts | 66 +++ docker/docker-compose.dev.yml | 2 +- pocketbase/pb_hooks/agents_veille.pb.js | 333 ++++++++++++++ ...752000000_fix_rules_empty_string_quotes.js | 6 + .../1760000000_agents_veille_collections.js | 224 +++++++++ 10 files changed, 1137 insertions(+), 4 deletions(-) create mode 100644 app/components/recherche/VeilleAgentsTab.tsx create mode 100644 app/services/agentsApi.ts create mode 100644 pocketbase/pb_hooks/agents_veille.pb.js create mode 100644 pocketbase/pb_migrations/1760000000_agents_veille_collections.js diff --git a/.cursorrules b/.cursorrules index 818ecdc..88f4bd1 100644 --- a/.cursorrules +++ b/.cursorrules @@ -25,7 +25,9 @@ export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL); ## Collections PocketBase (toutes créées via migration) etapes_pipeline, contacts, biens, analyses_financieres, visites, taches, notes_biens, documents_biens, devis_travaux, -analyses_secteur, notes_prospection, grille_prix +analyses_secteur, notes_prospection, grille_prix, +recherches_sauvegardees, alertes_recherche, annonces_veille, flux_sources, +transactions_secteur, courriers_immobilier ## Règles de code - TypeScript strict, jamais de any diff --git a/AGENTS.md b/AGENTS.md index 483340e..ee30a3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - [x] Module Agenda (tâches, snooze, création modal) - [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour) - [x] Module Recherche & Analyse marché (onglet Recherche : Secteur / Opportunités / Grille de prix + fiche bien) +- [x] Multi-agents MVP (migration `1760000000`, hooks `agents_veille.pb.js`, onglet **Veille & agents** : recherches, alertes, annonces veille, transactions secteur, courriers + routes `/api/mdb/agent-*`) ## Roadmap — Agrégation type MoteurImmo & agents IA @@ -43,7 +44,7 @@ Référence produit : [moteurimmo.fr](https://moteurimmo.fr/) (agrégation multi - Admin : http://localhost:8090/_/ (admin@mdb.fr) - Binaire : /usr/local/bin/pocketbase - Données : /pb_data -- Hooks JS : volume `pb_hooks` → `--hooksDir=/pb_hooks` (image muchobien) +- Hooks JS : volume `pb_hooks` monté sur `/pb_hooks` ; **docker-compose.dev** : `command: --dir=/pb_data --hooksDir=/pb_hooks` pour charger les routes `/api/mdb/*` - Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase) - OS : Windows Git Bash (MSYS_NO_PATHCONV=1) - PocketBase : v0.23+ diff --git a/app/app/(tabs)/recherche.tsx b/app/app/(tabs)/recherche.tsx index 6230020..64a7a13 100644 --- a/app/app/(tabs)/recherche.tsx +++ b/app/app/(tabs)/recherche.tsx @@ -5,9 +5,10 @@ import { Pressable, Text, View } from 'react-native'; import { GrillePrixTab } from '@/components/recherche/GrillePrixTab'; import { OpportunitesTab } from '@/components/recherche/OpportunitesTab'; import { SecteurTab } from '@/components/recherche/SecteurTab'; +import { VeilleAgentsTab } from '@/components/recherche/VeilleAgentsTab'; import { UI } from '@/constants/uiTheme'; -const TABS = ['Secteur', 'Opportunités', 'Grille de prix'] as const; +const TABS = ['Secteur', 'Opportunités', 'Grille de prix', 'Veille & agents'] as const; export default function RechercheTab() { const [sub, setSub] = useState(0); @@ -38,6 +39,7 @@ export default function RechercheTab() { {sub === 0 ? : null} {sub === 1 ? : null} {sub === 2 ? : null} + {sub === 3 ? : null} ); diff --git a/app/components/recherche/VeilleAgentsTab.tsx b/app/components/recherche/VeilleAgentsTab.tsx new file mode 100644 index 0000000..9f882c8 --- /dev/null +++ b/app/components/recherche/VeilleAgentsTab.tsx @@ -0,0 +1,433 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + RefreshControl, + ScrollView, + Text, + TextInput, + View, +} from 'react-native'; + +import { UI } from '@/constants/uiTheme'; +import { + agentAlertesScan, + agentDvf, + agentImmobilier, + agentMarchand, + agentRedaction, + agentVeille, +} from '@/services/agentsApi'; +import { getCurrentUserId, pb } from '@/services/pocketbase'; +import type { + AlerteRechercheRecord, + AnnonceVeilleRecord, + CourrierImmobilierRecord, + RechercheSauvegardeeRecord, + TransactionSecteurRecord, +} from '@/types/collections'; +import { formatPocketBaseError } from '@/utils/pocketbaseErrors'; + +function showLong(title: string, body: string) { + Alert.alert(title, body.length > 3500 ? `${body.slice(0, 3500)}…` : body); +} + +export function VeilleAgentsTab() { + const uid = getCurrentUserId(); + const qc = useQueryClient(); + + const [nomRecherche, setNomRecherche] = useState(''); + const [critereJson, setCritereJson] = useState('{}'); + const [titreAnnonce, setTitreAnnonce] = useState(''); + const [urlAnnonce, setUrlAnnonce] = useState(''); + const [libelleDvf, setLibelleDvf] = useState(''); + const [prixM2, setPrixM2] = useState(''); + const [nbVentes, setNbVentes] = useState(''); + + const invalidateVeille = useCallback(() => { + void qc.invalidateQueries({ queryKey: ['veille'] }); + }, [qc]); + + const recherches = useQuery({ + queryKey: ['veille', 'recherches_sauvegardees', uid], + queryFn: () => + pb.collection('recherches_sauvegardees').getFullList({ sort: '-updated' }), + enabled: Boolean(uid), + }); + + const alertes = useQuery({ + queryKey: ['veille', 'alertes_recherche', uid], + queryFn: () => pb.collection('alertes_recherche').getFullList({ sort: '-updated' }), + enabled: Boolean(uid), + }); + + const annonces = useQuery({ + queryKey: ['veille', 'annonces_veille', uid], + queryFn: () => pb.collection('annonces_veille').getFullList({ sort: '-updated' }), + enabled: Boolean(uid), + }); + + const trans = useQuery({ + queryKey: ['veille', 'transactions_secteur', uid], + queryFn: () => pb.collection('transactions_secteur').getFullList({ sort: '-updated' }), + enabled: Boolean(uid), + }); + + const courriers = useQuery({ + queryKey: ['veille', 'courriers_immobilier', uid], + queryFn: () => pb.collection('courriers_immobilier').getFullList({ sort: '-updated' }), + enabled: Boolean(uid), + }); + + const createRecherche = useMutation({ + mutationFn: async () => { + if (!uid) throw new Error('Non connecté'); + const nom = nomRecherche.trim(); + if (!nom) throw new Error('Nom requis'); + return pb.collection('recherches_sauvegardees').create({ + user: uid, + nom, + critere_json: critereJson.trim() || '{}', + actif: true, + }); + }, + onSuccess: () => { + setNomRecherche(''); + invalidateVeille(); + }, + }); + + const createAlerteSimple = useMutation({ + mutationFn: async () => { + if (!uid) throw new Error('Non connecté'); + return pb.collection('alertes_recherche').create({ + user: uid, + nom: `Veille ${new Date().toLocaleDateString('fr-FR')}`, + canal: 'in_app', + actif: true, + }); + }, + onSuccess: invalidateVeille, + onError: (err) => Alert.alert('Erreur', formatPocketBaseError(err)), + }); + + const scanAlertes = useMutation({ + mutationFn: () => agentAlertesScan(), + onSuccess: (r) => { + invalidateVeille(); + Alert.alert('Scan alertes', `${r.processed} alertes mises à jour.\n${r.note ?? ''}`); + }, + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const pushAnnonce = useMutation({ + mutationFn: () => + agentVeille({ + titre: titreAnnonce.trim(), + url: urlAnnonce.trim() || undefined, + source: 'manuel', + }), + onSuccess: (r) => { + setTitreAnnonce(''); + setUrlAnnonce(''); + invalidateVeille(); + Alert.alert('Veille', r.dedupe ? 'Doublon ignoré.' : `Enregistrée (${r.id}).`); + }, + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const pushDvf = useMutation({ + mutationFn: () => { + const lib = libelleDvf.trim(); + if (!lib) throw new Error('Libellé requis'); + const pm = Number(String(prixM2).replace(',', '.')); + const nv = Number(String(nbVentes).trim()); + return agentDvf({ + libelle: lib, + prix_m2_median: Number.isFinite(pm) ? pm : undefined, + nb_ventes: Number.isFinite(nv) ? nv : undefined, + }); + }, + onSuccess: (r) => { + setLibelleDvf(''); + setPrixM2(''); + setNbVentes(''); + invalidateVeille(); + showLong('Synthèse marché', r.synthese); + }, + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const runImmobilier = useMutation({ + mutationFn: () => + agentImmobilier({ + objectif: 'Prospection ciblée secteur', + contexte: 'Génère un plan + un message court pour relancer des mandataires potentiels.', + save: false, + }), + onSuccess: (r) => showLong('Agent immobilier', r.brouillon), + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const runMarchand = useMutation({ + mutationFn: () => + agentMarchand({ + titre: titreAnnonce.trim() || 'Annonce test', + notes: 'Comparer avec ma grille perso (onglet Grille de prix).', + }), + onSuccess: (r) => showLong('Agent marchand de biens', r.analyse), + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const runRedaction = useMutation({ + mutationFn: () => + agentRedaction({ + kind: 'annonce_agence', + bullets: ['Lumineux', 'Proche transports', 'Charges faibles'], + save: false, + }), + onSuccess: (r) => showLong('Agent rédaction', r.texte), + onError: (e) => Alert.alert('Erreur', formatPocketBaseError(e)), + }); + + const loading = + recherches.isPending || + alertes.isPending || + annonces.isPending || + trans.isPending || + courriers.isPending; + + return ( + { + void recherches.refetch(); + void alertes.refetch(); + void annonces.refetch(); + void trans.refetch(); + void courriers.refetch(); + }} + /> + } + > + + Agents IA (MVP) + + + Connexion serveur + clé Anthropic requises. Les données restent dans PocketBase. + + + + + Lancer un agent + + + runImmobilier.mutate()} /> + runMarchand.mutate()} + /> + runRedaction.mutate()} /> + + + + + + Agent data / secteur (stub DVF) + + + + + + + pushDvf.mutate()} + disabled={pushDvf.isPending || !libelleDvf.trim()} + > + {pushDvf.isPending ? ( + + ) : ( + Enregistrer + synthèse IA + )} + + + + + + Agent veille (dédoublonnage MD5) + + + + pushAnnonce.mutate()} + disabled={pushAnnonce.isPending || !titreAnnonce.trim()} + > + {pushAnnonce.isPending ? ( + + ) : ( + Ajouter à la veille + )} + + + + + + Recherches sauvegardées + + + + createRecherche.mutate()} + disabled={createRecherche.isPending} + > + Créer la recherche + + {recherches.data?.map((r) => ( + + • {r.nom} + + ))} + + + + + Agent alertes (stub scan) + + createAlerteSimple.mutate()} + disabled={createAlerteSimple.isPending} + > + Nouvelle alerte in-app + + scanAlertes.mutate()} + disabled={scanAlertes.isPending} + > + {scanAlertes.isPending ? ( + + ) : ( + Scanner mes alertes actives + )} + + {alertes.data?.map((a) => ( + + • {a.nom} ({a.canal}) {a.actif === false ? '— off' : ''} + + ))} + + + + Annonces veille ({annonces.data?.length ?? 0}) + + {annonces.data?.slice(0, 8).map((a) => ( + + [{a.statut}] {a.titre} + + ))} + + + Transactions secteur ({trans.data?.length ?? 0}) + + {trans.data?.slice(0, 6).map((t) => ( + + {t.libelle} + {t.prix_m2_median != null ? ` — ${t.prix_m2_median} €/m²` : ''} + + ))} + + + Courriers ({courriers.data?.length ?? 0}) + + {courriers.data?.slice(0, 5).map((c) => ( + + {c.titre} ({c.kind}/{c.etat}) + + ))} + + + + ); +} + +function AgentButton(props: { label: string; loading: boolean; onPress: () => void }) { + return ( + + {props.loading ? : {props.label}} + + ); +} diff --git a/app/services/agentsApi.ts b/app/services/agentsApi.ts new file mode 100644 index 0000000..adfc6bc --- /dev/null +++ b/app/services/agentsApi.ts @@ -0,0 +1,66 @@ +import { pb } from '@/services/pocketbase'; + +export type AgentImmobilierBody = { + objectif?: string; + contexte?: string; + save?: boolean; +}; + +export type AgentMarchandBody = { + titre?: string; + prix?: number; + surface?: number; + code_postal?: string; + ville?: string; + notes?: string; + grille_json?: string; +}; + +export type AgentDvfBody = { + libelle: string; + code_insee?: string; + annee?: number; + prix_m2_median?: number; + nb_ventes?: number; + detail_json?: string; +}; + +export type AgentVeilleBody = { + titre: string; + url?: string; + source?: string; + prix?: number; + surface?: number; + code_postal?: string; + ville?: string; +}; + +export type AgentRedactionBody = { + kind?: string; + bullets?: string[]; + save?: boolean; +}; + +export async function agentImmobilier(body: AgentImmobilierBody): Promise<{ brouillon: string; courrier_id?: string }> { + return pb.send('/api/mdb/agent-immobilier', { method: 'POST', body }); +} + +export async function agentMarchand(body: AgentMarchandBody): Promise<{ analyse: string }> { + return pb.send('/api/mdb/agent-marchand', { method: 'POST', body }); +} + +export async function agentDvf(body: AgentDvfBody): Promise<{ id: string; synthese: string }> { + return pb.send('/api/mdb/agent-dvf', { method: 'POST', body }); +} + +export async function agentVeille(body: AgentVeilleBody): Promise<{ id: string; dedupe: boolean; message?: string }> { + return pb.send('/api/mdb/agent-veille', { method: 'POST', body }); +} + +export async function agentRedaction(body: AgentRedactionBody): Promise<{ texte: string; courrier_id?: string }> { + return pb.send('/api/mdb/agent-redaction', { method: 'POST', body }); +} + +export async function agentAlertesScan(): Promise<{ processed: number; note?: string }> { + return pb.send('/api/mdb/agent-alertes-scan', { method: 'POST', body: {} }); +} diff --git a/app/types/collections.ts b/app/types/collections.ts index 2b16b58..b364687 100644 --- a/app/types/collections.ts +++ b/app/types/collections.ts @@ -146,6 +146,72 @@ export type GrillePrixRecord = RecordModel & { ville?: string; }; +export type RechercheSauvegardeeRecord = RecordModel & { + user: string; + nom: string; + critere_json?: string; + actif?: boolean; +}; + +export type AlerteRechercheRecord = RecordModel & { + user: string; + recherche?: string; + nom: string; + canal: 'in_app' | 'email' | 'push'; + actif?: boolean; + derniere_verification?: string; + dernier_nb_resultats?: number; +}; + +export type AnnonceVeilleStatut = 'nouveau' | 'vu' | 'ecarte' | 'raccroche'; + +export type AnnonceVeilleRecord = RecordModel & { + user: string; + titre: string; + url?: string; + source?: string; + prix?: number; + surface?: number; + code_postal?: string; + ville?: string; + empreinte?: string; + statut: AnnonceVeilleStatut; +}; + +export type FluxSourceType = 'api' | 'manuel' | 'csv'; + +export type FluxSourceRecord = RecordModel & { + user: string; + nom: string; + type: FluxSourceType; + notes?: string; + actif?: boolean; +}; + +export type TransactionSecteurSource = 'manuel' | 'dvf_import' | 'api_tiers'; + +export type TransactionSecteurRecord = RecordModel & { + user: string; + libelle: string; + code_insee?: string; + annee?: number; + prix_m2_median?: number; + nb_ventes?: number; + source: TransactionSecteurSource; + detail_json?: string; +}; + +export type CourrierImmobilierKind = 'prospection' | 'annonce_agence' | 'relance'; +export type CourrierImmobilierEtat = 'brouillon' | 'pret'; + +export type CourrierImmobilierRecord = RecordModel & { + user: string; + titre: string; + corps?: string; + kind: CourrierImmobilierKind; + etat: CourrierImmobilierEtat; +}; + export type VisiteRecord = RecordModel & { user: string; bien: string; diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index ae6c3cc..c8246d4 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -3,7 +3,7 @@ services: image: ghcr.io/muchobien/pocketbase:latest container_name: mdb-pocketbase-dev restart: unless-stopped - command: --dir=/pb_data + command: --dir=/pb_data --hooksDir=/pb_hooks ports: - "8090:8090" volumes: diff --git a/pocketbase/pb_hooks/agents_veille.pb.js b/pocketbase/pb_hooks/agents_veille.pb.js new file mode 100644 index 0000000..872b1c7 --- /dev/null +++ b/pocketbase/pb_hooks/agents_veille.pb.js @@ -0,0 +1,333 @@ +/// + +const ANTHROPIC_MODEL = "claude-sonnet-4-20250514"; + +function getAnthropicKey() { + return $os.getenv("ANTHROPIC_API_KEY"); +} + +/** + * @param {string} userText + * @returns {{ text: string, error?: { status: number, body: unknown } }} + */ +function callAnthropic(userText) { + const key = getAnthropicKey(); + if (!key) { + return { text: "", error: { status: 500, body: { message: "ANTHROPIC_API_KEY manquante" } } }; + } + const payload = { + model: ANTHROPIC_MODEL, + max_tokens: 2200, + messages: [{ role: "user", content: userText }], + }; + const res = $http.send({ + url: "https://api.anthropic.com/v1/messages", + method: "POST", + headers: { + "x-api-key": key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + timeout: 120, + }); + if (res.statusCode >= 400) { + const errText = typeof res.raw === "string" ? res.raw : ""; + return { + text: "", + error: { + status: 502, + body: { message: "Anthropic", statusCode: res.statusCode, detail: errText.slice(0, 1500) }, + }, + }; + } + let parsed = res.json; + if (parsed == null && typeof res.raw === "string" && res.raw.length > 0) { + try { + parsed = JSON.parse(res.raw); + } catch (_) { + parsed = null; + } + } + const text = + parsed && Array.isArray(parsed.content) && parsed.content[0] && parsed.content[0].text + ? parsed.content[0].text + : ""; + return { text }; +} + +function readJsonBody(e) { + const b = e.requestInfo().body || {}; + return typeof b === "object" && b != null ? b : {}; +} + +routerAdd( + "POST", + "/api/mdb/agent-immobilier", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const body = readJsonBody(e); + const objectif = typeof body.objectif === "string" ? body.objectif : "prospection off-market"; + const contexte = typeof body.contexte === "string" ? body.contexte : ""; + const prompt = + "Tu es un agent immobilier senior en France. Rédige un plan d'actions concret (puces) puis un brouillon de message court (email ou message) pour : " + + objectif + + ".\nContexte fourni par l'utilisateur :\n" + + contexte; + const { text, error } = callAnthropic(prompt); + if (error) { + return e.json(error.status, error.body); + } + const save = body.save === true; + if (save && text) { + const rec = new Record($app.findCollectionByNameOrId("courriers_immobilier"), { + user: e.auth.id, + titre: "Brouillon — " + objectif.slice(0, 80), + corps: text, + kind: "prospection", + etat: "brouillon", + }); + $app.save(rec); + return e.json(200, { brouillon: text, courrier_id: rec.id }); + } + return e.json(200, { brouillon: text }); + }, + $apis.requireAuth(), +); + +routerAdd( + "POST", + "/api/mdb/agent-marchand", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const body = readJsonBody(e); + const titre = typeof body.titre === "string" ? body.titre : "Annonce"; + const prix = typeof body.prix === "number" ? body.prix : null; + const surface = typeof body.surface === "number" ? body.surface : null; + const code_postal = typeof body.code_postal === "string" ? body.code_postal : ""; + const ville = typeof body.ville === "string" ? body.ville : ""; + const notes = typeof body.notes === "string" ? body.notes : ""; + const grille = typeof body.grille_json === "string" ? body.grille_json : ""; + const prompt = + "Tu es un marchand de biens en France. Analyse l'offre suivante : titre=" + + titre + + ", prix=" + + String(prix) + + ", surface_m2=" + + String(surface) + + ", CP=" + + code_postal + + ", ville=" + + ville + + ".\nNotes utilisateur : " + + notes + + "\nRéférentiel grille perso (JSON optionnel) : " + + grille + + "\nRéponds en français : (1) fourchette €/m² si calculable, (2) points de vigilance, (3) verdict rapide opportunité / neutre / risqué."; + const { text, error } = callAnthropic(prompt); + if (error) { + return e.json(error.status, error.body); + } + return e.json(200, { analyse: text }); + }, + $apis.requireAuth(), +); + +routerAdd( + "POST", + "/api/mdb/agent-dvf", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const body = readJsonBody(e); + const libelle = typeof body.libelle === "string" ? body.libelle : ""; + if (!libelle.trim()) { + return e.json(400, { message: "libelle requis" }); + } + const code_insee = typeof body.code_insee === "string" ? body.code_insee : ""; + const annee = typeof body.annee === "number" ? body.annee : null; + const prix_m2_median = typeof body.prix_m2_median === "number" ? body.prix_m2_median : null; + const nb_ventes = typeof body.nb_ventes === "number" ? body.nb_ventes : null; + const detail_json = typeof body.detail_json === "string" ? body.detail_json : ""; + const col = $app.findCollectionByNameOrId("transactions_secteur"); + const data = { + user: e.auth.id, + libelle: libelle.trim(), + source: "manuel", + }; + if (code_insee) { + data.code_insee = code_insee; + } + if (annee != null) { + data.annee = annee; + } + if (prix_m2_median != null) { + data.prix_m2_median = prix_m2_median; + } + if (nb_ventes != null) { + data.nb_ventes = nb_ventes; + } + if (detail_json) { + data.detail_json = detail_json; + } + const rec = new Record(col, data); + $app.save(rec); + const prompt = + "Tu es un analyste immobilier. Synthétise en 5 phrases maximum l'intérêt de ces statistiques de marché (secteur, médiane €/m², volume) pour un marchand de biens.\nDonnées : " + + JSON.stringify({ + libelle: libelle.trim(), + code_insee, + annee, + prix_m2_median, + nb_ventes, + }); + const { text, error } = callAnthropic(prompt); + if (error) { + return e.json(error.status, error.body); + } + return e.json(200, { id: rec.id, synthese: text }); + }, + $apis.requireAuth(), +); + +routerAdd( + "POST", + "/api/mdb/agent-veille", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const body = readJsonBody(e); + const titre = typeof body.titre === "string" ? body.titre.trim() : ""; + if (!titre) { + return e.json(400, { message: "titre requis" }); + } + const url = typeof body.url === "string" ? body.url.trim() : ""; + const source = typeof body.source === "string" ? body.source.trim() : "manuel"; + const prix = typeof body.prix === "number" ? body.prix : undefined; + const surface = typeof body.surface === "number" ? body.surface : undefined; + const code_postal = typeof body.code_postal === "string" ? body.code_postal : ""; + const ville = typeof body.ville === "string" ? body.ville : ""; + const empreinte = $security.md5((url || "") + "\n" + titre); + const col = $app.findCollectionByNameOrId("annonces_veille"); + const filt = 'user = "' + e.auth.id + '" && empreinte = "' + empreinte + '"'; + let existing = null; + try { + existing = $app.findFirstRecordByFilter("annonces_veille", filt); + } catch (_) { + existing = null; + } + if (existing != null && existing.id) { + return e.json(200, { id: existing.id, dedupe: true, message: "Déjà enregistrée" }); + } + const ann = { + user: e.auth.id, + titre, + url, + source, + empreinte, + statut: "nouveau", + }; + if (prix !== undefined) { + ann.prix = prix; + } + if (surface !== undefined) { + ann.surface = surface; + } + if (code_postal) { + ann.code_postal = code_postal; + } + if (ville) { + ann.ville = ville; + } + const rec = new Record(col, ann); + $app.save(rec); + return e.json(200, { id: rec.id, dedupe: false }); + }, + $apis.requireAuth(), +); + +routerAdd( + "POST", + "/api/mdb/agent-redaction", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const body = readJsonBody(e); + const kind = typeof body.kind === "string" ? body.kind : "annonce_agence"; + const bullets = Array.isArray(body.bullets) ? body.bullets.map(String).join("\n- ") : ""; + const prompt = + "Tu es rédacteur pour une agence immobilière en France. Rédige un texte court et professionnel (titres + paragraphes) à partir des puces :\n- " + + bullets + + "\nType de contenu : " + + kind + + "."; + const { text, error } = callAnthropic(prompt); + if (error) { + return e.json(error.status, error.body); + } + const save = body.save === true; + if (save && text) { + const col = $app.findCollectionByNameOrId("courriers_immobilier"); + const rec = new Record(col, { + user: e.auth.id, + titre: "Rédaction — " + kind, + corps: text, + kind: kind === "relance" || kind === "prospection" ? kind : "annonce_agence", + etat: "pret", + }); + $app.save(rec); + return e.json(200, { texte: text, courrier_id: rec.id }); + } + return e.json(200, { texte: text }); + }, + $apis.requireAuth(), +); + +routerAdd( + "POST", + "/api/mdb/agent-alertes-scan", + (e) => { + if (!e.auth) { + return e.json(401, { message: "Non autorisé" }); + } + const now = new Date().toISOString(); + let updated = 0; + try { + const rows = $app.findRecordsByFilter( + "alertes_recherche", + 'user = "' + e.auth.id + '"', + "-created", + 50, + 0, + ); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + if (!r) { + continue; + } + if (r.get("actif") === false) { + continue; + } + r.set("derniere_verification", now); + r.set("dernier_nb_resultats", 0); + $app.save(r); + updated++; + } + } catch (_) { + /* noop */ + } + return e.json(200, { processed: updated, note: "Stub: branchement annonces agrégées à venir." }); + }, + $apis.requireAuth(), +); + +cronAdd("mdb_agents_alertes_tick", "0 * * * *", () => { + /* Placeholder : futur batch serveur (sans auth utilisateur). */ +}); diff --git a/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js b/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js index bd1d5d0..3b7bfb3 100644 --- a/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js +++ b/pocketbase/pb_migrations/1752000000_fix_rules_empty_string_quotes.js @@ -17,6 +17,12 @@ migrate( "analyses_secteur", "notes_prospection", "grille_prix", + "recherches_sauvegardees", + "alertes_recherche", + "annonces_veille", + "flux_sources", + "transactions_secteur", + "courriers_immobilier", ]; const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]; for (const name of names) { diff --git a/pocketbase/pb_migrations/1760000000_agents_veille_collections.js b/pocketbase/pb_migrations/1760000000_agents_veille_collections.js new file mode 100644 index 0000000..0438339 --- /dev/null +++ b/pocketbase/pb_migrations/1760000000_agents_veille_collections.js @@ -0,0 +1,224 @@ +/// +/** + * Fondations multi-agents : recherches sauvegardées, alertes, veille annonces, + * sources de flux, transactions secteur (stub DVF), courriers prospection. + */ +migrate( + (app) => { + const usersCol = app.findCollectionByNameOrId("users"); + let usersId = ""; + if (usersCol) { + const a = usersCol.id != null && String(usersCol.id) !== "" ? usersCol.id : null; + const b = usersCol.Id != null && String(usersCol.Id) !== "" ? usersCol.Id : null; + usersId = String(a != null ? a : b != null ? b : "").trim(); + } + if (!usersId) { + throw new Error("migration 1760000000: collection users introuvable ou id vide"); + } + + function findExistingCollection(name) { + try { + return app.findCollectionByNameOrId(name); + } catch (_) {} + try { + const all = app.findAllCollections(); + const want = String(name).toLowerCase(); + for (let i = 0; i < all.length; i++) { + const c = all[i]; + if (c && c.name && String(c.name).toLowerCase() === want) { + return c; + } + } + } catch (_) {} + return null; + } + + function loadOrCreate(name, factory) { + const existing = findExistingCollection(name); + if (existing != null) { + return existing; + } + try { + const col = factory(); + app.save(col); + return col; + } catch (err) { + const msg = String(err && err.value ? err.value : err && err.message ? err.message : err); + if (msg.includes("unique") || msg.includes("Unique")) { + const again = findExistingCollection(name); + if (again != null) { + return again; + } + } + throw err; + } + } + + const ownRecords = '@request.auth.id != "" && user.id = @request.auth.id'; + const authOnly = '@request.auth.id != ""'; + + function addUserRules(col) { + col.listRule = ownRecords; + col.viewRule = ownRecords; + col.createRule = authOnly; + col.updateRule = ownRecords; + col.deleteRule = ownRecords; + } + + function userRel() { + return new RelationField({ + name: "user", + required: true, + collectionId: usersId, + maxSelect: 1, + cascadeDelete: true, + }); + } + + loadOrCreate("recherches_sauvegardees", () => { + const col = new Collection({ name: "recherches_sauvegardees", type: "base" }); + col.fields.add(userRel()); + col.fields.add(new TextField({ name: "nom", required: true })); + col.fields.add(new TextField({ name: "critere_json", required: false })); + col.fields.add(new BoolField({ name: "actif", required: false })); + addUserRules(col); + return col; + }); + + const rechRef = findExistingCollection("recherches_sauvegardees"); + const rechId = rechRef ? String(rechRef.id || rechRef.Id || "").trim() : ""; + if (!rechId) { + throw new Error("migration 1760000000: recherches_sauvegardees introuvable après création"); + } + + loadOrCreate("alertes_recherche", () => { + const col = new Collection({ name: "alertes_recherche", type: "base" }); + col.fields.add(userRel()); + col.fields.add( + new RelationField({ + name: "recherche", + required: false, + collectionId: rechId, + maxSelect: 1, + cascadeDelete: false, + }), + ); + col.fields.add(new TextField({ name: "nom", required: true })); + col.fields.add( + new SelectField({ + name: "canal", + required: true, + maxSelect: 1, + values: ["in_app", "email", "push"], + }), + ); + col.fields.add(new BoolField({ name: "actif", required: false })); + col.fields.add(new TextField({ name: "derniere_verification", required: false })); + col.fields.add(new NumberField({ name: "dernier_nb_resultats", required: false })); + addUserRules(col); + return col; + }); + + loadOrCreate("annonces_veille", () => { + const col = new Collection({ name: "annonces_veille", type: "base" }); + col.fields.add(userRel()); + col.fields.add(new TextField({ name: "titre", required: true })); + col.fields.add(new TextField({ name: "url", required: false })); + col.fields.add(new TextField({ name: "source", required: false })); + col.fields.add(new NumberField({ name: "prix", required: false })); + col.fields.add(new NumberField({ name: "surface", required: false })); + col.fields.add(new TextField({ name: "code_postal", required: false })); + col.fields.add(new TextField({ name: "ville", required: false })); + col.fields.add(new TextField({ name: "empreinte", required: false })); + col.fields.add( + new SelectField({ + name: "statut", + required: true, + maxSelect: 1, + values: ["nouveau", "vu", "ecarte", "raccroche"], + }), + ); + addUserRules(col); + return col; + }); + + loadOrCreate("flux_sources", () => { + const col = new Collection({ name: "flux_sources", type: "base" }); + col.fields.add(userRel()); + col.fields.add(new TextField({ name: "nom", required: true })); + col.fields.add( + new SelectField({ + name: "type", + required: true, + maxSelect: 1, + values: ["api", "manuel", "csv"], + }), + ); + col.fields.add(new TextField({ name: "notes", required: false })); + col.fields.add(new BoolField({ name: "actif", required: false })); + addUserRules(col); + return col; + }); + + loadOrCreate("transactions_secteur", () => { + const col = new Collection({ name: "transactions_secteur", type: "base" }); + col.fields.add(userRel()); + col.fields.add(new TextField({ name: "libelle", required: true })); + col.fields.add(new TextField({ name: "code_insee", required: false })); + col.fields.add(new NumberField({ name: "annee", required: false })); + col.fields.add(new NumberField({ name: "prix_m2_median", required: false })); + col.fields.add(new NumberField({ name: "nb_ventes", required: false })); + col.fields.add( + new SelectField({ + name: "source", + required: true, + maxSelect: 1, + values: ["manuel", "dvf_import", "api_tiers"], + }), + ); + col.fields.add(new TextField({ name: "detail_json", required: false })); + addUserRules(col); + return col; + }); + + loadOrCreate("courriers_immobilier", () => { + const col = new Collection({ name: "courriers_immobilier", type: "base" }); + col.fields.add(userRel()); + col.fields.add(new TextField({ name: "titre", required: true })); + col.fields.add(new TextField({ name: "corps", required: false })); + col.fields.add( + new SelectField({ + name: "kind", + required: true, + maxSelect: 1, + values: ["prospection", "annonce_agence", "relance"], + }), + ); + col.fields.add( + new SelectField({ + name: "etat", + required: true, + maxSelect: 1, + values: ["brouillon", "pret"], + }), + ); + addUserRules(col); + return col; + }); + }, + (app) => { + const names = [ + "alertes_recherche", + "annonces_veille", + "flux_sources", + "transactions_secteur", + "courriers_immobilier", + "recherches_sauvegardees", + ]; + for (const name of names) { + try { + app.delete(app.findCollectionByNameOrId(name)); + } catch (_) {} + } + }, +);