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 (_) {}
+ }
+ },
+);