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,431 @@
1
+ // {{nameTr}} — uygunsuzluk ve düzeltici faaliyet kaydı. Uçlar (hepsi oturum ister; kimlik platformdan gelir):
2
+ // POST /api/nonconformities uygunsuzluk bildir (herkes) { title, source, severity, description, location?, detected_on?, attachment? }
3
+ // GET /api/my/nonconformities[?…] bildirdiklerim — hazır liste
4
+ // GET /api/nonconformities[?…] bütün kayıtlar (yönetici) — hazır liste + Excel
5
+ // PATCH /api/nonconformities/{id} kayıt bilgisi ve kök neden (yönetici; kapanmış kayıt değişmez)
6
+ // POST /api/nonconformities/{id}/status { status, note? } — durum akışı `src/capa.ts` (yönetici)
7
+ // GET /api/nonconformities/{id}/detail kayıt + faaliyetler (yönetici, bildiren ya da faaliyet sorumlusu)
8
+ // GET /api/nonconformities/{id}/attachment dosya eki (aynı kişiler)
9
+ // POST /api/nonconformities/{id}/actions { description, owner: { id, name }, due_date } — faaliyet ekle (yönetici)
10
+ // PATCH /api/actions/{id} · DELETE /api/actions/{id} faaliyeti değiştir / sil (yönetici; yalnız açık faaliyet)
11
+ // POST /api/actions/{id}/complete { note } — faaliyeti tamamla (sorumlusu ya da yönetici)
12
+ // GET /api/my/actions[?…] faaliyetlerim (sorumlusu BEN) — hazır liste + Excel
13
+ // GET /api/actions[?…] bütün faaliyetler (yönetici) — hazır liste + Excel
14
+ // GET /api/summary sayılar, geciken faaliyetler, son hatırlatma turu (yönetici)
15
+ // POST /api/files dosya yükleme (yalnız kendi ön ekinize)
16
+ // Durum, kapanış ve tamamlanma bilgileri istemciden ALINMAZ: sunucu yazar. Kayıt ve faaliyet genel uçlardan yazılmaz.
17
+ import {
18
+ OneError,
19
+ createCrudHandler,
20
+ foldSearchText,
21
+ getMyOrgContext,
22
+ hasPermission,
23
+ logAudit,
24
+ logError,
25
+ requirePermission,
26
+ requireUser,
27
+ systemNotice,
28
+ type BackendClient,
29
+ type OneFilesBinding,
30
+ } from '@devoplus/one-sdk'
31
+ import manifest from '../one.manifest.json' with { type: 'json' }
32
+ import { agentTools } from './agent.ts'
33
+ import { ACTION_NOTE_MAX, actionsEditable, checkDueDate, formatDay, isStatus, istanbulDay, transitionRequirement, type NcStatus } from './capa.ts'
34
+ import { dataModel } from './data/types.ts'
35
+ import { jobs } from './jobs.ts'
36
+ import { fileResponse, uploadFile } from './lib/files.ts'
37
+ import { fieldError, forward, matchId, nowIso, onlyFields, ownFileKey, personValue, readJsonObject, withQuery } from './lib/http.ts'
38
+ import { and, applied, changedOne, insertWhen, recordWrite, refuseActingSession, removeWhen, sql, updateWhen } from './lib/record.ts'
39
+ import { sendMailSafely } from './lib/mail.ts'
40
+ import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
41
+ import { actionsOf, counts, findAction, findNc, lastReminderRun, overdueCount, type NcRow } from './queries.ts'
42
+ import { renderShell, renderShellScript } from './shell.ts'
43
+
44
+ export interface Env extends BaseEnv {
45
+ /** Bu kurulumun dosya deposu (manifest services.files) — uygunsuzluk ekleri. */
46
+ FILES: OneFilesBinding
47
+ }
48
+
49
+ export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
50
+ /** Yönetici izni (manifest `permissions`): kayıtları görür, kök nedeni yazar, faaliyet planlar, durumu ilerletir ve kapatır. */
51
+ export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
52
+
53
+ /** Bildirdiklerim: herkes yalnız KENDİ bildirdiği kayıtları görür. */
54
+ export const myReports = createCrudHandler({ model: dataModel, entity: 'nonconformities', records: 'own', basePath: '/api/my' })
55
+ /** Bütün kayıtlar ve faaliyetler: yalnız yönetici; salt okunur (yazma uygulamanın kendi uçlarından). */
56
+ export const records = createCrudHandler({ model: dataModel, entity: 'nonconformities', records: 'shared', canRead: MANAGE_PERMISSION })
57
+ export const allActions = createCrudHandler({ model: dataModel, entity: 'actions', records: 'shared', canRead: MANAGE_PERMISSION })
58
+ /** Faaliyetlerim: liste sunucuda "sorumlu = ben" süzgeciyle ZORLANIR (`myActions`). */
59
+ const myActionsList = createCrudHandler({ model: dataModel, entity: 'actions', records: 'shared', basePath: '/api/my' })
60
+
61
+ const REPORT_FIELDS = ['title', 'source', 'severity', 'description', 'location', 'detected_on', 'attachment'] as const
62
+ const EDIT_FIELDS = ['title', 'source', 'severity', 'description', 'location', 'detected_on', 'root_cause'] as const
63
+ const ACTION_FIELDS = ['description', 'owner', 'due_date'] as const
64
+ const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f]/
65
+
66
+ async function myOrg(api: BackendClient): Promise<{ id: string; name: string } | null> {
67
+ const me = await getMyOrgContext(api)
68
+ return me ? { id: me.orgUserId.toLowerCase(), name: me.fullName ?? '' } : null
69
+ }
70
+
71
+ async function myName(input: RouteInput<Env>): Promise<string | null> {
72
+ try {
73
+ return (await myOrg(input.api()))?.name || null
74
+ } catch (error) {
75
+ // Ad bir kolaylıktır: okunamazsa kayıt yine yazılır (ad boş kalır).
76
+ logError(input.ctx, 'kişi adı okunamadı', error)
77
+ return null
78
+ }
79
+ }
80
+
81
+ function requiredText(raw: unknown, field: string, max: number, multiline: boolean): string {
82
+ if (typeof raw !== 'string' || raw.trim() === '') throw fieldError(field, 'Bu alan zorunlu.')
83
+ const value = raw.trim()
84
+ if ([...value].length > max) throw fieldError(field, `En çok ${max} karakter olabilir.`)
85
+ if (CONTROL_RE.test(value) || (!multiline && /[\r\n\t]/.test(value))) throw fieldError(field, 'Geçersiz karakter var.')
86
+ return value
87
+ }
88
+
89
+ const today = (): string => istanbulDay(new Date())
90
+
91
+ /** Organizasyon şemasından gelen adı veri modelinin sınırına kırpar (boş ad `null`). */
92
+ function clip(value: string | null | undefined, max: number): string | null {
93
+ const text = value?.trim() ?? ''
94
+ return text === '' ? null : [...text].slice(0, max).join('')
95
+ }
96
+
97
+ /** Kaydı görebilen: yönetici, bildiren ya da kayıtta faaliyeti olan kişi. Göremeyen için "yok" — kimlik yoklanamaz. */
98
+ async function visibleNc(input: RouteInput<Env>, id: string): Promise<{ nc: NcRow; manager: boolean; me: { id: string; name: string } | null }> {
99
+ const { env, ctx } = input
100
+ const user = requireUser(ctx)
101
+ const nc = await findNc(env, id)
102
+ if (!nc) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
103
+ const manager = hasPermission(ctx, MANAGE_PERMISSION)
104
+ if (manager || nc.created_by === user.sub) return { nc, manager, me: null }
105
+ const me = await myOrg(input.api()).catch(() => null)
106
+ const owns = me
107
+ ? await env.DB.prepare('SELECT 1 AS x FROM "actions" WHERE nonconformity_id = ? AND owner_org_id = ? AND deleted_at IS NULL LIMIT 1').bind(id, me.id).first<{ x: number }>()
108
+ : null
109
+ if (!owns) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
110
+ return { nc, manager, me }
111
+ }
112
+
113
+ async function report(input: RouteInput<Env>): Promise<Response> {
114
+ const { request, env, ctx } = input
115
+ const user = requireUser(ctx)
116
+ // ⛔ Durum, kök neden ve kapanış alanları bildirimde GÖNDERİLEMEZ (alan listesi dışındaki her ad 400).
117
+ const body = onlyFields(await readJsonObject(request), REPORT_FIELDS)
118
+ if (typeof body.detected_on === 'string' && body.detected_on > today()) throw fieldError('detected_on', 'Tespit tarihi ileri bir tarih olamaz.')
119
+ const attachment = ownFileKey(body.attachment, user.sub, 'attachment')
120
+ const attachmentName =
121
+ attachment && typeof body.attachment === 'object' && body.attachment !== null && typeof (body.attachment as { name?: unknown }).name === 'string'
122
+ ? (body.attachment as { name: string }).name.slice(0, 200)
123
+ : null
124
+ const record = { ...body, attachment, attachment_name: attachmentName, status: 'open', reporter_name: await myName(input) }
125
+ const res = (await myReports.handle(forward(request, myReports.path, 'POST', record), env.DB, ctx))!
126
+ if (res.status === 201) logAudit(ctx, 'nonconformity.report', ((await res.clone().json()) as { id: string }).id)
127
+ return res
128
+ }
129
+
130
+ async function edit(input: RouteInput<Env>, id: string): Promise<Response> {
131
+ const { request, env, ctx } = input
132
+ const user = requirePermission(ctx, MANAGE_PERMISSION)
133
+ refuseActingSession(ctx)
134
+ // Durum bu uçtan değişmez (400): durum akışının ucu `/status`'tur.
135
+ const body = onlyFields(await readJsonObject(request), EDIT_FIELDS)
136
+ // Bildirimdeki kural düzenlemede de geçerlidir: tespit tarihi ileri bir tarih olamaz.
137
+ if (typeof body.detected_on === 'string' && body.detected_on > today()) throw fieldError('detected_on', 'Tespit tarihi ileri bir tarih olamaz.')
138
+ const write = recordWrite(dataModel, 'nonconformities', body, 'update')
139
+ const now = nowIso()
140
+ // ⛔ "Kapanmamış" koşulu yazımla AYNI ifadededir: durumu önce okuyup sonra yazmak, arada kapanan kaydı (kök neden,
141
+ // tespit bilgisi) değiştirirdi. Koşul tutmazsa hiçbir alan yazılmaz.
142
+ // Faaliyetler kaydın başlığının KOPYASINI taşır (Faaliyetlerim ve hatırlatma e-postası onu gösterir): başlık değişince
143
+ // kopyalar aynı toplu yazımda güncellenir. Faaliyet ekleme de başlığı kendi ifadesinde kaydın o anki başlığıyla
144
+ // karşılaştırır (bayat başlık yazılmaz).
145
+ const title = typeof body.title === 'string' ? (write.values[write.columns.indexOf('title')] as string) : null
146
+ const [updated] = await env.DB.batch([
147
+ updateWhen(env.DB, write, { id, actor: user.sub, now, when: sql(`"status" <> 'closed'`) }),
148
+ ...(title === null
149
+ ? []
150
+ : [
151
+ env.DB.prepare(
152
+ 'UPDATE "actions" SET nonconformity_title = ?, nonconformity_title_fold = ? WHERE nonconformity_id = ? AND EXISTS (SELECT 1 FROM "nonconformities" n WHERE n.id = ? AND n.title = ?)',
153
+ ).bind(title, foldSearchText(title), id, id, title),
154
+ ]),
155
+ ])
156
+ if (!changedOne(updated)) {
157
+ if (!(await findNc(env, id))) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
158
+ throw new OneError(409, 'APP-E-020', 'Kapanmış kayıt değiştirilemez.')
159
+ }
160
+ return (await records.handle(forward(request, `${records.path}/${id}`, 'GET'), env.DB, ctx))!
161
+ }
162
+
163
+ /** Geçiş koşulu SQL'dedir (aynı ifadede): araya giren bir faaliyet değişikliği kapanışı geçersiz kılamaz. */
164
+ async function changeStatus(input: RouteInput<Env>, id: string): Promise<Response> {
165
+ const { request, env, ctx } = input
166
+ const user = requirePermission(ctx, MANAGE_PERMISSION)
167
+ refuseActingSession(ctx)
168
+ const body = onlyFields(await readJsonObject(request), ['status', 'note'])
169
+ if (!isStatus(body.status)) throw fieldError('status', 'Geçerli bir durum seçin.')
170
+ const to: NcStatus = body.status
171
+ const nc = await findNc(env, id)
172
+ if (!nc) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
173
+ const requirement = transitionRequirement(nc.status, to)
174
+ if (requirement === null) throw new OneError(409, 'APP-E-020', 'Kayıt bu durumdan seçilen duruma geçirilemez.')
175
+ if (body.note !== undefined && body.note !== '' && to !== 'closed') throw fieldError('note', 'Not yalnız kapatırken yazılır.')
176
+ const note = to === 'closed' && typeof body.note === 'string' && body.note.trim() ? requiredText(body.note, 'note', 2000, true) : null
177
+ const hasAction = 'EXISTS (SELECT 1 FROM "actions" WHERE nonconformity_id = "nonconformities".id AND deleted_at IS NULL)'
178
+ const noOpenAction = 'NOT EXISTS (SELECT 1 FROM "actions" WHERE nonconformity_id = "nonconformities".id AND status = \'open\' AND deleted_at IS NULL)'
179
+ // Doğrulamada etkisiz bulunan kayıt yeniden doğrulamaya, ancak o andan SONRA eklenmiş bir faaliyetle gider: eski
180
+ // faaliyetlerin hepsi zaten tamamlanmış olduğu için yalnız "hepsi tamamlandı" koşulu yeni faaliyeti atlatırdı.
181
+ const freshAction =
182
+ 'EXISTS (SELECT 1 FROM "actions" WHERE nonconformity_id = "nonconformities".id AND deleted_at IS NULL AND created_at > "nonconformities".verification_failed_at)'
183
+ const guard =
184
+ requirement === 'root-cause-and-action'
185
+ ? ` AND trim(coalesce(root_cause, '')) <> '' AND ${hasAction}`
186
+ : requirement === 'all-actions-done'
187
+ ? ` AND ${hasAction} AND ${noOpenAction}` + (to === 'verification' ? ` AND (verification_failed_at IS NULL OR ${freshAction})` : '')
188
+ : ''
189
+ const now = nowIso()
190
+ const closing = to === 'closed'
191
+ const failedVerification = nc.status === 'verification' && to === 'planned'
192
+ const write = recordWrite(
193
+ dataModel,
194
+ 'nonconformities',
195
+ { status: to, closed_at: closing ? now : null, closed_by_name: closing ? clip(await myName(input), 120) : null, closure_note: note },
196
+ 'update',
197
+ )
198
+ // ⛔ Okunan durum ve geçişin koşulu yazımla AYNI ifadededir (aynı anda iki geçiş birbirini ezemez).
199
+ const statement = updateWhen(env.DB, write, {
200
+ id,
201
+ actor: user.sub,
202
+ now,
203
+ when: sql(`"status" = ?${guard}`, nc.status),
204
+ also: sql('"verification_failed_at" = CASE WHEN ? THEN ? ELSE "verification_failed_at" END', failedVerification ? 1 : 0, now),
205
+ })
206
+ if (!(await applied(write, statement))) throw new OneError(409, 'APP-E-021', await blockedReason(env, id, nc.status, requirement))
207
+ logAudit(ctx, 'nonconformity.status', id, { from: nc.status, to })
208
+ return json({ id, status: to })
209
+ }
210
+
211
+ async function blockedReason(env: Env, id: string, from: NcStatus, requirement: string): Promise<string> {
212
+ const now = await findNc(env, id)
213
+ if (!now || now.status !== from) return 'Kayıt bu arada değişti; sayfayı yenileyip yeniden deneyin.'
214
+ const actions = await actionsOf(env, id)
215
+ if (requirement === 'root-cause-and-action' && !(now.root_cause ?? '').trim()) return 'Önce kök nedeni yazın.'
216
+ if (actions.length === 0) return 'En az bir düzeltici faaliyet ekleyin.'
217
+ const open = actions.filter((a) => a.status === 'open').length
218
+ if (requirement === 'all-actions-done' && open > 0) return `${open} faaliyet henüz tamamlanmadı; bütün faaliyetler tamamlanınca devam edilir.`
219
+ const failedAt = now.verification_failed_at
220
+ if (from === 'planned' && failedAt && !actions.some((a) => a.created_at > failedAt))
221
+ return 'Kayıt doğrulamada etkisiz bulundu; yeniden doğrulamadan önce yeni bir düzeltici faaliyet ekleyip tamamlayın.'
222
+ return 'Kayıt bu arada değişti; sayfayı yenileyip yeniden deneyin.'
223
+ }
224
+
225
+ async function detail(input: RouteInput<Env>, id: string): Promise<Response> {
226
+ const { nc, manager } = await visibleNc(input, id)
227
+ return json({ record: nc, actions: await actionsOf(input.env, id), manager })
228
+ }
229
+
230
+ async function attachment(input: RouteInput<Env>, id: string): Promise<Response> {
231
+ const { nc } = await visibleNc(input, id)
232
+ if (!nc.attachment) throw new OneError(404, 'APP-E-404', 'Bu kayıtta dosya eki yok.')
233
+ return fileResponse(input.env.FILES, nc.attachment)
234
+ }
235
+
236
+ async function notifyOwner(input: RouteInput<Env>, owner: string, title: string, description: string, dueDate: string) {
237
+ return sendMailSafely(
238
+ input.ctx,
239
+ input.api(),
240
+ systemNotice({ appName: APP_NAME.tr, title: `Size bir düzeltici faaliyet atandı: ${title}`, message: `${description}\n\nTermin: ${formatDay(dueDate)}. Uygulamanın Faaliyetlerim sekmesinden tamamlayabilirsiniz.` }),
241
+ { toOrgUserIds: [owner] },
242
+ )
243
+ }
244
+
245
+ async function addAction(input: RouteInput<Env>, ncId: string): Promise<Response> {
246
+ const { request, env, ctx } = input
247
+ const user = requirePermission(ctx, MANAGE_PERMISSION)
248
+ refuseActingSession(ctx)
249
+ const body = onlyFields(await readJsonObject(request), ACTION_FIELDS)
250
+ const nc = await findNc(env, ncId)
251
+ if (!nc) throw new OneError(404, 'APP-E-404', 'Kayıt bulunamadı.')
252
+ if (!actionsEditable(nc.status)) throw new OneError(409, 'APP-E-022', 'Bu durumdaki kayda faaliyet eklenemez.')
253
+ const description = requiredText(body.description, 'description', 2000, true)
254
+ const owner = personValue(body.owner, 'owner')
255
+ if (!owner) throw fieldError('owner', 'Faaliyetin sorumlusunu seçin.')
256
+ const due = checkDueDate(body.due_date, today())
257
+ if (!due.ok) throw fieldError('due_date', due.message)
258
+ const write = recordWrite(
259
+ dataModel,
260
+ 'actions',
261
+ { nonconformity_id: ncId, nonconformity_title: nc.title, description, owner_org_id: owner.id, owner_name: clip(owner.name, 120), due_date: due.value, status: 'open' },
262
+ 'create',
263
+ )
264
+ const id = crypto.randomUUID()
265
+ const now = nowIso()
266
+ // ⛔ Faaliyet yalnız kayıt HÂLÂ açık ya da planlandı durumundaysa ve başlığı okuduğumuz başlıksa eklenir (aynı ifadede):
267
+ // arada başlığı değişen kaydın faaliyetine eski başlık kopyalanmaz.
268
+ const when = sql(`EXISTS (SELECT 1 FROM "nonconformities" n WHERE n.id = ? AND n.status IN ('open', 'planned') AND n.deleted_at IS NULL AND n.title = ?)`, ncId, nc.title)
269
+ if (!(await applied(write, insertWhen(env.DB, write, { id, actor: user.sub, now, when })))) throw new OneError(409, 'APP-E-012', 'Kayıt bu arada değişti; sayfayı yenileyin.')
270
+ logAudit(ctx, 'action.create', id, { nonconformity: ncId })
271
+ const notification = await notifyOwner(input, owner.id, nc.title, description, due.value)
272
+ return json({ action: await findAction(env, id), notification }, 201)
273
+ }
274
+
275
+ /** Faaliyet açık ve kaydı açık ya da planlandı durumunda (yazılan `actions` satırı için). */
276
+ const ACTION_OPEN = sql(
277
+ `"actions".status = 'open' AND EXISTS (SELECT 1 FROM "nonconformities" n WHERE n.id = "actions".nonconformity_id AND n.status IN ('open', 'planned') AND n.deleted_at IS NULL)`,
278
+ )
279
+
280
+ async function editAction(input: RouteInput<Env>, id: string): Promise<Response> {
281
+ const { request, env, ctx, method } = input
282
+ const user = requirePermission(ctx, MANAGE_PERMISSION)
283
+ refuseActingSession(ctx)
284
+ const action = await findAction(env, id)
285
+ if (!action) throw new OneError(404, 'APP-E-404', 'Faaliyet bulunamadı.')
286
+ const now = nowIso()
287
+ if (method === 'DELETE') {
288
+ if (!changedOne(await removeWhen(env.DB, 'actions', { id, actor: user.sub, now, when: ACTION_OPEN }).run()))
289
+ throw new OneError(409, 'APP-E-022', 'Yalnız açık kayıttaki tamamlanmamış faaliyet silinebilir.')
290
+ logAudit(ctx, 'action.delete', id)
291
+ return new Response(null, { status: 204 })
292
+ }
293
+ const body = onlyFields(await readJsonObject(request), ACTION_FIELDS)
294
+ const record: Record<string, unknown> = {}
295
+ if ('description' in body) record.description = requiredText(body.description, 'description', 2000, true)
296
+ let owner: { id: string; name: string } | null = null
297
+ if ('owner' in body) {
298
+ owner = personValue(body.owner, 'owner')
299
+ if (!owner) throw fieldError('owner', 'Faaliyetin sorumlusunu seçin.')
300
+ Object.assign(record, { owner_org_id: owner.id, owner_name: clip(owner.name, 120) })
301
+ }
302
+ // Termin yalnız DEĞİŞTİYSE yeni termin kuralına tabidir: düzenleme formu termini her seferinde geri gönderir ve termini
303
+ // geçmiş (gecikmiş) bir faaliyetin açıklaması ya da sorumlusu, termini uzatmadan düzeltilebilmelidir.
304
+ const dueChanged = 'due_date' in body && body.due_date !== action.due_date
305
+ if (dueChanged) {
306
+ const due = checkDueDate(body.due_date, today())
307
+ if (!due.ok) throw fieldError('due_date', due.message)
308
+ record.due_date = due.value
309
+ }
310
+ if (Object.keys(record).length === 0) throw fieldError('', 'Değiştirilecek alan gönderilmedi.')
311
+ // Sorumlu ya da termin değişince hatırlatma damgası sıfırlanır (yeni termin geçerse yeniden hatırlatılır).
312
+ if ('owner' in body || dueChanged) record.reminded_on = null
313
+ const write = recordWrite(dataModel, 'actions', record, 'update')
314
+ // ⛔ Faaliyetin ve kaydın açık olması yazımla AYNI ifadededir. Termin "değişmedi" kararı okunan termine göre verilir ama
315
+ // o durumda termin YAZILMAZ: arada değişen termin olduğu gibi kalır (iki düzenlemenin bir sırayla yapılmasıyla aynı sonuç).
316
+ if (!(await applied(write, updateWhen(env.DB, write, { id, actor: user.sub, now, when: ACTION_OPEN }))))
317
+ throw new OneError(409, 'APP-E-022', 'Yalnız açık kayıttaki tamamlanmamış faaliyet değiştirilebilir.')
318
+ logAudit(ctx, 'action.update', id)
319
+ const after = (await findAction(env, id))!
320
+ const notification = owner && owner.id !== action.owner_org_id ? await notifyOwner(input, owner.id, action.nonconformity_title ?? '', after.description, after.due_date) : 'skipped'
321
+ return json({ action: after, notification })
322
+ }
323
+
324
+ async function complete(input: RouteInput<Env>, id: string): Promise<Response> {
325
+ const { request, env, ctx } = input
326
+ const user = requireUser(ctx)
327
+ refuseActingSession(ctx)
328
+ const body = onlyFields(await readJsonObject(request), ['note'])
329
+ const action = await findAction(env, id)
330
+ const manager = hasPermission(ctx, MANAGE_PERMISSION)
331
+ let allowed = action !== null && manager
332
+ let me: { id: string; name: string } | null = null
333
+ if (action && !allowed) {
334
+ me = await myOrg(input.api()).catch(() => null)
335
+ allowed = me !== null && me.id === action.owner_org_id.toLowerCase()
336
+ }
337
+ // Başkasının faaliyeti ile hiç olmayan faaliyet aynı yanıtı alır.
338
+ if (!action || !allowed) throw new OneError(404, 'APP-E-404', 'Faaliyet bulunamadı.')
339
+ if (action.status === 'done') throw new OneError(409, 'APP-E-020', 'Faaliyet zaten tamamlandı.')
340
+ const note = requiredText(body.note, 'note', ACTION_NOTE_MAX, false)
341
+ const now = nowIso()
342
+ const write = recordWrite(dataModel, 'actions', { status: 'done', done_at: now, done_note: note, done_by_name: clip(me?.name || (await myName(input)), 120) }, 'update')
343
+ // ⛔ Yetki yazımla AYNI ifadededir: yönetici olmayan kişi faaliyeti yalnız HÂLÂ sorumlusuysa tamamlar (denetimden sonra
344
+ // faaliyet başkasına verildiyse yazım olmaz). Faaliyetin ve kaydın açık olması da aynı koşuldadır.
345
+ const owned = manager ? sql('') : sql('lower("actions".owner_org_id) = ?', me!.id)
346
+ if (!(await applied(write, updateWhen(env.DB, write, { id, actor: user.sub, now, when: and(ACTION_OPEN, owned) })))) {
347
+ const latest = await findAction(env, id)
348
+ if (!latest || (!manager && latest.owner_org_id.toLowerCase() !== me!.id)) throw new OneError(404, 'APP-E-404', 'Faaliyet bulunamadı.')
349
+ if (latest.status === 'done') throw new OneError(409, 'APP-E-020', 'Faaliyet zaten tamamlandı.')
350
+ throw new OneError(409, 'APP-E-022', 'Faaliyet bu durumdaki kayıtta tamamlanamaz.')
351
+ }
352
+ logAudit(ctx, 'action.complete', id)
353
+ return json({ action: await findAction(env, id) })
354
+ }
355
+
356
+ /** Faaliyetlerim: istemcinin gönderdiği sorumlu süzgeci YOK SAYILIR, sunucu kendi değerini yazar. */
357
+ async function myActions(input: RouteInput<Env>): Promise<Response> {
358
+ const { request, env, ctx, url, method } = input
359
+ const rest = url.pathname.slice(myActionsList.path.length)
360
+ if (rest !== '' && rest !== '/export.xlsx' && rest !== '/export.csv' && rest !== '/report.html') throw new OneError(404, 'APP-E-404', 'Bulunamadı.')
361
+ if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bu liste yalnız okunur; faaliyeti tamamlamak için faaliyetin tamamlama ucunu kullanın.')
362
+ requireUser(ctx)
363
+ const me = await myOrg(input.api())
364
+ if (!me) throw new OneError(409, 'APP-E-010', 'Portalda kişi kaydınız bulunamadı; faaliyetleriniz gösterilemiyor.')
365
+ const scoped = withQuery(request, url.pathname, (params) => {
366
+ params.delete('owner_org_id')
367
+ params.set('owner_org_id', me.id)
368
+ })
369
+ return (await myActionsList.handle(scoped, env.DB, ctx))!
370
+ }
371
+
372
+ async function summary({ env, ctx }: RouteInput<Env>): Promise<Response> {
373
+ requirePermission(ctx, MANAGE_PERMISSION)
374
+ const [c, overdue, run] = await Promise.all([counts(env), overdueCount(env, today()), lastReminderRun(env)])
375
+ return json({ ...c, overdue, lastReminderRun: run ? { ...run, consent_needed: run.consent_needed === 1 } : null })
376
+ }
377
+
378
+ function readOnly(method: string, what: string): void {
379
+ if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', `${what} bu adresten değiştirilemez; uygulamanın işlem uçlarını kullanın.`)
380
+ }
381
+
382
+ async function routes(input: RouteInput<Env>): Promise<Response | null> {
383
+ const { url, method, env, ctx } = input
384
+ const path = url.pathname
385
+ if (path === '/api/files' && method === 'POST') return uploadFile(input.request, env.FILES, ctx)
386
+ if (path === '/api/summary' && method === 'GET') return summary(input)
387
+ if (path === myActionsList.path || path.startsWith(`${myActionsList.path}/`)) return myActions(input)
388
+ if (path === myReports.path || path.startsWith(`${myReports.path}/`)) {
389
+ readOnly(method, 'Kayıt')
390
+ return myReports.handle(input.request, env.DB, ctx)
391
+ }
392
+
393
+ if (path === records.path && method === 'POST') return report(input)
394
+ let id = matchId(path, records.path, 'status')
395
+ if (id && method === 'POST') return changeStatus(input, id)
396
+ id = matchId(path, records.path, 'detail')
397
+ if (id && method === 'GET') return detail(input, id)
398
+ id = matchId(path, records.path, 'attachment')
399
+ if (id && method === 'GET') return attachment(input, id)
400
+ id = matchId(path, records.path, 'actions')
401
+ if (id && method === 'POST') return addAction(input, id)
402
+ id = matchId(path, records.path)
403
+ if (id && method === 'PATCH') return edit(input, id)
404
+ if (path === records.path || path.startsWith(`${records.path}/`)) {
405
+ readOnly(method, 'Kayıt')
406
+ return records.handle(input.request, env.DB, ctx)
407
+ }
408
+
409
+ id = matchId(path, allActions.path, 'complete')
410
+ if (id && method === 'POST') return complete(input, id)
411
+ id = matchId(path, allActions.path)
412
+ if (id && (method === 'PATCH' || method === 'DELETE')) return editAction(input, id)
413
+ if (path === allActions.path || path.startsWith(`${allActions.path}/`)) {
414
+ readOnly(method, 'Faaliyet')
415
+ return allActions.handle(input.request, env.DB, ctx)
416
+ }
417
+ return null
418
+ }
419
+
420
+ export const definition: Omit<AppDefinition<Env>, 'guide'> = {
421
+ manifest,
422
+ tools: agentTools,
423
+ jobs,
424
+ page: renderShell,
425
+ script: renderShellScript,
426
+ routes,
427
+ }
428
+
429
+ export function buildApp(guide: string) {
430
+ return createApp<Env>({ ...definition, guide })
431
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { STATUSES, actionsEditable, checkDueDate, daysOverdue, formatDay, isOverdue, isStatus, istanbulDay, nextStatuses, reminderMessage, transitionRequirement } from './capa.ts'
3
+
4
+ describe('durum akışı', () => {
5
+ it('yalnız tanımlı geçişler; her geçişin koşulu', () => {
6
+ expect(transitionRequirement('open', 'planned')).toBe('root-cause-and-action')
7
+ expect(transitionRequirement('planned', 'verification')).toBe('all-actions-done')
8
+ expect(transitionRequirement('verification', 'closed')).toBe('all-actions-done')
9
+ expect(transitionRequirement('verification', 'planned')).toBe('none')
10
+ // Atlama ve geri dönüş yok; kapanmış kayıt açılmaz.
11
+ expect(transitionRequirement('open', 'closed')).toBeNull()
12
+ expect(transitionRequirement('open', 'verification')).toBeNull()
13
+ expect(transitionRequirement('planned', 'open')).toBeNull()
14
+ for (const s of STATUSES) expect(transitionRequirement('closed', s)).toBeNull()
15
+ expect(nextStatuses('verification')).toEqual(['planned', 'closed'])
16
+ expect(nextStatuses('closed')).toEqual([])
17
+ })
18
+
19
+ it('faaliyetler yalnız açık ve planlandı durumunda değişir', () => {
20
+ expect(actionsEditable('open')).toBe(true)
21
+ expect(actionsEditable('planned')).toBe(true)
22
+ expect(actionsEditable('verification')).toBe(false)
23
+ expect(actionsEditable('closed')).toBe(false)
24
+ expect(isStatus('planned')).toBe(true)
25
+ expect(isStatus('kapali')).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe('termin ve gecikme', () => {
30
+ it('termin: geçerli gün, bugün ya da sonrası, iki yıldan uzak değil', () => {
31
+ expect(checkDueDate('2026-10-20', '2026-10-20')).toEqual({ ok: true, value: '2026-10-20' })
32
+ expect(checkDueDate('2026-10-19', '2026-10-20')).toMatchObject({ ok: false })
33
+ expect(checkDueDate('2026-02-30', '2026-01-01')).toMatchObject({ ok: false })
34
+ expect(checkDueDate('2030-01-01', '2026-10-20')).toMatchObject({ ok: false })
35
+ expect(checkDueDate(20261020, '2026-10-20')).toMatchObject({ ok: false })
36
+ })
37
+
38
+ it('gecikme terminden SONRAKİ gün başlar; gün İstanbul saatine göredir', () => {
39
+ expect(isOverdue('2026-10-20', '2026-10-20')).toBe(false)
40
+ expect(isOverdue('2026-10-19', '2026-10-20')).toBe(true)
41
+ expect(daysOverdue('2026-10-15', '2026-10-20')).toBe(5)
42
+ expect(istanbulDay(new Date('2026-10-20T20:59:00Z'))).toBe('2026-10-20')
43
+ expect(istanbulDay(new Date('2026-10-20T21:00:00Z'))).toBe('2026-10-21')
44
+ expect(formatDay('2026-10-05')).toBe('05.10.2026')
45
+ })
46
+
47
+ it('hatırlatma metni faaliyeti, kaydı, termini ve gecikmeyi yazar', () => {
48
+ const m = reminderMessage([{ description: 'Yangın tüpünü yenile', nonconformity_title: 'Depo denetimi', due_date: '2026-10-15' }], '2026-10-20', 2)
49
+ expect(m.title).toBe('Termini geçmiş 3 düzeltici faaliyetiniz var')
50
+ expect(m.message).toContain('- Yangın tüpünü yenile (Depo denetimi): termin 15.10.2026, 5 gün gecikti')
51
+ expect(m.message).toContain('ve 2 faaliyet daha')
52
+ })
53
+ })
@@ -0,0 +1,114 @@
1
+ // Uygunsuzluk ve düzeltici faaliyet kuralları — SAF fonksiyonlar (veritabanı ve ağ yok; `capa.test.ts` sınar). Durum
2
+ // akışı YALNIZ sunucuda yürür; istemci durumu doğrudan yazamaz. Kuralı değiştiren bu dosyayı ve testini birlikte değiştirir.
3
+ //
4
+ // açık ──► faaliyet planlandı ──► doğrulama ──► kapandı
5
+ // ▲ │
6
+ // └──────────────────┘ (doğrulamada etkisiz bulundu: yeni faaliyet eklenecek)
7
+ //
8
+ // açık → faaliyet planlandı kök neden yazılmış ve en az bir faaliyet eklenmiş olmalı
9
+ // planlandı → doğrulama bütün faaliyetler tamamlanmış olmalı; kayıt daha önce doğrulamada etkisiz bulunduysa o
10
+ // andan SONRA eklenmiş en az bir faaliyet de olmalı (koşul `app.ts` → `changeStatus`'ta, SQL'de)
11
+ // doğrulama → kapandı bütün faaliyetler tamamlanmış olmalı (kapanış notu isteğe bağlı)
12
+ // Bütün geçişler yönetici (`…Manage`) işidir. Faaliyet yalnız "açık" ve "faaliyet planlandı" durumunda eklenir, değişir,
13
+ // silinir ve tamamlanır.
14
+ //
15
+ // ⚠️ İş kuralıdır — keşif görüşmesinde kullanıcıyla netleştirin ("Kaydı kim kapatır?", "Doğrulama kim yapar?").
16
+ export const STATUSES = ['open', 'planned', 'verification', 'closed'] as const
17
+ export type NcStatus = (typeof STATUSES)[number]
18
+ export type Requirement = 'root-cause-and-action' | 'all-actions-done' | 'none'
19
+
20
+ const TRANSITIONS: Record<NcStatus, Partial<Record<NcStatus, Requirement>>> = {
21
+ open: { planned: 'root-cause-and-action' },
22
+ planned: { verification: 'all-actions-done' },
23
+ verification: { closed: 'all-actions-done', planned: 'none' },
24
+ closed: {},
25
+ }
26
+
27
+ /** Geciken faaliyet hatırlatmasında bir koşumda en çok kaç kişiye e-posta gider (kalanlar sonraki güne kalır). */
28
+ export const REMINDER_OWNERS_PER_RUN = 8
29
+ /** Bir hatırlatma e-postasında listelenen en çok faaliyet. */
30
+ export const REMINDER_ACTIONS_PER_MAIL = 20
31
+ /** Termin bugünden en çok bu kadar gün ileride olabilir (yanlış yazılan yılı yakalar). */
32
+ export const DUE_DATE_MAX_DAYS = 730
33
+ export const ACTION_NOTE_MAX = 500
34
+
35
+ export function isStatus(value: unknown): value is NcStatus {
36
+ return typeof value === 'string' && (STATUSES as readonly string[]).includes(value)
37
+ }
38
+
39
+ /** Geçişin koşulu; geçiş yoksa `null`. */
40
+ export function transitionRequirement(from: NcStatus, to: NcStatus): Requirement | null {
41
+ return TRANSITIONS[from][to] ?? null
42
+ }
43
+
44
+ /** Gidilebilecek durumlar (arayüz düğmeleri için). */
45
+ export function nextStatuses(from: NcStatus): NcStatus[] {
46
+ return STATUSES.filter((to) => transitionRequirement(from, to) !== null)
47
+ }
48
+
49
+ /** Faaliyet eklenebilir / değiştirilebilir / tamamlanabilir mi? */
50
+ export function actionsEditable(status: NcStatus): boolean {
51
+ return status === 'open' || status === 'planned'
52
+ }
53
+
54
+ /** İstanbul saatiyle gün (YYYY-AA-GG) — termin karşılaştırması ve "günde bir hatırlatma" anahtarı. */
55
+ export function istanbulDay(at: Date): string {
56
+ return new Date(at.getTime() + 3 * 3_600_000).toISOString().slice(0, 10)
57
+ }
58
+
59
+ function isCalendarDay(value: string): boolean {
60
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
61
+ const d = new Date(`${value}T00:00:00Z`)
62
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value
63
+ }
64
+
65
+ function addDays(day: string, days: number): string {
66
+ return new Date(Date.parse(`${day}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
67
+ }
68
+
69
+ export type DueDateCheck = { ok: true; value: string } | { ok: false; message: string }
70
+
71
+ /** Termin: geçerli bir gün, bugün ya da sonrası, en çok DUE_DATE_MAX_DAYS gün ileride. */
72
+ export function checkDueDate(raw: unknown, today: string): DueDateCheck {
73
+ if (typeof raw !== 'string' || !isCalendarDay(raw)) return { ok: false, message: 'Termin YYYY-AA-GG biçiminde geçerli bir tarih olmalı.' }
74
+ if (raw < today) return { ok: false, message: 'Termin geçmiş bir tarih olamaz.' }
75
+ if (raw > addDays(today, DUE_DATE_MAX_DAYS)) return { ok: false, message: `Termin en çok ${DUE_DATE_MAX_DAYS} gün ileride olabilir.` }
76
+ return { ok: true, value: raw }
77
+ }
78
+
79
+ /** Termin bugünden önceyse faaliyet gecikmiştir (terminin günü gecikme sayılmaz). */
80
+ export function isOverdue(dueDate: string, today: string): boolean {
81
+ return dueDate < today
82
+ }
83
+
84
+ export function daysOverdue(dueDate: string, today: string): number {
85
+ return Math.max(0, Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${dueDate}T00:00:00Z`)) / 86_400_000))
86
+ }
87
+
88
+ /** YYYY-AA-GG → gg.aa.yyyy */
89
+ export function formatDay(day: string): string {
90
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day)
91
+ return m ? `${m[3]}.${m[2]}.${m[1]}` : day
92
+ }
93
+
94
+ export interface OverdueItem {
95
+ description: string
96
+ nonconformity_title: string | null
97
+ due_date: string
98
+ }
99
+
100
+ /** Hatırlatma e-postasının metni (düz metin; sunucu markalı şablona sarar). */
101
+ export function reminderMessage(items: readonly OverdueItem[], today: string, more = 0): { title: string; message: string } {
102
+ const lines = items.map((item) => {
103
+ const text = item.description.replace(/\s+/g, ' ').trim()
104
+ const clipped = [...text].length > 160 ? `${[...text].slice(0, 159).join('')}…` : text
105
+ const where = item.nonconformity_title ? ` (${item.nonconformity_title})` : ''
106
+ return `- ${clipped}${where}: termin ${formatDay(item.due_date)}, ${daysOverdue(item.due_date, today)} gün gecikti`
107
+ })
108
+ if (more > 0) lines.push(`- ve ${more} faaliyet daha`)
109
+ const total = items.length + more
110
+ return {
111
+ title: `Termini geçmiş ${total} düzeltici faaliyetiniz var`,
112
+ message: ['Size atanan şu düzeltici faaliyetlerin termini geçti:', '', ...lines, '', 'Uygulamanın Faaliyetlerim sekmesinden tamamlayabilirsiniz.'].join('\n'),
113
+ }
114
+ }