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.
Files changed (122) hide show
  1. package/README.md +62 -2
  2. package/dist/cli.mjs +17 -0
  3. package/package.json +35 -3
  4. package/templates/0001_init.sql.tpl +10 -0
  5. package/templates/AGENTS.md.tpl +286 -0
  6. package/templates/AGENT_GUIDE.md.tpl +36 -0
  7. package/templates/README.md.tpl +58 -0
  8. package/templates/_shared/AGENTS.writes.md.tpl +38 -0
  9. package/templates/_shared/README.tail.md.tpl +20 -0
  10. package/templates/_shared/files/src/app.test.ts.tpl +36 -0
  11. package/templates/_shared/files/src/index.ts.tpl +8 -0
  12. package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
  13. package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
  14. package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
  15. package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
  16. package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
  17. package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
  18. package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
  19. package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
  20. package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
  21. package/templates/agent.ts.tpl +23 -0
  22. package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
  23. package/templates/bakim-takvimi/README.md.tpl +45 -0
  24. package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
  25. package/templates/bakim-takvimi/files/one.data.json +45 -0
  26. package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
  27. package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
  28. package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
  29. package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
  30. package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
  31. package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
  32. package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
  33. package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
  34. package/templates/bakim-takvimi/manifest.json.tpl +64 -0
  35. package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
  36. package/templates/egitim-sertifika/README.md.tpl +46 -0
  37. package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
  38. package/templates/egitim-sertifika/files/one.data.json +43 -0
  39. package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
  40. package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
  41. package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
  42. package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
  43. package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
  44. package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
  45. package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
  46. package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
  47. package/templates/egitim-sertifika/manifest.json.tpl +57 -0
  48. package/templates/envanter/AGENTS.template.md.tpl +28 -0
  49. package/templates/envanter/README.md.tpl +40 -0
  50. package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
  51. package/templates/envanter/files/one.data.json +62 -0
  52. package/templates/envanter/files/src/agent.ts.tpl +20 -0
  53. package/templates/envanter/files/src/app.ts.tpl +204 -0
  54. package/templates/envanter/files/src/jobs.ts.tpl +30 -0
  55. package/templates/envanter/files/src/queries.ts.tpl +53 -0
  56. package/templates/envanter/files/src/shell.ts.tpl +265 -0
  57. package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
  58. package/templates/envanter/files/src/stock.ts.tpl +33 -0
  59. package/templates/envanter/manifest.json.tpl +56 -0
  60. package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
  61. package/templates/hedef-takibi/README.md.tpl +52 -0
  62. package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
  63. package/templates/hedef-takibi/files/one.data.json +76 -0
  64. package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
  65. package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
  66. package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
  67. package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
  68. package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
  69. package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
  70. package/templates/hedef-takibi/manifest.json.tpl +38 -0
  71. package/templates/index.ts.tpl +262 -0
  72. package/templates/markdown.d.ts.tpl +6 -0
  73. package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
  74. package/templates/musteri-ziyaret/README.md.tpl +48 -0
  75. package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
  76. package/templates/musteri-ziyaret/files/one.data.json +53 -0
  77. package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
  78. package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
  79. package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
  80. package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
  81. package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
  82. package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
  83. package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
  84. package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
  85. package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
  86. package/templates/saha-denetimi/README.md.tpl +44 -0
  87. package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
  88. package/templates/saha-denetimi/files/one.data.json +87 -0
  89. package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
  90. package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
  91. package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
  92. package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
  93. package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
  94. package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
  95. package/templates/saha-denetimi/manifest.json.tpl +49 -0
  96. package/templates/shell.test.ts.tpl +30 -0
  97. package/templates/shell.ts.tpl +201 -0
  98. package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
  99. package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
  100. package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
  101. package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
  102. package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
  103. package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
  104. package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
  105. package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
  106. package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
  107. package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
  108. package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
  109. package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
  110. package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
  111. package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
  112. package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
  113. package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
  114. package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
  115. package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
  116. package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
  117. package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
  118. package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
  119. package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
  120. package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
  121. package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
  122. package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
