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,740 @@
1
+ // {{nameTr}} — hedef ve aksiyon takibi. Uçlar (hepsi oturum ister; kimlik platformdan gelir):
2
+ // GET /api/periods[?…] · /api/periods/{id} dönemler (herkes) — hazır liste + Excel
3
+ // POST /api/periods · PATCH/DELETE /api/periods/{id} dönem tanımı (hedef yöneticisi); DURUM bu uçlardan yazılmaz
4
+ // POST /api/periods/{id}/close · /reopen dönemi kapat / yeniden aç (hedef yöneticisi)
5
+ // GET /api/goals[?…] Hedeflerim: sorumlusu BEN olan hedefler (süzgeç sunucuda zorlanır)
6
+ // POST /api/goals · GET/PATCH/DELETE /api/goals/{id} hedef (sorumlu, sorumlunun yöneticisi ya da hedef yöneticisi)
7
+ // GET/POST /api/goals/{id}/key-results ölçülebilir sonuçlar
8
+ // PATCH/DELETE /api/key-results/{id} ölçülebilir sonucun tanımı (güncel değer bu uçtan YAZILMAZ)
9
+ // POST /api/key-results/{id}/check-ins { value, note? } — güncel değer + geçmiş satırı TEK toplu yazım
10
+ // GET /api/goals/{id}/check-ins değer güncelleme geçmişi
11
+ // GET/POST /api/goals/{id}/actions · PATCH/DELETE /api/actions/{id} aksiyonlar
12
+ // GET /api/team/goals[?period_id=…] Ekip panosu: doğrudan bana bağlı çalışanların hedefleri (+ Excel)
13
+ // GET /api/board[?period_id=…] · /api/board/goals[?…] Dönem panosu: ekip başına ağırlıklı ilerleme + hedef listesi (+ Excel)
14
+ // ⛔ İlerleme YALNIZ sunucuda hesaplanır (`progress.ts`); istemcinin gönderdiği `progress` ya da `current_value` 400 alır.
15
+ // ⛔ Görünürlük sunucuda organizasyon şemasından türetilir: kişi kendi hedeflerini, yönetici doğrudan bağlılarının
16
+ // hedeflerini, hedef yöneticisi ({{permissionPrefix}}Manage) hepsini görür. Görülemeyen hedef 404'tür (var olduğu sızmaz).
17
+ // ⛔ Kapanmış dönemin hedefleri, ölçülebilir sonuçları ve aksiyonları değiştirilemez (409).
18
+ import {
19
+ OneError,
20
+ createCrudHandler,
21
+ exportResponse,
22
+ getMyOrgContext,
23
+ getOrgSchema,
24
+ getOrgUsersWithManagers,
25
+ hasPermission,
26
+ isActingSession,
27
+ logAudit,
28
+ logError,
29
+ requirePermission,
30
+ requireUser,
31
+ toCsv,
32
+ toXlsx,
33
+ type ExportColumn,
34
+ type OneContext,
35
+ type OneUser,
36
+ type OrgUserWithManagers,
37
+ } from '@devoplus/one-sdk'
38
+ import manifest from '../one.manifest.json' with { type: 'json' }
39
+ import { agentTools } from './agent.ts'
40
+ import { dataModel } from './data/types.ts'
41
+ import { fieldError, forward, matchId, nowIso, onlyFields, personValue, readJsonObject, withQuery } from './lib/http.ts'
42
+ import { and, applied, changedOne, insertWhen, recordWrite, removeWhen, sql, updateWhen, withUniqueCheck, type Sql } from './lib/record.ts'
43
+ import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
44
+ import { directReports, keyResultProgress, parseValue } from './progress.ts'
45
+ import { findPeriod, periodSummary } from './queries.ts'
46
+ import { renderShell, renderShellScript } from './shell.ts'
47
+
48
+ export type Env = BaseEnv
49
+
50
+ export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
51
+ /** Hedef yöneticisi izni (manifest `permissions`): dönem tanımlar ve kapatır, bütün hedefleri görür ve düzenler. */
52
+ export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
53
+
54
+ export const periods = createCrudHandler({ model: dataModel, entity: 'periods', records: 'shared', canWrite: MANAGE_PERMISSION })
55
+ // ⛔ Aşağıdaki kayıt uçları DOĞRUDAN yönlendirilmez: görünürlük denetimi `routes` içinde yapılıp istek onlara iletilir.
56
+ // Alt kayıtlar `/internal` önekindedir; o önek dışarıdan hiçbir uca bağlanmaz (404).
57
+ export const goals = createCrudHandler({ model: dataModel, entity: 'goals', records: 'shared' })
58
+ const INTERNAL = '/internal'
59
+ export const keyResults = createCrudHandler({ model: dataModel, entity: 'key_results', records: 'shared', basePath: INTERNAL })
60
+ export const checkIns = createCrudHandler({ model: dataModel, entity: 'check_ins', records: 'shared', basePath: INTERNAL })
61
+ export const actions = createCrudHandler({ model: dataModel, entity: 'actions', records: 'shared', basePath: INTERNAL })
62
+
63
+ const PERIOD_FIELDS = ['code', 'name', 'start_date', 'end_date']
64
+ const GOAL_FIELDS = ['period_id', 'title', 'description', 'owner', 'weight']
65
+ const KEY_RESULT_FIELDS = ['name', 'unit', 'start_value', 'target_value']
66
+ const ACTION_FIELDS = ['description', 'owner', 'due_date', 'done']
67
+ const NOTE_MAX = 200
68
+ /** Kişi ve ekip adlarının veri modelindeki sınırı (`owner_name`, `team_name`, `actor_name`). */
69
+ const NAME_MAX = 120
70
+ const SINGLE_LINE_BAD = /[\u0000-\u001f\u007f]/
71
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
72
+ const LIST_MAX = 200
73
+ const EXPORT_MAX = 10_000
74
+
75
+ /** Hedefin bağlı olduğu dönem AÇIK mı (toplu yazımların koşulu; `goals` tablosu `g` adıyla). */
76
+ const OPEN_PERIOD_SQL = `EXISTS (SELECT 1 FROM "periods" p WHERE p.id = g.period_id AND p.status = 'open' AND p.deleted_at IS NULL)`
77
+ /**
78
+ * Hedefin ilerlemesini ölçülebilir sonuçlarından yeniden hesaplar: ortalama, bir ondalık (`progress.ts` → `goalProgress`
79
+ * ile aynı kural). Bağlar: hedef kimliği (iki kez), zaman, kişi. Dönem kapalıysa hiçbir şey yazmaz.
80
+ */
81
+ const GOAL_PROGRESS_SQL =
82
+ 'UPDATE "goals" AS g SET progress = coalesce((SELECT ROUND(AVG(k.progress), 1) FROM "key_results" k WHERE k.goal_id = ? AND k.deleted_at IS NULL), 0), ' +
83
+ `updated_at = ?, updated_by = ? WHERE g.id = ? AND g.deleted_at IS NULL AND ${OPEN_PERIOD_SQL}`
84
+
85
+ const LOCKED = () => new OneError(409, 'APP-E-030', 'Dönem kapandı; bu dönemin hedefleri yalnız görüntülenebilir.')
86
+ const NOT_FOUND = () => new OneError(404, 'APP-E-404', 'Hedef bulunamadı.')
87
+ const CHANGED = () => new OneError(409, 'APP-E-031', 'Kayıt bu arada değişti; sayfayı yenileyip yeniden deneyin.')
88
+
89
+ // ── Kim kimi görür ───────────────────────────────────────────────────────────────────────────────────────────────
90
+
91
+ interface Viewer {
92
+ user: OneUser
93
+ /** Oturumdaki kişinin org kimliği (küçük harf); portalda kişi kaydı yoksa `null`. */
94
+ orgId: string | null
95
+ name: string | null
96
+ manage: boolean
97
+ users(): Promise<OrgUserWithManagers[]>
98
+ reports(): Promise<Set<string>>
99
+ }
100
+
101
+ async function viewer(input: RouteInput<Env>): Promise<Viewer> {
102
+ const user = requireUser(input.ctx)
103
+ const api = input.api()
104
+ const me = await getMyOrgContext(api)
105
+ const orgId = me ? me.orgUserId.toLowerCase() : null
106
+ let list: Promise<OrgUserWithManagers[]> | null = null
107
+ const users = () => (list ??= getOrgUsersWithManagers(api))
108
+ return {
109
+ user,
110
+ orgId,
111
+ name: me?.fullName ?? null,
112
+ manage: hasPermission(input.ctx, MANAGE_PERMISSION),
113
+ users,
114
+ reports: async () => (orgId ? directReports(await users(), orgId) : new Set<string>()),
115
+ }
116
+ }
117
+
118
+ async function canSee(v: Viewer, ownerOrgId: string): Promise<boolean> {
119
+ const owner = ownerOrgId.toLowerCase()
120
+ return v.manage || owner === v.orgId || (await v.reports()).has(owner)
121
+ }
122
+
123
+ /**
124
+ * Bir hedefe (ve alt kayıtlarına) YAZIM koşulu — `goals` satırı `g` adıyla: hedef silinmemiş, dönemi açık ve sorumlusu
125
+ * yazanın görebildiği biri (hedef yöneticisi için koşul yok). ⛔ Okumadaki `goalAccess` denetiminin yazım ifadesindeki
126
+ * karşılığıdır: denetimden sonra hedef silinir, dönem kapanır ya da hedef başkasına verilirse yazım olmaz.
127
+ */
128
+ async function goalWritable(v: Viewer): Promise<Sql> {
129
+ const owners = await visibleOwners(v, true)
130
+ return and(
131
+ sql('g.deleted_at IS NULL'),
132
+ sql(OPEN_PERIOD_SQL),
133
+ owners === null ? sql('') : sql('g.owner_org_id IN (SELECT value FROM json_each(?))', JSON.stringify(owners)),
134
+ )
135
+ }
136
+
137
+ /** `EXISTS (hedef yazılabilir)` — `goalRef` hedef kimliğidir: bağlı değer (`?`) ya da yazılan satırın kolonu. */
138
+ async function goalGate(v: Viewer, goalRef: Sql, extra: Sql = sql('')): Promise<Sql> {
139
+ const w = and(await goalWritable(v), extra)
140
+ return sql(`EXISTS (SELECT 1 FROM "goals" g WHERE g.id = ${goalRef.sql} AND ${w.sql})`, ...goalRef.params, ...w.params)
141
+ }
142
+
143
+ /** Yazma kapısı (savunma derinliği): Vekalet / Yerine Görüntüle oturumunda platform yazmayı zaten durdurur. */
144
+ function requireWriter(ctx: OneContext): OneUser {
145
+ const user = requireUser(ctx)
146
+ if (isActingSession(ctx)) throw new OneError(403, 'APP-E-403', 'Vekalet ya da Yerine Görüntüle oturumunda bu uygulamada kayıt değiştirilemez; yalnız görüntüleyebilirsiniz.')
147
+ return user
148
+ }
149
+
150
+ interface GoalRow {
151
+ id: string
152
+ period_id: string
153
+ owner_org_id: string
154
+ owner_name: string | null
155
+ title: string
156
+ period_status: string | null
157
+ }
158
+
159
+ async function findGoal(env: Env, id: string): Promise<GoalRow | null> {
160
+ return env.DB.prepare(
161
+ 'SELECT g.id, g.period_id, g.owner_org_id, g.owner_name, g.title, p.status AS period_status FROM "goals" g ' +
162
+ 'LEFT JOIN "periods" p ON p.id = g.period_id AND p.deleted_at IS NULL WHERE g.id = ? AND g.deleted_at IS NULL',
163
+ )
164
+ .bind(id)
165
+ .first<GoalRow>()
166
+ }
167
+
168
+ /** Hedefe erişim: görülemeyen hedef 404; yazmada dönem açık olmalı (409). */
169
+ async function goalAccess(input: RouteInput<Env>, id: string, mode: 'read' | 'write'): Promise<{ goal: GoalRow; v: Viewer }> {
170
+ if (mode === 'write') requireWriter(input.ctx)
171
+ const goal = await findGoal(input.env, id)
172
+ if (!goal) throw NOT_FOUND()
173
+ const v = await viewer(input)
174
+ if (!(await canSee(v, goal.owner_org_id))) throw NOT_FOUND()
175
+ if (mode === 'write' && goal.period_status !== 'open') throw LOCKED()
176
+ return { goal, v }
177
+ }
178
+
179
+ /** Sorumlunun adı ve ekibi (departmanı) organizasyon şemasından; şema okunamazsa ekip adı boş kalır. */
180
+ async function ownerDetails(input: RouteInput<Env>, v: Viewer, ownerOrgId: string, fallbackName: string) {
181
+ const person = (await v.users()).find((u) => u.id.toLowerCase() === ownerOrgId)
182
+ if (!person) throw fieldError('owner', 'Kişi organizasyon şemasında bulunamadı.')
183
+ let teamName: string | null = null
184
+ if (person.departmentId) {
185
+ try {
186
+ const schema = await getOrgSchema(input.api())
187
+ teamName = schema.departments.find((d) => d.id === person.departmentId)?.departmentName?.trim() || null
188
+ } catch (error) {
189
+ logError(input.ctx, 'departman adı okunamadı', error)
190
+ }
191
+ }
192
+ return {
193
+ owner_name: clip(person.fullName || fallbackName, NAME_MAX),
194
+ team_id: person.departmentId ? person.departmentId.toLowerCase() : null,
195
+ team_name: clip(teamName, NAME_MAX),
196
+ }
197
+ }
198
+
199
+ /** Organizasyon şemasından gelen adı veri modelinin sınırına kırpar (boş ad `null`). */
200
+ function clip(value: string | null | undefined, max: number): string | null {
201
+ const text = value?.trim() ?? ''
202
+ return text === '' ? null : [...text].slice(0, max).join('')
203
+ }
204
+
205
+ function textValue(raw: unknown, field: string, max: number, required: boolean): string | null {
206
+ if (raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '')) {
207
+ if (required) throw fieldError(field, 'Bu alan zorunlu.')
208
+ return null
209
+ }
210
+ if (typeof raw !== 'string' || SINGLE_LINE_BAD.test(raw) || [...raw.trim()].length > max) throw fieldError(field, `En çok ${max} karakterlik tek satır olmalı.`)
211
+ return raw.trim()
212
+ }
213
+
214
+ function numberValue(raw: unknown, field: string): number {
215
+ const check = parseValue(raw)
216
+ if (!check.ok) throw fieldError(field, check.message)
217
+ return check.value
218
+ }
219
+
220
+ // ── Dönemler ────────────────────────────────────────────────────────────────────────────────────────────────────
221
+
222
+ function checkDates(start: unknown, end: unknown): void {
223
+ if (typeof start === 'string' && typeof end === 'string' && DATE_RE.test(start) && DATE_RE.test(end) && start > end)
224
+ throw fieldError('end_date', 'Bitiş tarihi başlangıçtan önce olamaz.')
225
+ }
226
+
227
+ async function periodWrite(input: RouteInput<Env>, id: string | null): Promise<Response> {
228
+ const { request, env, ctx, method } = input
229
+ const user = requireWriter(ctx)
230
+ requirePermission(ctx, MANAGE_PERMISSION)
231
+ if (id && method === 'DELETE') {
232
+ // ⛔ "Dönemde hedef yok" koşulu silmeyle AYNI ifadededir; hedef ekleme de dönemin açık ve silinmemiş olmasını kendi
233
+ // ifadesinde ister (karşılıklı kapı). Önce sayıp sonra silmek, arada eklenen hedefi silinmiş bir döneme bağlı bırakırdı.
234
+ const now = nowIso()
235
+ const res = await env.DB.prepare(
236
+ 'UPDATE "periods" SET deleted_at = ?, updated_at = ?, updated_by = ? WHERE id = ? AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM "goals" WHERE period_id = ? AND deleted_at IS NULL)',
237
+ )
238
+ .bind(now, now, user.sub, id, id)
239
+ .run()
240
+ if (Number(res.meta.changes ?? 0) !== 1) {
241
+ if (!(await findPeriod(env, { id }))) throw new OneError(404, 'APP-E-404', 'Dönem bulunamadı.')
242
+ throw new OneError(409, 'APP-E-032', 'Bu dönemde hedef var; dönem silinemez. Dönemi kapatabilirsiniz.')
243
+ }
244
+ logAudit(ctx, 'period.delete', id)
245
+ return new Response(null, { status: 204 })
246
+ }
247
+ const body = onlyFields(await readJsonObject(request), PERIOD_FIELDS)
248
+ if (id) {
249
+ checkDates(body.start_date, body.end_date)
250
+ const write = recordWrite(dataModel, 'periods', body, 'update')
251
+ const start = typeof body.start_date === 'string' ? body.start_date : null
252
+ const end = typeof body.end_date === 'string' ? body.end_date : null
253
+ const now = nowIso()
254
+ // ⛔ "Başlangıç bitişten sonra olamaz" kuralı yazımla AYNI ifadededir ve YAZIMDAN SONRAKİ satıra uygulanır: gönderilmeyen
255
+ // tarih satırın o anki değeridir. Önce okuyup sonra yazmak, iki ayrı düzenlemenin (biri başlangıcı, öteki bitişi
256
+ // değiştiren) ikisinin de ayrı ayrı geçip kuralı birlikte bozmasına izin verirdi.
257
+ // Hedefler dönem kodunun KOPYASINI taşır (liste ve Excel onu gösterir): kod değişince kopyalar aynı toplu yazımda
258
+ // güncellenir. Hedef ekleme de kodu kendi ifadesinde dönemin o anki koduyla karşılaştırır (bayat kod yazılmaz).
259
+ const [updated] = await withUniqueCheck(dataModel, 'periods', () =>
260
+ env.DB.batch([
261
+ updateWhen(env.DB, write, { id, actor: user.sub, now, when: sql('coalesce(?, "start_date") <= coalesce(?, "end_date")', start, end) }),
262
+ ...(body.code !== undefined
263
+ ? [env.DB.prepare('UPDATE "goals" SET period_code = (SELECT code FROM "periods" WHERE id = ?) WHERE period_id = ? AND deleted_at IS NULL').bind(id, id)]
264
+ : []),
265
+ ]),
266
+ )
267
+ if (!changedOne(updated)) {
268
+ const current = await findPeriod(env, { id })
269
+ if (!current) throw new OneError(404, 'APP-E-404', 'Dönem bulunamadı.')
270
+ if ((start ?? current.start_date) > (end ?? current.end_date)) throw fieldError('end_date', 'Bitiş tarihi başlangıçtan önce olamaz.')
271
+ throw CHANGED()
272
+ }
273
+ return recordResponse(input, periods, id, 200)
274
+ }
275
+ checkDates(body.start_date, body.end_date)
276
+ return (await periods.handle(forward(request, periods.path, 'POST', { ...body, status: 'open' }), env.DB, ctx))!
277
+ }
278
+
279
+ async function periodStatus(input: RouteInput<Env>, id: string, to: 'open' | 'closed'): Promise<Response> {
280
+ const { env, ctx } = input
281
+ const user = requireWriter(ctx)
282
+ requirePermission(ctx, MANAGE_PERMISSION)
283
+ const now = nowIso()
284
+ const res = await env.DB.prepare('UPDATE "periods" SET status = ?, closed_at = ?, updated_at = ?, updated_by = ? WHERE id = ? AND status <> ? AND deleted_at IS NULL')
285
+ .bind(to, to === 'closed' ? now : null, now, user.sub, id, to)
286
+ .run()
287
+ if (Number(res.meta.changes ?? 0) !== 1) {
288
+ if (!(await findPeriod(env, { id }))) throw new OneError(404, 'APP-E-404', 'Dönem bulunamadı.')
289
+ throw new OneError(409, 'APP-E-033', to === 'closed' ? 'Dönem zaten kapalı.' : 'Dönem zaten açık.')
290
+ }
291
+ logAudit(ctx, to === 'closed' ? 'period.close' : 'period.reopen', id)
292
+ return json(await findPeriod(env, { id }))
293
+ }
294
+
295
+ // ── Hedefler ────────────────────────────────────────────────────────────────────────────────────────────────────
296
+
297
+ async function myGoals(input: RouteInput<Env>): Promise<Response> {
298
+ const v = await viewer(input)
299
+ if (!v.orgId) throw new OneError(409, 'APP-E-010', 'Portalda kişi kaydınız bulunamadı; hedefleriniz gösterilemiyor. Portal yöneticinize başvurun.')
300
+ // Süzgeç sunucuda ZORLANIR: istemcinin gönderdiği sorumlu değeri yok sayılır.
301
+ return (await goals.handle(withQuery(input.request, input.url.pathname, (p) => p.set('owner_org_id', v.orgId!)), input.env.DB, input.ctx))!
302
+ }
303
+
304
+ async function readGoal(input: RouteInput<Env>, id: string): Promise<Response> {
305
+ const { goal, v } = await goalAccess(input, id, 'read')
306
+ const res = (await goals.handle(input.request, input.env.DB, input.ctx))!
307
+ if (!res.ok) return res
308
+ const record = (await res.json()) as Record<string, unknown>
309
+ return json({ ...record, period_status: goal.period_status, editable: goal.period_status === 'open' && !isActingSession(input.ctx) && (await canSee(v, goal.owner_org_id)) })
310
+ }
311
+
312
+ /** Kaydı hazır kayıt ucunun gösterimiyle döndürür (yazımı uç kendisi yaptığında). */
313
+ async function recordResponse(input: RouteInput<Env>, handler: typeof goals, id: string, status: 200 | 201): Promise<Response> {
314
+ const res = (await handler.handle(forward(input.request, `${handler.path}/${id}`, 'GET'), input.env.DB, input.ctx))!
315
+ if (!res.ok || status === 200) return res
316
+ const out = json(await res.json(), 201)
317
+ out.headers.set('location', `${handler.path}/${id}`)
318
+ return out
319
+ }
320
+
321
+ async function createGoal(input: RouteInput<Env>): Promise<Response> {
322
+ const { request, env, ctx } = input
323
+ const user = requireWriter(ctx)
324
+ const body = onlyFields(await readJsonObject(request), GOAL_FIELDS)
325
+ const v = await viewer(input)
326
+ if (typeof body.period_id !== 'string' || body.period_id === '') throw fieldError('period_id', 'Dönem seçin.')
327
+ const period = await findPeriod(env, { id: body.period_id })
328
+ if (!period) throw fieldError('period_id', 'Dönem bulunamadı.')
329
+ if (period.status !== 'open') throw LOCKED()
330
+ const picked = personValue(body.owner, 'owner')
331
+ const ownerId = picked?.id ?? v.orgId
332
+ if (!ownerId) throw fieldError('owner', 'Hedefin sorumlusunu seçin.')
333
+ if (!(await canSee(v, ownerId)))
334
+ throw new OneError(403, 'APP-E-403', 'Yalnız kendinize ya da doğrudan size bağlı çalışanlara hedef verebilirsiniz.')
335
+ const details = await ownerDetails(input, v, ownerId, picked?.name ?? v.name ?? '')
336
+ const { owner: _owner, ...rest } = body
337
+ const write = recordWrite(dataModel, 'goals', { ...rest, period_id: period.id, period_code: period.code, owner_org_id: ownerId, ...details, progress: 0 }, 'create')
338
+ const id = crypto.randomUUID()
339
+ const now = nowIso()
340
+ // ⛔ Hedef yalnız dönem HÂLÂ açık, silinmemiş ve kodu okuduğumuz kodsa eklenir (aynı ifadede): okuma ile ekleme arasında
341
+ // dönem kapanır, silinir ya da kodu değişirse hedef yazılmaz (bayat kod kopyası kalmaz). Dönem silme de "dönemde hedef
342
+ // yok" koşulunu kendi ifadesinde ister.
343
+ const when = sql(`EXISTS (SELECT 1 FROM "periods" p WHERE p.id = ? AND p.status = 'open' AND p.deleted_at IS NULL AND p.code = ?)`, period.id, period.code)
344
+ if (!(await applied(write, insertWhen(env.DB, write, { id, actor: user.sub, now, when })))) {
345
+ const latest = await findPeriod(env, { id: period.id })
346
+ if (!latest) throw fieldError('period_id', 'Dönem bulunamadı.')
347
+ throw latest.status !== 'open' ? LOCKED() : CHANGED()
348
+ }
349
+ logAudit(ctx, 'goal.create', id)
350
+ return recordResponse(input, goals, id, 201)
351
+ }
352
+
353
+ /**
354
+ * Koşullu yazım hiçbir satırı değiştirmediyse nedeni: hedef yok ya da artık görülemiyor (404), dönem kapalı (409) ya da
355
+ * kayıt bu arada değişti (409).
356
+ */
357
+ async function goalWriteRefused(env: Env, v: Viewer, id: string): Promise<OneError> {
358
+ const goal = await findGoal(env, id)
359
+ if (!goal || !(await canSee(v, goal.owner_org_id))) return NOT_FOUND()
360
+ return goal.period_status !== 'open' ? LOCKED() : CHANGED()
361
+ }
362
+
363
+ async function writeGoal(input: RouteInput<Env>, id: string): Promise<Response> {
364
+ const { request, env, ctx, method } = input
365
+ const user = requireWriter(ctx)
366
+ const { v } = await goalAccess(input, id, 'write')
367
+ const now = nowIso()
368
+ // ⛔ Erişim denetiminin (hedef var, dönem açık, sorumlusu görülebilir) karşılığı yazımla AYNI ifadededir: denetimden
369
+ // sonra dönem kapanır, hedef silinir ya da başkasına verilirse yazım olmaz.
370
+ const when = await goalWritable(v)
371
+ if (method === 'DELETE') {
372
+ if (!changedOne(await removeWhen(env.DB, 'goals', { id, actor: user.sub, now, when, alias: 'g' }).run())) throw await goalWriteRefused(env, v, id)
373
+ logAudit(ctx, 'goal.delete', id)
374
+ return new Response(null, { status: 204 })
375
+ }
376
+ const body = onlyFields(await readJsonObject(request), GOAL_FIELDS.filter((f) => f !== 'period_id'))
377
+ const { owner, ...record } = body as Record<string, unknown>
378
+ if ('owner' in body) {
379
+ const picked = personValue(owner, 'owner')
380
+ if (!picked) throw fieldError('owner', 'Hedefin sorumlusunu seçin.')
381
+ if (!(await canSee(v, picked.id))) throw new OneError(403, 'APP-E-403', 'Hedefi yalnız kendinize ya da doğrudan size bağlı bir çalışana verebilirsiniz.')
382
+ Object.assign(record, { owner_org_id: picked.id }, await ownerDetails(input, v, picked.id, picked.name))
383
+ }
384
+ const write = recordWrite(dataModel, 'goals', record, 'update')
385
+ if (!(await applied(write, updateWhen(env.DB, write, { id, actor: user.sub, now, when, alias: 'g' })))) throw await goalWriteRefused(env, v, id)
386
+ return recordResponse(input, goals, id, 200)
387
+ }
388
+
389
+ // ── Ölçülebilir sonuçlar ve değer güncellemesi ─────────────────────────────────────────────────────────────────
390
+
391
+ interface KeyResultRow {
392
+ id: string
393
+ goal_id: string
394
+ name: string
395
+ unit: string | null
396
+ start_value: number
397
+ target_value: number
398
+ current_value: number
399
+ }
400
+
401
+ async function findKeyResult(env: Env, id: string): Promise<KeyResultRow | null> {
402
+ return env.DB.prepare('SELECT id, goal_id, name, unit, start_value, target_value, current_value FROM "key_results" WHERE id = ? AND deleted_at IS NULL')
403
+ .bind(id)
404
+ .first<KeyResultRow>()
405
+ }
406
+
407
+ async function readKeyResult(env: Env, id: string) {
408
+ return env.DB.prepare('SELECT id, goal_id, name, unit, start_value, target_value, current_value, progress, updated_at FROM "key_results" WHERE id = ?')
409
+ .bind(id)
410
+ .first<Record<string, unknown>>()
411
+ }
412
+
413
+ /**
414
+ * Hedefin ilerlemesini yeniden hesaplayan ifade; yalnız `marker` tutuyorsa (yani aynı toplu yazımdaki asıl yazım
415
+ * gerçekten olduysa) yazar. Asıl yazımın koşulu tutmadıysa hedefin damgası (`updated_by`) da değişmez.
416
+ */
417
+ function goalProgress(env: Env, goalId: string, now: string, actor: string, marker: Sql) {
418
+ return env.DB.prepare(`${GOAL_PROGRESS_SQL} AND ${marker.sql}`).bind(goalId, now, actor, goalId, ...marker.params)
419
+ }
420
+
421
+ /** Ölçülebilir sonuca koşullu yazım tutmadıysa nedeni: sonuç yok (404) ya da hedefin nedeni. */
422
+ async function keyResultWriteRefused(env: Env, v: Viewer, id: string, goalId: string): Promise<OneError> {
423
+ if (!(await findKeyResult(env, id))) return new OneError(404, 'APP-E-404', 'Ölçülebilir sonuç bulunamadı.')
424
+ return goalWriteRefused(env, v, goalId)
425
+ }
426
+
427
+ async function createKeyResult(input: RouteInput<Env>, goalId: string): Promise<Response> {
428
+ const { request, env, ctx } = input
429
+ const user = requireWriter(ctx)
430
+ const { v } = await goalAccess(input, goalId, 'write')
431
+ const body = onlyFields(await readJsonObject(request), KEY_RESULT_FIELDS)
432
+ const name = textValue(body.name, 'name', 160, true)!
433
+ const unit = textValue(body.unit, 'unit', 20, false)
434
+ const start = body.start_value === undefined ? 0 : numberValue(body.start_value, 'start_value')
435
+ if (body.target_value === undefined) throw fieldError('target_value', 'Hedef değeri girin.')
436
+ const target = numberValue(body.target_value, 'target_value')
437
+ const write = recordWrite(
438
+ dataModel,
439
+ 'key_results',
440
+ { goal_id: goalId, name, unit, start_value: start, target_value: target, current_value: start, progress: keyResultProgress(start, target, start) },
441
+ 'create',
442
+ )
443
+ const id = crypto.randomUUID()
444
+ const now = nowIso()
445
+ // ⛔ Ölçülebilir sonuç ve hedefin yeni ilerlemesi TEK toplu yazım; ekleme hedefin erişim koşulunu (silinmemiş, dönemi
446
+ // açık, sorumlusu görülebilir) kendi ifadesinde ister ve ilerleme yalnız ekleme olduysa yeniden hesaplanır.
447
+ const [inserted] = await env.DB.batch([
448
+ insertWhen(env.DB, write, { id, actor: user.sub, now, when: await goalGate(v, sql('?', goalId)) }),
449
+ goalProgress(env, goalId, now, user.sub, sql('EXISTS (SELECT 1 FROM "key_results" WHERE id = ? AND deleted_at IS NULL)', id)),
450
+ ])
451
+ if (!changedOne(inserted)) throw await goalWriteRefused(env, v, goalId)
452
+ logAudit(ctx, 'key-result.create', id)
453
+ return json(await readKeyResult(env, id), 201)
454
+ }
455
+
456
+ async function writeKeyResult(input: RouteInput<Env>, id: string): Promise<Response> {
457
+ const { request, env, ctx, method } = input
458
+ const user = requireWriter(ctx)
459
+ const kr = await findKeyResult(env, id)
460
+ if (!kr) throw new OneError(404, 'APP-E-404', 'Ölçülebilir sonuç bulunamadı.')
461
+ const { v } = await goalAccess(input, kr.goal_id, 'write')
462
+ const now = nowIso()
463
+ // ⛔ Hedefin erişim koşulu (silinmemiş, dönemi açık, sorumlusu görülebilir) yazımla AYNI ifadededir.
464
+ const gate = await goalGate(v, sql('"key_results".goal_id'))
465
+ // Damga (`updated_at` + `updated_by`) bu yazımın gerçekten olduğunun işaretidir: ilerleme yalnız o zaman yeniden hesaplanır.
466
+ const stamped = (column: 'updated_at' | 'deleted_at') => sql(`EXISTS (SELECT 1 FROM "key_results" WHERE id = ? AND "${column}" = ? AND updated_by = ?)`, id, now, user.sub)
467
+ if (method === 'DELETE') {
468
+ const [removed] = await env.DB.batch([removeWhen(env.DB, 'key_results', { id, actor: user.sub, now, when: gate }), goalProgress(env, kr.goal_id, now, user.sub, stamped('deleted_at'))])
469
+ if (!changedOne(removed)) throw await keyResultWriteRefused(env, v, id, kr.goal_id)
470
+ return new Response(null, { status: 204 })
471
+ }
472
+ // Güncel değer ve ilerleme bu uçtan YAZILMAZ (`onlyFields` 400 döner): değer yalnız değer güncellemesiyle değişir.
473
+ const body = onlyFields(await readJsonObject(request), KEY_RESULT_FIELDS)
474
+ if (Object.keys(body).length === 0) throw fieldError('', 'Güncellenecek alan gönderilmedi.')
475
+ // Yalnız GÖNDERİLEN alanlar yazılır (ad ve birim okunmuş değerle ezilmez); metin alanlarının arama kolonu da güncellenir.
476
+ const record: Record<string, unknown> = {}
477
+ if ('name' in body) record.name = textValue(body.name, 'name', 160, true)
478
+ if ('unit' in body) record.unit = textValue(body.unit, 'unit', 20, false)
479
+ if ('start_value' in body) record.start_value = numberValue(body.start_value, 'start_value')
480
+ if ('target_value' in body) record.target_value = numberValue(body.target_value, 'target_value')
481
+ const start = (record.start_value as number | undefined) ?? kr.start_value
482
+ const target = (record.target_value as number | undefined) ?? kr.target_value
483
+ const write = recordWrite(dataModel, 'key_results', record, 'update')
484
+ // İlerleme okunan başlangıç/hedef/güncel değerden hesaplanır: üçü okuduğumuz hâlde değilse (eşzamanlı değişiklik) yazılmaz.
485
+ const when = and(sql('start_value = ? AND target_value = ? AND current_value = ?', kr.start_value, kr.target_value, kr.current_value), gate)
486
+ const [updated] = await env.DB.batch([
487
+ updateWhen(env.DB, write, { id, actor: user.sub, now, when, also: sql('"progress" = ?', keyResultProgress(start, target, kr.current_value)) }),
488
+ goalProgress(env, kr.goal_id, now, user.sub, stamped('updated_at')),
489
+ ])
490
+ if (!changedOne(updated)) throw await keyResultWriteRefused(env, v, id, kr.goal_id)
491
+ return json(await readKeyResult(env, id))
492
+ }
493
+
494
+ async function checkIn(input: RouteInput<Env>, id: string): Promise<Response> {
495
+ const { request, env, ctx } = input
496
+ const user = requireWriter(ctx)
497
+ const kr = await findKeyResult(env, id)
498
+ if (!kr) throw new OneError(404, 'APP-E-404', 'Ölçülebilir sonuç bulunamadı.')
499
+ const { v } = await goalAccess(input, kr.goal_id, 'write')
500
+ const body = onlyFields(await readJsonObject(request), ['value', 'note'])
501
+ if (body.value === undefined) throw fieldError('value', 'Yeni değeri girin.')
502
+ const value = numberValue(body.value, 'value')
503
+ const note = textValue(body.note, 'note', NOTE_MAX, false)
504
+ const progress = keyResultProgress(kr.start_value, kr.target_value, value)
505
+ const checkInId = crypto.randomUUID()
506
+ const now = nowIso()
507
+ const write = recordWrite(dataModel, 'check_ins', { key_result_id: id, goal_id: kr.goal_id, old_value: kr.current_value, new_value: value, note, actor_name: clip(v.name, NAME_MAX) }, 'create')
508
+ // ⛔ Geçmiş satırı, güncel değer ve hedefin ilerlemesi TEK toplu yazımdır (ikisi birlikte ya da hiçbiri):
509
+ // 1. Geçmiş satırı yalnız değer HÂLÂ okuduğumuz değerse ve hedef yazılabilirse (silinmemiş, dönemi açık, sorumlusu
510
+ // görülebilir) eklenir: eşzamanlı iki güncelleme birbirinin önceki değerini ezmez; ikincisi 409 alır.
511
+ // 2. Değer ve ilerleme yalnız BU geçmiş satırı eklendiyse değişir. 3. Hedefin ilerlemesi yeniden hesaplanır.
512
+ const krCurrent = sql(
513
+ 'EXISTS (SELECT 1 FROM "key_results" k WHERE k.id = ? AND k.deleted_at IS NULL AND k.current_value = ? AND k.start_value = ? AND k.target_value = ?)',
514
+ id,
515
+ kr.current_value,
516
+ kr.start_value,
517
+ kr.target_value,
518
+ )
519
+ const marker = sql('EXISTS (SELECT 1 FROM "check_ins" WHERE id = ?)', checkInId)
520
+ const [inserted, changed] = await env.DB.batch([
521
+ insertWhen(env.DB, write, { id: checkInId, actor: user.sub, now, when: and(krCurrent, await goalGate(v, sql('?', kr.goal_id))) }),
522
+ env.DB.prepare(`UPDATE "key_results" SET current_value = ?, progress = ?, updated_at = ?, updated_by = ? WHERE id = ? AND ${marker.sql}`).bind(value, progress, now, user.sub, id, ...marker.params),
523
+ goalProgress(env, kr.goal_id, now, user.sub, marker),
524
+ ])
525
+ if (!changedOne(inserted) || !changedOne(changed)) throw await keyResultWriteRefused(env, v, id, kr.goal_id)
526
+ logAudit(ctx, 'key-result.check-in', id, { from: kr.current_value, to: value })
527
+ const goal = await env.DB.prepare('SELECT progress FROM "goals" WHERE id = ?').bind(kr.goal_id).first<{ progress: number }>()
528
+ return json({ id: checkInId, keyResult: await readKeyResult(env, id), goal: { id: kr.goal_id, progress: goal?.progress ?? 0 } }, 201)
529
+ }
530
+
531
+ // ── Aksiyonlar ──────────────────────────────────────────────────────────────────────────────────────────────────
532
+
533
+ function actionRecord(body: Record<string, unknown>): Record<string, unknown> {
534
+ const { owner, done, ...rest } = body
535
+ const record: Record<string, unknown> = { ...rest }
536
+ if ('owner' in body) {
537
+ const person = personValue(owner, 'owner')
538
+ record.owner_org_id = person?.id ?? null
539
+ record.owner_name = person?.name || null
540
+ }
541
+ if ('done' in body) {
542
+ if (typeof done !== 'boolean') throw fieldError('done', 'Tamamlandı alanı evet ya da hayır olmalı.')
543
+ record.done = done
544
+ }
545
+ return record
546
+ }
547
+
548
+ async function createAction(input: RouteInput<Env>, goalId: string): Promise<Response> {
549
+ const { request, env, ctx } = input
550
+ const user = requireWriter(ctx)
551
+ const { goal, v } = await goalAccess(input, goalId, 'write')
552
+ const body = onlyFields(await readJsonObject(request), ACTION_FIELDS.filter((f) => f !== 'done'))
553
+ const record = actionRecord(body)
554
+ // Sorumlu verilmezse hedefin sorumlusu aksiyona KOPYALANIR: kopyalanan değer okunduğu hâlde değilse (hedef bu arada
555
+ // başkasına verildiyse) ekleme olmaz, eski sorumlu yazılmaz.
556
+ const inherited = !('owner' in body)
557
+ if (inherited) Object.assign(record, { owner_org_id: goal.owner_org_id, owner_name: goal.owner_name })
558
+ const write = recordWrite(dataModel, 'actions', { ...record, goal_id: goalId, done: false }, 'create')
559
+ const id = crypto.randomUUID()
560
+ const now = nowIso()
561
+ // ⛔ Aksiyon yalnız hedef HÂLÂ yazılabilirse (silinmemiş, dönemi açık, sorumlusu görülebilir) eklenir (aynı ifadede).
562
+ const when = await goalGate(v, sql('?', goalId), inherited ? sql('g.owner_org_id = ?', goal.owner_org_id) : sql(''))
563
+ if (!(await applied(write, insertWhen(env.DB, write, { id, actor: user.sub, now, when })))) throw await goalWriteRefused(env, v, goalId)
564
+ return recordResponse(input, actions, id, 201)
565
+ }
566
+
567
+ async function writeAction(input: RouteInput<Env>, id: string): Promise<Response> {
568
+ const { request, env, ctx, method } = input
569
+ const user = requireWriter(ctx)
570
+ const row = await env.DB.prepare('SELECT goal_id FROM "actions" WHERE id = ? AND deleted_at IS NULL').bind(id).first<{ goal_id: string }>()
571
+ if (!row) throw new OneError(404, 'APP-E-404', 'Aksiyon bulunamadı.')
572
+ const { v } = await goalAccess(input, row.goal_id, 'write')
573
+ const now = nowIso()
574
+ // ⛔ Hedefin erişim koşulu (silinmemiş, dönemi açık, sorumlusu görülebilir) yazımla AYNI ifadededir.
575
+ const when = await goalGate(v, sql('"actions".goal_id'))
576
+ const refused = async () => {
577
+ const still = await env.DB.prepare('SELECT 1 AS n FROM "actions" WHERE id = ? AND deleted_at IS NULL').bind(id).first()
578
+ return still ? goalWriteRefused(env, v, row.goal_id) : new OneError(404, 'APP-E-404', 'Aksiyon bulunamadı.')
579
+ }
580
+ if (method === 'DELETE') {
581
+ if (!changedOne(await removeWhen(env.DB, 'actions', { id, actor: user.sub, now, when }).run())) throw await refused()
582
+ return new Response(null, { status: 204 })
583
+ }
584
+ const record = actionRecord(onlyFields(await readJsonObject(request), ACTION_FIELDS))
585
+ const write = recordWrite(dataModel, 'actions', record, 'update')
586
+ // Tamamlanma zamanını sunucu yazar ve durum DEĞİŞMEDİYSE dokunmaz: karşılaştırma satırın yazımdan önceki değeriyle,
587
+ // aynı ifadede yapılır (okunmuş bir değere göre değil).
588
+ const done = 'done' in record ? (record.done ? 1 : 0) : null
589
+ const also = done === null ? undefined : sql('"done_at" = CASE WHEN "done" = ? THEN "done_at" WHEN ? = 1 THEN ? ELSE NULL END', done, done, now)
590
+ if (!(await applied(write, updateWhen(env.DB, write, { id, actor: user.sub, now, when, ...(also ? { also } : {}) })))) throw await refused()
591
+ return recordResponse(input, actions, id, 200)
592
+ }
593
+
594
+ /** Hedefin alt kayıtları (ölçülebilir sonuç · değer geçmişi · aksiyon): hedef görülebiliyorsa, hedefe süzülmüş liste. */
595
+ async function childList(input: RouteInput<Env>, goalId: string, handler: typeof actions): Promise<Response> {
596
+ await goalAccess(input, goalId, 'read')
597
+ return (await handler.handle(withQuery(input.request, handler.path, (p) => p.set('goal_id', goalId)), input.env.DB, input.ctx))!
598
+ }
599
+
600
+ // ── Ekip panosu ve dönem panosu ─────────────────────────────────────────────────────────────────────────────────
601
+
602
+ const GOAL_COLUMNS: ExportColumn[] = [
603
+ { key: 'period_code', title: { tr: 'Dönem', en: 'Period' } },
604
+ { key: 'team_name', title: { tr: 'Ekip', en: 'Team' } },
605
+ { key: 'owner_name', title: { tr: 'Sorumlu', en: 'Owner' } },
606
+ { key: 'title', title: { tr: 'Hedef', en: 'Goal' } },
607
+ { key: 'weight', title: { tr: 'Ağırlık', en: 'Weight' }, type: 'number' },
608
+ { key: 'progress', title: { tr: 'İlerleme (%)', en: 'Progress (%)' }, type: 'number' },
609
+ { key: 'updated_at', title: { tr: 'Son güncelleme', en: 'Last updated' }, type: 'datetime' },
610
+ ]
611
+ const SORTABLE = new Set(['title', 'owner_name', 'team_name', 'weight', 'progress', 'updated_at'])
612
+
613
+ /** Kimin hedefleri: hedef yöneticisi için `null` (hepsi); diğerleri için doğrudan bağlılar (+ istenirse kendisi). */
614
+ async function visibleOwners(v: Viewer, includeSelf: boolean): Promise<string[] | null> {
615
+ if (v.manage) return null
616
+ const owners = [...(await v.reports())]
617
+ if (includeSelf && v.orgId) owners.push(v.orgId)
618
+ return owners
619
+ }
620
+
621
+ function intParam(params: URLSearchParams, name: string, min: number, max: number, fallback: number): number {
622
+ const raw = params.get(name)
623
+ if (raw === null) return fallback
624
+ const n = /^\d{1,6}$/.test(raw) ? Number(raw) : NaN
625
+ if (!Number.isInteger(n) || n < min || n > max) throw fieldError(name, `"${name}" ${min} ile ${max} arasında bir tamsayı olmalı.`)
626
+ return n
627
+ }
628
+
629
+ // Ekip ve dönem panosunun hedef listesi: hazır liste bileşeninin `page` / `limit` parametrelerini okur, `_page` /
630
+ // `_size` okumaz. Bu yüzden sayfa betiğindeki loadAll'a BAĞLANMAZ (her istekte ilk sayfa döner).
631
+ async function goalList(input: RouteInput<Env>, includeSelf: boolean): Promise<Response> {
632
+ const { env, url } = input
633
+ const v = await viewer(input)
634
+ const owners = await visibleOwners(v, includeSelf)
635
+ const p = url.searchParams
636
+ const format = p.get('format')
637
+ if (format !== null && format !== 'xlsx' && format !== 'csv') throw fieldError('format', '"format" xlsx ya da csv olmalı.')
638
+ const lang = p.get('lang') === 'en' || p.get('_lang') === 'en' ? 'en' : 'tr'
639
+ const sort = p.get('sort') ?? 'team_name'
640
+ if (!SORTABLE.has(sort)) throw fieldError('sort', `"${sort}" alanına göre sıralanamaz.`)
641
+ const order = p.get('order') === 'desc' ? 'DESC' : 'ASC'
642
+ const page = intParam(p, 'page', 1, 100_000, 1)
643
+ const size = format ? EXPORT_MAX : intParam(p, 'limit', 1, LIST_MAX, 50)
644
+ // Dönem verilmezse en son başlayan dönem (dönem panosunun grafiğiyle aynı varsayılan).
645
+ const periodId = p.get('period_id') || (await findPeriod(env, {}))?.id || null
646
+ const where = ['g.deleted_at IS NULL', 'g.period_id = ?']
647
+ const params: string[] = [periodId ?? '']
648
+ let items: Record<string, unknown>[] = []
649
+ let total = 0
650
+ if (periodId && (owners === null || owners.length > 0)) {
651
+ if (owners !== null) {
652
+ where.push('g.owner_org_id IN (SELECT value FROM json_each(?))')
653
+ params.push(JSON.stringify(owners))
654
+ }
655
+ const sql = where.join(' AND ')
656
+ const [count, list] = await env.DB.batch<Record<string, unknown>>([
657
+ env.DB.prepare(`SELECT COUNT(*) AS n FROM "goals" g WHERE ${sql}`).bind(...params),
658
+ env.DB.prepare(
659
+ 'SELECT g.id, g.period_id, g.period_code, g.title, g.owner_org_id, g.owner_name, g.team_id, g.team_name, g.weight, g.progress, g.updated_at ' +
660
+ `FROM "goals" g WHERE ${sql} ORDER BY g."${sort}" ${order}, g.id ${order} LIMIT ? OFFSET ?`,
661
+ ).bind(...params, size, format ? 0 : (page - 1) * size),
662
+ ])
663
+ total = Number(count?.results[0]?.n ?? 0)
664
+ items = list?.results ?? []
665
+ }
666
+ if (format) {
667
+ if (total > EXPORT_MAX) throw new OneError(400, 'APP-E-400', `${total} hedef var; en çok ${EXPORT_MAX} hedef aktarılabilir. Süzgeci daraltın.`)
668
+ const fileName = `${lang === 'tr' ? 'Hedefler' : 'Goals'} ${new Date().toISOString().slice(0, 10)}`
669
+ if (format === 'csv') return exportResponse(toCsv(items, GOAL_COLUMNS, { lang }), { fileName, format })
670
+ return exportResponse(toXlsx(items, GOAL_COLUMNS, { lang, sheetName: lang === 'tr' ? 'Hedefler' : 'Goals' }), { fileName, format: 'xlsx' })
671
+ }
672
+ return json({ items, page, pageSize: size, total, nextCursor: page * size < total ? String(page + 1) : null })
673
+ }
674
+
675
+ async function board(input: RouteInput<Env>): Promise<Response> {
676
+ const v = await viewer(input)
677
+ const period = await findPeriod(input.env, { id: input.url.searchParams.get('period_id') })
678
+ if (!period) return json({ period: null, overall: 0, goalCount: 0, teams: [] })
679
+ const owners = await visibleOwners(v, true)
680
+ return json({ period, ...(await periodSummary(input.env, period.id, owners)) })
681
+ }
682
+
683
+ async function routes(input: RouteInput<Env>): Promise<Response | null> {
684
+ const { url, method, env, ctx, request } = input
685
+ const path = url.pathname
686
+ const read = method === 'GET' || method === 'HEAD'
687
+
688
+ // Dönemler
689
+ const periodClose = matchId(path, periods.path, 'close')
690
+ if (periodClose && method === 'POST') return periodStatus(input, periodClose, 'closed')
691
+ const periodReopen = matchId(path, periods.path, 'reopen')
692
+ if (periodReopen && method === 'POST') return periodStatus(input, periodReopen, 'open')
693
+ if (path === periods.path && method === 'POST') return periodWrite(input, null)
694
+ const periodId = matchId(path, periods.path)
695
+ if (periodId && (method === 'PATCH' || method === 'DELETE')) return periodWrite(input, periodId)
696
+ if ((path === periods.path || path.startsWith(`${periods.path}/`)) && read) return periods.handle(request, env.DB, ctx)
697
+
698
+ // Panolar
699
+ if (path === '/api/team/goals' && read) return goalList(input, false)
700
+ if (path === '/api/board/goals' && read) return goalList(input, true)
701
+ if (path === '/api/board' && read) return board(input)
702
+
703
+ // Hedefin alt kayıtları
704
+ for (const [segment, handler] of [['key-results', keyResults], ['check-ins', checkIns], ['actions', actions]] as const) {
705
+ const goalId = matchId(path, goals.path, segment)
706
+ if (!goalId) continue
707
+ if (read) return childList(input, goalId, handler)
708
+ if (method === 'POST' && segment === 'key-results') return createKeyResult(input, goalId)
709
+ if (method === 'POST' && segment === 'actions') return createAction(input, goalId)
710
+ throw new OneError(405, 'APP-E-405', 'Bu adreste bu yöntem kullanılamaz.')
711
+ }
712
+ const krCheckIn = matchId(path, '/api/key-results', 'check-ins')
713
+ if (krCheckIn && method === 'POST') return checkIn(input, krCheckIn)
714
+ const krId = matchId(path, '/api/key-results')
715
+ if (krId && (method === 'PATCH' || method === 'DELETE')) return writeKeyResult(input, krId)
716
+ const actionId = matchId(path, '/api/actions')
717
+ if (actionId && (method === 'PATCH' || method === 'DELETE')) return writeAction(input, actionId)
718
+
719
+ // Hedefler
720
+ if (path === goals.path && method === 'POST') return createGoal(input)
721
+ const goalId = matchId(path, goals.path)
722
+ if (goalId && read) return readGoal(input, goalId)
723
+ if (goalId && (method === 'PATCH' || method === 'DELETE')) return writeGoal(input, goalId)
724
+ if (goalId && method === 'PUT') throw new OneError(405, 'APP-E-405', 'Hedef PATCH ile güncellenir.')
725
+ // Liste, Excel ve yazdırma: yalnız sorumlusu BEN olan hedefler.
726
+ if ((path === goals.path || path.startsWith(`${goals.path}/`)) && read) return myGoals(input)
727
+ return null
728
+ }
729
+
730
+ export const definition: Omit<AppDefinition<Env>, 'guide'> = {
731
+ manifest,
732
+ tools: agentTools,
733
+ page: renderShell,
734
+ script: renderShellScript,
735
+ routes,
736
+ }
737
+
738
+ export function buildApp(guide: string) {
739
+ return createApp<Env>({ ...definition, guide })
740
+ }