feat: otomatik e-posta bildirimi

Kullanıcı kriter kaydediyor; sunucu periyodik olarak eşleşen YENİ ihaleleri
bulup özet e-posta gönderiyor.

- server/notify-store.mjs: abonelik deposu (SQLite, kalıcı volume'de)
  kriterler: il · sektör · alım türü · anahtar kelime · favori kurumlar
- server/notifier.mjs: eşleştirme + gönderim motoru
  · aynı ihale iki kez bildirilmez (sonGorulenler takibi)
  · ilk abonelikte geçmiş bildirilmez, yalnızca temel alınır (spam önleme)
  · sonuçlanmış/arşiv kayıtları bildirime girmez
- /api/notify/subscriptions (GET/POST/DELETE) + /api/notify/run (admin, anlık test)
- scheduler: NOTIFY_INTERVAL_SAAT (varsayılan 6) ile periyodik tur
- NotifySubscriptions paneli: kriter ekle/sil, 'ekrandaki filtreleri kullan'
- tendersPayload ortak yardımcısına çıkarıldı (API ve bildirim aynı veriyi kullanır)
This commit is contained in:
2026-08-29 03:19:47 +03:00
parent 667b335ea7
commit f86339417a
6 changed files with 525 additions and 33 deletions
@@ -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 (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
<BellRing className="w-4 h-4 text-emerald-600" /> Otomatik E-posta Bildirimi
</h3>
{abonelikler && (
<span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">
{abonelikler.length} kriter
</span>
)}
</div>
<p className="text-xs text-slate-600 leading-relaxed">
Kaydettiğiniz kriterlere uyan <strong>yeni</strong> ihaleler çıktığında
<strong> {authUser.email}</strong> adresine otomatik özet gönderilir. Sunucu bunu
periyodik olarak kontrol eder; aynı ihale iki kez bildirilmez.
</p>
{/* Mevcut abonelikler */}
{abonelikler === null ? (
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
) : abonelikler.length === 0 ? (
<p className="text-xs text-slate-400 bg-slate-50 border border-dashed border-slate-200 rounded-lg p-3">
Henüz kriter eklemediniz. Aşağıdan ekleyin — boş bırakırsanız tüm yeni ihaleler bildirilir.
</p>
) : (
<div className="space-y-2">
{abonelikler.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">{ozet(a)}</p>
<p className="text-[10px] text-slate-400">
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'}
</p>
</div>
<button onClick={() => sil(a.id)} disabled={busy} className="text-slate-400 hover:text-red-500 shrink-0" title="Kriteri sil">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Yeni kriter */}
<div className="pt-2 border-t border-slate-100 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wide">Yeni kriter</span>
<button onClick={filtreleriAl} className="text-[11px] font-bold text-blue-600 hover:underline">
Ekrandaki filtreleri kullan
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="relative">
<MapPin className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input value={kriter.sehir} onChange={(e) => setKriter({ ...kriter, sehir: e.target.value })}
placeholder="İl (boş = tümü)"
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
</label>
<label className="relative">
<Tag className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input value={kriter.sektor} onChange={(e) => setKriter({ ...kriter, sektor: e.target.value })}
placeholder="Sektör"
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
</label>
<label className="relative">
<Building2 className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input value={kriter.alimTuru} onChange={(e) => setKriter({ ...kriter, alimTuru: e.target.value })}
placeholder="Alım türü"
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
</label>
<label className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input value={kriter.anahtar} onChange={(e) => setKriter({ ...kriter, anahtar: e.target.value })}
placeholder="Anahtar kelime"
className="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-2 py-2 text-xs" />
</label>
</div>
{favoriteKurumlar?.length > 0 && (
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input type="checkbox" checked={kriter.favoriKurumlar}
onChange={(e) => setKriter({ ...kriter, favoriKurumlar: e.target.checked })}
className="w-3.5 h-3.5 accent-emerald-600 rounded" />
Yalnızca favori kurumlarım ({favoriteKurumlar.length})
</label>
)}
<button onClick={kaydet} disabled={busy}
className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-2">
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Bildirim Kriteri Ekle
</button>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-red-800">{error}</p>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex gap-2">
<Info className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-900 leading-relaxed">
İlk kriter eklendiğinde mevcut ihaleler <strong>bildirilmez</strong> — 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.
</p>
</div>
</div>
);
};
+4
View File
@@ -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 = () => {
</div>
{/* Otomatik bildirim abonelikleri */}
<NotifySubscriptions />
</div>
);
};