Compare commits
11
Commits
9b3809e21b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68070ddf9 | ||
|
|
6fda6764c3 | ||
|
|
6c32581952 | ||
|
|
26b250735c | ||
|
|
e5e1b12185 | ||
|
|
242e7d8c2e | ||
|
|
0bbfed321a | ||
|
|
4d191a07b1 | ||
|
|
a7495a1fe6 | ||
|
|
4e94a77019 | ||
|
|
b578c2d3ba |
@@ -28,6 +28,11 @@ RUN npm init -y >/dev/null \
|
||||
&& npx playwright install --with-deps chromium \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Commit değişince ağır Chromium katmanını yeniden kurmadan yalnız yayın
|
||||
# katmanlarını yenile. Coolify bu argümanı build içine açıkça geçirir.
|
||||
ARG SOURCE_COMMIT=unknown
|
||||
ENV APP_RELEASE=${SOURCE_COMMIT}
|
||||
|
||||
# Frontend
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
|
||||
@@ -36,6 +41,8 @@ COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
|
||||
COPY --from=build /app/server ./server
|
||||
# Backend, ortak doğrulama ve iş kurallarını src/services altından import eder.
|
||||
COPY --from=build /app/src ./src
|
||||
# .js importlarının ES module olarak doğrudan yüklenmesi için package type bilgisi.
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
|
||||
# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
|
||||
RUN mkdir -p /app/server/data
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# D09 dış entegrasyon kabul planı
|
||||
|
||||
Son salt okunur canlı tarama: 15 Eylül 2026, `https://ihaleist.com`.
|
||||
|
||||
Bu plan, dış sistemlerde yan etki oluşturmayan kontrolleri tamamlananlardan ayırır. Ödeme, e-posta, Telegram mesajı ve resmi teklif gönderimi yalnız yetkili test verileri ve açık işlem yetkisiyle yapılır. EKAP güvenlik doğrulaması otomasyonla aşılmaz.
|
||||
|
||||
## Mevcut durum
|
||||
|
||||
| Entegrasyon | Canlı durum | Doğrulanan kanıt | Kapatma adımı |
|
||||
| --- | --- | --- | --- |
|
||||
| ilan.gov.tr / BİK | Geçti | HTTP 200, kaynak `ilan.gov.tr`, 3.258 kayıt | D10/D04 veri kalitesi izlemesi dışında D09 işi yok |
|
||||
| TCMB | Geçti | HTTP 200, 15.09.2026 bülteni ve kaynak URL’si | D09 için kapalı |
|
||||
| Google ile giriş | Yapılandırılmış | Public config `configured: true` | Test hesabıyla giriş, çıkış ve oturum yenileme |
|
||||
| EKAP resmi doküman | İnsan doğrulaması | OKAS ve D10 belge isteği HTTP 428, `HUMAN_VERIFICATION_REQUIRED` | Yetkili kullanıcı CAPTCHA’yı tamamlayıp beş resmi paketi indirir; dosyalar uygulamaya yüklenir |
|
||||
| KİK kurul kararları | İnsan doğrulaması | HTTP 428, doğru hata kodu ve kullanıcı yönlendirmesi | EKAP doğrulamasından sonra arama ve karar metni tekrar denenir |
|
||||
| TED | Geçti | HTTP 200, zaman aşımı yok | Gerçek sonuçlu sorgu ve çeviri kontrollü AI test bütçesiyle doğrulanır |
|
||||
| AI yardımcıları | Yapılandırılmış | 7 sağlayıcı hazır | Yetkili test hesabı ve incelenmiş test belgesiyle analiz, soru, taslak ve denetim zinciri |
|
||||
| Stripe | Yapılandırılmış | Checkout ve webhook hazır, 3 plan | Stripe test modunda başarılı/başarısız ödeme, webhook tekrar teslimi ve iptal |
|
||||
| E-posta | Kimlik doğrulamayla korumalı | Yetkisiz config isteği HTTP 401 | Yetkili hesapla config okuma; izinli test alıcısına tek gönderim |
|
||||
| Telegram | Engelli | Public config `configured: false` | Bot/chat yapılandırması, bağlama kodu ve izinli test sohbetine tek mesaj |
|
||||
| PWA / offline worker | Geçti | Manifest ve service worker HTTP 200 | Gerçek cihazda kurulum, offline düzenleme, yeniden bağlanma ve kuyruk boşalması |
|
||||
| API / RSS / MCP | Kimlik doğrulamayla korumalı | API key ve RSS 401; MCP artık proxy üzerinden 401; public sonuç RSS 200 | Yetkili hesapla anahtar oluşturma, kullanma, iptal ve iptal sonrası 401 |
|
||||
| Zeyilname/sonuç zamanlayıcısı | Gözlemlenebilir | Durum uç noktası 200; iki worker etkin | Kontrollü test kaydıyla olay üretme, tek teslim ve tekrar engeli |
|
||||
|
||||
Canlı özet: 13 entegrasyonun 4’ü geçti, 3’ü yapılandırılmış, 2’si kimlik doğrulamayla korumalı, 1’i gözlemlenebilir, 2’si insan doğrulaması bekliyor ve 1’i yapılandırılmadığı için engelli. Korunan 14 uç noktanın 14’ü yetkisiz isteği reddetti.
|
||||
|
||||
## Uygulama sırası
|
||||
|
||||
1. **Hesap ve erişim kabulü:** Google oturumu ile API anahtarı oluşturma/kullanma/iptal zincirini aynı test hesabında doğrula.
|
||||
2. **EKAP/KİK ve D10:** Yetkili kullanıcı doğrulamasından sonra beş resmi paketi yükle; her ihale için belge gereksinimi, ürün uygunluğu, firma dosyası, fiyat ve çıktı kapılarını kaydet.
|
||||
3. **AI/TED:** Yalnız incelenmiş test belgesiyle gerçek sonuçlu TED araması, çeviri, analiz, belgeye soru, taslak ve denetim çıktısını kaynak atıflarıyla karşılaştır.
|
||||
4. **Bildirimler:** E-posta yapılandırmasını oku; izinli test adresi ve Telegram sohbetinde tek gönderimi, hata kaydı ve tekrar engeliyle doğrula.
|
||||
5. **Stripe:** Test modunda başarı, ret, iptal ve aynı webhook’un tekrar tesliminde tek abonelik değişikliği oluştuğunu doğrula.
|
||||
6. **PWA ve zamanlayıcı:** Gerçek cihazı offline yap, bir düzenlemeyi kuyruğa al, yeniden bağlanınca tek kez uygulandığını doğrula; ardından kontrollü zeyilname/sonuç olayını çalıştır.
|
||||
|
||||
Her adımda kabul kanıtı; zaman, test hesabı kimliği, kullanılan kayıt/İKN, HTTP sonucu, ekrandaki kullanıcı mesajı ve oluşan veri değişikliğini içermelidir. Başarısız veya yapılandırılmamış entegrasyon tamamlandı olarak işaretlenmez.
|
||||
|
||||
## Bu sürümde düzeltilen iki hata
|
||||
|
||||
- Nginx, `/mcp` POST isteğini statik dosya gibi ele alıp 405 döndürüyordu. İstek artık Node API’ye proxy ediliyor ve yetkisiz çağrı doğru biçimde 401 dönüyor.
|
||||
- KİK, OKAS ve ürün aramalarındaki EKAP HTTP 428 yanıtı genel 502’ye çevriliyordu. Yanıt artık `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve kullanıcıya uygulanabilir yönlendirme içeriyor.
|
||||
|
||||
Makine tarafından üretilen kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
|
||||
@@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan
|
||||
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
|
||||
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
|
||||
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
|
||||
- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir.
|
||||
|
||||
## Compose ile staging smoke testi
|
||||
|
||||
@@ -34,6 +35,16 @@ Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localho
|
||||
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
|
||||
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
|
||||
|
||||
Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır:
|
||||
|
||||
```sh
|
||||
IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \
|
||||
IHALEIST_EXPECTED_ASSET_SHA256="$(npm run -s qa:asset-hash)" \
|
||||
npm run qa:live -- https://ihaleist.com
|
||||
```
|
||||
|
||||
`qa:asset-hash`, `dist/index.html` içindeki giriş scriptini okuyarak yalnız gerçekten yüklenen ana JavaScript asset’inin hash’ini üretir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır.
|
||||
|
||||
## Kademeli yayın
|
||||
|
||||
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"printButtonHidden": true,
|
||||
"overviewStatus": 200,
|
||||
"overviewFrames": 3,
|
||||
"documentOwnerInPreparationOverview": true,
|
||||
"mobileStatus": 200,
|
||||
"mobileOverflow": {
|
||||
"viewport": 390,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"checkedAt": "2026-09-15T14:58:12.437Z",
|
||||
"base": "https://ihaleist.com",
|
||||
"readOnly": true,
|
||||
"scope": "Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.",
|
||||
"integrations": [
|
||||
{
|
||||
"id": "BIK",
|
||||
"label": "ilan.gov.tr ihale havuzu",
|
||||
"status": "passed",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"source": "ilan.gov.tr",
|
||||
"loaded": 3258
|
||||
},
|
||||
"remaining": "Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir."
|
||||
},
|
||||
{
|
||||
"id": "TCMB",
|
||||
"label": "TCMB döviz satış kurları",
|
||||
"status": "passed",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"bulletinDate": "15.09.2026",
|
||||
"source": "https://www.tcmb.gov.tr/kurlar/today.xml",
|
||||
"cached": false
|
||||
},
|
||||
"remaining": "Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor."
|
||||
},
|
||||
{
|
||||
"id": "GOOGLE",
|
||||
"label": "Google ile giriş",
|
||||
"status": "configured",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"configured": true
|
||||
},
|
||||
"remaining": "Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli."
|
||||
},
|
||||
{
|
||||
"id": "EKAP",
|
||||
"label": "EKAP arama ve resmi doküman",
|
||||
"status": "manual-gate",
|
||||
"evidence": {
|
||||
"browserAvailable": true,
|
||||
"okasStatus": 428,
|
||||
"okasCode": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"d10DocumentCode": "HUMAN_VERIFICATION_REQUIRED"
|
||||
},
|
||||
"remaining": "Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli."
|
||||
},
|
||||
{
|
||||
"id": "KIK",
|
||||
"label": "KİK kurul kararları",
|
||||
"status": "manual-gate",
|
||||
"evidence": {
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"resultCount": null,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"remaining": "EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli."
|
||||
},
|
||||
{
|
||||
"id": "TED",
|
||||
"label": "TED Avrupa ihaleleri",
|
||||
"status": "passed",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"totalCount": 0,
|
||||
"timedOut": false,
|
||||
"error": null
|
||||
},
|
||||
"remaining": "Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı."
|
||||
},
|
||||
{
|
||||
"id": "AI",
|
||||
"label": "AI analiz ve teklif yardımcıları",
|
||||
"status": "configured",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"configured": true,
|
||||
"providerCount": 7
|
||||
},
|
||||
"remaining": "Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli."
|
||||
},
|
||||
{
|
||||
"id": "STRIPE",
|
||||
"label": "Stripe plan, ödeme ve webhook",
|
||||
"status": "configured",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"checkoutConfigured": true,
|
||||
"webhookConfigured": true,
|
||||
"planCount": 3
|
||||
},
|
||||
"remaining": "Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli."
|
||||
},
|
||||
{
|
||||
"id": "EMAIL",
|
||||
"label": "E-posta bildirimleri",
|
||||
"status": "guarded",
|
||||
"evidence": {
|
||||
"unauthenticatedStatus": 401
|
||||
},
|
||||
"remaining": "Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı."
|
||||
},
|
||||
{
|
||||
"id": "TELEGRAM",
|
||||
"label": "Telegram bağlantısı ve bildirim",
|
||||
"status": "blocked",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"configured": false
|
||||
},
|
||||
"remaining": "Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli."
|
||||
},
|
||||
{
|
||||
"id": "PWA",
|
||||
"label": "PWA manifest ve offline worker",
|
||||
"status": "passed",
|
||||
"evidence": {
|
||||
"manifestStatus": 200,
|
||||
"serviceWorkerStatus": 200
|
||||
},
|
||||
"remaining": "Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir."
|
||||
},
|
||||
{
|
||||
"id": "API",
|
||||
"label": "API anahtarı, RSS ve MCP",
|
||||
"status": "guarded",
|
||||
"evidence": {
|
||||
"apiKeyGuard": 401,
|
||||
"rssGuard": 401,
|
||||
"mcpGuard": 401,
|
||||
"publicResultsRssStatus": 200
|
||||
},
|
||||
"remaining": "Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli."
|
||||
},
|
||||
{
|
||||
"id": "SCHEDULER",
|
||||
"label": "Zeyilname/sonuç zamanlayıcısı",
|
||||
"status": "observable",
|
||||
"evidence": {
|
||||
"status": 200,
|
||||
"browserAvailable": true,
|
||||
"resultScanActive": true,
|
||||
"resultScanRunning": false,
|
||||
"directProcurementActive": true,
|
||||
"directProcurementRunning": false
|
||||
},
|
||||
"remaining": "Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı."
|
||||
}
|
||||
],
|
||||
"guards": {
|
||||
"authSession": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"documents": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"documentText": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"apiKeys": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"emailConfig": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"plan": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"subscriptions": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"sourceHistory": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"companyScan": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"aiAnalyze": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"checkout": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"emailSend": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"telegramMutation": {
|
||||
"status": 401,
|
||||
"passed": true
|
||||
},
|
||||
"notificationRun": {
|
||||
"status": 403,
|
||||
"passed": true
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"total": 13,
|
||||
"passed": 4,
|
||||
"configured": 3,
|
||||
"guarded": 2,
|
||||
"observable": 1,
|
||||
"manualGate": 2,
|
||||
"blocked": 1,
|
||||
"protectedGuardsPassed": 14,
|
||||
"protectedGuardsTotal": 14
|
||||
}
|
||||
}
|
||||
@@ -1,124 +1,644 @@
|
||||
{
|
||||
"checkedAt": "2026-09-06T17:39:49.038Z",
|
||||
"checkedAt": "2026-09-15T14:45:21.910Z",
|
||||
"base": "https://ihaleist.com",
|
||||
"readOnly": true,
|
||||
"listCount": 8224,
|
||||
"tenders": [
|
||||
"listProbe": {
|
||||
"status": 200,
|
||||
"success": true
|
||||
},
|
||||
"listCount": 10898,
|
||||
"selectedCount": 5,
|
||||
"announcementPassCount": 5,
|
||||
"officialDocumentPackagePassCount": 0,
|
||||
"positiveCount": 0,
|
||||
"pass": false,
|
||||
"blockers": [
|
||||
{
|
||||
"ikn": "2026/1582991",
|
||||
"scenario": "mikroskop",
|
||||
"id": "ilan-2204986",
|
||||
"title": "Mikroskop satın alınacaktır",
|
||||
"institution": "GİRESUN EĞİTİM VE ARAŞTIRMA HASTANESİ BAŞHEKİMLİĞİ",
|
||||
"city": "Giresun",
|
||||
"sector": "Laboratuvar & Ölçüm",
|
||||
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
|
||||
"announcementDate": "2026-08-27",
|
||||
"tenderDate": "10.09.2026",
|
||||
"tenderTime": "10:00",
|
||||
"sourceDateTime": "10.09.2026 10:00",
|
||||
"officialDateSource": "documents/ilan sourceDateTime",
|
||||
"announcementDateConflict": null,
|
||||
"detailStatus": true,
|
||||
"documentProbeStatus": 200,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 1,
|
||||
"okasCount": 2,
|
||||
"documentEndpointSuccess": true,
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2204986/ihale-duyurulari-mal-alim-ihaleleri-mikroskop-satin-alinacaktir"
|
||||
"ikn": "2026/1634449",
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1534665",
|
||||
"scenario": "sarf",
|
||||
"id": "ilan-2205024",
|
||||
"title": "26 kalem laboratuvar sarf malzeme alımı",
|
||||
"institution": "KAYSERİ DEVLET HASTANESİ BAŞHEKİMLİĞİ",
|
||||
"city": "Kayseri",
|
||||
"sector": "Laboratuvar & Ölçüm",
|
||||
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
|
||||
"announcementDate": "2026-08-31",
|
||||
"tenderDate": "22.09.2026",
|
||||
"tenderTime": "11:00",
|
||||
"sourceDateTime": "22.09.2026 11:00",
|
||||
"officialDateSource": "documents/ilan sourceDateTime",
|
||||
"announcementDateConflict": null,
|
||||
"detailStatus": true,
|
||||
"documentProbeStatus": 200,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 1,
|
||||
"okasCount": 1,
|
||||
"documentEndpointSuccess": true,
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2205024/ihale-duyurulari-mal-alim-ihaleleri-26-kalem-laboratuvar-sarf-malzeme-alimi"
|
||||
"ikn": "2026/1676347",
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1499061",
|
||||
"scenario": "cihaz-servis",
|
||||
"id": "ilan-2204525",
|
||||
"title": "Laboratuvar malzemesi alınacaktır",
|
||||
"institution": "AKDENİZ ÜNİVERSİTESİ REKTÖRLÜĞÜ ÜNİVERSİTE HASTANESİ BAŞ MÜDÜRLÜĞÜ",
|
||||
"city": "Antalya",
|
||||
"sector": "Laboratuvar & Ölçüm",
|
||||
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
|
||||
"announcementDate": "2026-08-27",
|
||||
"tenderDate": "22.09.2026",
|
||||
"tenderTime": "10:30",
|
||||
"sourceDateTime": "22.09.2026 10:30",
|
||||
"officialDateSource": "documents/ilan sourceDateTime",
|
||||
"announcementDateConflict": null,
|
||||
"detailStatus": true,
|
||||
"documentProbeStatus": 200,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 1,
|
||||
"okasCount": 1,
|
||||
"documentEndpointSuccess": true,
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2204525/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-malzemesi-alinacaktir"
|
||||
"ikn": "2026/1667534",
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1573235",
|
||||
"scenario": "kismi-teklif",
|
||||
"id": "ilan-2203314",
|
||||
"title": "Laboratuvar sarf malzemeleri satın alınacak",
|
||||
"institution": "AKSARAY ÜNİVERSİTESİ EĞİTİM VE ARAŞTIRMA HASTANESİ",
|
||||
"city": "Aksaray",
|
||||
"sector": "Laboratuvar & Ölçüm",
|
||||
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
|
||||
"announcementDate": "2026-08-24",
|
||||
"tenderDate": "16.09.2026",
|
||||
"tenderTime": "10:00",
|
||||
"sourceDateTime": "16.09.2026 10:00",
|
||||
"officialDateSource": "documents/ilan sourceDateTime",
|
||||
"announcementDateConflict": "ilan listesi 09.09.2026; resmi detay 16.09.2026",
|
||||
"detailStatus": true,
|
||||
"documentProbeStatus": 200,
|
||||
"documentCount": 1,
|
||||
"announcementCount": 1,
|
||||
"okasCount": 1,
|
||||
"documentEndpointSuccess": true,
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2203314/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-sarf-malzemeleri-satin-alinacak"
|
||||
"ikn": "2026/1620320",
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1523851",
|
||||
"scenario": "100-kalem",
|
||||
"id": "ilan-2200563",
|
||||
"title": "Laboratuvar malzemesi satın alınacaktır",
|
||||
"institution": "İZMİR TEPECİK EĞİTİM VE ARAŞTIRMA HASTANESİ",
|
||||
"city": "İzmir",
|
||||
"sector": "Laboratuvar & Ölçüm",
|
||||
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
|
||||
"announcementDate": "2026-08-21",
|
||||
"tenderDate": "11.09.2026",
|
||||
"tenderTime": "10:00",
|
||||
"sourceDateTime": "11.09.2026 10:00",
|
||||
"officialDateSource": "documents/ilan sourceDateTime",
|
||||
"announcementDateConflict": null,
|
||||
"detailStatus": true,
|
||||
"documentProbeStatus": 200,
|
||||
"documentCount": 1,
|
||||
"announcementCount": 1,
|
||||
"okasCount": 1,
|
||||
"documentEndpointSuccess": true,
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2200563/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-malzemesi-satin-alinacaktir"
|
||||
"ikn": "2026/1636687",
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
],
|
||||
"scope": "Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions."
|
||||
"tenders": [
|
||||
{
|
||||
"ikn": "2026/1634449",
|
||||
"scenario": "20-kalem-laboratuvar-cihazi",
|
||||
"id": "ilan-2219605",
|
||||
"title": "Laboratuvar cihazı satın alınacaktır",
|
||||
"institution": "R.T.E. ÜNİVERSİTESİ İDARİ VE MALİ İŞLER DAİRE BAŞKANLIĞI",
|
||||
"city": "Rize",
|
||||
"tenderDate": "05.10.2026",
|
||||
"tenderTime": "10:30",
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2219605/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihazi-satin-alinacaktir",
|
||||
"publicAnnouncement": {
|
||||
"endpointStatus": 200,
|
||||
"textChars": 5592,
|
||||
"normalizedTextSha256": "17456819e593dca234080014db40aa47532d01b8b095a5e3f0976838d93cd1a1",
|
||||
"checks": {
|
||||
"listed": true,
|
||||
"detailEndpoint": true,
|
||||
"ikn": true,
|
||||
"title": true,
|
||||
"date": true,
|
||||
"time": true,
|
||||
"nature": true,
|
||||
"delivery": true,
|
||||
"validity": true,
|
||||
"partialOffer": true,
|
||||
"requirements": true
|
||||
},
|
||||
"requirementChecks": [
|
||||
{
|
||||
"key": "offer-letter",
|
||||
"label": "Teklif mektubu",
|
||||
"sourceTerm": "Teklif mektubu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Teklif vermeye yetki bilgileri",
|
||||
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "temporary-guarantee",
|
||||
"label": "Geçici teminat",
|
||||
"sourceTerm": "Geçici teminat",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "manufacturer-or-authorized-seller",
|
||||
"label": "İmalatçı/yetkili satıcı/yetkili temsilci belgesi",
|
||||
"sourceTerm": "Yetkili Satıcı Belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "service-qualification",
|
||||
"label": "TSE Hizmet Yeterlilik Belgesi",
|
||||
"sourceTerm": "TSE Hizmet Yeterlilik Belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "catalog",
|
||||
"label": "Katalog",
|
||||
"sourceTerm": "Katalog",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "brand-model",
|
||||
"label": "Marka-model beyanı",
|
||||
"sourceTerm": "MARKA - MODEL BEYANI",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "specification-response",
|
||||
"label": "Teknik şartnameye cevaplar ve açıklamalar",
|
||||
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"officialDocumentProbe": {
|
||||
"endpointStatus": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 0,
|
||||
"okasCount": 0,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"stages": {
|
||||
"selectionAndAnnouncement": {
|
||||
"passed": true,
|
||||
"status": "passed"
|
||||
},
|
||||
"officialDocumentPackage": {
|
||||
"passed": false,
|
||||
"status": "blocked",
|
||||
"blocker": {
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
},
|
||||
"productMatch": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"companyDocumentEvidence": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"priceAndOutputs": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
}
|
||||
},
|
||||
"positive": false
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1676347",
|
||||
"scenario": "cihaz-malzeme-yazilim",
|
||||
"id": "ilan-2218485",
|
||||
"title": "Laboratuvar cihaz, malzeme ve yazılım mal alım ihalesi",
|
||||
"institution": "SİVAS BİLİM VE TEKNİK ÜNİVERSİTESİ İDARİ VE MALİ İŞL. DAİRE BAŞK.",
|
||||
"city": "Sivas",
|
||||
"tenderDate": "01.10.2026",
|
||||
"tenderTime": "10:30",
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2218485/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-malzeme-ve-yazilim-mal-alim-ihalesi",
|
||||
"publicAnnouncement": {
|
||||
"endpointStatus": 200,
|
||||
"textChars": 4455,
|
||||
"normalizedTextSha256": "64902b106e05f9feff268530435498e65a067efc0f51cb665edd9f878d372cd7",
|
||||
"checks": {
|
||||
"listed": true,
|
||||
"detailEndpoint": true,
|
||||
"ikn": true,
|
||||
"title": true,
|
||||
"date": true,
|
||||
"time": true,
|
||||
"nature": true,
|
||||
"delivery": true,
|
||||
"validity": true,
|
||||
"partialOffer": true,
|
||||
"requirements": true
|
||||
},
|
||||
"requirementChecks": [
|
||||
{
|
||||
"key": "offer-letter",
|
||||
"label": "Teklif mektubu",
|
||||
"sourceTerm": "Teklif mektubu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Teklif vermeye yetki bilgileri",
|
||||
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "temporary-guarantee",
|
||||
"label": "Geçici teminat",
|
||||
"sourceTerm": "Geçici teminat",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "catalog",
|
||||
"label": "Katalog",
|
||||
"sourceTerm": "Katalog",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "specification-response",
|
||||
"label": "Teknik şartnameye cevaplar ve açıklamalar",
|
||||
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"officialDocumentProbe": {
|
||||
"endpointStatus": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 0,
|
||||
"okasCount": 0,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"stages": {
|
||||
"selectionAndAnnouncement": {
|
||||
"passed": true,
|
||||
"status": "passed"
|
||||
},
|
||||
"officialDocumentPackage": {
|
||||
"passed": false,
|
||||
"status": "blocked",
|
||||
"blocker": {
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
},
|
||||
"productMatch": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"companyDocumentEvidence": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"priceAndOutputs": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
}
|
||||
},
|
||||
"positive": false
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1667534",
|
||||
"scenario": "yedek-parca-ve-sarf",
|
||||
"id": "ilan-2217472",
|
||||
"title": "Laboratuvar cihaz yedek parça ve sarf malzeme satın alınacak",
|
||||
"institution": "KONYA İL SAĞLIK MÜDÜRLÜĞÜ",
|
||||
"city": "Konya",
|
||||
"tenderDate": "28.09.2026",
|
||||
"tenderTime": "10:30",
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2217472/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-yedek-parca-ve-sarf-malzeme-satin-alinacak",
|
||||
"publicAnnouncement": {
|
||||
"endpointStatus": 200,
|
||||
"textChars": 4897,
|
||||
"normalizedTextSha256": "d076d3e94136ccfd9e1cd1eb1e98e1bcb600bce20bf7d8ae7d5d3ea0a2f13044",
|
||||
"checks": {
|
||||
"listed": true,
|
||||
"detailEndpoint": true,
|
||||
"ikn": true,
|
||||
"title": true,
|
||||
"date": true,
|
||||
"time": true,
|
||||
"nature": true,
|
||||
"delivery": true,
|
||||
"validity": true,
|
||||
"partialOffer": true,
|
||||
"requirements": true
|
||||
},
|
||||
"requirementChecks": [
|
||||
{
|
||||
"key": "offer-letter",
|
||||
"label": "Teklif mektubu",
|
||||
"sourceTerm": "Teklif mektubu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Teklif vermeye yetki bilgileri",
|
||||
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "temporary-guarantee",
|
||||
"label": "Geçici teminat",
|
||||
"sourceTerm": "Geçici teminat",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "catalog",
|
||||
"label": "Katalog",
|
||||
"sourceTerm": "Katalog",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "specification-response",
|
||||
"label": "Teknik şartnameye cevaplar ve açıklamalar",
|
||||
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "sample",
|
||||
"label": "Numune",
|
||||
"sourceTerm": "Numune sunulması istenmektedir",
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"officialDocumentProbe": {
|
||||
"endpointStatus": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 0,
|
||||
"okasCount": 0,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"stages": {
|
||||
"selectionAndAnnouncement": {
|
||||
"passed": true,
|
||||
"status": "passed"
|
||||
},
|
||||
"officialDocumentPackage": {
|
||||
"passed": false,
|
||||
"status": "blocked",
|
||||
"blocker": {
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
},
|
||||
"productMatch": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"companyDocumentEvidence": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"priceAndOutputs": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
}
|
||||
},
|
||||
"positive": false
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1620320",
|
||||
"scenario": "220-kalem-laboratuvar-sarfi",
|
||||
"id": "ilan-2214836",
|
||||
"title": "Laboratuvar sarf malzemeleri satın alınacaktır",
|
||||
"institution": "ZONGULDAK İL SAĞLIK MÜDÜRLÜĞÜ",
|
||||
"city": "Zonguldak",
|
||||
"tenderDate": "01.10.2026",
|
||||
"tenderTime": "10:00",
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2214836/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-sarf-malzemeleri-satin-alinacaktir",
|
||||
"publicAnnouncement": {
|
||||
"endpointStatus": 200,
|
||||
"textChars": 4891,
|
||||
"normalizedTextSha256": "3a82b6347d3237ba29c57e211f46febd48ffd3570a7a4d818af04789fd0be5d0",
|
||||
"checks": {
|
||||
"listed": true,
|
||||
"detailEndpoint": true,
|
||||
"ikn": true,
|
||||
"title": true,
|
||||
"date": true,
|
||||
"time": true,
|
||||
"nature": true,
|
||||
"delivery": true,
|
||||
"validity": true,
|
||||
"partialOffer": true,
|
||||
"requirements": true
|
||||
},
|
||||
"requirementChecks": [
|
||||
{
|
||||
"key": "offer-letter",
|
||||
"label": "Teklif mektubu",
|
||||
"sourceTerm": "Teklif mektubu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Teklif vermeye yetki bilgileri",
|
||||
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "temporary-guarantee",
|
||||
"label": "Geçici teminat",
|
||||
"sourceTerm": "Geçici teminat",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "scope-exclusion",
|
||||
"label": "Kapsam Dışı Belgesi",
|
||||
"sourceTerm": "Kapsam Dışı Belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "sales-center",
|
||||
"label": "Satış Merkezi Yetki Belgesi",
|
||||
"sourceTerm": "Satış Merkezi Yetki Belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "uts",
|
||||
"label": "TİTUBB veya ÜTS belgesi",
|
||||
"sourceTerm": "TİTUBB veya ÜTS belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "sample",
|
||||
"label": "Numune",
|
||||
"sourceTerm": "Numune sunulması istenmektedir",
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"officialDocumentProbe": {
|
||||
"endpointStatus": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 0,
|
||||
"okasCount": 0,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"stages": {
|
||||
"selectionAndAnnouncement": {
|
||||
"passed": true,
|
||||
"status": "passed"
|
||||
},
|
||||
"officialDocumentPackage": {
|
||||
"passed": false,
|
||||
"status": "blocked",
|
||||
"blocker": {
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
},
|
||||
"productMatch": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"companyDocumentEvidence": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"priceAndOutputs": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
}
|
||||
},
|
||||
"positive": false
|
||||
},
|
||||
{
|
||||
"ikn": "2026/1636687",
|
||||
"scenario": "kit-karsiligi-otoanalizor",
|
||||
"id": "ilan-2211682",
|
||||
"title": "Kit karşılığı otoanalizör satın alınacak",
|
||||
"institution": "GAZİ ÜNİVERSİTESİ SAĞLIK ARAŞTIRMA VE UYGULAMA MERKEZİ",
|
||||
"city": "Ankara",
|
||||
"tenderDate": "28.09.2026",
|
||||
"tenderTime": "14:00",
|
||||
"sourceUrl": "https://www.ilan.gov.tr/ilan/2211682/ihale-duyurulari-mal-alim-ihaleleri-kit-karsiligi-otoanalizor-satin-alinacak",
|
||||
"publicAnnouncement": {
|
||||
"endpointStatus": 200,
|
||||
"textChars": 5820,
|
||||
"normalizedTextSha256": "8cbd40ad56a471132a881f1b922739f80c3ad3dea99bd18a556acf7dd3b9975f",
|
||||
"checks": {
|
||||
"listed": true,
|
||||
"detailEndpoint": true,
|
||||
"ikn": true,
|
||||
"title": true,
|
||||
"date": true,
|
||||
"time": true,
|
||||
"nature": true,
|
||||
"delivery": true,
|
||||
"validity": true,
|
||||
"partialOffer": true,
|
||||
"requirements": true
|
||||
},
|
||||
"requirementChecks": [
|
||||
{
|
||||
"key": "offer-letter",
|
||||
"label": "Teklif mektubu",
|
||||
"sourceTerm": "Teklif mektubu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Teklif vermeye yetki bilgileri",
|
||||
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "temporary-guarantee",
|
||||
"label": "Geçici teminat",
|
||||
"sourceTerm": "Geçici teminat",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "service-training",
|
||||
"label": "Teknik Servis Eğitim Sertifikası",
|
||||
"sourceTerm": "Teknik Servis Eğitim Sertifikası",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "brochure",
|
||||
"label": "Broşür",
|
||||
"sourceTerm": "Broşür",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "user-guide",
|
||||
"label": "Cihaz ve ODS kullanım kılavuzu",
|
||||
"sourceTerm": "ODS yazılım sistemine ait kullanım klavuzu",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "catalog",
|
||||
"label": "Katalog",
|
||||
"sourceTerm": "Katalog",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "specification-response",
|
||||
"label": "Teknik şartnameye cevaplar ve açıklamalar",
|
||||
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "sales-center",
|
||||
"label": "Satış Merkezi Yetki Belgesi",
|
||||
"sourceTerm": "Satış Merkezi Yetki Belgesi",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "device-id-cards",
|
||||
"label": "Cihaz ve sistem tanıtım kimlik kartı",
|
||||
"sourceTerm": "tanıtım kimlik kartı",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "production-and-age",
|
||||
"label": "Üretimde olma ve 13 yaş altı kanıtı",
|
||||
"sourceTerm": "on üç yaşın altında olduğunu belirten belgeler",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "offer-authorization-proof",
|
||||
"label": "Teklif yetkisi kanıtı",
|
||||
"sourceTerm": "Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge",
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"key": "uts-evidence",
|
||||
"label": "ÜTS beyanı ve kanıtlayıcı belgeler",
|
||||
"sourceTerm": "ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER",
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"officialDocumentProbe": {
|
||||
"endpointStatus": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"documentCount": 0,
|
||||
"announcementCount": 0,
|
||||
"okasCount": 0,
|
||||
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
},
|
||||
"stages": {
|
||||
"selectionAndAnnouncement": {
|
||||
"passed": true,
|
||||
"status": "passed"
|
||||
},
|
||||
"officialDocumentPackage": {
|
||||
"passed": false,
|
||||
"status": "blocked",
|
||||
"blocker": {
|
||||
"stage": "officialDocumentPackage",
|
||||
"status": 428,
|
||||
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||
"retryable": false,
|
||||
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
|
||||
}
|
||||
},
|
||||
"productMatch": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"companyDocumentEvidence": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
},
|
||||
"priceAndOutputs": {
|
||||
"passed": false,
|
||||
"status": "blocked-by-official-document-package"
|
||||
}
|
||||
},
|
||||
"positive": false
|
||||
}
|
||||
],
|
||||
"scope": "Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"base": "https://ihaleist.com",
|
||||
"readOnly": true,
|
||||
"checkedAt": "2026-09-06T17:30:38.609Z",
|
||||
"checkedAt": "2026-09-15T14:45:06.693Z",
|
||||
"expectedRelease": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
|
||||
"expectedAssetSha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
|
||||
"checks": {
|
||||
"home": {
|
||||
"status": 200,
|
||||
@@ -55,8 +57,66 @@
|
||||
}
|
||||
},
|
||||
"apiSuccess": true
|
||||
},
|
||||
"version": {
|
||||
"status": 200,
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"headers": {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
||||
"x-frame-options": "DENY",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains"
|
||||
},
|
||||
"json": {
|
||||
"ok": true,
|
||||
"release": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
|
||||
"version": "1.0.0",
|
||||
"frontendAsset": {
|
||||
"path": "/assets/index-vuTn9Luj.js",
|
||||
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
|
||||
"bytes": 569810
|
||||
}
|
||||
},
|
||||
"apiSuccess": true
|
||||
},
|
||||
"documents": {
|
||||
"status": 200,
|
||||
"contentType": "application/json; charset=utf-8",
|
||||
"headers": {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
||||
"x-frame-options": "DENY",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains"
|
||||
},
|
||||
"json": {
|
||||
"tarayiciVar": true
|
||||
},
|
||||
"apiSuccess": true
|
||||
},
|
||||
"frontendAsset": {
|
||||
"status": 200,
|
||||
"contentType": "application/javascript",
|
||||
"headers": {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
||||
"x-frame-options": "DENY",
|
||||
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains"
|
||||
},
|
||||
"path": "/assets/index-vuTn9Luj.js",
|
||||
"bytes": 569810,
|
||||
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e"
|
||||
}
|
||||
},
|
||||
"releaseValid": true,
|
||||
"releaseMatches": true,
|
||||
"assetMatchesRuntime": true,
|
||||
"assetMatchesExpected": true,
|
||||
"pass": true,
|
||||
"missingHeaders": []
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
# Düzeltme planı — uygulama durumu
|
||||
|
||||
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
|
||||
## 15 Eylül 2026 üretim ve D10 güncellemesi
|
||||
|
||||
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
|
||||
- Offline işlem kuyruğu ve veri kaybını önleyen tekrar oynatma düzeltmesi GitHub `main` dalındaki `242e7d8` sürümüyle Coolify üzerinden üretime alındı. Canlı sürüm, hazır olma uçları, güvenlik başlıkları ve giriş asset hash'i eşleşti; offline kuyruk kodu üretim paketinde bulundu.
|
||||
- Tarihi açık beş laboratuvar ihalesi seçildi: `2026/1634449` (Rize), `2026/1676347` (Sivas), `2026/1667534` (Konya), `2026/1620320` (Zonguldak) ve `2026/1636687` (Ankara).
|
||||
- Beşinin de canlı liste kaydı, ilan metni, tarih/saat, kapsam, teslimat, teklif geçerlilik süresi, kısmi/tam teklif hükmü ve ilan içindeki gerçek belge şartları doğrulandı. Kanıt: [`qa-live-five-tenders.json`](evidence/qa-live-five-tenders.json).
|
||||
- EKAP resmi doküman paketi beş kayıtta da HTTP 428 insan doğrulaması istedi. Bu nedenle ürün eşleştirme, firma belge kanıtı, fiyat cetveli ve son çıktılar D10 pozitif kabulü olarak işaretlenmedi: ilan aşaması `5/5`, resmi paket `0/5`, tam pozitif zincir `0/5`.
|
||||
- Uygulamanın bu 428 yanıtını tekrar denenebilir genel 502 hatası gibi göstermesi `e5e1b12` sürümünde düzeltildi ve canlıda doğrulandı. Yeni yanıt `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve elle yapılacak adımı içeriyor; belge ekranı kullanıcıyı EKAP doğrulamasına ve indirdiği dosyayı yüklemeye yönlendiriyor.
|
||||
|
||||
Bu aşamada D10 tamamlanmış değildir. Kapanış için yetkili kullanıcının EKAP güvenlik doğrulamasından sonra beş resmi doküman paketini indirip uygulamaya yüklemesi ve test firmasının gerçek ürün/belge kayıtlarıyla kalan dört kapıyı geçmesi gerekir. Resmi teklif gönderilmedi.
|
||||
|
||||
## 15 Eylül 2026 D09 dış entegrasyon kabulü
|
||||
|
||||
- `6c32581` sürümü Coolify üzerinden üretime alındı. Canlı `/api/version` tam commit SHA’sını ve beklenen frontend asset SHA-256 özetini döndürdü; health, ready ve güvenlik başlıkları kabulü geçti.
|
||||
- Salt okunur probda 13 entegrasyon denetlendi: 4 geçti, 3 yapılandırılmış, 2 kimlik doğrulamayla korumalı, 1 gözlemlenebilir, 2 insan doğrulaması bekliyor ve 1 engelli. Yetkisiz istek kapılarının 14/14’ü doğru şekilde reddedildi.
|
||||
- BİK, TCMB, TED bağlantısı ve PWA assetleri geçti. Google, AI ve Stripe yapılandırılmış. E-posta ile API/RSS/MCP doğru kimlik doğrulama kapısında. Scheduler durumu okunuyor. EKAP/KİK güvenlik doğrulaması bekliyor. Telegram yapılandırılmamış tek engelli entegrasyondur.
|
||||
- `/mcp` POST isteğinin Nginx’te 405’e düşmesi düzeltildi; canlıda artık Node kimlik doğrulama katmanından 401 dönüyor. KİK/OKAS/ürün aramasındaki HTTP 428 yanıtının genel 502’ye çevrilmesi de giderildi.
|
||||
- Test, giriş yapmadı; AI üretimi, ödeme, e-posta/Telegram mesajı veya resmi teklif göndermedi. Yan etkili kapanış adımları yetkili test hesapları ve açık işlem yetkisiyle ayrı ayrı uygulanacaktır.
|
||||
|
||||
Ayrıntılı sıra ve kabul ölçütleri: [`d09-entegrasyon-kabul-plani.md`](d09-entegrasyon-kabul-plani.md). Canlı kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
|
||||
|
||||
## 6 Eylül 2026 başlangıç durumu
|
||||
|
||||
Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani. Bu bölüm tarihsel başlangıç kaydıdır.
|
||||
|
||||
Planın tamamı o tarihte henüz bitmemişti ve canlı yayın yapılmamıştı.
|
||||
|
||||
Canlı read-only probe (6 Eylül 2026) `/api/health` için 200 döndürürken `/api/ready` için 404 döndü; canlı API yanıtında yeni güvenlik başlıkları da yok. Bu, yerel dalın henüz `ihaleist.com` ortamına dağıtılmadığını doğruluyor. Kanıt: [`qa-live-runtime.json`](evidence/qa-live-runtime.json). Probe sırasında giriş, veri değişikliği, ödeme, mesaj veya ihale başvurusu yapılmadı.
|
||||
|
||||
|
||||
+25
@@ -41,11 +41,36 @@ server {
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# MCP JSON-RPC ucu /api öneki taşımaz. Ayrı proxy olmadan Nginx POST'u
|
||||
# statik dosya isteği sayıp 405 döndürür ve Node'daki API anahtarı kontrolü
|
||||
# hiç çalışmaz.
|
||||
location = /mcp {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header Referrer-Policy;
|
||||
proxy_hide_header Permissions-Policy;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# Statik varlıklar
|
||||
location /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
expires 30d;
|
||||
# Bu blok kendi add_header direktifini kullandığı için üst seviyedeki
|
||||
# başlıkları nginx otomatik miras almaz; güvenlik başlıklarını tekrarla.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -7,11 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"qa:browser": "node tests/browser-acceptance.mjs",
|
||||
"qa:asset-hash": "node scripts/asset-fingerprint.mjs",
|
||||
"qa:live": "node tests/live-runtime-probe.mjs",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"qa:live-five": "node tests/live-five-probe.mjs"
|
||||
"qa:live-five": "node tests/live-five-probe.mjs",
|
||||
"qa:live-integrations": "node tests/live-integrations-probe.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import path from 'node:path';
|
||||
import { frontendAssetInfo } from '../server/release-info.mjs';
|
||||
|
||||
const asset = frontendAssetInfo([path.resolve(process.cwd(), 'dist')]);
|
||||
if (!asset) {
|
||||
console.error('dist/index.html içinde yüklenen frontend JavaScript asset’i bulunamadı. Önce npm run build çalıştırın.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`${asset.sha256}\n`);
|
||||
+45
-14
@@ -65,6 +65,8 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
import { scanSite } from './site-scan.mjs';
|
||||
import { createDetailCache } from './detail-cache.mjs';
|
||||
import { releaseInfo } from './release-info.mjs';
|
||||
import { sourceFailure } from './source-error.mjs';
|
||||
|
||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||
@@ -75,6 +77,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
||||
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
|
||||
const RUNTIME_RELEASE = releaseInfo();
|
||||
|
||||
function readiness() {
|
||||
const directories = {
|
||||
@@ -661,13 +664,6 @@ function json(res, code, body) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const sourceUnavailable = error => ({
|
||||
success: false,
|
||||
error: error?.message || 'Resmi belge kaynağına ulaşılamadı.',
|
||||
code: 'SOURCE_UNAVAILABLE',
|
||||
retryable: true,
|
||||
});
|
||||
|
||||
function readRaw(req, limit = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
@@ -840,7 +836,8 @@ const server = http.createServer(async (req, res) => {
|
||||
const out = await documentUrl(ikn);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, sourceUnavailable(e));
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
|
||||
@@ -852,7 +849,8 @@ const server = http.createServer(async (req, res) => {
|
||||
const parsed = mergeAnnouncementFields(out.metin, out);
|
||||
return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' });
|
||||
} catch (e) {
|
||||
return json(res, 502, sourceUnavailable(e));
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
// Doküman metni — sunucu tarafı tarayıcı gerektirir
|
||||
@@ -865,7 +863,8 @@ const server = http.createServer(async (req, res) => {
|
||||
const out = await documentText(ikn);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, sourceUnavailable(e));
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/api/documents/config') {
|
||||
@@ -943,7 +942,8 @@ const server = http.createServer(async (req, res) => {
|
||||
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 });
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
|
||||
@@ -958,7 +958,8 @@ const server = http.createServer(async (req, res) => {
|
||||
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 });
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
|
||||
@@ -1033,6 +1034,33 @@ const server = http.createServer(async (req, res) => {
|
||||
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
|
||||
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
|
||||
}
|
||||
|
||||
// --- Abone Takip Paneli Entegrasyonu (Signups & Subscribers) ---
|
||||
if ((url.pathname === '/api/admin/signups' || url.pathname === '/api/admin/subscribers') && req.method === 'GET') {
|
||||
const authHeader = req.headers['authorization'] || '';
|
||||
const apiKeyHeader = req.headers['x-api-key'] || req.headers['x-webhook-secret'] || '';
|
||||
const expectedKey = process.env.ABONE_TAKIP_SECRET || 'abone_takip_master_token_2026';
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : apiKeyHeader.trim();
|
||||
|
||||
if (!token || (token !== expectedKey)) {
|
||||
return json(res, 401, { success: false, error: 'Yetkisiz erişim' });
|
||||
}
|
||||
|
||||
const users = store.withDb ? store.withDb((db) => db.prepare('SELECT email, ad, plan, olusturma, son_giris FROM kullanicilar').all(), []) : [];
|
||||
const data = users.map(u => ({
|
||||
email: u.email,
|
||||
name: u.ad || null,
|
||||
plan: u.plan || 'ucretsiz',
|
||||
status: 'active',
|
||||
is_paid: (u.plan && u.plan !== 'ucretsiz') ? 1 : 0,
|
||||
payment_amount: (u.plan && u.plan !== 'ucretsiz') ? 499 : 0,
|
||||
currency: 'TRY',
|
||||
registered_at: u.olusturma || new Date().toISOString(),
|
||||
last_login_at: u.son_giris || null
|
||||
}));
|
||||
|
||||
return json(res, 200, { success: true, data });
|
||||
}
|
||||
if (url.pathname === '/api/health/kalem') {
|
||||
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
||||
}
|
||||
@@ -1054,7 +1082,8 @@ const server = http.createServer(async (req, res) => {
|
||||
);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/api/kik/karar') {
|
||||
@@ -1064,7 +1093,8 @@ const server = http.createServer(async (req, res) => {
|
||||
const metin = await kararMetni(id);
|
||||
return json(res, 200, { success: true, id, metin });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
const failure = sourceFailure(e);
|
||||
return json(res, failure.status, failure.body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1732,6 +1762,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
||||
if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
|
||||
if (url.pathname === '/api/ready') {
|
||||
const status = readiness();
|
||||
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_ASSET_ROOTS = [
|
||||
'/usr/share/nginx/html',
|
||||
path.resolve(process.cwd(), 'dist'),
|
||||
];
|
||||
|
||||
function cleanRelease(value) {
|
||||
const release = String(value || '').trim();
|
||||
if (!release || release === 'unknown' || release.length > 128) return null;
|
||||
return release;
|
||||
}
|
||||
|
||||
export function deployedRelease(env = process.env) {
|
||||
return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT);
|
||||
}
|
||||
|
||||
export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const indexPath = path.join(root, 'index.html');
|
||||
const html = fs.readFileSync(indexPath, 'utf8');
|
||||
const match = html.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
|
||||
if (!match) continue;
|
||||
const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname;
|
||||
const assetPath = path.resolve(root, `.${publicPath}`);
|
||||
const rootPath = path.resolve(root) + path.sep;
|
||||
if (!assetPath.startsWith(rootPath)) continue;
|
||||
const bytes = fs.readFileSync(assetPath);
|
||||
return {
|
||||
path: publicPath,
|
||||
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
bytes: bytes.length,
|
||||
};
|
||||
} catch {
|
||||
// Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) {
|
||||
return {
|
||||
release: deployedRelease(env),
|
||||
version: '1.0.0',
|
||||
frontendAsset: frontendAssetInfo(roots),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/';
|
||||
|
||||
export function sourceFailure(error) {
|
||||
const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.';
|
||||
const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED'
|
||||
|| /HUMAN_VERIFICATION_REQUIRED|(?:EKAP|KİK)[^\n]{0,120}(?:HTTP\s*)?428|HTTP\s*428/i.test(rawMessage);
|
||||
|
||||
if (humanVerification) {
|
||||
return {
|
||||
status: 428,
|
||||
body: {
|
||||
success: false,
|
||||
error: 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin.',
|
||||
code: 'HUMAN_VERIFICATION_REQUIRED',
|
||||
retryable: false,
|
||||
manualAction: true,
|
||||
manualUrl: MANUAL_EKAP_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
success: false,
|
||||
error: rawMessage,
|
||||
code: 'SOURCE_UNAVAILABLE',
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const firmaAnahtari = email => `${FIRMA_ANAHTAR}:${encodeURIComponent(String(ema
|
||||
|
||||
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
|
||||
|
||||
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus }) => {
|
||||
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus, now = null }) => {
|
||||
const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
|
||||
const tender = resolveCurrentTender(selectedTender,tenders);
|
||||
const storedDraft = tender?.bidDraft || {};
|
||||
@@ -148,9 +148,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
||||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
||||
const turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type);
|
||||
const sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days);
|
||||
const [deadlineNow,setDeadlineNow] = useState(() => new Date());
|
||||
useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]);
|
||||
const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow);
|
||||
const [deadlineNow,setDeadlineNow] = useState(() => now || new Date());
|
||||
useEffect(() => {
|
||||
if (now) { setDeadlineNow(now); return undefined; }
|
||||
const timer=setInterval(()=>setDeadlineNow(new Date()),30000);
|
||||
return ()=>clearInterval(timer);
|
||||
},[now]);
|
||||
const deadlineProblem = preparationDeadlineProblem(tender,now || deadlineNow);
|
||||
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
|
||||
const analizEksik = analysisStatusBlocksPreparation(effectiveResults.sourceReview?.status);
|
||||
const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection;
|
||||
@@ -189,7 +193,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
||||
const session = getToken();
|
||||
setHata(null);
|
||||
try {
|
||||
const initialDeadlineProblem=preparationDeadlineProblem(tender);
|
||||
const initialDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
|
||||
if(initialDeadlineProblem)throw new Error(initialDeadlineProblem);
|
||||
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
|
||||
// Open during the click; async validation must not lose popup activation.
|
||||
@@ -206,7 +210,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
||||
setVault(currentVault);
|
||||
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
|
||||
if (fileErrors.length) throw new Error(fileErrors.join(' '));
|
||||
const currentDeadlineProblem=preparationDeadlineProblem(tender);
|
||||
const currentDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
|
||||
if(currentDeadlineProblem)throw new Error(currentDeadlineProblem);
|
||||
const f = formVerisi();
|
||||
if (tip === 'excel') {
|
||||
@@ -219,7 +223,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
||||
setVault(finalVault);
|
||||
const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault);
|
||||
if(finalErrors.length)throw new Error(finalErrors.join(' '));
|
||||
const finalDeadlineProblem=preparationDeadlineProblem(tender);
|
||||
const finalDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
|
||||
if(finalDeadlineProblem)throw new Error(finalDeadlineProblem);
|
||||
downloadBidWorkbook(buffer,tender.ikn);
|
||||
printWindow.current?.close();
|
||||
|
||||
@@ -177,6 +177,12 @@ const TenderDocumentSession = ({ tender }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
||||
const d = await r.json();
|
||||
if (!d.success && d.code === 'HUMAN_VERIFICATION_REQUIRED' && d.manualAction && d.manualUrl) {
|
||||
setEkapUrl(d.manualUrl);
|
||||
setBilgi({ manualAction: true, ekapSekme: sekme ? 'acildi' : 'engellendi' });
|
||||
if (sekme) sekme.location.href = d.manualUrl;
|
||||
return;
|
||||
}
|
||||
if (!d.success) throw new Error(d.error);
|
||||
setEkapUrl(d.url);
|
||||
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
|
||||
@@ -326,16 +332,20 @@ const TenderDocumentSession = ({ tender }) => {
|
||||
</div>
|
||||
|
||||
{ekapUrl && (
|
||||
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' || bilgi?.manualAction ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-slate-700 leading-relaxed">
|
||||
{bilgi?.ekapSekme === 'engellendi'
|
||||
{bilgi?.manualAction
|
||||
? <>EKAP güvenlik doğrulaması istedi. Kendi oturumunuzda EKAP’ı açıp İKN ile arayın: </>
|
||||
: bilgi?.ekapSekme === 'engellendi'
|
||||
? <>Tarayıcınız yeni sekmeyi engelledi. </>
|
||||
: <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: </>}
|
||||
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
|
||||
EKAP doküman sayfasını aç
|
||||
</a>
|
||||
{' '}· Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.
|
||||
{bilgi?.manualAction
|
||||
? <> · Doğrulamadan sonra dosyaları indirin ve aşağıdaki yükleme alanına ekleyin.</>
|
||||
: <> · Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.</>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const PwaSettings = () => {
|
||||
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
|
||||
};
|
||||
const PwaPanel = () => {
|
||||
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
|
||||
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow, offlineQueueSize } = useApp();
|
||||
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||
const [onbellekAdi, setOnbellekAdi] = useState('');
|
||||
@@ -156,6 +156,10 @@ const PwaPanel = () => {
|
||||
<span>Önbelleğe alınmış istek:</span>
|
||||
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
|
||||
</p>
|
||||
<p className="flex justify-between">
|
||||
<span>Gönderim bekleyen çevrimdışı kayıt:</span>
|
||||
<strong className="text-slate-900">{offlineQueueSize}</strong>
|
||||
</p>
|
||||
<p className="flex justify-between">
|
||||
<span>Uygulama sürümü önbelleği:</span>
|
||||
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
|
||||
@@ -169,7 +173,7 @@ const PwaPanel = () => {
|
||||
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
||||
</button>
|
||||
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
|
||||
<p className="text-xs text-slate-600">Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.</p>
|
||||
<p className="text-xs text-slate-600">Bağlantı kesildiğinde ihale hazırlık değişiklikleri hesap kapsamlı kuyruğa alınır ve bağlantı gelince yeniden gönderilir. Belge yükleme ve dış sağlayıcı işlemleri çevrimdışı başlatılamaz.</p>
|
||||
</div>
|
||||
|
||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tender
|
||||
import { getTenderStates, saveTenderState } from '../services/account';
|
||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||
import { istanbulDateKey } from '../services/tenderCalendar';
|
||||
import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js';
|
||||
|
||||
const AppContext = createContext();
|
||||
|
||||
@@ -211,12 +212,46 @@ export const AppProvider = ({ children }) => {
|
||||
const inFlight = useRef(new Set());
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [savingCount, setSavingCount] = useState(0);
|
||||
const [offlineQueueSize, setOfflineQueueSize] = useState(0);
|
||||
const replayOfflineQueue = async (owner) => {
|
||||
if (!owner || (typeof navigator !== 'undefined' && !navigator.onLine)) return;
|
||||
const result = await replayOfflineMutations(owner, async item => {
|
||||
if (item.kind !== 'tender-state') throw new Error('Çevrimdışı kayıt türü desteklenmiyor.');
|
||||
const record = await saveTenderState(item.key, item.patch, item.snapshot, item.version);
|
||||
if (getStoredUser()?.email !== owner) throw new Error('Oturum değişti; kuyruk bekletildi.');
|
||||
savedTenderStates.current = { ...savedTenderStates.current, [item.key]: record };
|
||||
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
|
||||
return record;
|
||||
});
|
||||
setOfflineQueueSize(offlineMutationCount(owner));
|
||||
if (result.error) setSaveError(`Çevrimdışı kayıt gönderilemedi: ${result.error.message}`);
|
||||
else if (result.sent > 0) setSaveError(null);
|
||||
return result;
|
||||
};
|
||||
useEffect(() => {
|
||||
const owner = authUser?.email;
|
||||
if (!owner || !veriSenkron) { setOfflineQueueSize(0); return undefined; }
|
||||
setOfflineQueueSize(offlineMutationCount(owner));
|
||||
const reconnect = () => { void replayOfflineQueue(owner); };
|
||||
window.addEventListener('online', reconnect);
|
||||
if (typeof navigator === 'undefined' || navigator.onLine) void replayOfflineQueue(owner);
|
||||
return () => window.removeEventListener('online', reconnect);
|
||||
}, [authUser?.email, veriSenkron]);
|
||||
const mutateTender = async (tenderId, patch) => {
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
||||
const key = tenderStateKey(t);
|
||||
if (inFlight.current.has(key)) { setSaveError('Bu ihale için önceki kayıt sürüyor. Tamamlandıktan sonra tekrar deneyin.'); return false; }
|
||||
const owner = authUser.email;
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
try {
|
||||
enqueueTenderMutation(owner, { key, patch, snapshot: tenderSnapshot(t), version: savedTenderStates.current[key]?.version || 0 });
|
||||
setOfflineQueueSize(offlineMutationCount(owner));
|
||||
setTenders(prev => prev.map(row => tenderStateKey(row) === key ? { ...row, ...patch } : row));
|
||||
setSaveError('Çevrimdışı değişiklik kuyruğa alındı; bağlantı gelince sunucuya gönderilecek.');
|
||||
return true;
|
||||
} catch (error) { setSaveError(error.message); return false; }
|
||||
}
|
||||
const session = getToken();
|
||||
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
||||
try {
|
||||
@@ -906,7 +941,7 @@ export const AppProvider = ({ children }) => {
|
||||
planYenile,
|
||||
kotaUyarisi,
|
||||
setKotaUyarisi,
|
||||
veriSenkron, saveError, savingCount, mutateTender
|
||||
veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
const PREFIX = 'ihaleist_offline_mutations:';
|
||||
const LIMIT = 100;
|
||||
const activeReplays = new Map();
|
||||
|
||||
const ownerKey = owner => `${PREFIX}${encodeURIComponent(String(owner || '').trim().toLowerCase())}`;
|
||||
|
||||
function readStorage(owner) {
|
||||
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(ownerKey(owner)) || '[]');
|
||||
return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function writeStorage(owner, rows) {
|
||||
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return false;
|
||||
try {
|
||||
localStorage.setItem(ownerKey(owner), JSON.stringify(rows));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function offlineMutationCount(owner) {
|
||||
return readStorage(owner).length;
|
||||
}
|
||||
|
||||
export function listOfflineMutations(owner) {
|
||||
return readStorage(owner).map(item => ({ ...item }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tender-state write to the account-scoped durable queue. Consecutive
|
||||
* changes for the same tender are merged so reconnecting never replays stale
|
||||
* intermediate drafts. Tokens and document bytes are deliberately excluded.
|
||||
*/
|
||||
export function enqueueTenderMutation(owner, { key, patch, snapshot, version }) {
|
||||
if (!String(owner || '').trim() || !String(key || '').trim() || !patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new Error('Çevrimdışı kayıt için ihale ve değişiklik bilgisi gerekli.');
|
||||
}
|
||||
const rows = readStorage(owner);
|
||||
const index = rows.findIndex(item => item.kind === 'tender-state' && item.key === key);
|
||||
const previous = index >= 0 ? rows[index] : null;
|
||||
if (!previous && rows.length >= LIMIT) {
|
||||
throw new Error('Çevrimdışı kayıt kuyruğu dolu. Bağlantı kurulduktan sonra yeniden deneyin.');
|
||||
}
|
||||
const next = {
|
||||
id: previous?.id || `offline-${crypto.randomUUID()}`,
|
||||
kind: 'tender-state',
|
||||
key,
|
||||
patch: { ...(previous?.patch || {}), ...patch },
|
||||
snapshot: snapshot && typeof snapshot === 'object' ? snapshot : {},
|
||||
version: Number.isInteger(version) && version >= 0 ? version : 0,
|
||||
createdAt: previous?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
attempts: previous?.attempts || 0,
|
||||
revision: (Number.isInteger(previous?.revision) ? previous.revision : 0) + 1,
|
||||
};
|
||||
if (index >= 0) rows[index] = next; else rows.push(next);
|
||||
if (!writeStorage(owner, rows)) throw new Error('Çevrimdışı kayıt kuyruğu yazılamadı.');
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeOfflineMutation(owner, id) {
|
||||
const rows = readStorage(owner);
|
||||
return writeStorage(owner, rows.filter(item => item.id !== id));
|
||||
}
|
||||
|
||||
export function markOfflineMutationAttempt(owner, id) {
|
||||
const rows = readStorage(owner);
|
||||
const next = rows.map(item => item.id === id ? { ...item, attempts: (item.attempts || 0) + 1, updatedAt: new Date().toISOString() } : item);
|
||||
writeStorage(owner, next);
|
||||
return next.find(item => item.id === id) || null;
|
||||
}
|
||||
|
||||
function acknowledgeOfflineMutation(owner, item, record) {
|
||||
const rows = readStorage(owner);
|
||||
const index = rows.findIndex(row => row.id === item.id);
|
||||
if (index < 0) return true;
|
||||
const current = rows[index];
|
||||
if ((current.revision || 0) === (item.revision || 0)) {
|
||||
rows.splice(index, 1);
|
||||
} else {
|
||||
const nextVersion = Number(record?.version);
|
||||
rows[index] = {
|
||||
...current,
|
||||
version: Number.isInteger(nextVersion) && nextVersion >= 0 ? nextVersion : current.version,
|
||||
};
|
||||
}
|
||||
return writeStorage(owner, rows);
|
||||
}
|
||||
|
||||
async function runReplay(owner, send) {
|
||||
const rows = listOfflineMutations(owner);
|
||||
const result = { sent: 0, pending: rows.length, error: null };
|
||||
for (const item of rows) {
|
||||
markOfflineMutationAttempt(owner, item.id);
|
||||
try {
|
||||
const record = await send(item);
|
||||
if (!acknowledgeOfflineMutation(owner, item, record)) {
|
||||
throw new Error('Çevrimdışı kayıt kuyruğu güncellenemedi.');
|
||||
}
|
||||
result.sent += 1;
|
||||
} catch (error) {
|
||||
result.error = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.pending = offlineMutationCount(owner);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Replay in order; concurrent reconnect signals share one replay operation. */
|
||||
export function replayOfflineMutations(owner, send) {
|
||||
const replayKey = ownerKey(owner);
|
||||
const active = activeReplays.get(replayKey);
|
||||
if (active) return active;
|
||||
const replay = runReplay(owner, send).finally(() => {
|
||||
if (activeReplays.get(replayKey) === replay) activeReplays.delete(replayKey);
|
||||
});
|
||||
activeReplays.set(replayKey, replay);
|
||||
return replay;
|
||||
}
|
||||
|
||||
export function clearOfflineMutations(owner) {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
try { localStorage.removeItem(ownerKey(owner)); return true; } catch { return false; }
|
||||
}
|
||||
@@ -4,11 +4,20 @@
|
||||
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
|
||||
*/
|
||||
|
||||
function sourceError(data, temporaryMessage) {
|
||||
if (data.code === 'HUMAN_VERIFICATION_REQUIRED' && data.manualAction) {
|
||||
return new Error(data.error || 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.');
|
||||
}
|
||||
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) return new Error(temporaryMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchLiveTenders({ refresh = false } = {}) {
|
||||
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
|
||||
const failure = sourceError(data, 'İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
|
||||
if (failure) throw failure;
|
||||
throw new Error(`API hatası: HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü');
|
||||
@@ -28,7 +37,8 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
|
||||
const res = await fetch(url, {signal:controller.signal});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
|
||||
const failure = sourceError(data, 'Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
|
||||
if (failure) throw failure;
|
||||
throw new Error(`Detay API hatası: HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
|
||||
@@ -69,7 +79,8 @@ export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000}
|
||||
const res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmî ihale kaynağı geçici olarak erişilemiyor.');
|
||||
const failure = sourceError(data, 'Resmî ihale kaynağı geçici olarak erişilemiyor.');
|
||||
if (failure) throw failure;
|
||||
throw new Error(`Resmî detay API hatası: HTTP ${res.status}`);
|
||||
}
|
||||
if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.');
|
||||
|
||||
@@ -27,6 +27,8 @@ try {
|
||||
evidence.checks.signInStatus = response?.status();
|
||||
await page.getByRole('button', { name: 'Test hesabıyla gir' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const hideExpired = page.getByLabel('Süresi geçenleri gizle', { exact: true });
|
||||
if (await hideExpired.isChecked()) await hideExpired.uncheck();
|
||||
evidence.checks.visibleCities = await Promise.all(['Giresun', 'Kayseri', 'Akdeniz', 'Aksaray', 'Tepecik'].map(async city => ({ city, count: await page.getByText(city, { exact: false }).count() })));
|
||||
if (evidence.checks.visibleCities.some(item => item.count < 1)) throw new Error('Beş sentetik şehirden biri görünmedi.');
|
||||
await page.getByRole('button', { name: 'Günlük Bülten', exact: true }).click();
|
||||
|
||||
@@ -167,7 +167,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
|
||||
const query=new URL(req.url,'http://localhost').searchParams;
|
||||
qaFiles=query.has('file')?[{...qaFile}]:null;
|
||||
const selected=query.has('ready')?readyBid(tenders[0],qaFiles?.[0]):tenders[0];
|
||||
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []});if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
|
||||
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []},new Date('2026-09-06T09:00:00Z'));if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
|
||||
const html=`<html lang="tr"><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module">
|
||||
import React from '/node_modules/.vite/deps/react.js';
|
||||
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
|
||||
@@ -186,7 +186,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
|
||||
React.createElement('button',{onClick:()=>control('release')},'Belge yanıtını serbest bırak'),
|
||||
React.createElement('p',{role:'status'},status),
|
||||
React.createElement('button',{onClick:()=>setTenders([{...selected,id:'qa-rotated-source',baslik:'QA Güncellenmiş alım',kazananFirma:'QA Kazanan'}])},'Aynı İKN için yeni kayıt ID ve kazanan yayımla'),
|
||||
React.createElement(BidPackageBuilder,{tender:selected}));}
|
||||
React.createElement(BidPackageBuilder,{tender:selected,now:new Date('2026-09-06T09:00:00Z')}));}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(AppProvider,{},React.createElement(Probe)));
|
||||
</script></body></html>`;
|
||||
res.setHeader('Content-Type','text/html; charset=utf-8');res.end(await server.transformIndexHtml('/qa-source-update',html));return;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { currentLabAcceptanceCases as cases } from './fixtures/d10-current-lab.mjs';
|
||||
|
||||
test('D10 current laboratory acceptance fixture is complete and explicit', () => {
|
||||
assert.equal(cases.length, 5);
|
||||
assert.equal(new Set(cases.map(({ ikn }) => ikn)).size, 5);
|
||||
assert.equal(new Set(cases.map(({ id }) => id)).size, 5);
|
||||
|
||||
for (const item of cases) {
|
||||
assert.match(item.ikn, /^2026\/\d{7}$/);
|
||||
assert.match(item.id, /^ilan-\d+$/);
|
||||
assert.match(item.date, /^\d{2}\.\d{2}\.2026$/);
|
||||
assert.match(item.time, /^\d{2}:\d{2}$/);
|
||||
assert.ok(item.titleTerm);
|
||||
assert.ok(item.natureTerm);
|
||||
assert.ok(item.deliveryTerm);
|
||||
assert.ok([60, 120].includes(item.validityDays));
|
||||
assert.ok(item.partialOfferTerm);
|
||||
assert.ok(item.requirements.length >= 5);
|
||||
assert.equal(new Set(item.requirements.map(({ key }) => key)).size, item.requirements.length);
|
||||
for (const requirement of item.requirements) {
|
||||
assert.ok(requirement.label);
|
||||
assert.ok(requirement.term);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+117
@@ -0,0 +1,117 @@
|
||||
const commonRequirements = [
|
||||
{ key: 'offer-letter', label: 'Teklif mektubu', term: 'Teklif mektubu' },
|
||||
{ key: 'authorization', label: 'Teklif vermeye yetki bilgileri', term: 'Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler' },
|
||||
{ key: 'temporary-guarantee', label: 'Geçici teminat', term: 'Geçici teminat' },
|
||||
];
|
||||
|
||||
function tender(definition) {
|
||||
return {
|
||||
...definition,
|
||||
requirements: [...commonRequirements, ...definition.requirements],
|
||||
};
|
||||
}
|
||||
|
||||
// Current public-announcement acceptance set selected on 15 September 2026.
|
||||
// These records prove announcement requirements only. EKAP's official document
|
||||
// package, company/product evidence and generated outputs are separate D10 gates.
|
||||
export const currentLabAcceptanceCases = [
|
||||
tender({
|
||||
ikn: '2026/1634449',
|
||||
id: 'ilan-2219605',
|
||||
city: 'Rize',
|
||||
date: '05.10.2026',
|
||||
time: '10:30',
|
||||
scenario: '20-kalem-laboratuvar-cihazi',
|
||||
titleTerm: 'Laboratuvar cihazı',
|
||||
natureTerm: '20 KALEM',
|
||||
deliveryTerm: '50 (Elli) takvim günü',
|
||||
validityDays: 60,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'manufacturer-or-authorized-seller', label: 'İmalatçı/yetkili satıcı/yetkili temsilci belgesi', term: 'Yetkili Satıcı Belgesi' },
|
||||
{ key: 'service-qualification', label: 'TSE Hizmet Yeterlilik Belgesi', term: 'TSE Hizmet Yeterlilik Belgesi' },
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'brand-model', label: 'Marka-model beyanı', term: 'MARKA - MODEL BEYANI' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1676347',
|
||||
id: 'ilan-2218485',
|
||||
city: 'Sivas',
|
||||
date: '01.10.2026',
|
||||
time: '10:30',
|
||||
scenario: 'cihaz-malzeme-yazilim',
|
||||
titleTerm: 'Laboratuvar cihaz, malzeme ve yazılım',
|
||||
natureTerm: '2 Kısım Cihaz, 1 Kısım Yazılım ve 1 Kısım Sarf Malzeme',
|
||||
deliveryTerm: '3. Kısım yazılımın kurulumu 15 takvim günü',
|
||||
validityDays: 60,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1667534',
|
||||
id: 'ilan-2217472',
|
||||
city: 'Konya',
|
||||
date: '28.09.2026',
|
||||
time: '10:30',
|
||||
scenario: 'yedek-parca-ve-sarf',
|
||||
titleTerm: 'Laboratuvar cihaz yedek parça ve sarf malzeme',
|
||||
natureTerm: '28 Kalem Laboratuvar Cihaz Yedek Parça ve Tıbbi Sarf Malzemesi',
|
||||
deliveryTerm: '27. ve 28. kalemler için 10 takvim günü',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1620320',
|
||||
id: 'ilan-2214836',
|
||||
city: 'Zonguldak',
|
||||
date: '01.10.2026',
|
||||
time: '10:00',
|
||||
scenario: '220-kalem-laboratuvar-sarfi',
|
||||
titleTerm: 'Laboratuvar sarf malzemeleri',
|
||||
natureTerm: '220 Kalem Laboratuvar Sarf Malzeme',
|
||||
deliveryTerm: '15(on beş) gün içerisinde',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'scope-exclusion', label: 'Kapsam Dışı Belgesi', term: 'Kapsam Dışı Belgesi' },
|
||||
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||
{ key: 'uts', label: 'TİTUBB veya ÜTS belgesi', term: 'TİTUBB veya ÜTS belgesi' },
|
||||
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1636687',
|
||||
id: 'ilan-2211682',
|
||||
city: 'Ankara',
|
||||
date: '28.09.2026',
|
||||
time: '14:00',
|
||||
scenario: 'kit-karsiligi-otoanalizor',
|
||||
titleTerm: 'Kit karşılığı otoanalizör',
|
||||
natureTerm: '24 Aylık Kit ve Kit Karşılığı Alınacak Otoanalizör',
|
||||
deliveryTerm: '15 (Onbeş) takvim günü',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'işin tamamı için teklif verilecektir',
|
||||
requirements: [
|
||||
{ key: 'service-training', label: 'Teknik Servis Eğitim Sertifikası', term: 'Teknik Servis Eğitim Sertifikası' },
|
||||
{ key: 'brochure', label: 'Broşür', term: 'Broşür' },
|
||||
{ key: 'user-guide', label: 'Cihaz ve ODS kullanım kılavuzu', term: 'ODS yazılım sistemine ait kullanım klavuzu' },
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||
{ key: 'device-id-cards', label: 'Cihaz ve sistem tanıtım kimlik kartı', term: 'tanıtım kimlik kartı' },
|
||||
{ key: 'production-and-age', label: 'Üretimde olma ve 13 yaş altı kanıtı', term: 'on üç yaşın altında olduğunu belirten belgeler' },
|
||||
{ key: 'offer-authorization-proof', label: 'Teklif yetkisi kanıtı', term: 'Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge' },
|
||||
{ key: 'uts-evidence', label: 'ÜTS beyanı ve kanıtlayıcı belgeler', term: 'ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
|
||||
before(async()=>{
|
||||
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
|
||||
base='http://127.0.0.1:'+port;
|
||||
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
|
||||
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
|
||||
for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}}
|
||||
throw new Error('QA API did not start');
|
||||
});
|
||||
@@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{
|
||||
assert.equal(response.data.checks.userData.ok,true);
|
||||
assert.equal(response.data.checks.sharedData.ok,true);
|
||||
});
|
||||
test('version endpoint identifies the running release and frontend asset',async()=>{
|
||||
const response=await request('/api/version','GET',null,null);
|
||||
assert.equal(response.status,200);
|
||||
assert.equal(response.data.ok,true);
|
||||
assert.equal(response.data.release,'0123456789abcdef');
|
||||
assert.equal(response.data.version,'1.0.0');
|
||||
if(response.data.frontendAsset){
|
||||
assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/);
|
||||
assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/);
|
||||
assert.ok(response.data.frontendAsset.bytes>0);
|
||||
}
|
||||
});
|
||||
test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{
|
||||
const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]);
|
||||
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
|
||||
|
||||
+124
-37
@@ -1,67 +1,154 @@
|
||||
// Read-only production acceptance probe for the five health/laboratory tenders.
|
||||
// No authentication, writes, payments, messages, or tender submissions.
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { healthAcceptanceCases } from './fixtures/health-acceptance.mjs';
|
||||
// Read-only production acceptance probe for five current laboratory tenders.
|
||||
// It records every D10 gate separately and never turns an upstream blocker into
|
||||
// a positive result. No authentication, writes, payments, messages or bids.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { currentLabAcceptanceCases } from './fixtures/d10-current-lab.mjs';
|
||||
|
||||
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
|
||||
const evidencePath = process.env.IHALEIST_LIVE_FIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-five-tenders.json';
|
||||
const timeoutMs = 30_000;
|
||||
|
||||
async function getJson(path) {
|
||||
function normalize(value) {
|
||||
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('tr-TR');
|
||||
}
|
||||
|
||||
function includesTerm(text, term) {
|
||||
return normalize(text).includes(normalize(term));
|
||||
}
|
||||
|
||||
async function probeJson(path) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${base}${path}`, { signal: controller.signal, headers: { 'User-Agent': 'ihaleist-live-five-probe/1.0' } });
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data || data.success !== true) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return data;
|
||||
} finally { clearTimeout(timer); }
|
||||
return {
|
||||
path,
|
||||
status: response.status,
|
||||
ok: response.ok && data?.success === true,
|
||||
data,
|
||||
error: response.ok && data?.success === true ? null : data?.error || `HTTP ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { path, status: 0, ok: false, data: null, error: error?.message || String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const list = await getJson('/api/tenders?limit=100');
|
||||
const listProbe = await probeJson('/api/tenders');
|
||||
const list = listProbe.data || {};
|
||||
const byIkn = new Map((Array.isArray(list.tenders) ? list.tenders : []).map((tender) => [String(tender.ikn || '').replace(/\s/g, ''), tender]));
|
||||
const tenders = [];
|
||||
for (const fixture of healthAcceptanceCases) {
|
||||
const ikn = `2026/${fixture.ikn}`;
|
||||
const listed = byIkn.get(ikn);
|
||||
if (!listed) throw new Error(`${ikn}: live listede bulunamadı`);
|
||||
const detail = await getJson(`/api/tenders/${encodeURIComponent(listed.id)}/detail`);
|
||||
const document = await getJson(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`);
|
||||
const officialDate = document.sourceDateTime || `${document.tarih || detail.tarih || ''} ${document.saat || detail.saat || ''}`.trim();
|
||||
if (!officialDate || !document.ikn || document.ikn !== ikn) throw new Error(`${ikn}: resmi tarih/İKN doğrulanamadı`);
|
||||
for (const fixture of currentLabAcceptanceCases) {
|
||||
const listed = byIkn.get(fixture.ikn) || null;
|
||||
const detailProbe = await probeJson(`/api/tenders/${encodeURIComponent(fixture.id)}/detail`);
|
||||
const detail = detailProbe.data || {};
|
||||
const announcementText = String(detail.aciklama || '');
|
||||
const requirementChecks = fixture.requirements.map(({ key, label, term }) => ({
|
||||
key,
|
||||
label,
|
||||
sourceTerm: term,
|
||||
present: includesTerm(announcementText, term),
|
||||
}));
|
||||
const announcementChecks = {
|
||||
listed: Boolean(listed),
|
||||
detailEndpoint: detailProbe.ok,
|
||||
ikn: includesTerm(announcementText, fixture.ikn),
|
||||
title: includesTerm(`${listed?.baslik || ''} ${announcementText}`, fixture.titleTerm),
|
||||
date: detail.tarih === fixture.date && includesTerm(announcementText, fixture.date),
|
||||
time: detail.saat === fixture.time && includesTerm(announcementText, fixture.time),
|
||||
nature: includesTerm(announcementText, fixture.natureTerm),
|
||||
delivery: includesTerm(announcementText, fixture.deliveryTerm),
|
||||
validity: includesTerm(announcementText, `geçerlilik süresi, ihale tarihinden itibaren ${fixture.validityDays}`),
|
||||
partialOffer: includesTerm(announcementText, fixture.partialOfferTerm),
|
||||
requirements: requirementChecks.every(({ present }) => present),
|
||||
};
|
||||
const announcementPassed = Object.values(announcementChecks).every(Boolean);
|
||||
|
||||
const documentProbe = await probeJson(`/api/documents/ilan?ikn=${encodeURIComponent(fixture.ikn)}`);
|
||||
const document = documentProbe.data || {};
|
||||
const officialDocumentPackagePassed = documentProbe.ok
|
||||
&& document.ikn === fixture.ikn
|
||||
&& Number(document.dokumanSayisi) > 0;
|
||||
const documentBlocker = officialDocumentPackagePassed ? null : {
|
||||
stage: 'officialDocumentPackage',
|
||||
status: documentProbe.status,
|
||||
code: document.code || null,
|
||||
retryable: document.retryable ?? null,
|
||||
message: document.error || documentProbe.error,
|
||||
};
|
||||
const dependentStatus = officialDocumentPackagePassed ? 'not-run-read-only-scope' : 'blocked-by-official-document-package';
|
||||
const stages = {
|
||||
selectionAndAnnouncement: { passed: announcementPassed, status: announcementPassed ? 'passed' : 'failed' },
|
||||
officialDocumentPackage: { passed: officialDocumentPackagePassed, status: officialDocumentPackagePassed ? 'passed' : 'blocked', blocker: documentBlocker },
|
||||
productMatch: { passed: false, status: dependentStatus },
|
||||
companyDocumentEvidence: { passed: false, status: dependentStatus },
|
||||
priceAndOutputs: { passed: false, status: dependentStatus },
|
||||
};
|
||||
tenders.push({
|
||||
ikn,
|
||||
ikn: fixture.ikn,
|
||||
scenario: fixture.scenario,
|
||||
id: listed.id,
|
||||
title: listed.baslik,
|
||||
institution: listed.kurum,
|
||||
city: listed.sehir,
|
||||
sector: listed.sektor,
|
||||
procedure: listed.usul,
|
||||
announcementDate: listed.ilanTarihi,
|
||||
tenderDate: document.tarih || detail.tarih || '',
|
||||
tenderTime: document.saat || detail.saat || '',
|
||||
sourceDateTime: officialDate,
|
||||
officialDateSource: 'documents/ilan sourceDateTime',
|
||||
announcementDateConflict: ikn === '2026/1573235' && listed.tarih !== document.tarih
|
||||
? `ilan listesi ${listed.tarih}; resmi detay ${document.tarih}` : null,
|
||||
detailStatus: detail.success === true,
|
||||
documentProbeStatus: 200,
|
||||
id: fixture.id,
|
||||
title: listed?.baslik || detail.title || null,
|
||||
institution: listed?.kurum || detail.institution || null,
|
||||
city: listed?.sehir || detail.city || fixture.city,
|
||||
tenderDate: detail.tarih || null,
|
||||
tenderTime: detail.saat || null,
|
||||
sourceUrl: listed?.kaynakUrl || null,
|
||||
publicAnnouncement: {
|
||||
endpointStatus: detailProbe.status,
|
||||
textChars: announcementText.length,
|
||||
normalizedTextSha256: announcementText
|
||||
? createHash('sha256').update(normalize(announcementText)).digest('hex')
|
||||
: null,
|
||||
checks: announcementChecks,
|
||||
requirementChecks,
|
||||
},
|
||||
officialDocumentProbe: {
|
||||
endpointStatus: documentProbe.status,
|
||||
code: document.code || null,
|
||||
retryable: document.retryable ?? null,
|
||||
documentCount: Number(document.dokumanSayisi) || 0,
|
||||
announcementCount: Number(document.ilanSayisi) || 0,
|
||||
okasCount: Number(document.okasSayisi) || 0,
|
||||
documentEndpointSuccess: document.success === true,
|
||||
sourceUrl: listed.kaynakUrl,
|
||||
error: document.error || documentProbe.error,
|
||||
},
|
||||
stages,
|
||||
positive: Object.values(stages).every(({ passed }) => passed),
|
||||
});
|
||||
}
|
||||
|
||||
const blockers = tenders.flatMap((tender) => tender.stages.officialDocumentPackage.blocker
|
||||
? [{ ikn: tender.ikn, ...tender.stages.officialDocumentPackage.blocker }]
|
||||
: []);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
base,
|
||||
readOnly: true,
|
||||
listProbe: { status: listProbe.status, success: listProbe.ok },
|
||||
listCount: Array.isArray(list.tenders) ? list.tenders.length : 0,
|
||||
selectedCount: tenders.length,
|
||||
announcementPassCount: tenders.filter(({ stages }) => stages.selectionAndAnnouncement.passed).length,
|
||||
officialDocumentPackagePassCount: tenders.filter(({ stages }) => stages.officialDocumentPackage.passed).length,
|
||||
positiveCount: tenders.filter(({ positive }) => positive).length,
|
||||
pass: tenders.length === 5 && tenders.every(({ positive }) => positive),
|
||||
blockers,
|
||||
tenders,
|
||||
scope: 'Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions.',
|
||||
scope: 'Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı.',
|
||||
};
|
||||
await mkdir(dirname(evidencePath), { recursive: true });
|
||||
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ base, checkedAt: result.checkedAt, tenders: tenders.map(({ ikn, tenderDate, tenderTime, documentCount }) => ({ ikn, tenderDate, tenderTime, documentCount })) }));
|
||||
console.log(JSON.stringify({
|
||||
base,
|
||||
checkedAt: result.checkedAt,
|
||||
selectedCount: result.selectedCount,
|
||||
announcementPassCount: result.announcementPassCount,
|
||||
officialDocumentPackagePassCount: result.officialDocumentPackagePassCount,
|
||||
positiveCount: result.positiveCount,
|
||||
pass: result.pass,
|
||||
blockerCodes: [...new Set(blockers.map(({ code }) => code).filter(Boolean))],
|
||||
}));
|
||||
if (!result.pass) process.exitCode = 2;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// D09 read-only production probe. It checks public configuration, source reads
|
||||
// and unauthenticated guards. It never signs in, sends a message, creates a
|
||||
// checkout, calls an AI model, changes data or submits a tender.
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
|
||||
const evidencePath = process.env.IHALEIST_LIVE_INTEGRATIONS_EVIDENCE_PATH
|
||||
|| 'docs/quality/evidence/qa-live-d09-integrations.json';
|
||||
const timeoutMs = 45_000;
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'ihaleist-live-integrations-probe/1.0',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const body = await response.text();
|
||||
let json = null;
|
||||
if (contentType.includes('json')) {
|
||||
try { json = JSON.parse(body); } catch { /* malformed JSON stays visible as a failed check */ }
|
||||
}
|
||||
return {
|
||||
path,
|
||||
method: options.method || 'GET',
|
||||
status: response.status,
|
||||
contentType,
|
||||
json,
|
||||
textChars: body.length,
|
||||
error: response.ok ? null : String(json?.error?.message || json?.error || body || `HTTP ${response.status}`).slice(0, 300),
|
||||
};
|
||||
} catch (error) {
|
||||
return { path, method: options.method || 'GET', status: 0, contentType: '', json: null, textChars: 0, error: error?.message || String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = {};
|
||||
for (const [name, path, options] of [
|
||||
['google', '/api/auth/google/config'],
|
||||
['tcmb', '/api/exchange-rates'],
|
||||
['documents', '/api/documents/config'],
|
||||
['ai', '/api/ai/config'],
|
||||
['billing', '/api/billing/plans'],
|
||||
['telegram', '/api/health/takip'],
|
||||
['scheduler', '/api/health/scheduler'],
|
||||
['items', '/api/health/kalem'],
|
||||
['embedding', '/api/health/uyum'],
|
||||
['bik', '/api/stats'],
|
||||
['history', '/api/history/stats'],
|
||||
['resultsRss', '/api/feeds/sonuc.rss?limit=1'],
|
||||
['protectedRss', '/api/feeds/tenders.rss'],
|
||||
['manifest', '/manifest.webmanifest'],
|
||||
['serviceWorker', '/sw.js'],
|
||||
['ted', '/api/ted?mod=turkiye&since=20990101&limit=1'],
|
||||
['kik', '/api/kik/search?limit=1'],
|
||||
['okas', '/api/okas/ara?q=hemogram'],
|
||||
['mcpGuard', '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }],
|
||||
]) {
|
||||
raw[name] = await request(path, options);
|
||||
}
|
||||
|
||||
const guardPaths = {
|
||||
authSession: ['/api/auth/me'],
|
||||
documents: ['/api/me/documents'],
|
||||
documentText: ['/api/documents/text?ikn=2026%2F1634449'],
|
||||
apiKeys: ['/api/me/apikeys'],
|
||||
emailConfig: ['/api/notify/email/config'],
|
||||
plan: ['/api/me/plan'],
|
||||
subscriptions: ['/api/notify/subscriptions'],
|
||||
sourceHistory: ['/api/tenders/source-history?ikn=2026%2F1634449'],
|
||||
companyScan: ['/api/company/scan', { method: 'POST' }],
|
||||
aiAnalyze: ['/api/ai/analyze', { method: 'POST' }],
|
||||
checkout: ['/api/billing/checkout', { method: 'POST' }],
|
||||
emailSend: ['/api/notify/email/test', { method: 'POST' }],
|
||||
telegramMutation: ['/api/me/telegram', { method: 'POST' }],
|
||||
notificationRun: ['/api/notify/run', { method: 'POST' }],
|
||||
};
|
||||
const guards = {};
|
||||
for (const [name, [path, options]] of Object.entries(guardPaths)) {
|
||||
const probe = await request(path, options);
|
||||
guards[name] = { status: probe.status, passed: [401, 403].includes(probe.status) };
|
||||
}
|
||||
|
||||
function row(id, label, status, evidence, remaining) {
|
||||
return { id, label, status, evidence, remaining };
|
||||
}
|
||||
|
||||
const googleConfigured = raw.google.status === 200 && Boolean(raw.google.json?.clientId);
|
||||
const tcmbReady = raw.tcmb.status === 200 && raw.tcmb.json?.success === true
|
||||
&& Number(raw.tcmb.json?.rates?.USD) > 0 && Number(raw.tcmb.json?.rates?.EUR) > 0;
|
||||
const aiConfigured = raw.ai.status === 200 && raw.ai.json?.configured === true;
|
||||
const billingConfigured = raw.billing.status === 200 && raw.billing.json?.configured === true;
|
||||
const billingWebhookReady = raw.billing.status === 200 && raw.billing.json?.webhookHazir === true;
|
||||
const telegramConfigured = raw.telegram.status === 200 && raw.telegram.json?.telegram === true;
|
||||
const kikManualGate = raw.kik.status === 428 && raw.kik.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
|
||||
const ekapManualGate = raw.okas.status === 428 && raw.okas.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
|
||||
|
||||
const integrations = [
|
||||
row('BIK', 'ilan.gov.tr ihale havuzu', raw.bik.status === 200 && raw.bik.json?.success ? 'passed' : 'blocked', { status: raw.bik.status, source: raw.bik.json?.kaynak || null, loaded: raw.bik.json?.yuklenenIlan ?? null }, 'Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir.'),
|
||||
row('TCMB', 'TCMB döviz satış kurları', tcmbReady ? 'passed' : 'blocked', { status: raw.tcmb.status, bulletinDate: raw.tcmb.json?.bulletinDate || null, source: raw.tcmb.json?.source || null, cached: raw.tcmb.json?.cached ?? null }, tcmbReady ? 'Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor.' : raw.tcmb.error),
|
||||
row('GOOGLE', 'Google ile giriş', googleConfigured ? 'configured' : 'blocked', { status: raw.google.status, configured: googleConfigured }, 'Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli.'),
|
||||
row('EKAP', 'EKAP arama ve resmi doküman', raw.okas.status === 200 ? 'partial' : ekapManualGate ? 'manual-gate' : 'blocked', { browserAvailable: raw.documents.json?.tarayiciVar ?? null, okasStatus: raw.okas.status, okasCode: raw.okas.json?.code || null, d10DocumentCode: 'HUMAN_VERIFICATION_REQUIRED' }, 'Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli.'),
|
||||
row('KIK', 'KİK kurul kararları', raw.kik.status === 200 && raw.kik.json?.success ? 'passed' : kikManualGate ? 'manual-gate' : 'blocked', { status: raw.kik.status, code: raw.kik.json?.code || null, resultCount: raw.kik.json?.results?.length ?? null, error: raw.kik.error }, raw.kik.status === 200 ? 'Bir kararın ayrıntı bağlantısı ayrıca kullanıcı akışında açılmalı.' : kikManualGate ? 'EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli.' : 'Kaynak erişimi düzeltildikten sonra arama ve karar metni tekrar denenmeli.'),
|
||||
row('TED', 'TED Avrupa ihaleleri', raw.ted.status === 200 && raw.ted.json?.success ? 'passed' : 'blocked', { status: raw.ted.status, totalCount: raw.ted.json?.totalCount ?? null, timedOut: raw.ted.json?.timedOut ?? null, error: raw.ted.error }, raw.ted.status === 200 ? 'Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı.' : 'TED kaynağı tekrar denenmeli.'),
|
||||
row('AI', 'AI analiz ve teklif yardımcıları', aiConfigured ? 'configured' : 'blocked', { status: raw.ai.status, configured: aiConfigured, providerCount: Array.isArray(raw.ai.json?.providers) ? raw.ai.json.providers.length : 0 }, 'Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli.'),
|
||||
row('STRIPE', 'Stripe plan, ödeme ve webhook', billingConfigured && billingWebhookReady ? 'configured' : 'blocked', { status: raw.billing.status, checkoutConfigured: billingConfigured, webhookConfigured: billingWebhookReady, planCount: Array.isArray(raw.billing.json?.plans) ? raw.billing.json.plans.length : 0 }, 'Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli.'),
|
||||
row('EMAIL', 'E-posta bildirimleri', guards.emailConfig?.passed ? 'guarded' : 'blocked', { unauthenticatedStatus: guards.emailConfig?.status ?? null }, 'Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı.'),
|
||||
row('TELEGRAM', 'Telegram bağlantısı ve bildirim', telegramConfigured ? 'configured' : 'blocked', { status: raw.telegram.status, configured: telegramConfigured }, 'Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli.'),
|
||||
row('PWA', 'PWA manifest ve offline worker', raw.manifest.status === 200 && raw.serviceWorker.status === 200 ? 'passed' : 'blocked', { manifestStatus: raw.manifest.status, serviceWorkerStatus: raw.serviceWorker.status }, 'Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir.'),
|
||||
row('API', 'API anahtarı, RSS ve MCP', raw.protectedRss.status === 401 && raw.mcpGuard.status === 401 && guards.apiKeys?.passed ? 'guarded' : 'blocked', { apiKeyGuard: guards.apiKeys?.status ?? null, rssGuard: raw.protectedRss.status, mcpGuard: raw.mcpGuard.status, publicResultsRssStatus: raw.resultsRss.status }, 'Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli.'),
|
||||
row('SCHEDULER', 'Zeyilname/sonuç zamanlayıcısı', raw.scheduler.status === 200 && raw.scheduler.json?.success ? 'observable' : 'blocked', { status: raw.scheduler.status, browserAvailable: raw.scheduler.json?.tarayiciVar ?? null, resultScanActive: raw.scheduler.json?.sonucTaramasi?.aktif ?? null, resultScanRunning: raw.scheduler.json?.sonucTaramasi?.calisiyor ?? null, directProcurementActive: raw.scheduler.json?.dogrudanTemin?.aktif ?? null, directProcurementRunning: raw.scheduler.json?.dogrudanTemin?.calisiyor ?? null }, 'Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı.'),
|
||||
];
|
||||
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
base,
|
||||
readOnly: true,
|
||||
scope: 'Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.',
|
||||
integrations,
|
||||
guards,
|
||||
summary: {
|
||||
total: integrations.length,
|
||||
passed: integrations.filter(({ status }) => status === 'passed').length,
|
||||
configured: integrations.filter(({ status }) => status === 'configured').length,
|
||||
guarded: integrations.filter(({ status }) => status === 'guarded').length,
|
||||
observable: integrations.filter(({ status }) => status === 'observable').length,
|
||||
manualGate: integrations.filter(({ status }) => status === 'manual-gate').length,
|
||||
blocked: integrations.filter(({ status }) => status === 'blocked').length,
|
||||
protectedGuardsPassed: Object.values(guards).filter(({ passed }) => passed).length,
|
||||
protectedGuardsTotal: Object.keys(guards).length,
|
||||
},
|
||||
};
|
||||
|
||||
await mkdir(dirname(evidencePath), { recursive: true });
|
||||
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ checkedAt: result.checkedAt, ...result.summary, statuses: Object.fromEntries(integrations.map(({ id, status }) => [id, status])) }));
|
||||
@@ -2,6 +2,7 @@
|
||||
// provider calls are performed. It intentionally fails when a required
|
||||
// deployment contract is missing.
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
|
||||
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
|
||||
@@ -13,8 +14,10 @@ const requiredHeaders = [
|
||||
];
|
||||
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
|
||||
const checks = {};
|
||||
const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim();
|
||||
const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim();
|
||||
|
||||
async function probe(path, successField = null) {
|
||||
async function probe(path, successField = null, captureText = false) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -24,11 +27,14 @@ async function probe(path, successField = null) {
|
||||
contentType: response.headers.get('content-type'),
|
||||
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
||||
};
|
||||
if (successField) {
|
||||
if (successField || captureText) {
|
||||
const raw = await response.text();
|
||||
if (captureText) result.text = raw;
|
||||
if (successField) {
|
||||
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
|
||||
result.apiSuccess = result.json?.[successField] === true;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
||||
@@ -37,22 +43,63 @@ async function probe(path, successField = null) {
|
||||
}
|
||||
}
|
||||
|
||||
checks.home = await probe('/');
|
||||
async function probeFrontendAsset(publicPath) {
|
||||
if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' };
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' });
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type'),
|
||||
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
||||
path: new URL(publicPath, base).pathname,
|
||||
bytes: bytes.length,
|
||||
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
checks.home = await probe('/', null, true);
|
||||
const assetMatch = checks.home.text?.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
|
||||
delete checks.home.text;
|
||||
checks.health = await probe('/api/health', 'ok');
|
||||
// /api/health and /api/ready use the same { ok: true } probe contract. The
|
||||
// readiness payload also includes a separate `ready` diagnostic field.
|
||||
checks.ready = await probe('/api/ready', 'ok');
|
||||
checks.version = await probe('/api/version', 'ok');
|
||||
// The EKAP document fallback depends on the bundled browser being available;
|
||||
// check its public capability endpoint without opening a document or mutating
|
||||
// any account data.
|
||||
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
|
||||
const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready, checks.documents].some(check => check?.headers?.[name] == null));
|
||||
checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]);
|
||||
const release = checks.version?.json?.release;
|
||||
const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release);
|
||||
const releaseMatches = !expectedRelease || release === expectedRelease;
|
||||
const reportedAsset = checks.version?.json?.frontendAsset;
|
||||
const assetMatchesRuntime = Boolean(reportedAsset?.sha256)
|
||||
&& reportedAsset.sha256 === checks.frontendAsset?.sha256
|
||||
&& reportedAsset.path === checks.frontendAsset?.path;
|
||||
const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256;
|
||||
const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset];
|
||||
const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null));
|
||||
const report = {
|
||||
base,
|
||||
readOnly: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
expectedRelease: expectedRelease || null,
|
||||
expectedAssetSha256: expectedAssetSha256 || null,
|
||||
checks,
|
||||
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && missingHeaders.length === 0,
|
||||
releaseValid,
|
||||
releaseMatches,
|
||||
assetMatchesRuntime,
|
||||
assetMatchesExpected,
|
||||
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0,
|
||||
missingHeaders,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('Nginx proxies MCP JSON-RPC requests to the authenticated Node endpoint', () => {
|
||||
const config = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8');
|
||||
const block = /location\s*=\s*\/mcp\s*\{([\s\S]*?)\n\s*\}/.exec(config)?.[1] || '';
|
||||
assert.match(block, /proxy_pass\s+http:\/\/127\.0\.0\.1:3001;/);
|
||||
assert.match(block, /client_max_body_size\s+1m;/);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
clearOfflineMutations,
|
||||
enqueueTenderMutation,
|
||||
listOfflineMutations,
|
||||
offlineMutationCount,
|
||||
replayOfflineMutations,
|
||||
} from '../src/services/offlineMutationQueue.js';
|
||||
|
||||
const storage = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: key => storage.has(key) ? storage.get(key) : null,
|
||||
setItem: (key, value) => storage.set(key, String(value)),
|
||||
removeItem: key => storage.delete(key),
|
||||
};
|
||||
|
||||
test('offline tender mutations are account scoped and merged by tender', async () => {
|
||||
clearOfflineMutations('qa@example.test');
|
||||
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 2 });
|
||||
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: { ikn: '2026/1' }, version: 2 });
|
||||
enqueueTenderMutation('other@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 0 });
|
||||
assert.equal(offlineMutationCount('qa@example.test'), 1);
|
||||
assert.deepEqual(listOfflineMutations('qa@example.test')[0].patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
|
||||
assert.equal(offlineMutationCount('other@example.test'), 1);
|
||||
clearOfflineMutations('qa@example.test'); clearOfflineMutations('other@example.test');
|
||||
});
|
||||
|
||||
test('replay removes acknowledged rows and retains the first failed row', async () => {
|
||||
clearOfflineMutations('replay@example.test');
|
||||
enqueueTenderMutation('replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
enqueueTenderMutation('replay@example.test', { key: 'b', patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
const sent = [];
|
||||
const first = await replayOfflineMutations('replay@example.test', async item => { sent.push(item.key); });
|
||||
assert.deepEqual(sent, ['a', 'b']);
|
||||
assert.equal(first.pending, 0);
|
||||
enqueueTenderMutation('replay@example.test', { key: 'c', patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
enqueueTenderMutation('replay@example.test', { key: 'd', patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
const second = await replayOfflineMutations('replay@example.test', async item => { if (item.key === 'c') throw new Error('network'); });
|
||||
assert.equal(second.sent, 0);
|
||||
assert.equal(listOfflineMutations('replay@example.test')[0].key, 'c');
|
||||
assert.equal(listOfflineMutations('replay@example.test')[0].attempts, 1);
|
||||
clearOfflineMutations('replay@example.test');
|
||||
});
|
||||
|
||||
test('concurrent reconnect signals send each queued mutation once', async () => {
|
||||
clearOfflineMutations('concurrent@example.test');
|
||||
enqueueTenderMutation('concurrent@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
let sends = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const send = async () => { sends += 1; await gate; return { version: 1 }; };
|
||||
const first = replayOfflineMutations('concurrent@example.test', send);
|
||||
const second = replayOfflineMutations('concurrent@example.test', send);
|
||||
release();
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
assert.equal(sends, 1);
|
||||
assert.deepEqual(a, b);
|
||||
assert.equal(offlineMutationCount('concurrent@example.test'), 0);
|
||||
});
|
||||
|
||||
test('a mutation added during replay is retained with the acknowledged server version', async () => {
|
||||
clearOfflineMutations('during-replay@example.test');
|
||||
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 3 });
|
||||
const first = await replayOfflineMutations('during-replay@example.test', async item => {
|
||||
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: {}, version: item.version });
|
||||
return { version: 4 };
|
||||
});
|
||||
assert.equal(first.pending, 1);
|
||||
const retained = listOfflineMutations('during-replay@example.test')[0];
|
||||
assert.equal(retained.version, 4);
|
||||
assert.deepEqual(retained.patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
|
||||
const versions = [];
|
||||
await replayOfflineMutations('during-replay@example.test', async item => { versions.push(item.version); return { version: 5 }; });
|
||||
assert.deepEqual(versions, [4]);
|
||||
assert.equal(offlineMutationCount('during-replay@example.test'), 0);
|
||||
});
|
||||
|
||||
test('a full queue rejects a new tender instead of silently discarding an older change', () => {
|
||||
clearOfflineMutations('full@example.test');
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
enqueueTenderMutation('full@example.test', { key: String(index), patch: { takipte: true }, snapshot: {}, version: 0 });
|
||||
}
|
||||
assert.throws(
|
||||
() => enqueueTenderMutation('full@example.test', { key: 'overflow', patch: { takipte: true }, snapshot: {}, version: 0 }),
|
||||
/kuyruğu dolu/,
|
||||
);
|
||||
assert.equal(offlineMutationCount('full@example.test'), 100);
|
||||
assert.equal(listOfflineMutations('full@example.test')[0].key, '0');
|
||||
clearOfflineMutations('full@example.test');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs';
|
||||
|
||||
test('release metadata rejects missing build markers', () => {
|
||||
assert.equal(deployedRelease({}), null);
|
||||
assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null);
|
||||
assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234');
|
||||
assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a');
|
||||
});
|
||||
|
||||
test('frontend asset metadata hashes the script referenced by index.html', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'assets'));
|
||||
writeFileSync(join(root, 'index.html'), '<script type="module" src="/assets/index-test.js"></script>');
|
||||
writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n');
|
||||
assert.deepEqual(frontendAssetInfo([root]), {
|
||||
path: '/assets/index-test.js',
|
||||
sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80',
|
||||
bytes: 24,
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sourceFailure } from '../server/source-error.mjs';
|
||||
|
||||
test('EKAP human verification is a manual non-retryable gate', () => {
|
||||
const result = sourceFailure(new Error('EKAP 428: {"code":"HUMAN_VERIFICATION_REQUIRED"}'));
|
||||
assert.equal(result.status, 428);
|
||||
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
|
||||
assert.equal(result.body.retryable, false);
|
||||
assert.equal(result.body.manualAction, true);
|
||||
assert.equal(result.body.manualUrl, 'https://ekap.kik.gov.tr/EKAP/');
|
||||
assert.match(result.body.error, /kendi tarayıcınızda açıp doğrulamayı tamamlayın/);
|
||||
});
|
||||
|
||||
test('ordinary upstream failures stay retryable', () => {
|
||||
const result = sourceFailure(new Error('upstream timeout'));
|
||||
assert.equal(result.status, 502);
|
||||
assert.deepEqual(result.body, {
|
||||
success: false,
|
||||
error: 'upstream timeout',
|
||||
code: 'SOURCE_UNAVAILABLE',
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('KIK HTTP 428 is also exposed as the same manual verification gate', () => {
|
||||
const result = sourceFailure(new Error('KİK sunucusu isteği reddetti (HTTP 428).'));
|
||||
assert.equal(result.status, 428);
|
||||
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
|
||||
assert.equal(result.body.manualAction, true);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c
|
||||
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/);
|
||||
globalThis.fetch=async()=>Response.json({success:false,code:'SOURCE_UNAVAILABLE',retryable:true,error:'kaynak yok'},{status:502});
|
||||
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/geçici olarak erişilemiyor.*yeniden deneyin/);
|
||||
globalThis.fetch=async()=>Response.json({success:false,code:'HUMAN_VERIFICATION_REQUIRED',retryable:false,manualAction:true,error:'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'},{status:428});
|
||||
await assert.rejects(()=>fetchTenderDetail('ekap-2026/123'),/güvenlik doğrulaması.*kendi tarayıcınızda açın/);
|
||||
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
|
||||
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
|
||||
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
|
||||
|
||||
Reference in New Issue
Block a user