@@ -0,0 +1,163 @@
1
+ // Eğitim ve sertifika kuralları — SAF fonksiyonlar (veritabanı ve ağ yok; `certificate.test.ts` sınar). Geçerlilik sonu
2
+ // HER ZAMAN sunucuda hesaplanır: tamamlanma tarihi + türün geçerlilik süresi (ay). İstemciden gelen tarih yazılmaz.
3
+ // Ekip de sunucuda, organizasyon şemasından çözülür; istemci "ekibim şunlar" diyemez.
4
+
5
+ /** Geçerlilik sonuna kaç gün kala hatırlatılır (dolmuş ve yenilenmemiş sertifika da bir kez hatırlatılır). ⚠️ İş kuralıdır. */
6
+ export const REMIND_DAYS = 30
7
+ export const VALIDITY_MAX_MONTHS = 240
8
+ export const EARLIEST_DATE = '2000-01-01'
9
+
10
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
11
+ const DAY_MS = 86_400_000
12
+
13
+ export function isCalendarDate(value: unknown): value is string {
14
+ if (typeof value !== 'string') return false
15
+ const m = DATE_RE.exec(value)
16
+ if (!m) return false
17
+ return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).toISOString().slice(0, 10) === value
18
+ }
19
+
20
+ /** İstanbul saatiyle bugünün tarihi (Türkiye'de yaz saati uygulaması yoktur: UTC+3). */
21
+ export function todayKey(at: Date): string {
22
+ return new Date(at.getTime() + 3 * 3_600_000).toISOString().slice(0, 10)
23
+ }
24
+
25
+ export function addDays(date: string, days: number): string {
26
+ return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
27
+ }
28
+
29
+ export function daysBetween(from: string, to: string): number {
30
+ return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS)
31
+ }
32
+
33
+ /**
34
+ * Takvim ayı ekler; hedef ayda o gün yoksa ayın SON günü alınır (31 Ocak + 1 ay = 28/29 Şubat). ⚠️ Bilinçli kural:
35
+ * taşma bir sonraki aya kaymaz, geçerlilik süresi kısalmaz-uzamaz.
36
+ */
37
+ export function addMonths(date: string, months: number): string {
38
+ const m = DATE_RE.exec(date)
39
+ if (!m || !isCalendarDate(date)) throw new Error(`geçersiz tarih: ${date}`)
40
+ const total = Number(m[1]) * 12 + (Number(m[2]) - 1) + months
41
+ const year = Math.floor(total / 12)
42
+ const month = total - year * 12
43
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
44
+ return new Date(Date.UTC(year, month, Math.min(Number(m[3]), lastDay))).toISOString().slice(0, 10)
45
+ }
46
+
47
+ /** Geçerlilik sonu: türün süresi yoksa sertifika süresizdir (`null`). */
48
+ export function expiryDate(completedOn: string, validityMonths: number | null): string | null {
49
+ return validityMonths === null ? null : addMonths(completedOn, validityMonths)
50
+ }
51
+
52
+ export type Check<T> = { ok: true; value: T } | { ok: false; message: string }
53
+
54
+ /** Geçerlilik süresi: boş = süresiz; aksi hâlde 1-240 arası tam ay. */
55
+ export function checkValidity(raw: unknown): Check<number | null> {
56
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null }
57
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
58
+ if (!Number.isInteger(n) || n < 1 || n > VALIDITY_MAX_MONTHS) return { ok: false, message: `Geçerlilik süresi 1 ile ${VALIDITY_MAX_MONTHS} arasında tam ay olmalı (süresizse boş bırakın).` }
59
+ return { ok: true, value: n }
60
+ }
61
+
62
+ /** Tamamlanma tarihi: geçerli bir gün, bugünden ileri değil, `EARLIEST_DATE`'ten eski değil. */
63
+ export function checkCompletionDate(raw: unknown, today: string): Check<string> {
64
+ if (!isCalendarDate(raw)) return { ok: false, message: 'Tarih YYYY-AA-GG biçiminde geçerli bir gün olmalı.' }
65
+ if (raw > today) return { ok: false, message: 'İleri tarihli tamamlanma girilemez.' }
66
+ if (raw < EARLIEST_DATE) return { ok: false, message: `Tarih ${EARLIEST_DATE} ya da daha yeni olmalı.` }
67
+ return { ok: true, value: raw }
68
+ }
69
+
70
+ export type CertStatus = 'renewed' | 'no-expiry' | 'expired' | 'expiring' | 'valid'
71
+
72
+ /** Durum: aynı kişi ve türde daha yeni bir kayıt varsa "yenilendi"; süresizse "süresiz"; sonra tarih karşılaştırması. */
73
+ export function certStatus(expiresOn: string | null, today: string, superseded: boolean, windowDays = REMIND_DAYS): CertStatus {
74
+ if (superseded) return 'renewed'
75
+ if (!expiresOn) return 'no-expiry'
76
+ if (expiresOn < today) return 'expired'
77
+ return daysBetween(today, expiresOn) <= windowDays ? 'expiring' : 'valid'
78
+ }
79
+
80
+ /** Organizasyon şemasındaki kişi (SDK `OrgUserWithManagers`'in burada kullanılan alanları). */
81
+ export interface OrgPerson {
82
+ id: string
83
+ fullName: string | null
84
+ functionalManager: string
85
+ isActive: boolean
86
+ }
87
+
88
+ /**
89
+ * Ekip: işlevsel yöneticisi `managerOrgId` olan AKTİF kişiler (doğrudan bağlılar). ⚠️ İş kuralıdır — "ekibim" yalnız
90
+ * doğrudan bağlılar mı, alt kademeler de mi, keşif görüşmesinde netleştirin.
91
+ */
92
+ export function teamOf(users: readonly OrgPerson[], managerOrgId: string): OrgPerson[] {
93
+ const me = managerOrgId.toLowerCase()
94
+ return users.filter((u) => u.isActive && typeof u.functionalManager === 'string' && u.functionalManager.toLowerCase() === me && u.id.toLowerCase() !== me)
95
+ }
96
+
97
+ /** Kişi `managerOrgId`'nin doğrudan bağlısı mı (ek dosyayı yöneticisi de indirebilsin diye). */
98
+ export function isDirectReport(users: readonly OrgPerson[], managerOrgId: string, personOrgId: string): boolean {
99
+ return teamOf(users, managerOrgId).some((u) => u.id.toLowerCase() === personOrgId.toLowerCase())
100
+ }
101
+
102
+ /** Bir hatırlatma e-postasında listelenen en çok kayıt (kalanlar damgalanmaz, sonraki koşumda gider). */
103
+ export const REMINDER_ITEMS_PER_MAIL = 20
104
+
105
+ export interface DueCertificate {
106
+ id: string
107
+ person_org_id: string
108
+ person_name: string | null
109
+ manager_org_id: string | null
110
+ type_name: string | null
111
+ expires_on: string
112
+ /** Kişiye hatırlatılan geçerlilik sonu (`expires_on` ile aynıysa kişiye gitti). */
113
+ reminder_sent_for: string | null
114
+ /** Yöneticiye hatırlatılan geçerlilik sonu (`expires_on` ile aynıysa yöneticiye gitti). */
115
+ manager_reminder_sent_for: string | null
116
+ }
117
+
118
+ /** Hatırlatmanın kime gideceği: kişi ve kayıttaki yönetici AYRI izlenir (biri ulaşıp öteki ulaşmazsa yalnız o yeniden denenir). */
119
+ export type ReminderSide = 'person' | 'manager'
120
+
121
+ /** Kayıttaki yönetici (kişinin kendisiyse ya da boşsa yok). */
122
+ export function managerOf(row: Pick<DueCertificate, 'person_org_id' | 'manager_org_id'>): string | null {
123
+ const raw = row.manager_org_id?.toLowerCase() || null
124
+ return raw && raw !== row.person_org_id.toLowerCase() ? raw : null
125
+ }
126
+
127
+ /** Bu kayıt için hatırlatmayı henüz ALMAMIŞ taraflar. */
128
+ export function pendingSides(row: DueCertificate): ReminderSide[] {
129
+ const sides: ReminderSide[] = []
130
+ if (row.reminder_sent_for !== row.expires_on) sides.push('person')
131
+ if (managerOf(row) && row.manager_reminder_sent_for !== row.expires_on) sides.push('manager')
132
+ return sides
133
+ }
134
+
135
+ /**
136
+ * Hatırlatılacak sertifikaları (kişi, kayıttaki yönetici, henüz almamış taraflar) üçlüsüne göre gruplar: grup başına TEK
137
+ * e-posta, alıcılar YALNIZ o kayıtların hatırlatmasını henüz almamış taraflar. Yönetici değiştiyse eski ve yeni
138
+ * yöneticinin kayıtları ayrı e-postaya düşer; bir yönetici başka bir yöneticiyle ilişkili kaydın ayrıntısını görmez.
139
+ * Hatırlatmayı almış bir taraf aynı kaydı yeniden almaz. Bir koşumda en çok `maxGroups` grup; sıra en acil sertifikadır.
140
+ * Kalanlar damgalanmadığı için ertesi gün gider.
141
+ */
142
+ export function groupByPerson(rows: readonly DueCertificate[], maxGroups: number, maxItems = REMINDER_ITEMS_PER_MAIL) {
143
+ const groups = new Map<string, { person: string; manager: string | null; sides: ReminderSide[]; items: DueCertificate[] }>()
144
+ const sorted = [...rows].sort((a, b) => (a.expires_on < b.expires_on ? -1 : a.expires_on > b.expires_on ? 1 : a.id < b.id ? -1 : 1))
145
+ for (const row of sorted) {
146
+ const sides = pendingSides(row)
147
+ if (sides.length === 0) continue
148
+ const person = row.person_org_id.toLowerCase()
149
+ const manager = managerOf(row)
150
+ const key = `${person}|${manager ?? ''}|${sides.join(',')}`
151
+ const group = groups.get(key)
152
+ if (group) {
153
+ if (group.items.length < maxItems) group.items.push(row)
154
+ } else if (groups.size < maxGroups) groups.set(key, { person, manager, sides, items: [row] })
155
+ }
156
+ return [...groups.values()].map(({ person, manager, sides, items }) => ({
157
+ person,
158
+ manager,
159
+ sides,
160
+ recipients: sides.map((side) => (side === 'person' ? person : manager!)),
161
+ items,
162
+ }))
163
+ }
@@ -0,0 +1,219 @@
1
+ // Zamanlanmış görev (manifest `jobs`). Her sabah 08:00 (İstanbul): geçerlilik sonu `REMIND_DAYS` gün içinde olan ya da
2
+ // geçmiş, YENİLENMEMİŞ sertifikaları kişiye VE kayıttaki yöneticisine e-postayla hatırlatır — (kişi, yönetici) başına TEK
3
+ // e-posta; bir yönetici başka bir yöneticiyle ilişkili kaydın ayrıntısını görmez.
4
+ //
5
+ // ⛔ Zamanlanmış iş KULLANICISIZDIR: e-posta uygulamanın kendi kimliğiyle gider ve yalnız manifest `jobs.services`
6
+ // beyanı + kurulumda portal yöneticisinin onayıyla açılır. Onay yoksa iş e-posta göndermez, sonucu "onay bekleniyor"
7
+ // diye kaydeder ve damgayı geri alır (onay verilince ertesi sabah gider).
8
+ // ⛔ Organizasyon şeması işte KULLANILAMAZ: yönetici, kayıt yazılırken sunucuda org şemasından çözülüp kayda yazılır
9
+ // (`manager_org_id`). Şemada yönetici değişirse eğitim sorumlusu "Yöneticileri yenile" ile kayıtları günceller.
10
+ // ⛔ Aynı sertifika her gün yeniden hatırlatılmaz ve damga ALICI BAŞINADIR: kişiye `reminder_sent_for`, yöneticiye
11
+ // `manager_reminder_sent_for` (= hatırlatılan geçerlilik sonu), ikisi de gönderimden ÖNCE koşullu konur. Posta servisi
12
+ // alıcılardan birini atlarsa (pasif kişi, adres yok, sınır…) yalnız onun damgası geri alınır: ertesi gün yalnız o
13
+ // yeniden denenir, iletiyi almış olan aynı kaydı ikinci kez almaz. Sertifika yenilenince (daha yeni kayıt) eski kayıt
14
+ // hatırlatılmaz; yeni kaydın süresi dolarken o da hatırlatılır.
15
+ // ⛔ Ulaşmayan hatırlatma sıranın SONUNA geçer (`reminder_failed_on`): hiç ulaşamayan bir alıcı (ör. ayrılmış bir
16
+ // yönetici) en acil kayda sahip olduğu için her sabah ilk sıraları alıp öteki kişileri hatırlatmasız bırakmaz.
17
+ // ⚠️ Platform bir istekte en çok 10 dış çağrıya izin verir: bir koşumda en çok `MAILS_PER_RUN` e-posta; kalanlar ertesi sabah.
18
+ import { BackendClient, defineJobs, logError, notify, systemNotice, type OneContext } from '@devoplus/one-sdk'
19
+ import type { Env } from './app.ts'
20
+ import { REMIND_DAYS, REMINDER_ITEMS_PER_MAIL, addDays, groupByPerson, todayKey, type DueCertificate, type ReminderSide } from './certificate.ts'
21
+ import { APP_NAME } from './names.ts'
22
+ import { SUPERSEDED_SQL } from './queries.ts'
23
+
24
+ /** Sistemin yazdığı kayıtlarda `created_by` değeri (kişi kimliği değildir). */
25
+ export const SYSTEM_ACTOR = 'system:daily-certificate-reminders'
26
+ /** Bir koşumda en çok kaç kişi için e-posta gider. */
27
+ export const MAILS_PER_RUN = 5
28
+ const MAIL_SCOPE = 'mail.execute'
29
+
30
+ /**
31
+ * İşte e-postanın sonucu. `sent`: en az bir alıcı kuyruğa alındı; `skipped` posta servisinin ATLADIĞI alıcılardır (küçük
32
+ * harf kişi kimliği) — onlara ileti gitmedi. `consent-needed`: servis beyanı/onayı yok. `failed`: hiçbir alıcıya gitmedi.
33
+ */
34
+ export type JobMailResult = { outcome: 'sent'; skipped: ReadonlySet<string> } | { outcome: 'consent-needed' | 'failed' }
35
+
36
+ /** İşte e-posta: servis beyanı/onayı yoksa `consent-needed` (gönderim denenmez ya da sunucu 403 döner). */
37
+ export async function sendJobMail(ctx: OneContext, api: BackendClient, message: ReturnType<typeof systemNotice>, toOrgUserIds: string[]): Promise<JobMailResult> {
38
+ if (!(ctx.jobServices ?? []).includes(MAIL_SCOPE)) return { outcome: 'consent-needed' }
39
+ try {
40
+ const mail = (await notify(api, message, { toOrgUserIds, channels: ['mail'] })).mail
41
+ if (mail && 'error' in mail) {
42
+ if (mail.error.status === 403) return { outcome: 'consent-needed' }
43
+ logError(ctx, 'sertifika hatırlatması gönderilemedi', mail.error)
44
+ return { outcome: 'failed' }
45
+ }
46
+ const asked = new Set(toOrgUserIds.map((id) => id.toLowerCase()))
47
+ const skipped = new Set((mail?.skipped ?? []).map((s) => s.sub.toLowerCase()).filter((id) => asked.has(id)))
48
+ // Sunucu isteği kabul edip HİÇBİR alıcıyı kuyruğa almadıysa (pasif kişi, adres yok, sınır…) ileti kimseye gitmedi:
49
+ // "gönderildi" sayılmaz, damga geri alınır ve sonraki koşumda yeniden denenir.
50
+ if (!mail || mail.queued === 0 || skipped.size >= asked.size) {
51
+ logError(ctx, 'sertifika hatırlatması hiçbir alıcıya ulaşmadı', mail?.skipped ?? [])
52
+ return { outcome: 'failed' }
53
+ }
54
+ if (skipped.size > 0) logError(ctx, 'sertifika hatırlatması bazı alıcılara ulaşmadı', mail.skipped)
55
+ return { outcome: 'sent', skipped }
56
+ } catch (error) {
57
+ logError(ctx, 'sertifika hatırlatması gönderilemedi', error)
58
+ return { outcome: 'failed' }
59
+ }
60
+ }
61
+
62
+ function formatDay(day: string): string {
63
+ return `${day.slice(8, 10)}.${day.slice(5, 7)}.${day.slice(0, 4)}`
64
+ }
65
+
66
+ const AUDIENCE: Record<string, string> = {
67
+ 'person,manager': 'Bu e-posta kişiye ve yöneticisine gönderildi.',
68
+ person: 'Bu e-posta kişiye gönderildi.',
69
+ manager: 'Bu e-posta kişinin yöneticisine gönderildi.',
70
+ }
71
+
72
+ function reminderMessage(items: readonly DueCertificate[], today: string, sides: readonly ReminderSide[], more = 0) {
73
+ const person = items[0]?.person_name || 'Çalışan'
74
+ const lines = items.map((i) => `- ${i.type_name || 'Sertifika'}: ${formatDay(i.expires_on)}${i.expires_on < today ? ' — SÜRESİ DOLDU' : ''}`)
75
+ // E-postaya sığmayan kayıtlar SÖYLENİR: damgalanmadıkları için sonraki sabahlarda ayrıca hatırlatılırlar.
76
+ if (more > 0) lines.push(`- ve ${more} kayıt daha (sonraki sabahlarda ayrıca hatırlatılır)`)
77
+ return systemNotice({
78
+ appName: APP_NAME.tr,
79
+ title: `Sertifika geçerliliği doluyor: ${person}`,
80
+ message: `${person} için aşağıdaki eğitim ve sertifikaların geçerlilik süresi doluyor ya da doldu:\n${lines.join('\n')}\n\nYenileme yapıldıktan sonra kaydın uygulamaya girilmesi gerekir. ${AUDIENCE[sides.join(',')] ?? ''}`.trimEnd(),
81
+ })
82
+ }
83
+
84
+ /** Tarafın damga kolonu. */
85
+ const STAMP: Record<ReminderSide, 'reminder_sent_for' | 'manager_reminder_sent_for'> = { person: 'reminder_sent_for', manager: 'manager_reminder_sent_for' }
86
+
87
+ export const jobs = defineJobs<Env>({
88
+ schedules: {
89
+ 'daily-certificate-reminders': async (job, env, ctx) => {
90
+ const at = job.scheduledTime ? new Date(job.scheduledTime) : new Date()
91
+ const today = todayKey(at)
92
+ const now = at.toISOString()
93
+ const dueUntil = addDays(today, REMIND_DAYS)
94
+ // Grup anahtarı `groupByPerson` ile AYNI kuraldır: kişi + kayıttaki yönetici (kişinin kendisiyse yönetici yok sayılır)
95
+ // + hatırlatmayı henüz almamış taraflar (`pendingSides`).
96
+ const managerKey =
97
+ "CASE WHEN c.manager_org_id IS NULL OR c.manager_org_id = '' OR lower(c.manager_org_id) = lower(c.person_org_id) THEN '' ELSE lower(c.manager_org_id) END"
98
+ const personDue = 'CASE WHEN c.reminder_sent_for IS NULL OR c.reminder_sent_for <> c.expires_on THEN 1 ELSE 0 END'
99
+ const managerDue = `CASE WHEN ${managerKey} <> '' AND (c.manager_reminder_sent_for IS NULL OR c.manager_reminder_sent_for <> c.expires_on) THEN 1 ELSE 0 END`
100
+ const pending = `c.deleted_at IS NULL AND c.expires_on IS NOT NULL AND c.expires_on <= ? AND (${personDue} = 1 OR ${managerDue} = 1) AND NOT ${SUPERSEDED_SQL}`
101
+ const dueCount = Number((await env.DB.prepare(`SELECT COUNT(*) AS n FROM "certificates" c WHERE ${pending}`).bind(dueUntil).first<{ n: number }>())?.n ?? 0)
102
+ // Gruplar ÖNCE seçilir, kayıtlar SONRA grup başına sınırlanır: satırlar tek bir sınırla okunsaydı çok sayıda kaydı olan
103
+ // bir kişi bütün sınırı doldurur, öteki kişiler günlerce hatırlatma almazdı. Sıra: hatırlatması ulaşmamış
104
+ // gruplar önce (ulaşmayan sıranın sonuna geçer), sonra en acil sertifika.
105
+ const picked = await env.DB.prepare(
106
+ `SELECT lower(c.person_org_id) AS person, ${managerKey} AS manager, ${personDue} AS p, ${managerDue} AS m, MIN(c.expires_on) AS first_due, ` +
107
+ `MAX(coalesce(c.reminder_failed_on, '')) AS failed FROM "certificates" c WHERE ${pending} ` +
108
+ 'GROUP BY person, manager, p, m ORDER BY failed, first_due, person, manager, p, m LIMIT ?',
109
+ )
110
+ .bind(dueUntil, MAILS_PER_RUN)
111
+ .all<{ person: string; manager: string; p: number; m: number }>()
112
+ const perGroup = picked.results.length
113
+ ? await env.DB.batch<DueCertificate>(
114
+ picked.results.map((g) =>
115
+ env.DB.prepare(
116
+ 'SELECT c.id, c.person_org_id, c.person_name, c.manager_org_id, c.type_name, c.expires_on, c.reminder_sent_for, c.manager_reminder_sent_for FROM "certificates" c ' +
117
+ `WHERE ${pending} AND lower(c.person_org_id) = ? AND ${managerKey} = ? AND ${personDue} = ? AND ${managerDue} = ? ORDER BY c.expires_on, c.id LIMIT ?`,
118
+ ).bind(dueUntil, g.person, g.manager, g.p, g.m, REMINDER_ITEMS_PER_MAIL),
119
+ ),
120
+ )
121
+ : []
122
+ // Grubun bekleyen kayıt SAYISI ayrı sayılır: e-postaya en çok REMINDER_ITEMS_PER_MAIL kayıt sığar, kalanı e-postada söylenir.
123
+ const sidesOf = (p: number, m: number) => [p ? 'person' : '', m ? 'manager' : ''].filter(Boolean).join(',')
124
+ const groupTotals = picked.results.length
125
+ ? await env.DB.batch<{ n: number }>(
126
+ picked.results.map((g) =>
127
+ env.DB.prepare(`SELECT COUNT(*) AS n FROM "certificates" c WHERE ${pending} AND lower(c.person_org_id) = ? AND ${managerKey} = ? AND ${personDue} = ? AND ${managerDue} = ?`).bind(
128
+ dueUntil,
129
+ g.person,
130
+ g.manager,
131
+ g.p,
132
+ g.m,
133
+ ),
134
+ ),
135
+ )
136
+ : []
137
+ const totals = new Map(picked.results.map((g, n) => [`${g.person}|${g.manager}|${sidesOf(g.p, g.m)}`, Number(groupTotals[n]?.results[0]?.n ?? 0)]))
138
+ const groups = groupByPerson(perGroup.flatMap((r) => r.results), MAILS_PER_RUN)
139
+ // ⛔ Damga TOPLU ve koşullu konur, taraf başına: yalnız bu koşum hâlâ hatırlatılmamış bulduğu tarafı damgalar. Kaydı
140
+ // seçen koşul da damgada yeniden istenir: okumadan sonra kayıt silinir ya da yenilenirse (daha yeni tarihli kayıt)
141
+ // damga konmaz ve kişiye "süresi doluyor" e-postası gitmez.
142
+ const live = `c.deleted_at IS NULL AND NOT ${SUPERSEDED_SQL}`
143
+ const claims = groups.flatMap((g) => g.items.flatMap((item) => g.sides.map((side) => ({ item, side, manager: g.manager }))))
144
+ const stamps = claims.length
145
+ ? await env.DB.batch(
146
+ claims.map(({ item, side, manager }) =>
147
+ side === 'person'
148
+ ? env.DB.prepare(
149
+ `UPDATE "certificates" AS c SET reminder_sent_for = expires_on WHERE c.id = ? AND c.expires_on = ? AND ${live} AND (c.reminder_sent_for IS NULL OR c.reminder_sent_for <> c.expires_on)`,
150
+ ).bind(item.id, item.expires_on)
151
+ : env.DB.prepare(
152
+ `UPDATE "certificates" AS c SET manager_reminder_sent_for = expires_on WHERE c.id = ? AND c.expires_on = ? AND ${live} ` +
153
+ `AND (c.manager_reminder_sent_for IS NULL OR c.manager_reminder_sent_for <> c.expires_on) AND ${managerKey} = ?`,
154
+ ).bind(item.id, item.expires_on, manager),
155
+ ),
156
+ )
157
+ : []
158
+ const claimed = new Map<string, Set<ReminderSide>>()
159
+ claims.forEach(({ item, side }, n) => {
160
+ if (Number(stamps[n]?.meta.changes ?? 0) !== 1) return
161
+ const sides = claimed.get(item.id) ?? new Set<ReminderSide>()
162
+ sides.add(side)
163
+ claimed.set(item.id, sides)
164
+ })
165
+ const api = new BackendClient({ baseUrl: env.API_BASE, authorization: null })
166
+ /** Geri alınacak damgalar; `failed` = ileti o tarafa ULAŞMADI (sıranın sonuna geçer). */
167
+ const rollback: Array<{ item: DueCertificate; side: ReminderSide; failed: boolean }> = []
168
+ /** İletisi bütün taraflara ulaşan kayıtlar: önceki başarısız denemenin izi silinir (sıradaki yerine döner). */
169
+ const delivered: DueCertificate[] = []
170
+ let sent = 0
171
+ let waiting = 0
172
+ let consentNeeded = false
173
+ for (const group of groups) {
174
+ // Tarafların hepsini bu koşum damgalayabildiyse gönderilir; eşzamanlı bir koşum bir tarafı aldıysa kayıt bu
175
+ // e-postadan çıkar ve bu koşumun koyduğu damga geri alınır (öteki koşum kendi tarafını gönderir).
176
+ const items: DueCertificate[] = []
177
+ for (const item of group.items) {
178
+ const mine = claimed.get(item.id)
179
+ if (mine && group.sides.every((side) => mine.has(side))) items.push(item)
180
+ else for (const side of mine ?? []) rollback.push({ item, side, failed: false })
181
+ }
182
+ if (items.length === 0) continue
183
+ const more = Math.max(0, (totals.get(`${group.person}|${group.manager ?? ''}|${group.sides.join(',')}`) ?? 0) - group.items.length)
184
+ const result = await sendJobMail(ctx, api, reminderMessage(items, today, group.sides, more), group.recipients)
185
+ const missed =
186
+ result.outcome === 'sent' ? group.sides.filter((side, k) => result.skipped.has(group.recipients[k]!)) : group.sides
187
+ if (missed.length < group.sides.length) sent++
188
+ if (missed.length > 0) waiting++
189
+ consentNeeded ||= result.outcome === 'consent-needed'
190
+ for (const side of missed) for (const item of items) rollback.push({ item, side, failed: result.outcome !== 'consent-needed' })
191
+ if (missed.length === 0) delivered.push(...items)
192
+ }
193
+ // Gönderilemeyenlerin damgası geri alınır (yalnız bu koşumun koyduğu damga) ve günün kaydı yazılır — tek toplu yazım.
194
+ await env.DB.batch([
195
+ ...delivered.map((item) => env.DB.prepare('UPDATE "certificates" SET reminder_failed_on = NULL WHERE id = ? AND reminder_failed_on IS NOT NULL').bind(item.id)),
196
+ ...rollback.map(({ item, side, failed }) =>
197
+ env.DB.prepare(`UPDATE "certificates" SET ${STAMP[side]} = ?, reminder_failed_on = coalesce(?, reminder_failed_on) WHERE id = ? AND ${STAMP[side]} = ?`).bind(
198
+ item[STAMP[side]],
199
+ failed ? today : null,
200
+ item.id,
201
+ item.expires_on,
202
+ ),
203
+ ),
204
+ env.DB.prepare('UPDATE "reminder_runs" SET due_count = ?, sent_count = sent_count + ?, waiting_count = ?, consent_needed = ?, updated_at = ? WHERE run_key = ? AND deleted_at IS NULL').bind(
205
+ dueCount,
206
+ sent,
207
+ waiting,
208
+ consentNeeded ? 1 : 0,
209
+ now,
210
+ today,
211
+ ),
212
+ env.DB.prepare(
213
+ 'INSERT INTO "reminder_runs" (id, created_at, created_by, updated_at, updated_by, run_key, due_count, sent_count, waiting_count, consent_needed) ' +
214
+ 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM "reminder_runs" WHERE run_key = ? AND deleted_at IS NULL)',
215
+ ).bind(crypto.randomUUID(), now, SYSTEM_ACTOR, now, SYSTEM_ACTOR, today, dueCount, sent, waiting, consentNeeded ? 1 : 0, today),
216
+ ])
217
+ },
218
+ },
219
+ })
@@ -0,0 +1,5 @@
1
+ // Uygulamanın adı ve izin kodları — sunucu, zamanlanmış iş ve sayfa aynı değerleri kullanır (döngüsel içe aktarma olmasın
2
+ // diye ayrı dosyada).
3
+ export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
4
+ /** Eğitim sorumlusu izni (manifest `permissions`): türleri tanımlar, herkesin kaydını görür ve yazar. */
5
+ export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
@@ -0,0 +1,115 @@
1
+ // Veritabanı okumaları — ekran, zamanlanmış iş ve Devoplus yapay zekâsı araçları aynı sorguları kullanır.
2
+ import { addDays, certStatus, daysBetween, REMIND_DAYS } from './certificate.ts'
3
+ import type { BaseEnv } from './lib/server.ts'
4
+
5
+ /**
6
+ * Aynı kişide aynı türün daha yeni (daha geç tamamlanmış) bir kaydı var mı — varsa eski kayıt "yenilendi" sayılır,
7
+ * hatırlatılmaz ve süresi dolmuş gösterilmez. `c` dış sorgudaki kaydın takma adıdır.
8
+ */
9
+ export const SUPERSEDED_SQL =
10
+ 'EXISTS (SELECT 1 FROM "certificates" n WHERE n.person_org_id = c.person_org_id AND n.type_id = c.type_id AND n.deleted_at IS NULL AND n.completed_on > c.completed_on)'
11
+
12
+ export interface CertificateRow {
13
+ id: string
14
+ person_org_id: string
15
+ person_name: string | null
16
+ manager_name: string | null
17
+ type_id: string
18
+ type_name: string | null
19
+ completed_on: string
20
+ expires_on: string | null
21
+ certificate_no: string | null
22
+ attachment_name: string | null
23
+ has_attachment: number
24
+ superseded: number
25
+ }
26
+
27
+ export interface CertificateView extends Omit<CertificateRow, 'has_attachment' | 'superseded'> {
28
+ has_attachment: boolean
29
+ status: ReturnType<typeof certStatus>
30
+ days_left: number | null
31
+ }
32
+
33
+ const SELECT =
34
+ 'SELECT c.id, c.person_org_id, c.person_name, c.manager_name, c.type_id, c.type_name, c.completed_on, c.expires_on, c.certificate_no, c.attachment_name, ' +
35
+ `(c.attachment IS NOT NULL) AS has_attachment, ${SUPERSEDED_SQL} AS superseded FROM "certificates" c`
36
+
37
+ function view(today: string) {
38
+ return ({ has_attachment, superseded, ...r }: CertificateRow): CertificateView => ({
39
+ ...r,
40
+ has_attachment: has_attachment === 1,
41
+ status: certStatus(r.expires_on, today, superseded === 1),
42
+ days_left: r.expires_on ? daysBetween(today, r.expires_on) : null,
43
+ })
44
+ }
45
+
46
+ /** Ekranda en çok kaç kayıt listelenir (kişinin ya da ekibin kayıtları); fazlası varsa yanıt `truncated: true` taşır. */
47
+ export const LIST_MAX = 1000
48
+ /** Excel'e en çok kaç kayıt aktarılır; fazlası varsa aktarım açık bir hatayla REDDEDİLİR (eksik dosya üretilmez). */
49
+ export const EXPORT_MAX = 10_000
50
+
51
+ /**
52
+ * Verilen kişilerin kayıtları — kişi listesi SUNUCUDA çözülür (oturumdaki kişi ya da org şemasındaki ekibi), istemciden
53
+ * gelmez. Kimlikler tek bağlı parametreyle (JSON dizisi) verilir: ekip ne kadar büyük olursa olsun sorgu aynıdır.
54
+ * Bir fazla satır okunur: `truncated` listenin kesildiğini SÖYLER, sessizce eksik liste dönmez.
55
+ */
56
+ export async function certificatesOf(
57
+ env: BaseEnv,
58
+ personOrgIds: readonly string[],
59
+ today: string,
60
+ max: number = LIST_MAX,
61
+ ): Promise<{ items: CertificateView[]; truncated: boolean }> {
62
+ if (personOrgIds.length === 0) return { items: [], truncated: false }
63
+ const rows = await env.DB.prepare(`${SELECT} WHERE c.deleted_at IS NULL AND c.person_org_id IN (SELECT value FROM json_each(?)) ORDER BY c.person_name, c.type_name, c.completed_on DESC LIMIT ?`)
64
+ .bind(JSON.stringify(personOrgIds.map((id) => id.toLowerCase())), max + 1)
65
+ .all<CertificateRow>()
66
+ const truncated = rows.results.length > max
67
+ return { items: rows.results.slice(0, max).map(view(today)), truncated }
68
+ }
69
+
70
+ /** Süresi `days` gün içinde dolacak ya da dolmuş, YENİLENMEMİŞ kayıtlar — en acil önce (portal geneli). */
71
+ export async function expiring(env: BaseEnv, today: string, days: number, limit: number) {
72
+ const until = addDays(today, days)
73
+ const where = `c.deleted_at IS NULL AND c.expires_on IS NOT NULL AND c.expires_on <= ? AND NOT ${SUPERSEDED_SQL}`
74
+ const [count, rows] = await Promise.all([
75
+ env.DB.prepare(`SELECT COUNT(*) AS n FROM "certificates" c WHERE ${where}`).bind(until).first<{ n: number }>(),
76
+ env.DB.prepare(`${SELECT} WHERE ${where} ORDER BY c.expires_on, c.person_name LIMIT ?`).bind(until, limit).all<CertificateRow>(),
77
+ ])
78
+ return {
79
+ today,
80
+ until,
81
+ count: count?.n ?? 0,
82
+ items: rows.results.map(view(today)).map(({ person_name, manager_name, type_name, expires_on, days_left, status }) => ({ person_name, manager_name, type_name, expires_on, days_left, status })),
83
+ }
84
+ }
85
+
86
+ /** Özet (eğitim sorumlusu): dolmuş ve `REMIND_DAYS` içinde dolacak, yenilenmemiş kayıt sayısı. */
87
+ export async function counts(env: BaseEnv, today: string) {
88
+ const row = await env.DB.prepare(
89
+ `SELECT SUM(CASE WHEN c.expires_on < ? THEN 1 ELSE 0 END) AS expired, SUM(CASE WHEN c.expires_on >= ? AND c.expires_on <= ? THEN 1 ELSE 0 END) AS expiring ` +
90
+ `FROM "certificates" c WHERE c.deleted_at IS NULL AND c.expires_on IS NOT NULL AND NOT ${SUPERSEDED_SQL}`,
91
+ )
92
+ .bind(today, today, addDays(today, REMIND_DAYS))
93
+ .first<{ expired: number | null; expiring: number | null }>()
94
+ return { expired: row?.expired ?? 0, expiring: row?.expiring ?? 0 }
95
+ }
96
+
97
+ /** Yapay zekâ aracının tür listesi en çok bu kadar kayıt taşır; fazlası varsa `truncated: true` (liste kesildiğini SÖYLER). */
98
+ export const TYPES_MAX = 200
99
+
100
+ export async function activeTypes(env: BaseEnv): Promise<{ items: Array<{ name: string; validity_months: number | null; description: string | null }>; truncated: boolean }> {
101
+ const rows = await env.DB.prepare('SELECT name, validity_months, description FROM "cert_types" WHERE active = 1 AND deleted_at IS NULL ORDER BY name LIMIT ?')
102
+ .bind(TYPES_MAX + 1)
103
+ .all<{ name: string; validity_months: number | null; description: string | null }>()
104
+ return { items: rows.results.slice(0, TYPES_MAX), truncated: rows.results.length > TYPES_MAX }
105
+ }
106
+
107
+ export async function lastRun(env: BaseEnv) {
108
+ return env.DB.prepare('SELECT run_key, due_count, sent_count, waiting_count, consent_needed FROM "reminder_runs" WHERE deleted_at IS NULL ORDER BY run_key DESC LIMIT 1').first<{
109
+ run_key: string
110
+ due_count: number
111
+ sent_count: number
112
+ waiting_count: number
113
+ consent_needed: number
114
+ }>()
115
+ }