feat: tüm mock veriyi kaldır + Resend e-posta entegrasyonu

- mockTenders.js silindi; referans listeleri (sektör/il/kurum) constants.js'e taşındı
- AppContext: tenders/liveStats/competitorWatchlist/vaultDocuments/notifications/
  savedFilters/apiKeys/scrapedCapabilities → gerçek/boş; sahte Math.random online
  sayacı kaldırıldı; competitor & belge kasası artık localStorage
- LiveStatsBar: gerçek alanlar (canlı havuz/yüklü/bugün/kalem/takibimdeki)
- AnalyticsDashboard: KPI'lar (katılım/kazanma oranı/kazanılan bedel) + aylık hacim
  + sektörel dağılım gerçek companyStats/monthlyStats/sectorStats'tan; ciro hedefi
  kullanıcı tarafından belirlenir; veri yoksa dürüst boş durum
- ContractSearch & Rakip Radar: gerçek firmStats
- analytics.js: companyStats/monthlyStats/parseDateTR eklendi
- Resend: server/email.mjs + /api/notify/email/config + /api/notify/email/test
  (auth'lu, kullanıcının kendi adresine); PwaSettings gerçek e-posta test kartı
This commit is contained in:
2026-08-28 08:49:10 +03:00
parent 7521f798f6
commit 3e129c2988
14 changed files with 497 additions and 555 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* Resend e-posta gönderimi — bağımlılık yok (Node 18+ built-in fetch).
*
* Env:
* RESEND_API_KEY — Resend API anahtarı (re_...). Yoksa e-posta kapalıdır.
* EMAIL_FROM — Gönderen adres. Üretimde Resend'de DNS (SPF/DKIM) ile
* doğrulanmış kendi domaininiz olmalı. Doğrulanana kadar
* Resend'in paylaşımlı test adresi kullanılır.
*
* Kaynak/Endpoint: POST https://api.resend.com/emails (Bearer auth)
*/
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
export function emailConfigured() {
return Boolean(process.env.RESEND_API_KEY);
}
export function emailFrom() {
return process.env.EMAIL_FROM || 'İhaleist <onboarding@resend.dev>';
}
export async function sendEmail({ to, subject, html, text }) {
const key = process.env.RESEND_API_KEY;
if (!key) throw new Error('E-posta yapılandırılmamış (RESEND_API_KEY yok)');
if (!to || !subject || (!html && !text)) throw new Error('Eksik alan: to, subject ve html/text gerekli');
const res = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: emailFrom(), to: Array.isArray(to) ? to : [to], subject, html, text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.message || `Resend hatası: HTTP ${res.status}`);
}
return { id: data.id };
}
// İhale bildirimi için basit, markasız HTML gövdesi
export function tenderDigestHtml({ name, tenders }) {
const rows = (tenders || [])
.slice(0, 15)
.map(
(t) => `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:13px;color:#0e1526">${escapeHtml(t.baslik || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:12px;color:#586074;white-space:nowrap">${escapeHtml(t.sehir || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:12px;color:#586074;white-space:nowrap">${escapeHtml(t.tarih || '')}</td>
</tr>`
)
.join('');
return `<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto">
<h2 style="color:#2563eb;margin:0 0 4px">İhaleist</h2>
<p style="color:#586074;font-size:14px;margin:0 0 16px">Merhaba ${escapeHtml(name || '')}, takip kriterlerinize uyan ihaleler:</p>
<table style="border-collapse:collapse;width:100%;border:1px solid #eef1f5;border-radius:8px;overflow:hidden">
<thead><tr style="background:#f6f8fb">
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">İhale</th>
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">İl</th>
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#586074">Tarih</th>
</tr></thead>
<tbody>${rows || '<tr><td style="padding:12px;color:#8b93a4;font-size:13px" colspan="3">Yeni ihale yok.</td></tr>'}</tbody>
</table>
<p style="color:#8b93a4;font-size:11px;margin-top:16px">Bu e-posta İhaleist tarafından gönderildi. Bildirim tercihlerinizi uygulama ayarlarından değiştirebilirsiniz.</p>
</div>`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+24
View File
@@ -17,6 +17,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readDtTenders } from './dt-store.mjs';
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -336,6 +337,29 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { success: true, user });
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
return json(res, 200, { configured: emailConfigured() });
}
// 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 });
}
}
if (url.pathname === '/api/tenders') {
const force = url.searchParams.get('refresh') === '1';
const c = await getTenders(force);