Files
ihaleist/server/index.js
T

1673 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {validateReminderPreferences} from '../src/services/reminderPreferences.js';
import {validateTeam} from '../src/services/teamData.js';
import { executeAnalysisJob } from './analysis-job.mjs';
import { resolveAiJobInput } from './ai-job-source.mjs';
import * as aiJobs from './ai-job-store.mjs';
import { startAiJobRunner } from './ai-job-runner.mjs';
import { observeTenders, tenderHistory } from './tender-history.mjs';
import { requestLifetime } from './request-lifetime.mjs';
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
import { deadlineInfo } from '../src/services/tenderCalendar.js';
import { exchangeRates } from './exchange-rates.mjs';
import { uniqueTenders, announcementFields } from './tender-quality.mjs';
/**
* ihaleist API Proxy — ilan.gov.tr canlı ihale verisi
*
* Kaynak: Basın İlan Kurumu resmi ilan portalı (www.ilan.gov.tr) açık JSON API'si.
* Kapsam: 4734 açık ihaleler + 2886 kira/satış ihaleleri. (Doğrudan temin 22/d bu
* kaynakta yayınlanmaz — Faz 2'de EKAP worker'ı ile eklenecek.)
*
* Bağımlılık yok: Node 18+ built-in http + fetch.
* Çalıştır: node server/index.js (PORT env ile port değişir, varsayılan 3001)
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readDtTenders } from './dt-store.mjs';
import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu, OKAS_BOLUM } from './siniflandir.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
import { kalemleriCikar } from './kalem-cikar.mjs';
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
import { iknCikar } from './kalem-cikar.mjs';
import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs';
import { telegramConfigured, botAdi, kodIleBagla, sendTelegram } from './telegram.mjs';
import { bildirimleriOku, okunduIsaretle, zeyilnameDurumu, zeyilnameTara, sonucTara } from './zeyilname.mjs';
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
const urunAramaCache = new Map();
import * as kalemStore from './kalem-store.mjs';
import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs';
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
import * as store from './user-store.mjs';
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
import { searchTed } from './ted.mjs';
import { tedCevir } from './ted-ceviri.mjs';
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi,
cpvCacheOku, cpvCacheYaz } from './history-store.mjs';
import { startScheduler, schedulerStatus } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs';
import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs';
import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni } from './ai-teklif.mjs';
import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolveDelivery, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXTRA_CA = fs.readFileSync(path.join(__dirname, 'geotrust-tls-rsa-ca-g1.pem'), 'utf8');
const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
const PORT = process.env.PORT || 3001;
const ILAN_BASE = 'https://www.ilan.gov.tr';
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 dk
const LIST_SIZE = 20; // ilan.gov.tr sayfa başına 20 kayıtla sınırlıyor
// ilan.gov.tr sayfa başına 20 kayıt veriyor; havuzun tamamı için çok sayfa gerekir.
// Sayfalar paralel çekilir, ilk yanıt beklemesin diye kalanı arka planda dolar.
const MAX_PAGES = Number(process.env.ILAN_MAX_PAGES || 300); // 300 × 20 = 6000 ilan
const ILK_PARTI = Number(process.env.ILAN_ILK_PARTI || 6); // ilk yanıt için yeterli sayfa
const ES_ZAMANLI = Number(process.env.ILAN_ESZAMANLI || 6); // paralel istek sayısı
const DT_LIMIT = Number(process.env.DT_LIMIT || 5000);
const HISTORY_LIMIT = Number(process.env.HISTORY_LIMIT || 5000);
const ILAN_HEADERS = {
accept: 'application/json',
'content-type': 'application/json',
origin: ILAN_BASE,
referer: `${ILAN_BASE}/ilan/tum-ilanlar`,
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
};
// ---------------------------------------------------------------------------
// İl → Bölge eşlemesi
// ---------------------------------------------------------------------------
const REGIONS = {
'MARMARA BÖLGESİ': ['İstanbul', 'Bursa', 'Kocaeli', 'Balıkesir', 'Tekirdağ', 'Sakarya', 'Çanakkale', 'Edirne', 'Kırklareli', 'Yalova', 'Bilecik'],
'İÇ ANADOLU BÖLGESİ': ['Ankara', 'Konya', 'Kayseri', 'Eskişehir', 'Sivas', 'Aksaray', 'Karaman', 'Kırıkkale', 'Kırşehir', 'Nevşehir', 'Niğde', 'Yozgat', 'Çankırı'],
'EGE BÖLGESİ': ['İzmir', 'Manisa', 'Aydın', 'Denizli', 'Muğla', 'Afyonkarahisar', 'Kütahya', 'Uşak'],
'AKDENİZ BÖLGESİ': ['Antalya', 'Adana', 'Mersin', 'Hatay', 'Kahramanmaraş', 'Osmaniye', 'Isparta', 'Burdur'],
'KARADENİZ BÖLGESİ': ['Samsun', 'Trabzon', 'Ordu', 'Rize', 'Giresun', 'Zonguldak', 'Tokat', 'Çorum', 'Amasya', 'Sinop', 'Kastamonu', 'Bartın', 'Karabük', 'Düzce', 'Bolu', 'Artvin', 'Gümüşhane', 'Bayburt'],
'DOĞU ANADOLU BÖLGESİ': ['Erzurum', 'Van', 'Malatya', 'Elazığ', 'Erzincan', 'Ağrı', 'Kars', 'Ardahan', 'Iğdır', 'Muş', 'Bitlis', 'Bingöl', 'Tunceli', 'Hakkari'],
'GÜNEYDOĞU ANADOLU BÖLGESİ': ['Gaziantep', 'Şanlıurfa', 'Diyarbakır', 'Mardin', 'Batman', 'Siirt', 'Şırnak', 'Adıyaman', 'Kilis'],
};
const CITY_TO_REGION = {};
for (const [region, cities] of Object.entries(REGIONS)) {
for (const c of cities) CITY_TO_REGION[c.toLocaleUpperCase('tr-TR')] = region;
}
// ---------------------------------------------------------------------------
// Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre)
// ---------------------------------------------------------------------------
function titleCaseTr(s) {
if (!s) return '';
return s
.toLocaleLowerCase('tr-TR')
.split(/\s+/)
.map((w) => (w ? w[0].toLocaleUpperCase('tr-TR') + w.slice(1) : w))
.join(' ');
}
function getFilter(ad, key) {
const f = (ad.adTypeFilters || []).find((x) => x.key === key);
return f ? f.value : null;
}
function relativeTimeTr(iso) {
if (!iso) return '';
const diffMs = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 60) return `${Math.max(mins, 1)} dk önce eklendi`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} saat önce eklendi`;
return `${Math.floor(hours / 24)} gün önce eklendi`;
}
// Türkiye günü ("yyyy-aa-gg"). Sunucu konteyneri UTC'de çalışıyor; ilan.gov.tr
// yayın saati TR gece yarısı = "…T21:00:00Z" olduğundan UTC'ye göre gün DÜN
// çıkıyor ve "bugün eklenen" hep 0 görünüyordu.
const TR_GUN = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Istanbul', year: 'numeric', month: '2-digit', day: '2-digit',
});
function trGun(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : TR_GUN.format(d);
}
const bugunTR = () => TR_GUN.format(new Date());
// isYeni / eklenmeZamani ilan havuza girerken hesaplanıp donuyordu; havuz
// günlerce yaşayınca "22 saat önce" damgası ve "bugün" bayrağı bayatlıyordu.
// Her yanıtta tazelenir (3 bin kayıt için birkaç ms).
function ilanTazele(t) {
if (!t.isLiveFeed || t.kaynak !== 'ilan.gov.tr') return t;
t.isYeni = Boolean(t.ilanTarihi) && t.ilanTarihi === bugunTR();
if (t.yayinZamani) t.eklenmeZamani = relativeTimeTr(t.yayinZamani);
return t;
}
const bugunEklenenSayisi = (liste) => {
const bugun = bugunTR();
return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length;
};
// ilan.gov.tr kaydını ihaleist tender modeline çevir
function mapAd(ad) {
const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR');
const sehir = titleCaseTr(ad.addressCityName || '');
const tarih = getFilter(ad, 'İhale ve Teklif Açma Tarihi') || '';
// İKN'yi boşluksuz sakla: ilan.gov.tr bazı kayıtlarda başa/sona boşluk ve
// kesme boşluğu (nbsp) koyuyor; " 2026/1622878" EKAP formatı sayılmıyor ve
// doküman/analiz düğmeleri kapanıyordu (havuzda 50 kayıt).
const ikn = String(getFilter(ad, 'İhale Kayıt No') || ad.adNo || '').replace(/[\s\u00a0]+/g, '');
const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş';
const ihaleTuru = getFilter(ad, 'İhale Türü') || '';
const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`; // yalnızca arama için
const kalanGun = deadlineInfo({ tarih }).days;
let aciliyetBadge = null;
if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN';
const ilanTarihi = trGun(ad.publishStartDate);
return {
id: `ilan-${ad.id}`,
kaynak: 'ilan.gov.tr',
kaynakUrl: `${ILAN_BASE}${ad.urlStr || ''}`,
ikn,
baslik: ad.title || '',
kurum: ad.advertiserName || '',
sehir,
bolge: CITY_TO_REGION[cityUpper] || '',
tarih,
saat: '',
alimTuru: ihaleTuru || 'İhale',
usul,
// Başlık önce, kurum adı yalnızca yedek (bkz. sektorBul)
sektor: sektorBul(ad.title || '', ad.advertiserName || ''),
brans: ihaleTuru || 'Genel',
kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'),
eklenmeZamani: relativeTimeTr(ad.publishStartDate),
yayinZamani: ad.publishStartDate || null,
ilanTarihi,
isYeni: Boolean(ilanTarihi) && ilanTarihi === bugunTR(),
isLiveFeed: true,
aciliyetBadge,
items: [],
aciklama: null, // detay çağrısıyla lazy doldurulur
yaklasikMaliyet: null,
teslimatSuresi: null,
teslimYeri: null,
yeterlikKriterleri: [],
aiMatchScore: null,
aiRiskScore: null,
aiBidRange: null,
kanbanStatus: 'incelenecek',
atananPersonel: null,
durum: 'Aktif',
incelendi: false,
takipte: false,
sozlesmeBedeli: null,
kazananFirma: null,
};
}
// ---------------------------------------------------------------------------
// ilan.gov.tr istemcisi + cache
// ---------------------------------------------------------------------------
function httpsJson(urlStr, { method = 'GET', body = null } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const payload = body ? JSON.stringify(body) : null;
const req = https.request(
{
hostname: u.hostname,
path: u.pathname + u.search,
method,
ca: CA_BUNDLE,
headers: {
...ILAN_HEADERS,
...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
},
timeout: 30000,
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`ilan.gov.tr HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
}
try {
resolve(JSON.parse(text));
} catch {
reject(new Error('ilan.gov.tr geçersiz JSON döndürdü'));
}
});
},
);
req.on('timeout', () => req.destroy(new Error('ilan.gov.tr zaman aşımı')));
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function ilanSearch(keys, skipCount = 0, maxResultCount = LIST_SIZE) {
const data = await httpsJson(`${ILAN_BASE}/api/api/services/app/Ad/AdsByFilter`, {
method: 'POST',
body: { keys, skipCount, maxResultCount },
});
return data.result || {};
}
async function ilanDetail(id) {
const data = await httpsJson(
`${ILAN_BASE}/api/api/services/app/AdDetail/GetAdDetail?id=${encodeURIComponent(id)}`,
);
return data.result || {};
}
function htmlToText(htmlStr) {
if (!htmlStr) return '';
return htmlStr
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<(?:br|\/tr|\/p|\/h\d|\/li)>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() };
let arkaPlanDoluyor = false;
// Bir sayfa grubunu paralel çeker
async function ilanSayfalari(baslangic, adet) {
const istekler = [];
for (let i = 0; i < adet; i++) {
const page = baslangic + i;
istekler.push(
ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE).catch((e) => {
console.warn(`[ihaleist-api] sayfa ${page} alınamadı: ${e.message}`);
return { ads: [], hata: true };
})
);
}
return Promise.all(istekler);
}
// --- CPV/OKAS sınıflandırma GATE'i -----------------------------------------
// "sektör" alanının aksine (bkz. sektorZenginlestir — önce yayınla, sonra
// arka planda zenginleştir), CPV alanı bir ihale cache.tenders'a GİRMEDEN
// ÖNCE tamamlanmış olmalı. Bu yüzden bu fonksiyon her zaman ilanlariBirlestir
// / getTenders içinde, sonuç diziye eklenmeden HEMEN ÖNCE await'lenir.
const CPV_ESZAMANLI = Number(process.env.CPV_ESZAMANLI || 4); // AI'ı yormamak için sınırlı paralellik
async function cpvUygula(t) {
const ikn = t.ikn || '';
if (ikn) {
const onbellek = cpvCacheOku(ikn);
if (onbellek) {
Object.assign(t, onbellek);
return t;
}
}
let sonuc;
try {
sonuc = await classifyTenderCpv(t, { analyzeSpec });
} catch (e) {
console.warn(`[cpv-classify] ${t.id} sınıflandırılamadı:`, e.message);
sonuc = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
}
Object.assign(t, sonuc);
if (ikn) cpvCacheYaz(ikn, sonuc);
return t;
}
/**
* HIZLI CPV geçişi — yalnızca önbellek + anahtar kelime, AI YOK.
*
* Eskiden havuz, her ilan için AI çağrısı bitene kadar yayınlanmıyordu:
* 3.000 ilan × AI = saatler, bu sürede kullanıcı yalnızca ilk 120 ilanı
* görüyordu. Artık havuz anında yayınlanır; anahtar kelimenin çözemediği
* ilanlar arka planda AI ile tamamlanır.
*/
function cpvHizli(t) {
const ikn = t.ikn || '';
if (ikn) {
const onbellek = cpvCacheOku(ikn);
if (onbellek) { Object.assign(t, onbellek); return true; }
}
const anahtar = classifyCpvByKeyword(t);
if (anahtar) {
Object.assign(t, anahtar);
if (ikn) cpvCacheYaz(ikn, anahtar);
return true;
}
Object.assign(t, { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'bekliyor' });
return false;
}
// Arka planda AI ile CPV tamamlama (havuzu bloklamaz)
const CPV_TUR_LIMIT = Number(process.env.CPV_TUR_LIMIT || 200);
let cpvCalisiyor = false;
async function cpvZenginlestir() {
if (cpvCalisiyor || !cache.tenders?.length) return;
cpvCalisiyor = true;
try {
const kuyruk = cache.tenders.filter((t) => t.cpvSource === 'bekliyor').slice(0, CPV_TUR_LIMIT);
if (!kuyruk.length) return;
const bekleyenToplam = cache.tenders.filter((t) => t.cpvSource === 'bekliyor').length;
console.log(`[cpv-zengin] ${kuyruk.length} ilan AI ile sınıflandırılacak (bekleyen ${bekleyenToplam})`);
for (let i = 0; i < kuyruk.length; i += CPV_ESZAMANLI) {
const grup = kuyruk.slice(i, i + CPV_ESZAMANLI);
await Promise.all(grup.map((t) => cpvUygula(t)));
}
console.log(`[cpv-zengin] ${kuyruk.length} ilan tamamlandı`);
} catch (e) {
console.warn('[cpv-zengin] hata:', e.message);
} finally {
cpvCalisiyor = false;
}
}
function ilanlariBirlestir(mevcut, yeniAds) {
const seen = new Set(mevcut.map((t) => t.id));
const eklenen = [];
for (const ad of yeniAds) {
const t = mapAd(ad);
if (seen.has(t.id)) continue;
seen.add(t.id);
eklenen.push(t);
}
// Hızlı sınıflandırma (önbellek + anahtar kelime). AI'a kalanlar arka planda
// tamamlanır; havuzun yayınlanması AI'ı beklemez.
for (const t of eklenen) cpvHizli(t);
return eklenen;
}
// Kalan sayfaları arka planda çeker. Yeni liste ayrı kurulur ve TAMAMLANINCA
// atomik olarak devreye alınır; böylece yenileme sırasında kullanıcı hiçbir
// zaman eksik listeye düşmez (önceden her yenilemede 120 kayda iniyordu).
async function arkaPlandaTamamla(numFound, ilkListe) {
if (arkaPlanDoluyor) return;
arkaPlanDoluyor = true;
try {
const yeni = [...ilkListe];
const gerekliSayfa = Math.min(MAX_PAGES, Math.ceil(numFound / LIST_SIZE));
let page = ILK_PARTI;
let eksiksiz = true; // her sayfa hatasız indi mi?
while (page < gerekliSayfa) {
const grup = Math.min(ES_ZAMANLI, gerekliSayfa - page);
const sonuclar = await ilanSayfalari(page, grup);
if (sonuclar.some((r) => r.hata)) eksiksiz = false;
const ads = sonuclar.flatMap((r) => r.ads || []);
if (!ads.length) { eksiksiz = false; break; }
const eklenen = ilanlariBirlestir(yeni, ads);
if (eklenen.length) yeni.push(...eklenen);
page += grup;
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
}
// EKSİKSİZ inen liste her zaman devreye girer — kaynaktaki ihale sayısı
// düşse bile. Eskiden "yeni liste eskisinden küçükse alma" kuralı vardı;
// kaynak 3.372'lik zirveden 3.100'e inince havuz o zirve anında DONDU:
// günlerce yeni ilan eklenmedi, "bugün eklenen" 0 kaldı. Yarım inen liste
// ise yalnızca eldekinden zenginse alınır (kullanıcı eksik listeye düşmesin).
if (eksiksiz || yeni.length >= cache.tenders.length) {
if (yeni.length < cache.tenders.length) {
console.log(`[ihaleist-api] havuz küçüldü: ${cache.tenders.length} → ${yeni.length} (kaynak ${numFound}); güncel liste devrede`);
}
cache.tenders = yeni;
cache.todayCount = bugunEklenenSayisi(yeni);
} else {
console.warn(`[ihaleist-api] yenileme eksik indi (${yeni.length}/${numFound}); eldeki ${cache.tenders.length} ilan korunuyor`);
}
console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
havuzKaydet(numFound, cache.tenders.length);
zenginUygula(); // önceden çözülmüşleri hemen uygula
setTimeout(() => sektorZenginlestir().catch(() => {}), 5000); // kalanları arka planda
setTimeout(() => cpvZenginlestir().catch(() => {}), 15000); // CPV'yi AI ile tamamla
} catch (e) {
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
} finally {
arkaPlanDoluyor = false;
}
}
// --- Sektör zenginleştirme -------------------------------------------------
// Başlığında sektör sinyali olmayan ilanların ("MUHTELİF MALZEME ALIMI")
// ilan METNİ indirilip sınıflandırılır. Sonuç kalıcı; aynı ilan bir daha
// indirilmez. Kaynağı yormamak için turda sınırlı sayıda ilan işlenir.
const ZENGIN_TUR_LIMIT = Number(process.env.SEKTOR_ZENGIN_LIMIT || 300);
const ZENGIN_PARALEL = Number(process.env.SEKTOR_ZENGIN_PARALEL || 4);
let zenginCalisiyor = false;
async function sektorZenginlestir() {
if (zenginCalisiyor || !cache.tenders?.length) return;
zenginCalisiyor = true;
try {
const adaylar = cache.tenders.filter((t) => t.sektor === 'Genel' && t.isLiveFeed && t.id?.startsWith('ilan-'));
if (!adaylar.length) return;
const denenmis = sektorZenginDenenmis(adaylar.map((t) => t.id));
const kuyruk = adaylar.filter((t) => !denenmis.has(t.id)).slice(0, ZENGIN_TUR_LIMIT);
if (!kuyruk.length) return;
console.log(`[sektor-zengin] ${kuyruk.length} ilan metni sınıflandırılacak (kalan aday ${adaylar.length - denenmis.size})`);
const sonuc = [];
for (let i = 0; i < kuyruk.length; i += ZENGIN_PARALEL) {
const grup = kuyruk.slice(i, i + ZENGIN_PARALEL);
await Promise.all(grup.map(async (t) => {
try {
const det = await getDetail(t.id.replace(/^ilan-/, ''));
// İlanın TAMAMI değil, yalnızca işin konusu taranır: standart
// hukuki metin ("e-imza ile imzalanarak", "numune sunulması")
// sınıflandırmayı zehirliyordu.
const sek = sektorBul(t.baslik || '', ilanKonusu(det.aciklama || ''));
sonuc.push({ id: t.id, sektor: sek, kaynak: 'dokuman-v3' });
} catch (e) {
// Metin alınamadıysa 'Genel' yaz — sonsuz yeniden deneme olmasın.
sonuc.push({ id: t.id, sektor: 'Genel', kaynak: 'dokuman-v3' });
}
}));
// Kaynağı yormamak için gruplar arasında kısa duraklama.
if (i + ZENGIN_PARALEL < kuyruk.length) await new Promise((r) => setTimeout(r, 250));
}
const yazilan = sektorZenginYaz(sonuc);
const cozulen = sonuc.filter((r) => r.sektor !== 'Genel').length;
console.log(`[sektor-zengin] ${yazilan} kayıt işlendi, ${cozulen} tanesi sınıflandırıldı`);
zenginUygula();
} catch (e) {
console.warn('[sektor-zengin] hata:', e.message);
} finally {
zenginCalisiyor = false;
}
}
/** Kayıtlı zenginleştirmeyi havuzdaki 'Genel' ilanlara uygular. */
function zenginUygula() {
if (!cache.tenders?.length) return 0;
const genel = cache.tenders.filter((t) => t.sektor === 'Genel');
if (!genel.length) return 0;
const harita = sektorZenginOku(genel.map((t) => t.id));
let n = 0;
for (const t of genel) {
const s = harita.get(t.id);
if (s && s !== 'Genel') { t.sektor = s; n++; }
}
if (n) console.log(`[sektor-zengin] ${n} ilan doküman metninden sınıflandırıldı`);
return n;
}
// Havuz büyüklüğünü kaydeder; ani düşüş / eksik yükleme varsa yöneticiyi uyarır.
let sonHavuzUyarisi = 0;
function dtSayisiGuvenli() {
try { return readDtTenders(1)?.totalCount ?? null; } catch { return null; }
}
function havuzKaydet(kaynakToplam, yuklenen) {
try {
const ist = historyStats?.() || {};
const sonuc = havuzOlc({
kaynakToplam,
yuklenen,
dt: dtSayisiGuvenli(),
arsiv: ist.toplam ?? null,
});
if (!sonuc?.uyari) return;
console.warn(`[havuz-izleme] ${sonuc.uyari}`);
// Aynı uyarıyı sürekli göndermemek için 6 saatte bir e-posta.
if (Date.now() - sonHavuzUyarisi < 6 * 3600e3) return;
sonHavuzUyarisi = Date.now();
const alicilar = adminEmails();
if (!alicilar.length || !emailConfigured()) return;
sendEmail({
to: alicilar,
subject: sonuc.tur === 'eksik_yukleme'
? 'İhaleist: havuz eksik yüklendi'
: 'İhaleist: canlı ihale havuzu ani düştü',
text: `${sonuc.uyari}\n\nÖnceki ölçüm: ${sonuc.onceki ?? '-'}\nŞimdi: ${sonuc.simdi ?? '-'}\n\n` +
`Ayrıntı: ${process.env.APP_URL || 'https://ihaleist.seymata.com'}/api/health/havuz`,
}).catch((e) => console.warn('[havuz-izleme] uyarı e-postası gönderilemedi:', e.message));
} catch (e) {
console.warn('[havuz-izleme] ölçüm alınamadı:', e.message);
}
}
async function getTenders(force = false) {
const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
if (fresh && !force) return cache;
const ilk = await ilanSayfalari(0, ILK_PARTI);
const numFound = ilk.find((r) => r.numFound)?.numFound || 0;
const ads = ilk.flatMap((r) => r.ads || []);
const seen = new Set();
const ilkListe = ads
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
.map(mapAd);
// Hızlı sınıflandırma; AI'a kalanlar arka planda tamamlanır.
for (const t of ilkListe) cpvHizli(t);
cache.totalCount = numFound || ilkListe.length;
cache.fetchedAt = Date.now();
// İlk yükleme: elde hiçbir şey yoksa ilk partiyi hemen göster.
// Yenileme: eldeki dolu listeyi KORU, yenisi arka planda hazırlansın.
if (!cache.tenders || cache.tenders.length === 0) {
cache.tenders = ilkListe;
cache.todayCount = bugunEklenenSayisi(ilkListe);
console.log(`[ihaleist-api] ilk yükleme: ${ilkListe.length} ilan (havuz ${cache.totalCount}) — kalanı arka planda`);
} else {
console.log(`[ihaleist-api] yenileme başladı (mevcut ${cache.tenders.length} ilan sunulmaya devam ediyor)`);
}
if (cache.totalCount > ilkListe.length) arkaPlandaTamamla(cache.totalCount, ilkListe);
return cache;
}
async function getDetail(ilanId) {
if (cache.detail.has(ilanId)) return cache.detail.get(ilanId);
const raw = await ilanDetail(ilanId);
const text = htmlToText(raw.content || '');
const detail = { ...announcementFields(text), adNo: raw.adNo || null };
const sourceTender=cache.tenders.find(t=>t.id===`ilan-${ilanId}`);
if(sourceTender) {
try { observeTenders([{...sourceTender,...detail,id:`ilan-${ilanId}:detail`}]); }
catch { detail.sourceWarning='İlan alındı ancak kaynak geçmişi kaydedilemedi.'; }
}
cache.detail.set(ilanId, detail);
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
return detail;
}
// ---------------------------------------------------------------------------
// HTTP sunucu
// ---------------------------------------------------------------------------
function json(res, code, body) {
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
}
function readRaw(req, limit = 1024 * 1024) {
return new Promise((resolve, reject) => {
let buf = '';
req.on('data', (c) => {
buf += c;
if (buf.length > limit) { reject(new Error('Gövde çok büyük')); req.destroy(); }
});
req.on('end', () => resolve(buf));
req.on('error', reject);
});
}
function readBody(req, limit = 100 * 1024) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (c) => {
data += c;
if (data.length > limit) reject(new Error('gövde çok büyük'));
});
req.on('end', () => {
try {
resolve(data ? JSON.parse(data) : {});
} catch {
reject(new Error('geçersiz JSON gövdesi'));
}
});
req.on('error', reject);
});
}
// /api/tenders gövdesini üretir; bildirim işi de aynı veriyi kullanır.
async function tendersPayload(force = false) {
const c = await getTenders(force);
let dt = { tenders: [], totalCount: 0, available: false };
try { dt = readDtTenders(DT_LIMIT); }
catch (e) { console.warn('[ihaleist-api] DT okuma hatası:', e.message); }
let hist = { tenders: [], totalCount: 0, available: false };
try { hist = readHistory(HISTORY_LIMIT); }
catch (e) { console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message); }
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
for (const t of c.tenders) ilanTazele(t); // isYeni + "x saat önce" gün değişse de doğru
c.todayCount = bugunEklenenSayisi(c.tenders);
// Ortak kalem havuzu: bir kullanıcı dokümanı yüklediyse o ihalenin teklif
// cetveli kalemleri herkesin kartında görünsün.
try {
const ozet = kalemStore.kalemOzetleri();
if (ozet.size) {
for (const t of [...c.tenders, ...dt.tenders, ...hist.tenders]) {
const k = t.ikn && ozet.get(t.ikn);
if (k) { t.kalemSayisi = k.sayi; t.kalemler = k.ilkler; }
}
}
} catch (e) {
console.warn('[kalem] özet uygulanamadı:', e.message);
}
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
let historyWarning=null;
try { observeTenders(combined); } catch { historyWarning='Kaynak geçmişi kaydedilemedi; değişiklik arşivi eksik olabilir.'; };
return {
success: true,
source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
ilanCount: c.totalCount,
todayCount: bugunEklenenSayisi(combined),
loadedUniqueCount: combined.length,
historyWarning,
dtCount: dt.totalCount,
historyCount: hist.totalCount,
tenders: combined,
};
}
const aiJobRunner = startAiJobRunner(async (job,{signal}) => {
if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.');
if(job.operation==='analiz')return executeAnalysisJob(job,{analyze:analyzeSpec,reviewSource:reviewedAnalysisSource,pin:aiJobs.pinAiJobInput,signal});
const body=await resolveAiJobInput(job,ihaleMetni,aiJobs.pinAiJobInput,signal,input=>gereklilikMetni(input.tender,input.gereklilikler,'',true));
const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text,resolvedSource:body.resolvedSource};
const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru})
: job.operation==='teklif-denetim' ? await teklifDenetle({...shared,taslak:body.taslak,gereklilikler:body.gereklilikler})
: await teklifTaslak({...shared,tur:body.tur,firma:body.firma,belgeler:body.belgeler,fiyat:body.fiyat,gereklilikler:body.gereklilikler});
return {success:true,...result};
});
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
try {
if (url.pathname === '/api/me/ai-jobs') {
const user=userFromAuthHeader(req.headers.authorization);
if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'});
const id=url.searchParams.get('id');
if(req.method==='GET') {
if(id){const job=aiJobs.getAiJob(user.email,id);return json(res,job?200:404,{success:!!job,job,error:job?undefined:'İş bulunamadı.'});}
return json(res,200,{success:true,jobs:aiJobs.listAiJobs(user.email,url.searchParams.get('ikn')).map(({result,...job})=>job)});
}
if(req.method==='DELETE') {
const ok=aiJobs.cancelAiJob(user.email,id);if(ok)aiJobRunner.cancel(id);
return json(res,ok?200:404,{success:ok,error:ok?undefined:'İş bulunamadı veya artık iptal edilemiyor.'});
}
if(req.method==='POST') {
try {
if(!aiConfigured())return json(res,503,{success:false,error:'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin.'});
const body=await readBody(req,2*1024*1024);
if(id) {if(body.action!=='retry'||!aiJobs.retryAiJob(user.email,id))return json(res,404,{success:false,error:'Yeniden başlatılabilir iş bulunamadı.'});}
const job=id?aiJobs.getAiJob(user.email,id):aiJobs.createAiJob(user.email,body.requestId,body.operation,body.input);
aiJobRunner.wake();return json(res,202,{success:true,job});
}catch(error){return json(res,400,{success:false,error:error.message});}
}
return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
}
if (url.pathname === '/api/exchange-rates') {
try { return json(res,200,{success:true,...await exchangeRates()}); }
catch(e) { return json(res,503,{success:false,error:e.message}); }
}
// --- Google ile Giriş ---
if (url.pathname === '/api/auth/google/config') {
return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null });
}
if (url.pathname === '/api/auth/google' && req.method === 'POST') {
try {
const body = await readBody(req);
const { token, user } = await loginWithGoogle(body.credential);
return json(res, 200, { success: true, token, user });
} catch (e) {
return json(res, 401, { success: false, error: e.message });
}
}
if (url.pathname === '/api/auth/me') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
return json(res, 200, { success: true, user });
}
// --- İhale dokümanları ---
if (url.pathname === '/api/documents/url') {
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await documentUrl(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
if (url.pathname === '/api/documents/ilan') {
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await ilanMetni(ikn);
return json(res, 200, { success: true, ...out, ...announcementFields(out.metin) });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// Doküman metni — sunucu tarafı tarayıcı gerektirir
if (url.pathname === '/api/documents/text') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const out = await documentText(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/documents/config') {
return json(res, 200, { tarayiciVar: await browserAvailable() });
}
// --- Ortak kalem havuzu ---
// PDF metni tarayıcıda çıkarılıyor; istemci buraya gönderir.
if (url.pathname === '/api/kalem/cikar' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
let validatedDocument = null;
try {
const body = await readBody(req, 4 * 1024 * 1024);
if (!body.ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
const sourceDocument = body.documentId && store.getDocument(user.email,String(body.documentId));
if (!sourceDocument || sourceDocument.tur !== 'ihale' || sourceDocument.ikn !== String(body.ikn))
return json(res,403,{success:false,error:'Kalem aktarımı için bu ihaleye bağlı, size ait ihale dokümanı gerekli. Firma belgeleri ortak havuza aktarılamaz.'});
validatedDocument = sourceDocument;
if (typeof body.metin !== 'string') throw new Error('Doküman metni gerekli.');
const { kalemler, yontem } = kalemleriCikar(body.metin || '');
if (!kalemler.length) {
store.setDocumentProcessing(user.email,sourceDocument.id,{status:'no_items',count:0});
return json(res, 200, { success: true, kalemler: [], yontem, not: 'Metinde teklif cetveli tablosu bulunamadı.' });
}
kalemStore.kalemYaz(String(body.ikn), kalemler, {
dosya: body.dosya ? String(body.dosya).slice(0, 200) : null, yontem, ekleyen: user.email,
});
store.setDocumentProcessing(user.email,sourceDocument.id,{status:'processed',count:kalemler.length});
return json(res, 200, { success: true, kalemler, yontem, sayi: kalemler.length });
} catch (e) {
if (validatedDocument) {
try { store.setDocumentProcessing(user.email,validatedDocument.id,{status:'error',error:'Kalem çıkarımı tamamlanamadı. Doküman metnini kontrol edip yeniden deneyin.'}); }
catch (statusError) { console.error('[document-processing] Durum kaydedilemedi:', statusError.message); }
}
return json(res, 400, { success: false, error: e.message });
}
}
if (url.pathname === '/api/kalem') {
const ikn = url.searchParams.get('ikn');
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
try { return json(res, 200, { success: true, ...kalemStore.kalemOku(ikn) }); }
catch { return json(res,503,{success:false,error:'Kalem deposuna erişilemiyor. Daha sonra yeniden deneyin.'}); }
}
if (url.pathname === '/api/kalem/ara') {
const q = url.searchParams.get('q') || '';
if (q.trim().length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' });
try {
const sonuc = kalemStore.kalemAra(q);
return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc });
} catch { return json(res,503,{success:false,error:'Kalem araması şu anda kullanılamıyor. Yeniden deneyin.'}); }
}
// --- Ürün araması: metni ihale DOKÜMANININ içinde arar ---
// EKAP teklif cetvelini kendi tarafında indeksliyor; doküman indirmeye
// gerek yok. "ORTOPEDİK VİDA" hiçbir başlıkta geçmiyor ama teklif
// cetvellerinde geçiyor — firmanın kendi ürününü bulma yolu bu.
if (url.pathname === '/api/urun-ara') {
const q = (url.searchParams.get('q') || '').trim();
const okas = (url.searchParams.get('okas') || '').split(',').map((k) => k.trim()).filter(Boolean);
if (!okas.length && q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' });
const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0);
const take = Math.min(50, Math.max(1, Number(url.searchParams.get('take')) || 20));
const teknik = url.searchParams.get('teknik') === '1';
const anahtar = `${okas.length ? 'okas:' + okas.join(',') : q}|${skip}|${take}|${teknik}`;
const simdi = Date.now();
const onbellek = urunAramaCache.get(anahtar);
if (onbellek && simdi - onbellek.zaman < 5 * 60 * 1000) {
return json(res, 200, { success: true, onbellekten: true, ...onbellek.veri });
}
try {
const out = okas.length
? await searchByOkas(okas, { skip, take })
: await searchInDocuments(q, { teknikSartname: teknik, skip, take });
urunAramaCache.set(anahtar, { zaman: simdi, veri: out });
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
if (url.pathname === '/api/okas/ara') {
const q = (url.searchParams.get('q') || '').trim();
if (q.length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' });
const anahtar = `okas|${q}`;
const onb = urunAramaCache.get(anahtar);
if (onb && Date.now() - onb.zaman < 60 * 60 * 1000) return json(res, 200, { success: true, kalemler: onb.veri });
try {
const kalemler = await okasAra(q, { take: 30 });
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
if (url.pathname === '/api/uyum/gomme' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req, 64 * 1024);
const metin = String(body.profilMetni || '').trim();
if (metin.length < 10) return json(res, 400, { success: false, error: 'profil metni yok' });
const c = await getTenders(false);
const out = await bonusHesapla(metin, c.tenders || []);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 500, { success: false, error: e.message });
}
}
if (url.pathname === '/api/health/uyum') {
return json(res, 200, { success: true, ...gommeDurumu() });
}
// --- Telegram bağlantısı (kullanıcı kodu bota yazar, biz getUpdates'te görürüz) ---
if (url.pathname.startsWith('/api/me/telegram')) {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const tg = store.getData(user.email, 'telegram');
if (req.method === 'GET') {
return json(res, 200, { success: true, yapilandirilmis: telegramConfigured(), bot: await botAdi(), bagli: !!tg?.chatId, ad: tg?.ad || null, baglanma: tg?.baglanma || null });
}
if (req.method === 'DELETE') { store.setData(user.email, 'telegram', null); return json(res, 200, { success: true }); }
if (req.method === 'POST') {
try {
const body = await readBody(req);
if (body.islem === 'test') {
if (!tg?.chatId) return json(res, 400, { success: false, error: 'Telegram bağlı değil' });
await sendTelegram(tg.chatId, '✅ İhaleist bağlantı testi — bildirimler bu sohbete gelecek.');
return json(res, 200, { success: true });
}
const kod = String(body.kod || '').replace(/\D/g, '');
if (kod.length !== 6) return json(res, 400, { success: false, error: '6 haneli kod gerekli' });
const bulunan = await kodIleBagla(kod);
if (!bulunan) return json(res, 404, { success: false, error: 'Kod bota gönderilmiş görünmüyor. Önce bota yazın, sonra doğrulayın.' });
store.setData(user.email, 'telegram', { ...bulunan, baglanma: new Date().toISOString() });
await sendTelegram(bulunan.chatId, `✅ İhaleist hesabınız (${user.email}) bu sohbete bağlandı.`).catch(() => {});
return json(res, 200, { success: true, ad: bulunan.ad });
} catch (e) { return json(res, 502, { success: false, error: e.message }); }
}
}
// --- Takip bildirimleri (zeyilname / sonuç) ---
if (url.pathname === '/api/me/bildirimler') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (req.method === 'POST') {
const body = await readBody(req);
return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) });
}
if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) });
}
if (url.pathname === '/api/health/takip') {
return json(res, 200, { success: true, ...zeyilnameDurumu(), telegram: telegramConfigured() });
}
if (url.pathname === '/api/admin/takip/tara' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici gerekli' });
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
// --- KİK kurul kararları ---
if (url.pathname === '/api/kik/search') {
try {
const q = url.searchParams.get('q') || '';
const out = await searchKik(
{
metin: url.searchParams.get('metin') || (url.searchParams.get('alan') ? null : q),
idare: url.searchParams.get('idare'),
basvuran: url.searchParams.get('basvuran'),
kararNo: url.searchParams.get('kararNo'),
yil: url.searchParams.get('yil'),
},
url.searchParams.get('tur') || 'uyusmazlik',
Number(url.searchParams.get('limit')) || 25
);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/kik/karar') {
try {
const id = url.searchParams.get('id');
if (!id) return json(res, 400, { success: false, error: 'id gerekli' });
const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Geçmiş sonuç ilanları (kazanan firma arşivi) ---
if (url.pathname === '/api/history') {
const out = readHistory(Number(url.searchParams.get('limit')) || 300, {
firma: url.searchParams.get('firma'),
idare: url.searchParams.get('idare'),
il: url.searchParams.get('il'),
});
return json(res, 200, { success: true, ...out });
}
if (url.pathname === '/api/history/stats') {
return json(res, 200, { success: true, ...historyStats() });
}
// --- TED (AB ihaleleri) ---
if (url.pathname === '/api/ted') {
try {
const out = await searchTed({
mod: url.searchParams.get('mod') || 'turkiye',
since: url.searchParams.get('since') || '20240101',
page: Number(url.searchParams.get('page')) || 1,
limit: Number(url.searchParams.get('limit')) || 25,
});
// Başlık/açıklama Türkçesi: TED'in kendi TUR metni → önbellek → model
const bekleyenCeviri = await tedCevir(out.tenders).catch(() => 0);
return json(res, 200, { success: true, ...out, ceviriBekleniyor: bekleyenCeviri > 0 });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- Belge deposu (GERÇEK dosya, GERÇEK boyut, kota uygulanır) ---
if (url.pathname === '/api/me/documents') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
const opts = { isAdmin: user.isAdmin };
if (req.method === 'POST') {
try {
const body = await readBody(req, 16 * 1024 * 1024); // 10 MB dosya × 1,34 base64 + JSON payı
if (!body.ad || !body.icerikBase64) {
return json(res, 400, { success: false, error: 'ad ve icerikBase64 gerekli' });
}
const documentType = body.documentType ?? 'firma';
if (!['firma','ihale'].includes(documentType)) throw new Error('Geçersiz belge türü.');
const metadata = documentMetadataPatch(body);
const buf = decodeDocumentUpload(body.icerikBase64);
const kullanimDurumu = store.documentUsage(user.email);
kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts);
kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts);
// Toplu yüklemede dosyanın hangi ihaleye ait olduğu metninden bulunur
// ("İhale Kayıt Numarası (İKN): 2026/1166496"); kullanıcıya sorulmaz.
// PDF tarayıcıda okunduğu için istemci ikn'yi kendisi gönderir.
let ikn = body.ikn ? String(body.ikn).slice(0, 40) : null;
let ofisMetni = null;
let readError = null;
if (sunucuOkuyabilir(body.ad)) {
try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { readError = 'Dosya metni okunamadı. Dosya biçimini ve bütünlüğünü kontrol edin.'; }
if (documentType === 'ihale' && !ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni);
}
const id = store.saveDocumentFile(user.email, {
ad: String(body.ad).slice(0, 200),
kategori: metadata.kategori || null,
mime: body.mime || null,
sonKullanma: metadata.sonKullanma || null,
tur: documentType,
ikn, // ihaleye bağlı belge
}, buf);
// Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan
// tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci
// metni /api/kalem/cikar'a gönderir.
let kalemBilgi = null;
let processing = documentType !== 'ihale' ? {status:'not_applicable'}
: readError ? {status:'error',error:readError}
: /\.(pdf|txt|md)$/i.test(body.ad) ? {status:'awaiting_text'}
: !sunucuOkuyabilir(body.ad) ? {status:'unsupported'}
: !ikn ? {status:'needs_tender'} : {status:'no_items',count:0};
if (documentType === 'ihale' && ikn && ofisMetni) {
try {
const { kalemler, yontem } = kalemleriCikar(ofisMetni);
if (kalemler.length) {
kalemStore.kalemYaz(ikn, kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email });
kalemBilgi = { sayi: kalemler.length, yontem };
processing = {status:'processed',count:kalemler.length};
console.log(`[kalem] ${ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`);
}
} catch (e) {
processing = {status:'error',error:'Kalem çıkarımı tamamlanamadı; yeniden deneyin.'};
console.warn('[kalem] çıkarılamadı:', e.message);
}
}
store.setDocumentProcessing(user.email,id,processing);
return json(res, 200, { success: true, id, ikn, processing, kalem: kalemBilgi, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
if (req.method === 'DELETE') {
const ok = store.deleteDocument(user.email, url.searchParams.get('id'));
return json(res, ok ? 200 : 404, { success: ok, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) });
}
// ?ikn=... verilirse yalnızca o ihaleye yüklenen belgeler döner
const iknFiltre = url.searchParams.get('ikn') || undefined;
return json(res, 200, {
success: true,
belgeler: store.listDocuments(user.email, { ikn: iknFiltre }),
kullanim: store.documentUsage(user.email),
haklar: { adet: haklar(user.email, opts).belgeAdet, bayt: haklar(user.email, opts).belgeBayt },
});
}
// Belge indirme
if (url.pathname === '/api/me/documents/download') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const rec = store.getDocument(user.email, url.searchParams.get('id'));
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
try {
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
res.writeHead(200, {
'Content-Type': rec.mime || 'application/octet-stream',
'Content-Length': buf.length,
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rec.ad)}`,
});
return res.end(buf);
} catch (e) {
return json(res, 500, { success: false, error: 'dosya okunamadı' });
}
}
// Belge üst verisi: son kullanma tarihi (süre takibi) / kategori
if (url.pathname === '/api/me/documents/meta' && req.method === 'PATCH') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req);
if (!body.id) return json(res, 400, { success: false, error: 'id gerekli' });
const ok = store.updateDocument(user.email, String(body.id), documentMetadataPatch(body));
if (!ok) return json(res, 404, { success: false, error: 'belge bulunamadı' });
return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) });
} catch (e) { return json(res, 400, { success: false, error: e.message }); }
}
if (url.pathname === '/api/me/documents/text-review/validate' && req.method === 'POST') {
const user=userFromAuthHeader(req.headers['authorization']);
if(!user) return json(res,401,{success:false,error:'oturum yok'});
try {
const body=await readBody(req,8192);
const resolved=reviewedAnalysisSource(user.email,body.review,body.ikn);
if(body.review.fileHash!==resolved.humanReview.fileHash || body.review.textHash!==resolved.humanReview.textHash)
return json(res,409,{success:false,error:'Analizin dayandığı metin incelemesi güncel değil. Analizi yenileyin.'});
return json(res,200,{success:true,review:resolved.humanReview});
} catch(error) {return json(res,error.status || 500,{success:false,error:error.message});}
}
if (url.pathname === '/api/me/documents/text-review') {
const user=userFromAuthHeader(req.headers['authorization']);
if(!user) return json(res,401,{success:false,error:'oturum yok'});
try {
if(req.method === 'GET') return json(res,200,{success:true,...documentReview(user.email,url.searchParams.get('id'))});
if(req.method !== 'POST') return json(res,405,{success:false,error:'desteklenmeyen metot'});
const body=await readBody(req,1100*1024);
if(typeof body.id !== 'string') return json(res,400,{success:false,error:'belge id gerekli'});
return json(res,200,{success:true,...saveDocumentReview(user.email,body.id,body)});
} catch(error) {return json(res,error.status || 500,{success:false,error:error.message});}
}
if (url.pathname === '/api/me/documents/read-error' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res,401,{success:false,error:'oturum yok'});
try {
const body = await readBody(req,8192);
const rec = body.id && store.getDocument(user.email,String(body.id));
if (!rec || rec.tur !== 'ihale') return json(res,404,{success:false,error:'ihale dokümanı bulunamadı'});
if (!Object.hasOwn(body,'expectedProcessing')) return json(res,400,{success:false,error:'işleme sürümü gerekli'});
if (!store.reportDocumentReadError(user.email,rec.id,body.expectedProcessing))
return json(res,409,{success:false,error:'Belge işleme durumu değişti. Listeyi yeniden yükleyin.'});
return json(res,200,{success:true});
} catch (e) { return json(res,400,{success:false,error:e.message}); }
}
// Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor;
// bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor.
if (url.pathname === '/api/me/documents/text') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const rec = store.getDocument(user.email, url.searchParams.get('id'));
if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' });
if (!sunucuOkuyabilir(rec.ad)) {
return json(res, 400, { success: false, error: 'Bu dosya türünden sunucuda metin çıkarılamıyor (docx, xlsx, zip destekleniyor).' });
}
try {
const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya));
const metin = arsivMetni(rec.ad, buf);
return json(res, 200, { success: true, ad: rec.ad, metin, karakter: metin.length });
} catch (e) {
return json(res, 422, { success: false, error: e.message });
}
}
if (url.pathname === '/api/me/tender-state') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (req.method === 'GET') return json(res, 200, {success:true, records:store.getTenderStates(user.email)});
if (req.method !== 'PUT') return json(res, 405, {success:false,error:'desteklenmeyen metot'});
try {
const body = await readBody(req, 512 * 1024);
const record = store.saveTenderState(user.email, body.key, body.patch, body.snapshot, body.version);
return json(res, 200, {success:true, record});
} catch (e) {
return json(res, e.kod === 'VERSION_CONFLICT' ? 409 : 400, {success:false,error:e.message,kod:e.kod});
}
}
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
if (url.pathname.startsWith('/api/me/data')) {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
const anahtar = url.searchParams.get('anahtar');
if (req.method === 'GET') {
if (anahtar) return json(res, 200, { success: true, anahtar, deger: store.getData(user.email, anahtar) });
return json(res, 200, { success: true, ...store.getDataSnapshot(user.email) });
}
if (req.method === 'PUT' || req.method === 'POST') {
try {
const body = await readBody(req, 512 * 1024);
const k = body.anahtar || anahtar;
if (!k) return json(res, 400, { success: false, error: 'anahtar gerekli' });
// Ekip kotası sunucu tarafında uygulanır
if (k === 'ekip') {
validateTeam(body.deger);
kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin });
}
if(k==='favori_kurumlar' && (!Array.isArray(body.deger) || body.deger.some(name=>typeof name!=='string'||!name.trim()||name.length>1000) || new Set(body.deger.map(name=>name.trim())).size!==body.deger.length))
return json(res,400,{success:false,error:'Favori kurum listesi geçersiz veya yinelenen kurum içeriyor.'});
if(k==='rakip_izleme' && (!Array.isArray(body.deger) || body.deger.some(item=>!item || typeof item.id!=='string'||!item.id||typeof item.name!=='string'||!item.name.trim()||item.name.length>1000||(item.sector!=null&&typeof item.sector!=='string')) || new Set(body.deger.map(item=>item.id)).size!==body.deger.length || new Set(body.deger.map(item=>item.name.trim().toLocaleLowerCase('tr-TR'))).size!==body.deger.length))
return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'});
if(k==='reminder')validateReminderPreferences(body.deger);
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder'].includes(k);
if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision)))))
return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'});
const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined);
return json(res, 200, { success: true, ...(protectedKey ? {revision} : {}) });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : e.kod === 'VERSION_CONFLICT' ? 409 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
return json(res, 405, { success: false, error: 'desteklenmeyen metot' });
}
// --- API anahtarları (gerçek, hash'li, sunucu doğrulamalı) ---
if (url.pathname === '/api/me/apikeys') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
if (req.method === 'POST') {
try {
const body = await readBody(req);
const k = store.createApiKey(user.email, body.ad, { limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
return json(res, 200, { success: true, anahtar: k });
} catch (e) {
return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null });
}
}
if (req.method === 'DELETE') {
const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
}
if (req.method !== 'GET') return json(res, 405, { success: false, error: 'Desteklenmeyen metot.' });
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
}
// --- Stripe webhook (imza doğrulamalı) ---
// NOT: Bu uç kimlik doğrulaması İSTEMEZ; güvenlik imza doğrulamasıyla sağlanır.
if (url.pathname === '/api/billing/cancel' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const kayit = store.getUser(user.email);
const subId = kayit?.stripe_sub_id;
if (!subId) return json(res, 400, { success: false, error: 'Aktif bir aboneliğiniz görünmüyor.' });
const body = await readJson(req);
try {
const out = await cancelSubscription({ subId, neden: body.neden, aciklama: body.aciklama });
// Neden kaydı: ürün kararları için kendi tarafımızda da tutulur.
store.setData(user.email, 'iptal_geri_bildirim', {
neden: body.neden || null,
aciklama: String(body.aciklama || '').slice(0, 500),
tarih: new Date().toISOString(),
});
console.log(`[billing] ${user.email} aboneliğini iptal etti (neden: ${body.neden || 'belirtilmedi'})`);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if (url.pathname === '/api/billing/webhook' && req.method === 'POST') {
if (!webhookConfigured()) {
return json(res, 503, { success: false, error: 'STRIPE_WEBHOOK_SECRET tanımlı değil' });
}
let olay;
try {
const ham = await readRaw(req);
olay = verifyWebhook(ham, req.headers['stripe-signature']);
} catch (e) {
console.warn('[stripe-webhook] imza reddedildi:', e.message);
return json(res, 400, { success: false, error: e.message });
}
try {
const o = olay.data?.object || {};
switch (olay.type) {
case 'checkout.session.completed': {
const email = (o.client_reference_id || o.customer_email || o.metadata?.email || '').toLowerCase();
const planId = o.metadata?.planId || 'pro';
if (email) {
store.setPlan(email, {
plan: planId, kaynak: 'stripe',
customerId: o.customer || null, subId: o.subscription || null,
});
console.log(`[stripe-webhook] ${email} → ${planId} planına geçti`);
}
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const priceId = o.items?.data?.[0]?.price?.id;
const planId = planForPrice(priceId) || 'pro';
const aktif = ['active', 'trialing'].includes(o.status);
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
if (k) {
store.setPlan(k.email, {
plan: aktif ? planId : 'ucretsiz', kaynak: 'stripe',
bitis: o.current_period_end ? new Date(o.current_period_end * 1000).toISOString() : null,
customerId: o.customer, subId: o.id,
});
console.log(`[stripe-webhook] ${k.email} aboneliği ${o.status} → ${aktif ? planId : 'ucretsiz'}`);
}
break;
}
case 'customer.subscription.deleted': {
const k = store.findByStripe({ customerId: o.customer, subId: o.id });
if (k) {
store.setPlan(k.email, { plan: 'ucretsiz', kaynak: 'stripe', customerId: o.customer, subId: o.id });
console.log(`[stripe-webhook] ${k.email} aboneliği iptal edildi`);
}
break;
}
default:
break; // ilgilenmediğimiz olaylar sessizce onaylanır
}
return json(res, 200, { received: true });
} catch (e) {
console.error('[stripe-webhook] işleme hatası:', e.message);
return json(res, 500, { success: false, error: e.message });
}
}
// --- Kullanıcının planı ve kullanımı ---
if (url.pathname === '/api/me/plan') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
return json(res, 200, { success: true, ...kullanim(user.email, { isAdmin: user.isAdmin }) });
}
// --- Abonelik / ödeme ---
if (url.pathname === '/api/billing/plans') {
return json(res, 200, { configured: billingConfigured(), webhookHazir: webhookConfigured(), plans: planCatalog(), iptalNedenleri: IPTAL_NEDENLERI });
}
if (url.pathname === '/api/billing/checkout' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req);
const out = await createCheckoutSession({ planId: body.planId, email: user.email, periyot: body.periyot === 'aylik' ? 'aylik' : 'yillik' });
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message });
}
}
// --- Gerçek AI şartname analizi ---
if (url.pathname === '/api/ai/config') {
return json(res, 200, { configured: aiConfigured(), providers: availableProviders() });
}
if (url.pathname === '/api/ai/analyze' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!aiConfigured()) {
return json(res, 503, {
success: false,
error: 'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.',
});
}
try {
const body = await readBody(req, 2 * 1024 * 1024);
const reviewed=body.reviewedSource ? reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn) : null;
const sourceText=reviewed ? reviewed.text : body.text;
if (!sourceText || String(sourceText).trim().length < 40) {
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
}
const out = await analyzeSpec({
text: sourceText,
fileName: reviewed?.fileName || body.fileName,
extraction: reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : body.extraction,
tender: body.tender,
chain: body.chain,
mode: body.mode === 'derin' ? 'derin' : undefined,
});
if (reviewed) {
const latest=reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn);
if(latest.humanReview.textHash!==reviewed.humanReview.textHash) throw Object.assign(new Error('İnceleme analiz sırasında değişti.'),{status:409});
out.result.sourceReview={...out.result.sourceReview,humanReview:reviewed.humanReview};
}
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
} catch (e) {
return json(res, e.status || 502, { success: false, error: e.message, attempts: e.attempts || null });
}
}
// --- Firma web sitesi tarama (gerçek fetch + AI) ---
if (url.pathname === '/api/company/scan' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
try {
const body = await readBody(req);
const out = await scanSite(body.url);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
if(url.pathname==='/api/admin/notify/outbox') {
const user=userFromAuthHeader(req.headers.authorization);
if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'});
if(!user.isAdmin)return json(res,403,{success:false,error:'Yönetici yetkisi gerekli.'});
try {
if(req.method==='GET')return json(res,200,{success:true,deliveries:deliveryQueue()});
if(req.method==='POST'){const body=await readBody(req);return json(res,200,{success:true,delivery:resolveDelivery(body.id,user.email,body)});}
return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
}catch(error){return json(res,error.status||400,{success:false,error:error.message});}
}
// --- Otomatik bildirim abonelikleri ---
if (url.pathname === '/api/notify/subscriptions') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!notifyAvailable()) return json(res, 503, { success: false, error: 'Bildirim deposu kullanılamıyor' });
if (req.method === 'POST') {
try {
const body = await readBody(req);
const abone = subscribe(user.email, body.kriter || {}, user.name);
return json(res, 200, { success: true, abonelikler: abone });
} catch (e) {
return json(res, 400, { success: false, error: e.message });
}
}
if (req.method === 'DELETE') {
const id = Number(url.searchParams.get('id'));
if(!Number.isSafeInteger(id)||id<1)return json(res,400,{success:false,error:'Geçersiz abonelik kimliği.'});
const ok = removeSub(id, user.email);
return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) });
}
if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
return json(res, 200, { success: true, abonelikler: listSubs(user.email) });
}
// Aboneliklerin hemen işlenmesi (admin/test)
if (url.pathname === '/api/notify/run' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici yetkisi gerekli' });
try {
const sonuc = await bildirimleriIsle(() => tendersPayload(), { zorla: url.searchParams.get('zorla') === '1' });
return json(res, 200, { success: true, ...sonuc });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
// 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar
return json(res, 200, {
configured: emailConfigured(),
from: emailFrom(),
envAyarli: Boolean((process.env.EMAIL_FROM || '').trim()),
});
}
// Giriş yapan kullanıcının KENDİ e-postasına test/özet gönderir (kötüye kullanımı önler).
if (url.pathname === '/api/notify/email/test' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!emailConfigured()) return json(res, 503, { success: false, error: 'E-posta yapılandırılmamış (RESEND_API_KEY yok)' });
try {
const body = await readBody(req).catch(() => ({}));
const html = tenderDigestHtml({ name: user.name || user.email, tenders: body.tenders || [] });
const { id } = await sendEmail({
to: user.email,
subject: body.subject || 'İhaleist — bildirim testi',
html,
});
return json(res, 200, { success: true, id, to: user.email });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// ---- RSS beslemeleri + MCP sunucusu + haftalık brief + teklif asistanı (İhaleAB'den taşındı) ----
if (url.pathname === '/api/feeds/tenders.rss') {
const key = url.searchParams.get('key') || '';
const sahip = key.startsWith('ih_live_') ? store.verifyApiKey(key) : null;
if (!sahip) { res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('API anahtarı gerekli: ?key=ih_live_… (API & Webhooks)'); }
const out = await tendersPayload();
const xml = ihaleRss(out.tenders, { q: url.searchParams.get('q'), il: url.searchParams.get('il'), sektor: url.searchParams.get('sektor'), sadeceAcik: url.searchParams.get('acik') !== '0', limit: url.searchParams.get('limit') || 50 });
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=600' });
return res.end(xml);
}
if (url.pathname === '/api/feeds/sonuc.rss') {
const xml = sonucRss({ firma: url.searchParams.get('firma'), idare: url.searchParams.get('idare'), il: url.searchParams.get('il') });
res.writeHead(200, { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' });
return res.end(xml);
}
if (url.pathname === '/mcp') {
return mcpIstek(req, res, { readBody, json, tenderGetir: async () => (await tendersPayload()).tenders });
}
if (url.pathname === '/api/me/yaklasan') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
const out = await tendersPayload();
return json(res, 200, { success: true, tenders: yaklasanIhaleler(user.email, out.tenders, Number(url.searchParams.get('gun')) || 14) });
}
if (url.pathname === '/api/me/brief') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name);
if (req.method === 'POST') {
const body = await readBody(req).catch(() => ({}));
store.setData(user.email, 'brief', { kapali: body.kapali === true });
return json(res, 200, { success: true, kapali: body.kapali === true });
}
const out = await tendersPayload();
const b = briefOlustur(user.email, out.tenders);
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) });
}
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders, { zorla: url.searchParams.get('zorla') === '1' });
return json(res, 200, { success: true, ...r });
}
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!aiConfigured()) return json(res, 503, { success: false, error: 'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin; sorun sürerse uygulama yöneticinize bildirin.' });
const lifetime = requestLifetime(req,res);
try {
const body = await readBody(req, 2 * 1024 * 1024);
const ortak = { signal:lifetime.signal, ikn: body.ikn, tender: body.tender, chain: body.chain, text: body.text };
const out = url.pathname === '/api/ai/belge-sor'
? await belgeyeSor({ ...ortak, soru: body.soru })
: url.pathname === '/api/ai/teklif-denetim'
? await teklifDenetle({ ...ortak, taslak: body.taslak, gereklilikler: body.gereklilikler })
: await teklifTaslak({ ...ortak, tur: body.tur, firma: body.firma, belgeler: body.belgeler, fiyat: body.fiyat, gereklilikler: body.gereklilikler });
return json(res, 200, { success: true, ...out });
} catch (e) {
if (lifetime.signal.aborted) return;
return json(res, e.attempts ? 502 : 400, { success: false, error: e.message });
} finally { lifetime.dispose(); }
}
if (url.pathname === '/api/tenders/source-history') {
if (!userFromAuthHeader(req.headers.authorization)) return json(res,401,{success:false,error:'Oturum gerekli.'});
if (req.method !== 'GET') return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
const before=url.searchParams.get('before');
if(before && (!/^\d+$/.test(before) || !Number.isSafeInteger(Number(before)))) return json(res,400,{success:false,error:'Geçersiz sayfa.'});
try { return json(res,200,{success:true,...tenderHistory({ikn:url.searchParams.get('ikn'),kurum:url.searchParams.get('kurum')},{before:before?Number(before):null})}); }
catch { return json(res,503,{success:false,error:'Kaynak geçmişi alınamadı. Yeniden deneyin.'}); }
}
if (url.pathname === '/api/tenders') {
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
return json(res, 200, out);
}
if (url.pathname === '/api/dt') {
let dt = { tenders: [], totalCount: 0, available: false };
try {
dt = readDtTenders(Number(url.searchParams.get('limit')) || 300);
} catch (e) {
return json(res, 200, { success: true, available: false, error: e.message, tenders: [] });
}
return json(res, 200, {
success: true,
available: dt.available,
source: 'EKAP Doğrudan Temin (22/d)',
totalCount: dt.totalCount,
tenders: dt.tenders,
});
}
const detailMatch = url.pathname.match(/^\/api\/tenders\/ilan-(\d+)\/detail$/);
if (detailMatch) {
const detail = await getDetail(detailMatch[1]);
return json(res, 200, { success: true, ...detail });
}
if (url.pathname === '/api/stats') {
const c = await getTenders();
return json(res, 200, {
success: true,
toplamIlan: c.totalCount,
bugunEklenen: bugunEklenenSayisi(c.tenders),
yuklenenIlan: c.tenders.length,
kaynak: 'ilan.gov.tr',
});
}
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
if (url.pathname === '/api/health/sektor') {
const say = sektorZenginSayi();
const genel = (cache.tenders || []).filter((t) => t.sektor === 'Genel').length;
const toplam = (cache.tenders || []).length;
return json(res, 200, {
success: true,
havuz: toplam,
genelKalan: genel,
genelOran: toplam ? Number((genel / toplam).toFixed(3)) : null,
dokumandanDenenen: say.toplam,
dokumandanCozulen: say.cozulen,
calisiyor: zenginCalisiyor,
});
}
if (url.pathname === '/api/meta/filtreler') {
return json(res, 200, {
success: true,
sektorler: SEKTOR_ETIKETLERI,
kurumTipleri: KURUM_ETIKETLERI,
cpvBolumler: Object.entries(OKAS_BOLUM).map(([kod, etiket]) => ({ kod, etiket })),
});
}
if (url.pathname === '/api/health/havuz') {
const ozet = havuzOzet();
if (!ozet) return json(res, 200, { success: true, son: null, olcumSayisi: 0, gecmis: [] });
const { son, hafta, ay } = ozet;
const oran = (ort, yeterli) =>
yeterli && son?.kaynak_toplam && ort ? Number((son.kaynak_toplam / ort).toFixed(3)) : null;
const yeterliVeri = hafta.adet >= 5;
const yeterliVeri30 = ay.adet >= 20;
return json(res, 200, {
success: true,
son,
// 7 gün: günlük dalgalanma
haftalikOrtalama: hafta.ortalama,
haftalikZirve: hafta.zirve,
olcumAdedi: hafta.adet,
yeterliVeri,
ortalamayaOran: oran(hafta.ortalama, yeterliVeri),
// 30 gün: kalıcı erime
aylikOrtalama: ay.ortalama,
aylikZirve: ay.zirve,
aylikDip: ay.dip,
olcumAdedi30: ay.adet,
yeterliVeri30,
aylikOrtalamayaOran: oran(ay.ortalama, yeterliVeri30),
gecmis: havuzGecmisi(Number(url.searchParams.get('limit')) || 60),
});
}
if (url.pathname === '/api/health/scheduler') {
return json(res, 200, { success: true, tarayiciVar: await browserAvailable(), ...schedulerStatus() });
}
return json(res, 404, { success: false, error: 'not found' });
} catch (err) {
console.error('[ihaleist-api] hata:', err.message);
return json(res, 502, { success: false, error: err.message });
}
});
server.listen(PORT, process.env.NODE_ENV === 'test' ? '127.0.0.1' : undefined, () => {
if (process.env.NODE_ENV === 'test') return;
// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
arkaPlandaIsit();
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
startScheduler({ tenderGetir: () => tendersPayload() });
});
server.on('close',()=>aiJobRunner.stop());