Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"react-router-dom": "^7.13.0",
"react-select": "^5.10.2",
"recharts": "^3.8.1",
"rss-parser": "^3.13.0",
"styled-components": "^6.3.12",
"tailwind-merge": "^3.5.0",
"tsx": "^4.21.0",
Expand Down
112 changes: 112 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import axios from "axios";
import https from "https";
import { createClient } from "@supabase/supabase-js";
import * as Sentry from "@sentry/node";
import { startTenderRssPolling, pollAllTenderRssSources } from "./server/tenderRssPoller";

interface GeoJSONGeometry {
type: string;
Expand Down Expand Up @@ -3080,6 +3081,113 @@ async function startServer() {
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to update tender: " + e.message }); }
});

// --- Veille RSS des appels d'offres ---

app.get("/api/tender-rss-sources", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { data, error } = await supabaseAdmin.from('tender_rss_sources').select('*').eq('tenant_id', tenantId).order('created_at', { ascending: false });
if (error) throw error;
res.json(data || []);
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to fetch tender RSS sources" }); }
});

app.post("/api/tender-rss-sources", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { name, url, enabled, include_keywords, exclude_keywords } = req.body;
const id = crypto.randomUUID();
const { error } = await supabaseAdmin.from('tender_rss_sources').insert({
id, tenant_id: tenantId, name, url, enabled: enabled !== false,
include_keywords: include_keywords || [], exclude_keywords: exclude_keywords || []
});
if (error) throw error;
const userName = await getUserName(tenantId, req.user.id, req.user.email);
logActivity(tenantId, req.user.id, userName, `Ajout de la source de veille RSS "${name}"`, name, id, 'tender_rss_source', 'Appels d\'offres');
res.status(201).json({ id });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to create tender RSS source: " + e.message }); }
});

app.put("/api/tender-rss-sources/:id", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { id } = req.params;
const { name, url, enabled, include_keywords, exclude_keywords } = req.body;
const { error } = await supabaseAdmin.from('tender_rss_sources').update({
name, url, enabled: !!enabled, include_keywords: include_keywords || [], exclude_keywords: exclude_keywords || []
}).eq('id', id).eq('tenant_id', tenantId);
if (error) throw error;
res.json({ success: true });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to update tender RSS source: " + e.message }); }
});

app.delete("/api/tender-rss-sources/:id", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { id } = req.params;
const { data: source } = await supabaseAdmin.from('tender_rss_sources').select('name').eq('id', id).eq('tenant_id', tenantId).maybeSingle();
const { error } = await supabaseAdmin.from('tender_rss_sources').delete().eq('id', id).eq('tenant_id', tenantId);
if (error) throw error;
const name = (source as any)?.name || '';
const userName = await getUserName(tenantId, req.user.id, req.user.email);
logActivity(tenantId, req.user.id, userName, `Suppression de la source de veille RSS "${name}"`, name, id, 'tender_rss_source', 'Appels d\'offres');
res.json({ success: true });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to delete tender RSS source" }); }
});

app.post("/api/tender-rss-sources/poll-now", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
await pollAllTenderRssSources(supabaseAdmin, tenantId);
res.json({ success: true });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to poll tender RSS sources: " + e.message }); }
});

app.get("/api/tender-rss-matches", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
let query = supabaseAdmin.from('tender_rss_matches').select('*, tender_rss_sources(name)').eq('tenant_id', tenantId).order('pub_date', { ascending: false, nullsFirst: false });
if (req.query.status) query = query.eq('status', req.query.status as string);
const { data, error } = await query;
if (error) throw error;
res.json((data || []).map((m: any) => ({ ...m, source_name: m.tender_rss_sources?.name || null, tender_rss_sources: undefined })));
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to fetch tender RSS matches" }); }
});

app.put("/api/tender-rss-matches/:id", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { id } = req.params;
const { status } = req.body;
const { error } = await supabaseAdmin.from('tender_rss_matches').update({ status }).eq('id', id).eq('tenant_id', tenantId);
if (error) throw error;
res.json({ success: true });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to update tender RSS match" }); }
});

app.post("/api/tender-rss-matches/:id/convert", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
const { id } = req.params;
const { data: match, error: me } = await supabaseAdmin.from('tender_rss_matches').select('*').eq('id', id).eq('tenant_id', tenantId).single();
if (me || !match) return res.status(404).json({ error: "Tender RSS match not found" });

const tenderId = crypto.randomUUID();
const notes = [match.link, match.description].filter(Boolean).join('\n\n');
const { error: te } = await supabaseAdmin.from('tenders').insert({
id: tenderId, tenant_id: tenantId, title: match.title, client: '',
submission_deadline: '', status: 'Draft', value: 0, notes, archived: false
});
if (te) throw te;

await supabaseAdmin.from('tender_rss_matches').update({ status: 'converted', tender_id: tenderId }).eq('id', id).eq('tenant_id', tenantId);

const userName = await getUserName(tenantId, req.user.id, req.user.email);
logActivity(tenantId, req.user.id, userName, `Appel d'offres créé depuis la veille RSS "${match.title}"`, match.title, tenderId, 'tender', 'Appels d\'offres');
res.status(201).json({ id: tenderId });
} catch (e: any) { console.error(e); res.status(500).json({ error: "Failed to convert tender RSS match: " + e.message }); }
});

