recherche
This commit is contained in:
@ -25,7 +25,9 @@ export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
|||||||
## Collections PocketBase (toutes créées via migration)
|
## Collections PocketBase (toutes créées via migration)
|
||||||
etapes_pipeline, contacts, biens, analyses_financieres,
|
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||||
visites, taches, notes_biens, documents_biens, devis_travaux,
|
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
|
## Règles de code
|
||||||
- TypeScript strict, jamais de any
|
- TypeScript strict, jamais de any
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
- [x] Module Agenda (tâches, snooze, création modal)
|
- [x] Module Agenda (tâches, snooze, création modal)
|
||||||
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
|
- [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] 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
|
## 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)
|
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||||
- Binaire : /usr/local/bin/pocketbase
|
- Binaire : /usr/local/bin/pocketbase
|
||||||
- Données : /pb_data
|
- 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)
|
- Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
|
||||||
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||||
- PocketBase : v0.23+
|
- PocketBase : v0.23+
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import { Pressable, Text, View } from 'react-native';
|
|||||||
import { GrillePrixTab } from '@/components/recherche/GrillePrixTab';
|
import { GrillePrixTab } from '@/components/recherche/GrillePrixTab';
|
||||||
import { OpportunitesTab } from '@/components/recherche/OpportunitesTab';
|
import { OpportunitesTab } from '@/components/recherche/OpportunitesTab';
|
||||||
import { SecteurTab } from '@/components/recherche/SecteurTab';
|
import { SecteurTab } from '@/components/recherche/SecteurTab';
|
||||||
|
import { VeilleAgentsTab } from '@/components/recherche/VeilleAgentsTab';
|
||||||
import { UI } from '@/constants/uiTheme';
|
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() {
|
export default function RechercheTab() {
|
||||||
const [sub, setSub] = useState(0);
|
const [sub, setSub] = useState(0);
|
||||||
@ -38,6 +39,7 @@ export default function RechercheTab() {
|
|||||||
{sub === 0 ? <SecteurTab /> : null}
|
{sub === 0 ? <SecteurTab /> : null}
|
||||||
{sub === 1 ? <OpportunitesTab /> : null}
|
{sub === 1 ? <OpportunitesTab /> : null}
|
||||||
{sub === 2 ? <GrillePrixTab /> : null}
|
{sub === 2 ? <GrillePrixTab /> : null}
|
||||||
|
{sub === 3 ? <VeilleAgentsTab /> : null}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
433
app/components/recherche/VeilleAgentsTab.tsx
Normal file
433
app/components/recherche/VeilleAgentsTab.tsx
Normal file
@ -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<RechercheSauvegardeeRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertes = useQuery({
|
||||||
|
queryKey: ['veille', 'alertes_recherche', uid],
|
||||||
|
queryFn: () => pb.collection('alertes_recherche').getFullList<AlerteRechercheRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const annonces = useQuery({
|
||||||
|
queryKey: ['veille', 'annonces_veille', uid],
|
||||||
|
queryFn: () => pb.collection('annonces_veille').getFullList<AnnonceVeilleRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trans = useQuery({
|
||||||
|
queryKey: ['veille', 'transactions_secteur', uid],
|
||||||
|
queryFn: () => pb.collection('transactions_secteur').getFullList<TransactionSecteurRecord>({ sort: '-updated' }),
|
||||||
|
enabled: Boolean(uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
const courriers = useQuery({
|
||||||
|
queryKey: ['veille', 'courriers_immobilier', uid],
|
||||||
|
queryFn: () => pb.collection('courriers_immobilier').getFullList<CourrierImmobilierRecord>({ 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<RechercheSauvegardeeRecord>({
|
||||||
|
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<AlerteRechercheRecord>({
|
||||||
|
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 (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-4 py-3"
|
||||||
|
style={{ backgroundColor: UI.screen }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={() => {
|
||||||
|
void recherches.refetch();
|
||||||
|
void alertes.refetch();
|
||||||
|
void annonces.refetch();
|
||||||
|
void trans.refetch();
|
||||||
|
void courriers.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="mb-2 text-lg font-bold" style={{ color: UI.text }}>
|
||||||
|
Agents IA (MVP)
|
||||||
|
</Text>
|
||||||
|
<Text className="mb-4 text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
Connexion serveur + clé Anthropic requises. Les données restent dans PocketBase.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mb-6 gap-2">
|
||||||
|
<Text className="font-semibold" style={{ color: UI.text }}>
|
||||||
|
Lancer un agent
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<AgentButton label="Immobilier" loading={runImmobilier.isPending} onPress={() => runImmobilier.mutate()} />
|
||||||
|
<AgentButton
|
||||||
|
label="Marchand"
|
||||||
|
loading={runMarchand.isPending}
|
||||||
|
onPress={() => runMarchand.mutate()}
|
||||||
|
/>
|
||||||
|
<AgentButton label="Rédaction" loading={runRedaction.isPending} onPress={() => runRedaction.mutate()} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent data / secteur (stub DVF)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Libellé zone (ex. Lyon 6e)"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={libelleDvf}
|
||||||
|
onChangeText={setLibelleDvf}
|
||||||
|
/>
|
||||||
|
<View className="mb-2 flex-row gap-2">
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Prix m² médian"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={prixM2}
|
||||||
|
onChangeText={setPrixM2}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Nb ventes"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={nbVentes}
|
||||||
|
onChangeText={setNbVentes}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-3"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={() => pushDvf.mutate()}
|
||||||
|
disabled={pushDvf.isPending || !libelleDvf.trim()}
|
||||||
|
>
|
||||||
|
{pushDvf.isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="font-semibold text-white">Enregistrer + synthèse IA</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent veille (dédoublonnage MD5)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Titre annonce"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={titreAnnonce}
|
||||||
|
onChangeText={setTitreAnnonce}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="URL (optionnel)"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={urlAnnonce}
|
||||||
|
onChangeText={setUrlAnnonce}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-3"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={() => pushAnnonce.mutate()}
|
||||||
|
disabled={pushAnnonce.isPending || !titreAnnonce.trim()}
|
||||||
|
>
|
||||||
|
{pushAnnonce.isPending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="font-semibold text-white">Ajouter à la veille</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Recherches sauvegardées
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder="Nom"
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={nomRecherche}
|
||||||
|
onChangeText={setNomRecherche}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="mb-2 rounded border px-2 py-2"
|
||||||
|
style={{ borderColor: UI.border, color: UI.text }}
|
||||||
|
placeholder='Critères JSON (ex. {"prix_max":250000})'
|
||||||
|
placeholderTextColor={UI.textMuted}
|
||||||
|
value={critereJson}
|
||||||
|
onChangeText={setCritereJson}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="mb-3 items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => createRecherche.mutate()}
|
||||||
|
disabled={createRecherche.isPending}
|
||||||
|
>
|
||||||
|
<Text style={{ color: UI.text }}>Créer la recherche</Text>
|
||||||
|
</Pressable>
|
||||||
|
{recherches.data?.map((r) => (
|
||||||
|
<Text key={r.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
• {r.nom}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-6 rounded-lg border p-3" style={{ borderColor: UI.border, backgroundColor: UI.card }}>
|
||||||
|
<Text className="mb-2 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Agent alertes (stub scan)
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
className="mb-2 items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => createAlerteSimple.mutate()}
|
||||||
|
disabled={createAlerteSimple.isPending}
|
||||||
|
>
|
||||||
|
<Text style={{ color: UI.text }}>Nouvelle alerte in-app</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
className="items-center rounded-lg py-2"
|
||||||
|
style={{ backgroundColor: UI.border }}
|
||||||
|
onPress={() => scanAlertes.mutate()}
|
||||||
|
disabled={scanAlertes.isPending}
|
||||||
|
>
|
||||||
|
{scanAlertes.isPending ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: UI.text }}>Scanner mes alertes actives</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{alertes.data?.map((a) => (
|
||||||
|
<Text key={a.id} className="mt-1 text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
• {a.nom} ({a.canal}) {a.actif === false ? '— off' : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="mb-1 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Annonces veille ({annonces.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{annonces.data?.slice(0, 8).map((a) => (
|
||||||
|
<Text key={a.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
[{a.statut}] {a.titre}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Transactions secteur ({trans.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{trans.data?.slice(0, 6).map((t) => (
|
||||||
|
<Text key={t.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
{t.libelle}
|
||||||
|
{t.prix_m2_median != null ? ` — ${t.prix_m2_median} €/m²` : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text className="mb-1 mt-4 font-semibold" style={{ color: UI.text }}>
|
||||||
|
Courriers ({courriers.data?.length ?? 0})
|
||||||
|
</Text>
|
||||||
|
{courriers.data?.slice(0, 5).map((c) => (
|
||||||
|
<Text key={c.id} className="text-sm" style={{ color: UI.textMuted }}>
|
||||||
|
{c.titre} ({c.kind}/{c.etat})
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View className="h-24" />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentButton(props: { label: string; loading: boolean; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
className="rounded-lg px-4 py-2"
|
||||||
|
style={{ backgroundColor: UI.primary }}
|
||||||
|
onPress={props.onPress}
|
||||||
|
disabled={props.loading}
|
||||||
|
>
|
||||||
|
{props.loading ? <ActivityIndicator color="#fff" size="small" /> : <Text className="text-white">{props.label}</Text>}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
app/services/agentsApi.ts
Normal file
66
app/services/agentsApi.ts
Normal file
@ -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: {} });
|
||||||
|
}
|
||||||
@ -146,6 +146,72 @@ export type GrillePrixRecord = RecordModel & {
|
|||||||
ville?: string;
|
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 & {
|
export type VisiteRecord = RecordModel & {
|
||||||
user: string;
|
user: string;
|
||||||
bien: string;
|
bien: string;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ services:
|
|||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
container_name: mdb-pocketbase-dev
|
container_name: mdb-pocketbase-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: --dir=/pb_data
|
command: --dir=/pb_data --hooksDir=/pb_hooks
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
333
pocketbase/pb_hooks/agents_veille.pb.js
Normal file
333
pocketbase/pb_hooks/agents_veille.pb.js
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
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). */
|
||||||
|
});
|
||||||
@ -17,6 +17,12 @@ migrate(
|
|||||||
"analyses_secteur",
|
"analyses_secteur",
|
||||||
"notes_prospection",
|
"notes_prospection",
|
||||||
"grille_prix",
|
"grille_prix",
|
||||||
|
"recherches_sauvegardees",
|
||||||
|
"alertes_recherche",
|
||||||
|
"annonces_veille",
|
||||||
|
"flux_sources",
|
||||||
|
"transactions_secteur",
|
||||||
|
"courriers_immobilier",
|
||||||
];
|
];
|
||||||
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
|
const ruleKeys = ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
|
|||||||
224
pocketbase/pb_migrations/1760000000_agents_veille_collections.js
Normal file
224
pocketbase/pb_migrations/1760000000_agents_veille_collections.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
/**
|
||||||
|
* 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 (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user