diff --git a/server/index.js b/server/index.js index b49d38c..f4555b7 100644 --- a/server/index.js +++ b/server/index.js @@ -25,6 +25,8 @@ import { readHistory, historyStats } 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 { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs'; +import { bildirimleriIsle } from './notifier.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -395,6 +397,33 @@ function readBody(req, limit = 100 * 1024) { }); } +// /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)'); + + 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: c.todayCount, + dtCount: dt.totalCount, + historyCount: hist.totalCount, + tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], + }; +} + const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); try { @@ -561,6 +590,40 @@ const server = http.createServer(async (req, res) => { } } + // --- 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')); + const ok = removeSub(id, user.email); + return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) }); + } + 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()); + 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') { return json(res, 200, { configured: emailConfigured() }); @@ -585,37 +648,8 @@ const server = http.createServer(async (req, res) => { } if (url.pathname === '/api/tenders') { - const force = url.searchParams.get('refresh') === '1'; - const c = await getTenders(force); - // Doğrudan temin (EKAP worker) verisini varsa birleştir - let dt = { tenders: [], totalCount: 0, available: false }; - try { - dt = readDtTenders(DT_LIMIT); - } catch (e) { - console.warn('[ihaleist-api] DT okuma hatası:', e.message); - } - // Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı) - 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)'); - return json(res, 200, { - success: true, - source: sources.join(' + '), - fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), - // Havuz = canlı ilan + doğrudan temin + sonuç arşivi (yüklü sayısıyla tutarlı olsun) - totalCount: c.totalCount + dt.totalCount + hist.totalCount, - ilanCount: c.totalCount, - todayCount: c.todayCount, - dtCount: dt.totalCount, - historyCount: hist.totalCount, - tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], - }); + const out = await tendersPayload(url.searchParams.get('refresh') === '1'); + return json(res, 200, out); } if (url.pathname === '/api/dt') { @@ -666,5 +700,6 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, () => { 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)); - startScheduler(); + // Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat + startScheduler({ tenderGetir: () => tendersPayload() }); }); diff --git a/server/notifier.mjs b/server/notifier.mjs new file mode 100644 index 0000000..27fab71 --- /dev/null +++ b/server/notifier.mjs @@ -0,0 +1,100 @@ +/** + * Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur + * ve özet e-posta gönderir. + * + * Env: + * NOTIFY_ENABLED=0 → kapat + * NOTIFY_INTERVAL_SAAT=6 → kontrol sıklığı + * NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale + */ + +import { list, markSent, available } from './notify-store.mjs'; +import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs'; + +const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR'); + +/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */ +export function eslesiyorMu(t, a) { + if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false; + if (a.sektor && norm(t.sektor) !== norm(a.sektor)) return false; + if (a.alimTuru && !norm(t.alimTuru).includes(norm(a.alimTuru))) return false; + if (a.anahtar) { + const q = norm(a.anahtar); + const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`); + if (!alan.includes(q)) return false; + } + if (a.kurumlar?.length) { + const kurum = norm(t.kurum); + if (!a.kurumlar.some((k) => norm(k) === kurum)) return false; + } + return true; +} + +/** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */ +function bildirilebilir(t) { + if (t.durum === 'Sonuçlandı') return false; + if (String(t.id || '').startsWith('hist-')) return false; + return true; +} + +/** + * Tüm aktif abonelikleri işler. + * @param {Function} tenderGetir () => Promise<{tenders: []}> + */ +export async function bildirimleriIsle(tenderGetir) { + if (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' }; + if (!available()) return { atlandi: 'depo yok' }; + if (!emailConfigured()) return { atlandi: 'e-posta yapılandırılmamış' }; + + const abonelikler = list(); + if (!abonelikler.length) return { abonelik: 0 }; + + const veri = await tenderGetir(); + const tumu = (veri.tenders || []).filter(bildirilebilir); + const maxAdet = Number(process.env.NOTIFY_MAX || 25); + + let gonderilen = 0; + let hata = 0; + + for (const a of abonelikler) { + try { + const gorulen = new Set(a.sonGorulenler || []); + const eslesen = tumu.filter((t) => eslesiyorMu(t, a)); + const yeniler = eslesen.filter((t) => !gorulen.has(t.id)); + + // İlk kurulumda geçmişi spam'lemeyelim: sadece işaretle, gönderme. + if (!a.sonGonderim) { + markSent(a.id, eslesen.map((t) => t.id)); + continue; + } + if (!yeniler.length) continue; + + const kriterMetni = [ + a.sehir && `İl: ${a.sehir}`, + a.sektor && `Sektör: ${a.sektor}`, + a.alimTuru && `Tür: ${a.alimTuru}`, + a.anahtar && `Anahtar: ${a.anahtar}`, + a.kurumlar?.length && `${a.kurumlar.length} favori kurum`, + ].filter(Boolean).join(' · ') || 'Tüm ihaleler'; + + const html = tenderDigestHtml({ name: a.ad || a.email, tenders: yeniler.slice(0, maxAdet) }) + + `