app.get("/api/milestones", async (req: any, res: any) => {
try {
const tenantId = await getTenantId(req.user.id);
Expand Down Expand Up @@ -9527,6 +9635,10 @@ Réponds UNIQUEMENT avec un tableau JSON valide (sans markdown, sans explication
if (process.env.OFFLINE_MODE === 'true') {
ensureStorageBuckets();
}
// Veille RSS des appels d'offres — sondage périodique (server/tenderRssPoller.ts).
// Démarré ici (pas plus tôt) car en mode offline, supabaseAdmin boucle sur le
// shim REST de ce même serveur, qui n'accepte les requêtes qu'une fois à l'écoute.
startTenderRssPolling(supabaseAdmin);
});
}

Expand Down
93 changes: 93 additions & 0 deletions server/tenderRssPoller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Periodic RSS polling for the "Veille RSS" tender-watch feature. Started once
// from server.ts's startServer(). Polls every enabled tender_rss_sources row
// across all tenants, applies each source's include/exclude keyword filters,
// and inserts newly-seen items into tender_rss_matches (deduped by the
// (source_id, guid) unique constraint from migrate_add_tender_rss_watch.sql).
import type { SupabaseClient } from '@supabase/supabase-js';
import { randomUUID } from 'node:crypto';
import axios from 'axios';
import Parser from 'rss-parser';

const DEFAULT_INTERVAL_MINUTES = 30;
const FETCH_TIMEOUT_MS = 15000;

const parser = new Parser();

interface TenderRssSourceRow {
id: string;
tenant_id: string;
url: string;
include_keywords: string[] | null;
exclude_keywords: string[] | null;
}

function matchesKeywords(text: string, includeKeywords: string[], excludeKeywords: string[]): boolean {
const haystack = text.toLowerCase();
if (includeKeywords.length && !includeKeywords.some(k => haystack.includes(k.toLowerCase()))) {
return false;
}
if (excludeKeywords.some(k => haystack.includes(k.toLowerCase()))) {
return false;
}
return true;
}

async function pollSource(supabaseAdmin: SupabaseClient, source: TenderRssSourceRow): Promise<void> {
const includeKeywords = source.include_keywords || [];
const excludeKeywords = source.exclude_keywords || [];

try {
const response = await axios.get(source.url, { timeout: FETCH_TIMEOUT_MS, responseType: 'text' });
const feed = await parser.parseString(response.data);

const rows = (feed.items || [])
.filter(item => matchesKeywords(`${item.title || ''} ${item.contentSnippet || item.content || ''}`, includeKeywords, excludeKeywords))
.map(item => ({
id: randomUUID(),
tenant_id: source.tenant_id,
source_id: source.id,
guid: item.guid || item.link || item.title || randomUUID(),
title: item.title || '(sans titre)',
link: item.link || null,
description: item.contentSnippet || item.content || null,
pub_date: item.isoDate || (item.pubDate ? new Date(item.pubDate).toISOString() : null),
status: 'new' as const,
}));

if (rows.length) {
await supabaseAdmin.from('tender_rss_matches').upsert(rows, { onConflict: 'source_id,guid', ignoreDuplicates: true });
}

await supabaseAdmin.from('tender_rss_sources')
.update({ last_polled_at: new Date().toISOString(), last_error: null })
.eq('id', source.id);
} catch (e: any) {
console.error(`[tenderRssPoller] Failed to poll source ${source.id} (${source.url}):`, e.message);
await supabaseAdmin.from('tender_rss_sources')
.update({ last_polled_at: new Date().toISOString(), last_error: e.message })
.eq('id', source.id);
}
}

export async function pollAllTenderRssSources(supabaseAdmin: SupabaseClient, tenantId?: string): Promise<void> {
let query = supabaseAdmin.from('tender_rss_sources').select('*').eq('enabled', true);
if (tenantId) query = query.eq('tenant_id', tenantId);
const { data, error } = await query;
if (error) {
console.error('[tenderRssPoller] Failed to list sources:', error.message);
return;
}
for (const source of (data || []) as TenderRssSourceRow[]) {
await pollSource(supabaseAdmin, source);
}
}

export function startTenderRssPolling(supabaseAdmin: SupabaseClient): void {
const intervalMinutes = parseInt(process.env.TENDER_RSS_POLL_INTERVAL_MINUTES || '', 10) || DEFAULT_INTERVAL_MINUTES;
const intervalMs = intervalMinutes * 60 * 1000;

pollAllTenderRssSources(supabaseAdmin).catch(e => console.error('[tenderRssPoller] Initial poll failed:', e.message));
setInterval(() => {
pollAllTenderRssSources(supabaseAdmin).catch(e => console.error('[tenderRssPoller] Poll cycle failed:', e.message));
}, intervalMs);
}
Loading
Loading