create-devoplus-one-app 0.0.0-stage → 0.1.0-next.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/cli.mjs +17 -0
- package/package.json +35 -3
- package/templates/0001_init.sql.tpl +10 -0
- package/templates/AGENTS.md.tpl +286 -0
- package/templates/AGENT_GUIDE.md.tpl +36 -0
- package/templates/README.md.tpl +58 -0
- package/templates/_shared/AGENTS.writes.md.tpl +38 -0
- package/templates/_shared/README.tail.md.tpl +20 -0
- package/templates/_shared/files/src/app.test.ts.tpl +36 -0
- package/templates/_shared/files/src/index.ts.tpl +8 -0
- package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
- package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
- package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
- package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
- package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
- package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
- package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
- package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
- package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
- package/templates/agent.ts.tpl +23 -0
- package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
- package/templates/bakim-takvimi/README.md.tpl +45 -0
- package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
- package/templates/bakim-takvimi/files/one.data.json +45 -0
- package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
- package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
- package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
- package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
- package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
- package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
- package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
- package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
- package/templates/bakim-takvimi/manifest.json.tpl +64 -0
- package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
- package/templates/egitim-sertifika/README.md.tpl +46 -0
- package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
- package/templates/egitim-sertifika/files/one.data.json +43 -0
- package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
- package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
- package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
- package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
- package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
- package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
- package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
- package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
- package/templates/egitim-sertifika/manifest.json.tpl +57 -0
- package/templates/envanter/AGENTS.template.md.tpl +28 -0
- package/templates/envanter/README.md.tpl +40 -0
- package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
- package/templates/envanter/files/one.data.json +62 -0
- package/templates/envanter/files/src/agent.ts.tpl +20 -0
- package/templates/envanter/files/src/app.ts.tpl +204 -0
- package/templates/envanter/files/src/jobs.ts.tpl +30 -0
- package/templates/envanter/files/src/queries.ts.tpl +53 -0
- package/templates/envanter/files/src/shell.ts.tpl +265 -0
- package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
- package/templates/envanter/files/src/stock.ts.tpl +33 -0
- package/templates/envanter/manifest.json.tpl +56 -0
- package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
- package/templates/hedef-takibi/README.md.tpl +52 -0
- package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
- package/templates/hedef-takibi/files/one.data.json +76 -0
- package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
- package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
- package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
- package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
- package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
- package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
- package/templates/hedef-takibi/manifest.json.tpl +38 -0
- package/templates/index.ts.tpl +262 -0
- package/templates/markdown.d.ts.tpl +6 -0
- package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
- package/templates/musteri-ziyaret/README.md.tpl +48 -0
- package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
- package/templates/musteri-ziyaret/files/one.data.json +53 -0
- package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
- package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
- package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
- package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
- package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
- package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
- package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
- package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
- package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
- package/templates/saha-denetimi/README.md.tpl +44 -0
- package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
- package/templates/saha-denetimi/files/one.data.json +87 -0
- package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
- package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
- package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
- package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
- package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
- package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
- package/templates/saha-denetimi/manifest.json.tpl +49 -0
- package/templates/shell.test.ts.tpl +30 -0
- package/templates/shell.ts.tpl +201 -0
- package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
- package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
- package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
- package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
- package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
- package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
- package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
- package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
- package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
- package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
- package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
- package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
- package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
- package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
- package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
- package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
- package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
- package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
- package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
- package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
- package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
- package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
- package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# {{nameTr}} — Devoplus yapay zekâsı için rehber
|
|
2
|
+
|
|
3
|
+
Bu dosyayı **Devoplus yapay zekâsı** okur (uygulama adresinde `/.well-known/one-agent.md`). Bir aracı eklerken,
|
|
4
|
+
değiştirirken ya da kaldırırken bu dosyayı AYNI değişiklikte güncelleyin; yayın kapısı dört başlığı ve her aracın adını
|
|
5
|
+
arar.
|
|
6
|
+
|
|
7
|
+
## Uygulama ne yapar
|
|
8
|
+
|
|
9
|
+
{{nameTr}}, çalışanların eğitim ve sertifikalarını izler. Her kayıt bir kişiye ve bir sertifika türüne aittir; geçerlilik
|
|
10
|
+
sonu, tamamlanma tarihi ve türün geçerlilik süresinden (ay) hesaplanır. Süresi 30 gün içinde dolacak ya da dolmuş
|
|
11
|
+
sertifikalar için kişiye ve yöneticisine her sabah bir kez e-posta gider. Aynı kişiye aynı türden daha yeni bir kayıt
|
|
12
|
+
girilince eski kayıt "yenilendi" sayılır. Kullanıcı "hangi sertifikaların süresi doluyor", "iş güvenliği eğitimi kaç ay
|
|
13
|
+
geçerli" gibi şeyler sorduğunda bu uygulamaya başvurun. Kayıt girmek için araç yoktur.
|
|
14
|
+
|
|
15
|
+
## Veri
|
|
16
|
+
|
|
17
|
+
- **Sertifika türü** (`cert_types`): ad (tekil), geçerlilik süresi (ay; boşsa süresiz), açıklama, kullanımda mı.
|
|
18
|
+
- **Kayıt** (`certificates`): kişinin adı, yöneticisinin adı (organizasyon şemasından), tür, tamamlanma tarihi,
|
|
19
|
+
geçerlilik sonu, belge numarası, isteğe bağlı belge dosyası.
|
|
20
|
+
- **Günlük hatırlatma kontrolü** (`reminder_runs`): her sabah 08:00'de (Türkiye saati) kaç kaydın hatırlatılacağı ve kaç
|
|
21
|
+
e-posta gittiği.
|
|
22
|
+
- Durumlar: `expired` (süresi doldu), `expiring` (30 gün içinde doluyor, bugün dahil), `valid` (geçerli), `renewed`
|
|
23
|
+
(yenilendi), `no-expiry` (süresiz). Tarihler `YYYY-AA-GG` biçimindedir; kullanıcıya `gg.aa.yyyy` olarak söyleyin.
|
|
24
|
+
- Kayıtlar kişisel veridir: bütün kayıtları yalnız eğitim sorumlusu görür. Bir çalışan kendi kayıtlarını, yönetici
|
|
25
|
+
ekibininkileri uygulamanın ekranında görür.
|
|
26
|
+
|
|
27
|
+
## Araçlar
|
|
28
|
+
|
|
29
|
+
### `expiring-certificates` — süresi yaklaşan sertifikalar (salt okunur)
|
|
30
|
+
|
|
31
|
+
- **Ne zaman:** eğitim sorumlusu süresi dolan ya da dolacak sertifikaları, yenilenmesi gereken eğitimleri sorduğunda.
|
|
32
|
+
- **Kim:** yalnız eğitim sorumlusu izni olanlar. İzni olmayan kullanıcı için araç çağrılamaz; ona kendi kayıtlarını
|
|
33
|
+
uygulamanın Sertifikalarım sekmesinde görebileceğini söyleyin.
|
|
34
|
+
- **Girdi:** `days` — bugünden itibaren kaç gün (0-365, varsayılan 30; dolmuş olanlar her zaman dahil); `limit` (1-50,
|
|
35
|
+
varsayılan 20).
|
|
36
|
+
- **Çıktı:** `{ "today": "2026-10-20", "until": "2026-11-19", "count": 3, "items": [{ "person_name", "manager_name",
|
|
37
|
+
"type_name", "expires_on", "days_left", "status" }] }` — en acil olan önce; yenilenmiş kayıtlar yoktur; `count` tam
|
|
38
|
+
sayıdır. `days_left` eksiyse süre o kadar gün önce dolmuştur.
|
|
39
|
+
|
|
40
|
+
### `certificate-types` — sertifika türleri (salt okunur)
|
|
41
|
+
|
|
42
|
+
- **Ne zaman:** kullanıcı hangi eğitim ve sertifikaların izlendiğini ya da bir türün kaç ay geçerli olduğunu sorduğunda.
|
|
43
|
+
- **Girdi:** yok.
|
|
44
|
+
- **Çıktı:** `{ "items": [{ "name", "validity_months", "description" }] }` — `validity_months` `null` ise tür süresizdir.
|
|
45
|
+
Kişisel veri içermez.
|
|
46
|
+
|
|
47
|
+
## Örnek istekler
|
|
48
|
+
|
|
49
|
+
- "Önümüzdeki ay hangi sertifikaların süresi doluyor?" → `expiring-certificates` (`days: 30`)
|
|
50
|
+
- "Süresi geçmiş sertifika var mı?" → `expiring-certificates` (`days: 0`) → `status: "expired"` olanlar
|
|
51
|
+
- "Forklift operatörlüğü belgesi kaç yıl geçerli?" → `certificate-types` → `validity_months` (ay; yıla çevirin)
|
|
52
|
+
- "Ahmet'in ilk yardım sertifikasını yeniledim, kaydet." → araç yok; kullanıcıyı uygulamanın Tüm kayıtlar sekmesine
|
|
53
|
+
yönlendirin.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entities": {
|
|
4
|
+
"cert_types": {
|
|
5
|
+
"title": { "tr": "Sertifika türleri", "en": "Certificate types" },
|
|
6
|
+
"fields": {
|
|
7
|
+
"name": { "type": "text", "title": { "tr": "Ad", "en": "Name" }, "required": true, "unique": true, "maxLength": 120 },
|
|
8
|
+
"validity_months": { "type": "number", "title": { "tr": "Geçerlilik (ay)", "en": "Validity (months)" }, "min": 1, "max": 240 },
|
|
9
|
+
"description": { "type": "text", "title": { "tr": "Açıklama", "en": "Description" }, "maxLength": 300 },
|
|
10
|
+
"active": { "type": "boolean", "title": { "tr": "Kullanımda", "en": "In use" }, "required": true, "default": true }
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"certificates": {
|
|
14
|
+
"title": { "tr": "Eğitim ve sertifika kayıtları", "en": "Training and certificate records" },
|
|
15
|
+
"fields": {
|
|
16
|
+
"person_org_id": { "type": "text", "title": { "tr": "Kişi (kimlik)", "en": "Person (id)" }, "required": true, "maxLength": 24, "index": true },
|
|
17
|
+
"person_name": { "type": "text", "title": { "tr": "Kişi", "en": "Person" }, "maxLength": 120 },
|
|
18
|
+
"manager_org_id": { "type": "text", "title": { "tr": "Yönetici (kimlik)", "en": "Manager (id)" }, "maxLength": 24, "index": true },
|
|
19
|
+
"manager_name": { "type": "text", "title": { "tr": "Yönetici", "en": "Manager" }, "maxLength": 120 },
|
|
20
|
+
"type_id": { "type": "text", "title": { "tr": "Tür", "en": "Type" }, "required": true, "maxLength": 36, "index": true },
|
|
21
|
+
"type_name": { "type": "text", "title": { "tr": "Sertifika", "en": "Certificate" }, "maxLength": 120 },
|
|
22
|
+
"completed_on": { "type": "date", "title": { "tr": "Tamamlanma tarihi", "en": "Completed on" }, "required": true, "index": true },
|
|
23
|
+
"expires_on": { "type": "date", "title": { "tr": "Geçerlilik sonu", "en": "Expires on" }, "index": true },
|
|
24
|
+
"certificate_no": { "type": "text", "title": { "tr": "Belge numarası", "en": "Certificate number" }, "maxLength": 60 },
|
|
25
|
+
"attachment": { "type": "file", "title": { "tr": "Belge", "en": "Document" } },
|
|
26
|
+
"attachment_name": { "type": "text", "title": { "tr": "Belge dosyası", "en": "Document file" }, "maxLength": 200 },
|
|
27
|
+
"reminder_sent_for": { "type": "date", "title": { "tr": "Hatırlatılan geçerlilik sonu", "en": "Reminded expiry" } },
|
|
28
|
+
"manager_reminder_sent_for": { "type": "date", "title": { "tr": "Yöneticiye hatırlatılan geçerlilik sonu", "en": "Expiry reminded to manager" } },
|
|
29
|
+
"reminder_failed_on": { "type": "date", "title": { "tr": "Hatırlatmanın ulaşmadığı son gün", "en": "Last undelivered reminder" } }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"reminder_runs": {
|
|
33
|
+
"title": { "tr": "Günlük hatırlatma kontrolleri", "en": "Daily reminder checks" },
|
|
34
|
+
"fields": {
|
|
35
|
+
"run_key": { "type": "text", "title": { "tr": "Kontrol günü", "en": "Check day" }, "required": true, "unique": true, "maxLength": 20 },
|
|
36
|
+
"due_count": { "type": "number", "title": { "tr": "Hatırlatılacak sertifika", "en": "Certificates to remind" }, "required": true, "default": 0, "min": 0 },
|
|
37
|
+
"sent_count": { "type": "number", "title": { "tr": "Gönderilen e-posta", "en": "E-mails sent" }, "required": true, "default": 0, "min": 0 },
|
|
38
|
+
"waiting_count": { "type": "number", "title": { "tr": "Gönderilemeyen e-posta", "en": "E-mails not sent" }, "required": true, "default": 0, "min": 0 },
|
|
39
|
+
"consent_needed": { "type": "boolean", "title": { "tr": "E-posta onayı bekleniyor", "en": "E-mail approval pending" }, "required": true, "default": false }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Devoplus yapay zekâsının bu uygulamada çağırabileceği araçların İŞLEYİCİLERİ. Adı, açıklaması, girdi şeması,
|
|
2
|
+
// salt-okunurluğu ve izni `one.manifest.json` → `agent.tools`'tadır; rehber `AGENT_GUIDE.md`'dir. Yeni araç = üçü birlikte.
|
|
3
|
+
// Araçlar yalnız uygulamanın KENDİ veritabanını okur. ⛔ Kişisel veri döndüren `expiring-certificates` manifest'te
|
|
4
|
+
// eğitim sorumlusu iznine (`…Manage`) bağlıdır: platform izni olmayan kişinin çağrısını araca ulaştırmaz. Kayıt YAZAN
|
|
5
|
+
// araç bilinçli olarak yoktur.
|
|
6
|
+
import { defineAgentTools } from '@devoplus/one-sdk'
|
|
7
|
+
import type { Env } from './app.ts'
|
|
8
|
+
import { todayKey } from './certificate.ts'
|
|
9
|
+
import { activeTypes, expiring } from './queries.ts'
|
|
10
|
+
|
|
11
|
+
export const agentTools = defineAgentTools<Env>({
|
|
12
|
+
'expiring-certificates': {
|
|
13
|
+
handler: async ({ input, env }) => {
|
|
14
|
+
const days = typeof input.days === 'number' ? input.days : 30
|
|
15
|
+
const limit = typeof input.limit === 'number' ? input.limit : 20
|
|
16
|
+
return expiring(env, todayKey(new Date()), days, limit)
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
'certificate-types': {
|
|
20
|
+
handler: async ({ env }) => activeTypes(env),
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// {{nameTr}} — eğitim ve sertifika takibi. Uçlar (hepsi oturum ister; kimlik platformdan gelir):
|
|
2
|
+
// GET /api/my/certificates[/export.xlsx] kendi kayıtlarım (herkes) — kişi oturumdan, SUNUCUDA çözülür
|
|
3
|
+
// GET /api/team/certificates[/export.xlsx] ekibimin kayıtları — ekip org şemasından SUNUCUDA çözülür
|
|
4
|
+
// (işlevsel yöneticisi ben olan aktif kişiler); ekibi olmayana 403
|
|
5
|
+
// GET /api/certificates[?…] bütün kayıtlar (eğitim sorumlusu) — hazır liste + Excel
|
|
6
|
+
// POST /api/certificates · PATCH/DELETE /api/certificates/{id} kayıt yazma (eğitim sorumlusu)
|
|
7
|
+
// ⛔ geçerlilik sonu ve yönetici istemciden ALINMAZ
|
|
8
|
+
// POST /api/certificates/refresh-managers kayıtlardaki yöneticiyi org şemasından yeniler (eğitim sorumlusu)
|
|
9
|
+
// GET /api/certificates/{id}/attachment belge: kişinin kendisi, doğrudan yöneticisi ya da eğitim sorumlusu
|
|
10
|
+
// GET /api/cert_types[?…] · POST/PATCH/DELETE sertifika türleri (okuma herkes, yazma eğitim sorumlusu)
|
|
11
|
+
// POST /api/files dosya yükleme (eğitim sorumlusu; yalnız kendi ön ekine)
|
|
12
|
+
// GET /api/summary dolmuş / dolacak sayısı, son hatırlatma kontrolü (eğitim sorumlusu)
|
|
13
|
+
import {
|
|
14
|
+
OneError,
|
|
15
|
+
approvalChainReport,
|
|
16
|
+
createCrudHandler,
|
|
17
|
+
exportResponse,
|
|
18
|
+
getMyOrgContext,
|
|
19
|
+
getOrgUsersWithManagers,
|
|
20
|
+
hasPermission,
|
|
21
|
+
logAudit,
|
|
22
|
+
requirePermission,
|
|
23
|
+
requireUser,
|
|
24
|
+
toXlsx,
|
|
25
|
+
type ExportColumn,
|
|
26
|
+
type MyOrgContext,
|
|
27
|
+
type OneFilesBinding,
|
|
28
|
+
} from '@devoplus/one-sdk'
|
|
29
|
+
import manifest from '../one.manifest.json' with { type: 'json' }
|
|
30
|
+
import { agentTools } from './agent.ts'
|
|
31
|
+
import { checkCompletionDate, checkValidity, expiryDate, isDirectReport, teamOf, todayKey } from './certificate.ts'
|
|
32
|
+
import { dataModel } from './data/types.ts'
|
|
33
|
+
import { jobs } from './jobs.ts'
|
|
34
|
+
import { fileResponse, uploadFile } from './lib/files.ts'
|
|
35
|
+
import { fieldError, forward, matchId, nowIso, onlyFields, ownFileKey, personValue, readJsonObject } from './lib/http.ts'
|
|
36
|
+
import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
|
|
37
|
+
import { MANAGE_PERMISSION } from './names.ts'
|
|
38
|
+
import { EXPORT_MAX, certificatesOf, counts, lastRun } from './queries.ts'
|
|
39
|
+
import { renderShell, renderShellScript } from './shell.ts'
|
|
40
|
+
|
|
41
|
+
export { APP_NAME, MANAGE_PERMISSION } from './names.ts'
|
|
42
|
+
|
|
43
|
+
export interface Env extends BaseEnv {
|
|
44
|
+
/** Bu kurulumun dosya deposu (manifest services.files): sertifika belgeleri. */
|
|
45
|
+
FILES: OneFilesBinding
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Bütün kayıtlar: yalnız eğitim sorumlusu okur ve yazar (kişisel veri). */
|
|
49
|
+
export const certificates = createCrudHandler({ model: dataModel, entity: 'certificates', records: 'shared', canRead: MANAGE_PERMISSION, canWrite: MANAGE_PERMISSION })
|
|
50
|
+
export const certTypes = createCrudHandler({ model: dataModel, entity: 'cert_types', records: 'shared', canWrite: MANAGE_PERMISSION })
|
|
51
|
+
|
|
52
|
+
const MY_PATH = '/api/my/certificates'
|
|
53
|
+
const TEAM_PATH = '/api/team/certificates'
|
|
54
|
+
const TYPE_FIELDS = ['name', 'validity_months', 'description', 'active']
|
|
55
|
+
|
|
56
|
+
const today = (): string => todayKey(new Date())
|
|
57
|
+
|
|
58
|
+
async function me(input: RouteInput<Env>): Promise<MyOrgContext> {
|
|
59
|
+
const ctx = await getMyOrgContext(input.api())
|
|
60
|
+
if (!ctx) throw new OneError(409, 'APP-E-010', 'Portalda kişi kaydınız bulunamadı; sertifika kayıtlarınız gösterilemiyor. Portal yöneticinize başvurun.')
|
|
61
|
+
return ctx
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fileValue(raw: unknown, sub: string): { attachment: string | null; attachment_name: string | null } {
|
|
65
|
+
const attachment = ownFileKey(raw, sub, 'attachment')
|
|
66
|
+
const name =
|
|
67
|
+
attachment && typeof raw === 'object' && raw !== null && typeof (raw as { name?: unknown }).name === 'string' ? (raw as { name: string }).name.slice(0, 200) : null
|
|
68
|
+
return { attachment, attachment_name: name }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sertifika türü. `active`: yeni kayıtta yalnız kullanımdaki (silinmemiş ve etkin) tür seçilebilir. `any`: var olan kaydın
|
|
73
|
+
* geçerlilik sonu yeniden hesaplanırken SİLİNMİŞ tür de okunur — tür silinse de geçerlilik süresi satırında durur; aksi
|
|
74
|
+
* hâlde süreli bir sertifika sessizce süresize dönerdi.
|
|
75
|
+
*/
|
|
76
|
+
async function typeOf(env: Env, id: unknown, mode: 'active' | 'any') {
|
|
77
|
+
if (typeof id !== 'string' || id === '') return null
|
|
78
|
+
return env.DB.prepare(`SELECT id, name, validity_months FROM "cert_types" WHERE id = ?${mode === 'active' ? ' AND deleted_at IS NULL AND active = 1' : ''}`)
|
|
79
|
+
.bind(id)
|
|
80
|
+
.first<{ id: string; name: string; validity_months: number | null }>()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function createCertificate(input: RouteInput<Env>): Promise<Response> {
|
|
84
|
+
const { request, env, ctx } = input
|
|
85
|
+
const user = requirePermission(ctx, MANAGE_PERMISSION)
|
|
86
|
+
const body = onlyFields(await readJsonObject(request), ['person', 'type_id', 'completed_on', 'certificate_no', 'attachment'])
|
|
87
|
+
const person = personValue(body.person, 'person')
|
|
88
|
+
if (!person) throw fieldError('person', 'Kişiyi seçin.')
|
|
89
|
+
const type = await typeOf(env, body.type_id, 'active')
|
|
90
|
+
if (!type) throw fieldError('type_id', 'Kullanımdaki bir sertifika türü seçin.')
|
|
91
|
+
const completed = checkCompletionDate(body.completed_on, today())
|
|
92
|
+
if (!completed.ok) throw fieldError('completed_on', completed.message)
|
|
93
|
+
// Kişi ve yöneticisi organizasyon şemasından SUNUCUDA çözülür (pasifler de gelir ki ayrılmış yönetici atlanabilsin).
|
|
94
|
+
const users = await getOrgUsersWithManagers(input.api(), { includeInactive: true })
|
|
95
|
+
const org = users.find((u) => u.id.toLowerCase() === person.id)
|
|
96
|
+
if (!org || !org.isActive) throw fieldError('person', 'Kişi bu portalın organizasyon şemasında bulunamadı.')
|
|
97
|
+
const manager = approvalChainReport(users, org.id, { maxDepth: 1 }).chain[0]?.toLowerCase() ?? null
|
|
98
|
+
const record = {
|
|
99
|
+
person_org_id: person.id,
|
|
100
|
+
person_name: org.fullName || person.name || null,
|
|
101
|
+
manager_org_id: manager,
|
|
102
|
+
manager_name: manager ? users.find((u) => u.id.toLowerCase() === manager)?.fullName || null : null,
|
|
103
|
+
type_id: type.id,
|
|
104
|
+
type_name: type.name,
|
|
105
|
+
completed_on: completed.value,
|
|
106
|
+
expires_on: expiryDate(completed.value, type.validity_months),
|
|
107
|
+
certificate_no: body.certificate_no ?? null,
|
|
108
|
+
...fileValue(body.attachment, user.sub),
|
|
109
|
+
}
|
|
110
|
+
const res = (await certificates.handle(forward(request, certificates.path, 'POST', record), env.DB, ctx))!
|
|
111
|
+
if (res.status === 201) logAudit(ctx, 'certificate.create', person.id, { type: type.id })
|
|
112
|
+
return res
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function updateCertificate(input: RouteInput<Env>, id: string): Promise<Response> {
|
|
116
|
+
const { request, env, ctx, method } = input
|
|
117
|
+
const user = requirePermission(ctx, MANAGE_PERMISSION)
|
|
118
|
+
if (method === 'DELETE') return (await certificates.handle(request, env.DB, ctx))!
|
|
119
|
+
// Kişi ve tür değiştirilmez (yanlışsa kayıt silinip yeniden girilir); geçerlilik sonu tamamlanma tarihinden hesaplanır.
|
|
120
|
+
const body = onlyFields(await readJsonObject(request), ['completed_on', 'certificate_no', 'attachment'])
|
|
121
|
+
const current = await env.DB.prepare('SELECT type_id, attachment FROM "certificates" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ type_id: string; attachment: string | null }>()
|
|
122
|
+
if (!current) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
|
|
123
|
+
const patch: Record<string, unknown> = {}
|
|
124
|
+
if ('certificate_no' in body) patch.certificate_no = body.certificate_no
|
|
125
|
+
// Kayıttaki belge olduğu gibi geri gönderildiyse dokunulmaz; YENİ belge yalnız güncelleyenin kendi ön ekinden olabilir.
|
|
126
|
+
const sentKey = typeof body.attachment === 'object' && body.attachment !== null ? (body.attachment as { key?: unknown }).key : body.attachment
|
|
127
|
+
if ('attachment' in body && !(current.attachment !== null && sentKey === current.attachment)) Object.assign(patch, fileValue(body.attachment, user.sub))
|
|
128
|
+
if ('completed_on' in body) {
|
|
129
|
+
const completed = checkCompletionDate(body.completed_on, today())
|
|
130
|
+
if (!completed.ok) throw fieldError('completed_on', completed.message)
|
|
131
|
+
const type = await typeOf(env, current.type_id, 'any')
|
|
132
|
+
// Tür kaydı hiç yoksa geçerlilik süresi bilinmez: "süresiz" saymak yerine düzenleme reddedilir.
|
|
133
|
+
if (!type) throw new OneError(409, 'APP-E-409', 'Kaydın sertifika türü bulunamadığı için geçerlilik sonu hesaplanamıyor; kaydı silip yeniden girin.')
|
|
134
|
+
patch.completed_on = completed.value
|
|
135
|
+
patch.expires_on = expiryDate(completed.value, type.validity_months)
|
|
136
|
+
}
|
|
137
|
+
return (await certificates.handle(forward(request, `${certificates.path}/${id}`, 'PATCH', patch), env.DB, ctx))!
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Kayıtlardaki yöneticiyi org şemasından yeniler: hatırlatma e-postası kişinin BUGÜNKÜ yöneticisine gitsin. */
|
|
141
|
+
async function refreshManagers(input: RouteInput<Env>): Promise<Response> {
|
|
142
|
+
const { env, ctx } = input
|
|
143
|
+
const user = requirePermission(ctx, MANAGE_PERMISSION)
|
|
144
|
+
const users = await getOrgUsersWithManagers(input.api(), { includeInactive: true })
|
|
145
|
+
const people = await env.DB.prepare('SELECT DISTINCT person_org_id AS id FROM "certificates" WHERE deleted_at IS NULL').all<{ id: string }>()
|
|
146
|
+
const now = nowIso()
|
|
147
|
+
const statements = people.results.map(({ id }) => {
|
|
148
|
+
const org = users.find((u) => u.id.toLowerCase() === id.toLowerCase())
|
|
149
|
+
const manager = org ? (approvalChainReport(users, org.id, { maxDepth: 1 }).chain[0]?.toLowerCase() ?? null) : null
|
|
150
|
+
const name = manager ? users.find((u) => u.id.toLowerCase() === manager)?.fullName || null : null
|
|
151
|
+
// Değişen metin alanlarının arama kolonu (`<alan>_fold`) NULL yapılır: arama boş kolonda metni kendisi katlar.
|
|
152
|
+
// Yönetici DEĞİŞTİYSE yöneticiye hatırlatma damgası sıfırlanır: eski yöneticiye gitmiş hatırlatma yenisine gitmiş
|
|
153
|
+
// sayılmaz (sağ taraftaki `manager_org_id` satırın güncellemeden önceki değeridir).
|
|
154
|
+
return env.DB.prepare(
|
|
155
|
+
'UPDATE "certificates" SET manager_org_id = ?, manager_name = ?, manager_org_id_fold = NULL, manager_name_fold = NULL, ' +
|
|
156
|
+
"manager_reminder_sent_for = CASE WHEN lower(coalesce(manager_org_id, '')) = lower(coalesce(?, '')) THEN manager_reminder_sent_for ELSE NULL END, updated_at = ?, updated_by = ? " +
|
|
157
|
+
'WHERE person_org_id = ? AND deleted_at IS NULL AND (manager_org_id IS NOT ? OR manager_name IS NOT ?)',
|
|
158
|
+
).bind(manager, name, manager, now, user.sub, id, manager, name)
|
|
159
|
+
})
|
|
160
|
+
const results = statements.length ? await env.DB.batch(statements) : []
|
|
161
|
+
const updated = results.reduce((n, r) => n + Number(r.meta.changes ?? 0), 0)
|
|
162
|
+
logAudit(ctx, 'certificate.refresh-managers', 'all', { updated })
|
|
163
|
+
return json({ updated })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Belgeyi kişinin kendisi, doğrudan yöneticisi (org şemasından) ya da eğitim sorumlusu indirir; başkasına "yok" (404). */
|
|
167
|
+
async function attachmentOf(input: RouteInput<Env>, id: string): Promise<Response> {
|
|
168
|
+
const { env, ctx } = input
|
|
169
|
+
requireUser(ctx)
|
|
170
|
+
const row = await env.DB.prepare('SELECT person_org_id, attachment FROM "certificates" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ person_org_id: string; attachment: string | null }>()
|
|
171
|
+
let allowed = row !== null && hasPermission(ctx, MANAGE_PERMISSION)
|
|
172
|
+
if (row && !allowed) {
|
|
173
|
+
const self = await getMyOrgContext(input.api())
|
|
174
|
+
allowed =
|
|
175
|
+
self !== null &&
|
|
176
|
+
(self.orgUserId.toLowerCase() === row.person_org_id.toLowerCase() || isDirectReport(await getOrgUsersWithManagers(input.api()), self.orgUserId, row.person_org_id))
|
|
177
|
+
}
|
|
178
|
+
if (!row || !allowed || !row.attachment) throw new OneError(404, 'APP-E-404', 'Belge bulunamadı.')
|
|
179
|
+
return fileResponse(env.FILES, row.attachment)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const EXPORT_COLUMNS: ExportColumn[] = [
|
|
183
|
+
{ key: 'person_name', title: { tr: 'Kişi', en: 'Person' } },
|
|
184
|
+
{ key: 'type_name', title: { tr: 'Sertifika', en: 'Certificate' } },
|
|
185
|
+
{ key: 'completed_on', title: { tr: 'Tamamlanma tarihi', en: 'Completed on' }, type: 'date' },
|
|
186
|
+
{ key: 'expires_on', title: { tr: 'Geçerlilik sonu', en: 'Expires on' }, type: 'date' },
|
|
187
|
+
{
|
|
188
|
+
key: 'status',
|
|
189
|
+
title: { tr: 'Durum', en: 'Status' },
|
|
190
|
+
labels: {
|
|
191
|
+
valid: { tr: 'Geçerli', en: 'Valid' },
|
|
192
|
+
expiring: { tr: 'Süresi doluyor', en: 'Expiring' },
|
|
193
|
+
expired: { tr: 'Süresi doldu', en: 'Expired' },
|
|
194
|
+
renewed: { tr: 'Yenilendi', en: 'Renewed' },
|
|
195
|
+
'no-expiry': { tr: 'Süresiz', en: 'No expiry' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{ key: 'certificate_no', title: { tr: 'Belge numarası', en: 'Certificate number' } },
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Liste ya da Excel. Kişi listesi `people` ile sunucuda çözülmüş gelir. Ekran en çok `LIST_MAX` kayıt gösterir ve kesildiyse
|
|
203
|
+
* `truncated: true` döner; Excel en çok `EXPORT_MAX` kayıt aktarır ve fazlası varsa EKSİK dosya üretmek yerine reddeder.
|
|
204
|
+
*/
|
|
205
|
+
async function listResponse(input: RouteInput<Env>, base: string, people: readonly string[], extra: Record<string, unknown>, fileName: string): Promise<Response> {
|
|
206
|
+
const rest = input.url.pathname.slice(base.length)
|
|
207
|
+
if (rest === '/export.xlsx') {
|
|
208
|
+
const { items, truncated } = await certificatesOf(input.env, people, today(), EXPORT_MAX)
|
|
209
|
+
if (truncated) throw new OneError(400, 'APP-E-400', `Aktarılacak kayıt sayısı ${EXPORT_MAX} sınırını aşıyor; eksik dosya üretilmedi.`)
|
|
210
|
+
const lang = input.url.searchParams.get('lang') === 'en' ? 'en' : 'tr'
|
|
211
|
+
return exportResponse(toXlsx(items.map((i) => ({ ...i })), EXPORT_COLUMNS, { lang, sheetName: fileName }), { fileName, format: 'xlsx' })
|
|
212
|
+
}
|
|
213
|
+
const { items, truncated } = await certificatesOf(input.env, people, today())
|
|
214
|
+
return json({ today: today(), items, truncated, ...extra })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Kendi kayıtlarım: kişi oturumdan çözülür; istemci başka bir kişi gönderemez. */
|
|
218
|
+
async function myList(input: RouteInput<Env>): Promise<Response> {
|
|
219
|
+
requireUser(input.ctx)
|
|
220
|
+
const self = await me(input)
|
|
221
|
+
return listResponse(input, MY_PATH, [self.orgUserId], {}, 'Sertifikalarım')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Ekibimin kayıtları: ekip org şemasından sunucuda çözülür; ekibi olmayan kişiye görünüm kapalıdır (403). */
|
|
225
|
+
async function teamList(input: RouteInput<Env>): Promise<Response> {
|
|
226
|
+
requireUser(input.ctx)
|
|
227
|
+
const self = await me(input)
|
|
228
|
+
const team = teamOf(await getOrgUsersWithManagers(input.api()), self.orgUserId)
|
|
229
|
+
if (team.length === 0) throw new OneError(403, 'APP-E-403', 'Bu görünüm, organizasyon şemasında kendisine bağlı çalışanı olan yöneticilere açıktır.')
|
|
230
|
+
return listResponse(
|
|
231
|
+
input,
|
|
232
|
+
TEAM_PATH,
|
|
233
|
+
team.map((u) => u.id),
|
|
234
|
+
{ team: team.map((u) => ({ id: u.id.toLowerCase(), name: u.fullName })) },
|
|
235
|
+
'Ekibimin sertifikaları',
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function typeWrite(input: RouteInput<Env>, id: string | null): Promise<Response> {
|
|
240
|
+
const { request, env, ctx, method } = input
|
|
241
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
242
|
+
if (id && method === 'DELETE') return (await certTypes.handle(request, env.DB, ctx))!
|
|
243
|
+
const body = onlyFields(await readJsonObject(request), TYPE_FIELDS)
|
|
244
|
+
if ('validity_months' in body) {
|
|
245
|
+
const validity = checkValidity(body.validity_months)
|
|
246
|
+
if (!validity.ok) throw fieldError('validity_months', validity.message)
|
|
247
|
+
body.validity_months = validity.value
|
|
248
|
+
}
|
|
249
|
+
return (await certTypes.handle(forward(request, id ? `${certTypes.path}/${id}` : certTypes.path, id ? 'PATCH' : 'POST', body), env.DB, ctx))!
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function summary({ env, ctx }: RouteInput<Env>): Promise<Response> {
|
|
253
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
254
|
+
const day = today()
|
|
255
|
+
const [c, run] = await Promise.all([counts(env, day), lastRun(env)])
|
|
256
|
+
return json({ today: day, ...c, lastRun: run ? { ...run, consent_needed: run.consent_needed === 1 } : null })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readOnly(input: RouteInput<Env>): void {
|
|
260
|
+
if (input.method !== 'GET' && input.method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bu liste yalnız okunur.')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function routes(input: RouteInput<Env>): Promise<Response | null> {
|
|
264
|
+
const { url, method, env, ctx } = input
|
|
265
|
+
const path = url.pathname
|
|
266
|
+
if (path === MY_PATH || path === `${MY_PATH}/export.xlsx`) {
|
|
267
|
+
readOnly(input)
|
|
268
|
+
return myList(input)
|
|
269
|
+
}
|
|
270
|
+
if (path === TEAM_PATH || path === `${TEAM_PATH}/export.xlsx`) {
|
|
271
|
+
readOnly(input)
|
|
272
|
+
return teamList(input)
|
|
273
|
+
}
|
|
274
|
+
if (path === '/api/summary' && method === 'GET') return summary(input)
|
|
275
|
+
if (path === '/api/files' && method === 'POST') {
|
|
276
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
277
|
+
return uploadFile(input.request, env.FILES, ctx)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (path === `${certificates.path}/refresh-managers` && method === 'POST') return refreshManagers(input)
|
|
281
|
+
const attachmentId = matchId(path, certificates.path, 'attachment')
|
|
282
|
+
if (attachmentId && method === 'GET') return attachmentOf(input, attachmentId)
|
|
283
|
+
if (path === certificates.path && method === 'POST') return createCertificate(input)
|
|
284
|
+
const certificateId = matchId(path, certificates.path)
|
|
285
|
+
if (certificateId && (method === 'PATCH' || method === 'PUT' || method === 'DELETE')) {
|
|
286
|
+
if (method === 'PUT') throw new OneError(405, 'APP-E-405', 'Kayıt PATCH ile güncellenir.')
|
|
287
|
+
return updateCertificate(input, certificateId)
|
|
288
|
+
}
|
|
289
|
+
if (path === certificates.path || path.startsWith(`${certificates.path}/`)) return certificates.handle(input.request, env.DB, ctx)
|
|
290
|
+
|
|
291
|
+
if (path === certTypes.path && method === 'POST') return typeWrite(input, null)
|
|
292
|
+
const typeId = matchId(path, certTypes.path)
|
|
293
|
+
if (typeId && (method === 'PATCH' || method === 'PUT' || method === 'DELETE')) {
|
|
294
|
+
if (method === 'PUT') throw new OneError(405, 'APP-E-405', 'Tür PATCH ile güncellenir.')
|
|
295
|
+
return typeWrite(input, typeId)
|
|
296
|
+
}
|
|
297
|
+
if (path === certTypes.path || path.startsWith(`${certTypes.path}/`)) return certTypes.handle(input.request, env.DB, ctx)
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const definition: Omit<AppDefinition<Env>, 'guide'> = {
|
|
302
|
+
manifest,
|
|
303
|
+
tools: agentTools,
|
|
304
|
+
jobs,
|
|
305
|
+
page: renderShell,
|
|
306
|
+
script: renderShellScript,
|
|
307
|
+
routes,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function buildApp(guide: string) {
|
|
311
|
+
return createApp<Env>({ ...definition, guide })
|
|
312
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { addMonths, certStatus, checkCompletionDate, checkValidity, expiryDate, groupByPerson, isDirectReport, teamOf, todayKey, type OrgPerson } from './certificate.ts'
|
|
3
|
+
|
|
4
|
+
describe('sertifika kuralları', () => {
|
|
5
|
+
it('geçerlilik sonu = tamamlanma + ay; ayın son günü korunur; süresiz türde yok', () => {
|
|
6
|
+
expect(addMonths('2026-01-31', 1)).toBe('2026-02-28')
|
|
7
|
+
expect(addMonths('2028-01-31', 1)).toBe('2028-02-29')
|
|
8
|
+
expect(addMonths('2026-03-15', 12)).toBe('2027-03-15')
|
|
9
|
+
expect(addMonths('2026-11-30', 3)).toBe('2027-02-28')
|
|
10
|
+
expect(expiryDate('2026-05-10', 24)).toBe('2028-05-10')
|
|
11
|
+
expect(expiryDate('2026-05-10', null)).toBeNull()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('geçerlilik süresi: boş = süresiz; tam ay ve sınırlar içinde', () => {
|
|
15
|
+
expect(checkValidity('')).toEqual({ ok: true, value: null })
|
|
16
|
+
expect(checkValidity(null)).toEqual({ ok: true, value: null })
|
|
17
|
+
expect(checkValidity(12)).toEqual({ ok: true, value: 12 })
|
|
18
|
+
expect(checkValidity(1.5)).toMatchObject({ ok: false })
|
|
19
|
+
expect(checkValidity(0)).toMatchObject({ ok: false })
|
|
20
|
+
expect(checkValidity(241)).toMatchObject({ ok: false })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('tamamlanma tarihi: geçerli gün, ileri tarih ve çok eski tarih reddedilir', () => {
|
|
24
|
+
expect(checkCompletionDate('2026-10-20', '2026-10-20')).toEqual({ ok: true, value: '2026-10-20' })
|
|
25
|
+
expect(checkCompletionDate('2026-10-21', '2026-10-20')).toMatchObject({ ok: false })
|
|
26
|
+
expect(checkCompletionDate('2026-02-30', '2026-10-20')).toMatchObject({ ok: false })
|
|
27
|
+
expect(checkCompletionDate('1999-01-01', '2026-10-20')).toMatchObject({ ok: false })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('durum: yenilenen, süresiz, dolmuş, dolmak üzere (30 gün, bugün dahil), geçerli', () => {
|
|
31
|
+
expect(certStatus('2026-10-01', '2026-10-20', true)).toBe('renewed')
|
|
32
|
+
expect(certStatus(null, '2026-10-20', false)).toBe('no-expiry')
|
|
33
|
+
expect(certStatus('2026-10-19', '2026-10-20', false)).toBe('expired')
|
|
34
|
+
expect(certStatus('2026-10-20', '2026-10-20', false)).toBe('expiring')
|
|
35
|
+
expect(certStatus('2026-11-19', '2026-10-20', false)).toBe('expiring')
|
|
36
|
+
expect(certStatus('2026-11-20', '2026-10-20', false)).toBe('valid')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('ekip: işlevsel yöneticisi ben olan AKTİF kişiler; büyük/küçük harf duyarsız', () => {
|
|
40
|
+
const p = (id: string, manager: string, isActive = true): OrgPerson => ({ id, fullName: id, functionalManager: manager, isActive })
|
|
41
|
+
const boss = 'a'.repeat(24)
|
|
42
|
+
const users = [p('b'.repeat(24), boss), p('c'.repeat(24), 'A'.repeat(24)), p('d'.repeat(24), boss, false), p('e'.repeat(24), 'f'.repeat(24))]
|
|
43
|
+
expect(teamOf(users, boss).map((u) => u.id)).toEqual(['b'.repeat(24), 'c'.repeat(24)])
|
|
44
|
+
expect(isDirectReport(users, boss, 'C'.repeat(24))).toBe(true)
|
|
45
|
+
expect(isDirectReport(users, boss, 'e'.repeat(24))).toBe(false)
|
|
46
|
+
expect(teamOf(users, 'e'.repeat(24))).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('gruplama: (kişi, yönetici) başına tek e-posta; alıcılar kişi + o kayıtların yöneticisi; grup sınırı', () => {
|
|
50
|
+
const row = (id: string, person: string, manager: string | null, expires: string, sent: { person?: boolean; manager?: boolean } = {}) => ({
|
|
51
|
+
id,
|
|
52
|
+
person_org_id: person,
|
|
53
|
+
person_name: person,
|
|
54
|
+
manager_org_id: manager,
|
|
55
|
+
type_name: id,
|
|
56
|
+
expires_on: expires,
|
|
57
|
+
reminder_sent_for: sent.person ? expires : null,
|
|
58
|
+
manager_reminder_sent_for: sent.manager ? expires : null,
|
|
59
|
+
})
|
|
60
|
+
const groups = groupByPerson([row('x', 'p1', 'm1', '2026-11-01'), row('y', 'p2', null, '2026-10-25'), row('z', 'p1', 'm1', '2026-10-30'), row('w', 'p3', 'm3', '2026-12-01')], 2)
|
|
61
|
+
expect(groups.map((g) => [g.person, g.recipients, g.items.map((i) => i.id)])).toEqual([
|
|
62
|
+
['p2', ['p2'], ['y']],
|
|
63
|
+
['p1', ['p1', 'm1'], ['z', 'x']],
|
|
64
|
+
])
|
|
65
|
+
// Kişinin kayıtları farklı yöneticilere bağlıysa ayrı e-posta: bir yönetici ötekinin kaydını görmez.
|
|
66
|
+
const split = groupByPerson([row('x', 'p1', 'm1', '2026-11-01'), row('y', 'p1', 'm2', '2026-11-02')], 5)
|
|
67
|
+
expect(split.map((g) => [g.recipients, g.items.map((i) => i.id)])).toEqual([
|
|
68
|
+
[['p1', 'm1'], ['x']],
|
|
69
|
+
[['p1', 'm2'], ['y']],
|
|
70
|
+
])
|
|
71
|
+
// Hatırlatmayı almış taraf aynı kaydı yeniden almaz: kişiye gitmiş, yöneticiye gitmemiş kayıt yalnız yöneticiye gider.
|
|
72
|
+
const partial = groupByPerson([row('x', 'p1', 'm1', '2026-11-01', { person: true }), row('y', 'p1', 'm1', '2026-11-02'), row('z', 'p1', 'm1', '2026-11-03', { person: true, manager: true })], 5)
|
|
73
|
+
expect(partial.map((g) => [g.recipients, g.items.map((i) => i.id)])).toEqual([
|
|
74
|
+
[['m1'], ['x']],
|
|
75
|
+
[['p1', 'm1'], ['y']],
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('bugün İstanbul saatine göre', () => {
|
|
80
|
+
expect(todayKey(new Date('2026-10-20T20:59:00Z'))).toBe('2026-10-20')
|
|
81
|
+
expect(todayKey(new Date('2026-10-20T21:00:00Z'))).toBe('2026-10-21')
|
|
82
|
+
})
|
|
83
|
+
})
|