+ Kriteriniz: ${kriterMetni} — toplam ${yeniler.length} yeni ihale. + Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz. +

`; + + await sendEmail({ + to: a.email, + subject: `İhaleist — ${yeniler.length} yeni ihale (${kriterMetni})`, + html, + }); + markSent(a.id, eslesen.map((t) => t.id)); + gonderilen++; + console.log(`[notifier] ${a.email} → ${yeniler.length} yeni ihale gönderildi (${kriterMetni})`); + } catch (e) { + hata++; + console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`); + } + } + return { abonelik: abonelikler.length, gonderilen, hata }; +} diff --git a/server/notify-store.mjs b/server/notify-store.mjs new file mode 100644 index 0000000..f7d4793 --- /dev/null +++ b/server/notify-store.mjs @@ -0,0 +1,128 @@ +/** + * Otomatik e-posta bildirimi abonelikleri (SQLite). + * Kullanıcı kendi filtre kriterlerini kaydeder; zamanlayıcı yeni eşleşen + * ihaleleri bulup özet e-posta gönderir. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let DatabaseSync = null; +try { + ({ DatabaseSync } = await import('node:sqlite')); +} catch { + console.warn('[notify-store] node:sqlite yok — otomatik bildirim devre dışı'); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'notify.sqlite'); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS abonelikler ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + ad TEXT, + sehir TEXT, + sektor TEXT, + alim_turu TEXT, + anahtar TEXT, + kurumlar TEXT, -- JSON: favori kurum listesi + aktif INTEGER NOT NULL DEFAULT 1, + sonGonderim TEXT, + sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri + olusturma TEXT, + UNIQUE(email, sehir, sektor, alim_turu, anahtar) +); +CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email); +`; + +export function available() { + return Boolean(DatabaseSync); +} + +function open({ create = true } = {}) { + if (!DatabaseSync) return null; + if (!fs.existsSync(DB_PATH)) { + if (!create) return null; + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + const db = new DatabaseSync(DB_PATH); + db.exec(SCHEMA); + return db; +} + +const bosaNull = (v) => (v == null || v === '' ? '' : String(v).trim()); + +export function subscribe(email, kriter = {}, ad = null) { + const db = open(); + if (!db) throw new Error('Bildirim deposu kullanılamıyor'); + try { + db.prepare(` + INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, aktif, olusturma) + VALUES (?,?,?,?,?,?,?,1,?) + ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET + aktif=1, kurumlar=excluded.kurumlar, ad=excluded.ad + `).run( + email, + ad, + bosaNull(kriter.sehir), + bosaNull(kriter.sektor), + bosaNull(kriter.alimTuru), + bosaNull(kriter.anahtar), + JSON.stringify(kriter.kurumlar || []), + new Date().toISOString() + ); + return list(email); + } finally { + db.close(); + } +} + +export function list(email) { + const db = open({ create: false }); + if (!db) return []; + try { + const rows = email + ? db.prepare('SELECT * FROM abonelikler WHERE email=? ORDER BY id DESC').all(email) + : db.prepare('SELECT * FROM abonelikler WHERE aktif=1 ORDER BY id').all(); + return rows.map((r) => ({ + id: r.id, + email: r.email, + ad: r.ad, + sehir: r.sehir || null, + sektor: r.sektor || null, + alimTuru: r.alim_turu || null, + anahtar: r.anahtar || null, + kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(), + aktif: Boolean(r.aktif), + sonGonderim: r.sonGonderim, + sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(), + })); + } finally { + db.close(); + } +} + +export function remove(id, email) { + const db = open({ create: false }); + if (!db) return false; + try { + const r = db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id, email); + return r.changes > 0; + } finally { + db.close(); + } +} + +export function markSent(id, ihaleIdleri) { + const db = open({ create: false }); + if (!db) return; + try { + db.prepare('UPDATE abonelikler SET sonGonderim=?, sonGorulenler=? WHERE id=?') + .run(new Date().toISOString(), JSON.stringify(ihaleIdleri.slice(0, 400)), id); + } finally { + db.close(); + } +} diff --git a/server/scheduler.mjs b/server/scheduler.mjs index 7a5d299..9fe933e 100644 --- a/server/scheduler.mjs +++ b/server/scheduler.mjs @@ -14,6 +14,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { historyStats } from './history-store.mjs'; +import { bildirimleriIsle } from './notifier.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -137,7 +138,24 @@ export function schedulerStatus() { }; } -export function startScheduler() { +// --- Otomatik e-posta bildirimi --- +let bildirimCalisiyor = false; + +async function bildirimTuru(tenderGetir) { + if (bildirimCalisiyor) return; + bildirimCalisiyor = true; + try { + const s = await bildirimleriIsle(tenderGetir); + if (s.atlandi) console.log(`[scheduler] bildirim atlandı: ${s.atlandi}`); + else console.log(`[scheduler] bildirim turu: ${s.abonelik} abonelik, ${s.gonderilen || 0} e-posta, ${s.hata || 0} hata`); + } catch (e) { + console.warn('[scheduler] bildirim hatası:', e.message); + } finally { + bildirimCalisiyor = false; + } +} + +export function startScheduler({ tenderGetir } = {}) { if (process.env.SCAN_ENABLED === '0') { console.log('[scheduler] devre dışı (SCAN_ENABLED=0)'); return; @@ -158,4 +176,14 @@ export function startScheduler() { setTimeout(dtTara, 4 * 60 * 1000).unref?.(); setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.(); } + + // Otomatik e-posta bildirimi + if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) { + console.log('[scheduler] otomatik e-posta bildirimi devre dışı'); + } else { + const nSaat = Number(process.env.NOTIFY_INTERVAL_SAAT || 6); + console.log(`[scheduler] otomatik e-posta bildirimi her ${nSaat} saatte bir kontrol edilecek`); + setTimeout(() => bildirimTuru(tenderGetir), 6 * 60 * 1000).unref?.(); + setInterval(() => bildirimTuru(tenderGetir), Math.max(1, nSaat) * 3600 * 1000).unref?.(); + } } diff --git a/src/components/tools/NotifySubscriptions.jsx b/src/components/tools/NotifySubscriptions.jsx new file mode 100644 index 0000000..7c6f00a --- /dev/null +++ b/src/components/tools/NotifySubscriptions.jsx @@ -0,0 +1,197 @@ +import React, { useState, useEffect } from 'react'; +import { useApp } from '../../context/AppContext'; +import { getToken } from '../../services/auth'; +import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, MapPin, Tag, Building2, Search } from 'lucide-react'; + +/** + * Otomatik e-posta bildirimi abonelikleri. + * Kullanıcı kriter kaydeder; sunucu periyodik olarak eşleşen YENİ ihaleleri + * e-posta ile gönderir. + */ +export const NotifySubscriptions = () => { + const { + authUser, favoriteKurumlar, + selectedCity, selectedSektor, selectedAlimTuru, searchQuery, + } = useApp(); + + const [abonelikler, setAbonelikler] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [kriter, setKriter] = useState({ sehir: '', sektor: '', alimTuru: '', anahtar: '', favoriKurumlar: false }); + + const yukle = () => { + if (!authUser) return; + fetch('/api/notify/subscriptions', { headers: { Authorization: `Bearer ${getToken()}` } }) + .then((r) => r.json()) + .then((d) => setAbonelikler(d.abonelikler || [])) + .catch(() => setAbonelikler([])); + }; + useEffect(yukle, [authUser]); + + // Ekrandaki aktif filtreleri forma taşı + const filtreleriAl = () => setKriter({ + sehir: selectedCity || '', + sektor: selectedSektor || '', + alimTuru: selectedAlimTuru || '', + anahtar: searchQuery || '', + favoriKurumlar: false, + }); + + const kaydet = async () => { + setBusy(true); setError(null); + try { + const res = await fetch('/api/notify/subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ + kriter: { + sehir: kriter.sehir, sektor: kriter.sektor, + alimTuru: kriter.alimTuru, anahtar: kriter.anahtar, + kurumlar: kriter.favoriKurumlar ? favoriteKurumlar : [], + }, + }), + }); + const d = await res.json(); + if (!d.success) throw new Error(d.error); + setAbonelikler(d.abonelikler); + setKriter({ sehir: '', sektor: '', alimTuru: '', anahtar: '', favoriKurumlar: false }); + } catch (e) { setError(e.message); } + finally { setBusy(false); } + }; + + const sil = async (id) => { + setBusy(true); + try { + const res = await fetch(`/api/notify/subscriptions?id=${id}`, { + method: 'DELETE', headers: { Authorization: `Bearer ${getToken()}` }, + }); + const d = await res.json(); + setAbonelikler(d.abonelikler || []); + } catch (e) { setError(e.message); } + finally { setBusy(false); } + }; + + const ozet = (a) => [ + a.sehir && `İl: ${a.sehir}`, + a.sektor && `Sektör: ${a.sektor}`, + a.alimTuru && `Tür: ${a.alimTuru}`, + a.anahtar && `“${a.anahtar}”`, + a.kurumlar?.length && `${a.kurumlar.length} favori kurum`, + ].filter(Boolean).join(' · ') || 'Tüm ihaleler'; + + if (!authUser) return null; + + return ( +
+
+

+ Otomatik E-posta Bildirimi +

+ {abonelikler && ( + + {abonelikler.length} kriter + + )} +
+ +

+ Kaydettiğiniz kriterlere uyan yeni ihaleler çıktığında + {authUser.email} adresine otomatik özet gönderilir. Sunucu bunu + periyodik olarak kontrol eder; aynı ihale iki kez bildirilmez. +

+ + {/* Mevcut abonelikler */} + {abonelikler === null ? ( +
yükleniyor…
+ ) : abonelikler.length === 0 ? ( +

+ Henüz kriter eklemediniz. Aşağıdan ekleyin — boş bırakırsanız tüm yeni ihaleler bildirilir. +

+ ) : ( +
+ {abonelikler.map((a) => ( +
+
+

{ozet(a)}

+

+ {a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'} +

+
+ +
+ ))} +
+ )} + + {/* Yeni kriter */} +
+
+ Yeni kriter + +
+ +
+ + + + +
+ + {favoriteKurumlar?.length > 0 && ( + + )} + + + + {error && ( +
+ +

{error}

+
+ )} +
+ +
+ +

+ İlk kriter eklendiğinde mevcut ihaleler bildirilmez — yalnızca temel alınır. + Bundan sonra çıkan yeni ihaleler gönderilir, böylece ilk gün yüzlerce eski ilanla dolmaz. + Sonuçlanmış arşiv kayıtları bildirime girmez. +

+
+
+ ); +}; diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index 5c7db6c..19a0074 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -3,6 +3,7 @@ import { useApp } from '../../context/AppContext'; import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react'; import confetti from 'canvas-confetti'; import { getToken } from '../../services/auth'; +import { NotifySubscriptions } from './NotifySubscriptions'; export const PwaSettings = () => { const { userProfile, authUser, tenders, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp(); @@ -249,6 +250,9 @@ export const PwaSettings = () => { + {/* Otomatik bildirim abonelikleri */} + + ); };