create-devoplus-one-app 0.0.0-stage → 0.1.0-next.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/cli.mjs +17 -0
- package/package.json +35 -3
- package/templates/0001_init.sql.tpl +10 -0
- package/templates/AGENTS.md.tpl +286 -0
- package/templates/AGENT_GUIDE.md.tpl +36 -0
- package/templates/README.md.tpl +58 -0
- package/templates/_shared/AGENTS.writes.md.tpl +38 -0
- package/templates/_shared/README.tail.md.tpl +20 -0
- package/templates/_shared/files/src/app.test.ts.tpl +36 -0
- package/templates/_shared/files/src/index.ts.tpl +8 -0
- package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
- package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
- package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
- package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
- package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
- package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
- package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
- package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
- package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
- package/templates/agent.ts.tpl +23 -0
- package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
- package/templates/bakim-takvimi/README.md.tpl +45 -0
- package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
- package/templates/bakim-takvimi/files/one.data.json +45 -0
- package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
- package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
- package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
- package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
- package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
- package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
- package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
- package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
- package/templates/bakim-takvimi/manifest.json.tpl +64 -0
- package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
- package/templates/egitim-sertifika/README.md.tpl +46 -0
- package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
- package/templates/egitim-sertifika/files/one.data.json +43 -0
- package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
- package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
- package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
- package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
- package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
- package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
- package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
- package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
- package/templates/egitim-sertifika/manifest.json.tpl +57 -0
- package/templates/envanter/AGENTS.template.md.tpl +28 -0
- package/templates/envanter/README.md.tpl +40 -0
- package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
- package/templates/envanter/files/one.data.json +62 -0
- package/templates/envanter/files/src/agent.ts.tpl +20 -0
- package/templates/envanter/files/src/app.ts.tpl +204 -0
- package/templates/envanter/files/src/jobs.ts.tpl +30 -0
- package/templates/envanter/files/src/queries.ts.tpl +53 -0
- package/templates/envanter/files/src/shell.ts.tpl +265 -0
- package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
- package/templates/envanter/files/src/stock.ts.tpl +33 -0
- package/templates/envanter/manifest.json.tpl +56 -0
- package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
- package/templates/hedef-takibi/README.md.tpl +52 -0
- package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
- package/templates/hedef-takibi/files/one.data.json +76 -0
- package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
- package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
- package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
- package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
- package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
- package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
- package/templates/hedef-takibi/manifest.json.tpl +38 -0
- package/templates/index.ts.tpl +262 -0
- package/templates/markdown.d.ts.tpl +6 -0
- package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
- package/templates/musteri-ziyaret/README.md.tpl +48 -0
- package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
- package/templates/musteri-ziyaret/files/one.data.json +53 -0
- package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
- package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
- package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
- package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
- package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
- package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
- package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
- package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
- package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
- package/templates/saha-denetimi/README.md.tpl +44 -0
- package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
- package/templates/saha-denetimi/files/one.data.json +87 -0
- package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
- package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
- package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
- package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
- package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
- package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
- package/templates/saha-denetimi/manifest.json.tpl +49 -0
- package/templates/shell.test.ts.tpl +30 -0
- package/templates/shell.ts.tpl +201 -0
- package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
- package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
- package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
- package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
- package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
- package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
- package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
- package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
- package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
- package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
- package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
- package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
- package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
- package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
- package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
- package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
- package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
- package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
- package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
- package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
- package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
- package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
- package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
- package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entities": {
|
|
4
|
+
"visitors": {
|
|
5
|
+
"title": { "tr": "Ziyaretçiler", "en": "Visitors" },
|
|
6
|
+
"fields": {
|
|
7
|
+
"full_name": { "type": "text", "title": { "tr": "Ad soyad", "en": "Full name" }, "required": true, "maxLength": 120 },
|
|
8
|
+
"company": { "type": "text", "title": { "tr": "Firma", "en": "Company" }, "maxLength": 120 },
|
|
9
|
+
"purpose": { "type": "text", "title": { "tr": "Ziyaret amacı", "en": "Purpose of visit" }, "required": true, "maxLength": 200 },
|
|
10
|
+
"host_org_id": { "type": "text", "title": { "tr": "Ziyaret edilen (kimlik)", "en": "Host (id)" }, "maxLength": 24, "index": true },
|
|
11
|
+
"host_name": { "type": "text", "title": { "tr": "Ziyaret edilen", "en": "Host" }, "maxLength": 120 },
|
|
12
|
+
"planned_at": { "type": "datetime", "title": { "tr": "Planlanan zaman", "en": "Planned time" }, "required": true, "index": true },
|
|
13
|
+
"visit_day": { "type": "text", "title": { "tr": "Ziyaret günü", "en": "Visit day" }, "required": true, "maxLength": 10, "index": true },
|
|
14
|
+
"status": {
|
|
15
|
+
"type": "choice",
|
|
16
|
+
"title": { "tr": "Durum", "en": "Status" },
|
|
17
|
+
"required": true,
|
|
18
|
+
"default": "expected",
|
|
19
|
+
"index": true,
|
|
20
|
+
"choices": [
|
|
21
|
+
{ "value": "expected", "title": { "tr": "Bekleniyor", "en": "Expected" } },
|
|
22
|
+
{ "value": "inside", "title": { "tr": "İçeride", "en": "Inside" } },
|
|
23
|
+
{ "value": "left", "title": { "tr": "Ayrıldı", "en": "Left" } },
|
|
24
|
+
{ "value": "cancelled", "title": { "tr": "İptal edildi", "en": "Cancelled" } }
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"checked_in_at": { "type": "datetime", "title": { "tr": "Giriş zamanı", "en": "Check-in time" } },
|
|
28
|
+
"checked_out_at": { "type": "datetime", "title": { "tr": "Çıkış zamanı", "en": "Check-out time" } },
|
|
29
|
+
"registered_by_name": { "type": "text", "title": { "tr": "Kaydeden", "en": "Registered by" }, "maxLength": 120 },
|
|
30
|
+
"anonymized_at": { "type": "datetime", "title": { "tr": "Kişisel bilgiler silindi", "en": "Personal data deleted" } }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"retention_runs": {
|
|
34
|
+
"title": { "tr": "Kişisel veri temizlikleri", "en": "Personal data clean-ups" },
|
|
35
|
+
"fields": {
|
|
36
|
+
"run_key": { "type": "text", "title": { "tr": "Temizlik günü", "en": "Clean-up day" }, "required": true, "unique": true, "maxLength": 20 },
|
|
37
|
+
"cutoff": { "type": "datetime", "title": { "tr": "Bu tarihten eski", "en": "Older than" }, "required": true },
|
|
38
|
+
"cleared_count": { "type": "number", "title": { "tr": "Silinen kayıt", "en": "Records cleared" }, "required": true, "default": 0, "min": 0 }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Devoplus yapay zekâsının bu uygulamada çağırabileceği araçların İŞLEYİCİLERİ. Adı, açıklaması, girdi şeması,
|
|
2
|
+
// salt-okunurluğu ve izni `one.manifest.json` → `agent.tools`'tadır; rehber `AGENT_GUIDE.md`'dir. Yeni araç = üçü birlikte.
|
|
3
|
+
// ⛔ Araçlar ziyaretçinin KİŞİSEL BİLGİSİNİ (ad soyad, firma, ziyaret amacı) döndürmez: yalnız sayılar. Ziyaretçi kaydeden,
|
|
4
|
+
// giriş ya da çıkış yapan araç bilinçli olarak yoktur.
|
|
5
|
+
import { defineAgentTools } from '@devoplus/one-sdk'
|
|
6
|
+
import type { Env } from './app.ts'
|
|
7
|
+
import { dayCounts } from './queries.ts'
|
|
8
|
+
|
|
9
|
+
export const agentTools = defineAgentTools<Env>({
|
|
10
|
+
'count-visitors-today': {
|
|
11
|
+
handler: async ({ env }) => dayCounts(env),
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// {{nameTr}} — ziyaretçi kaydı. Uçlar (hepsi oturum ister; kimlik platformdan gelir):
|
|
2
|
+
// GET /api/mine/visitors[?…] ziyaretçilerim: YALNIZ beni ziyaret edenler (süzgeç sunucuda zorlanır) + Excel
|
|
3
|
+
// GET /api/mine/visitors/{id} beni ziyaret eden tek kayıt (başkasınınki 404)
|
|
4
|
+
// GET /api/today/visitors[?…] bugünkü ziyaretçiler (resepsiyon) — gün sunucuda zorlanır + günün Excel'i
|
|
5
|
+
// GET /api/visitors[?…] · /api/visitors/{id} bütün kayıtlar (resepsiyon) + Excel
|
|
6
|
+
// POST /api/visitors · PATCH /api/visitors/{id} ziyaretçi kaydı (resepsiyon); durum ve giriş/çıkış zamanı istemciden ALINMAZ
|
|
7
|
+
// POST /api/visitors/{id}/check-in giriş: zamanı SUNUCU yazar, ziyaret edilen çalışana bildirim gider
|
|
8
|
+
// POST /api/visitors/{id}/check-out çıkış: zamanı SUNUCU yazar
|
|
9
|
+
// POST /api/visitors/{id}/cancel gelmeyecek ziyaretin iptali
|
|
10
|
+
// GET /api/summary bugünün sayıları (kişisel bilgi yok) + son kişisel veri temizliği
|
|
11
|
+
// Ziyaret silinmez (405): kişisel bilgiler saklama süresi dolunca gece işiyle silinir (`jobs.ts`, `RETENTION_DAYS`).
|
|
12
|
+
// Telefon numarası ve kimlik numarası bilinçli olarak TUTULMAZ (KVKK: amaçla sınırlı veri).
|
|
13
|
+
import {
|
|
14
|
+
OneError,
|
|
15
|
+
createCrudHandler,
|
|
16
|
+
getMyOrgContext,
|
|
17
|
+
logAudit,
|
|
18
|
+
logError,
|
|
19
|
+
requirePermission,
|
|
20
|
+
requireUser,
|
|
21
|
+
systemNotice,
|
|
22
|
+
} from '@devoplus/one-sdk'
|
|
23
|
+
import manifest from '../one.manifest.json' with { type: 'json' }
|
|
24
|
+
import { agentTools } from './agent.ts'
|
|
25
|
+
import { dataModel } from './data/types.ts'
|
|
26
|
+
import { jobs } from './jobs.ts'
|
|
27
|
+
import { fieldError, forward, matchId, nowIso, onlyFields, personValue, readJsonObject, withQuery } from './lib/http.ts'
|
|
28
|
+
import { refuseActingSession } from './lib/record.ts'
|
|
29
|
+
import { createApp, json, type AppDefinition, type BaseEnv, type RouteInput } from './lib/server.ts'
|
|
30
|
+
import { notifyHost } from './notice.ts'
|
|
31
|
+
import { dayCounts, lastRetentionRun } from './queries.ts'
|
|
32
|
+
import { renderShell, renderShellScript } from './shell.ts'
|
|
33
|
+
import { RETENTION_DAYS, TRANSITIONS, checkPlannedAt, istanbulDay, transitionConflict, type VisitAction } from './visitor.ts'
|
|
34
|
+
|
|
35
|
+
export type Env = BaseEnv
|
|
36
|
+
|
|
37
|
+
export const APP_NAME = { tr: {{nameTrJs}}, en: {{nameEnJs}} }
|
|
38
|
+
/** Resepsiyon izni (manifest `permissions`): ziyaretçi kaydeder, giriş ve çıkış yapar, bütün kayıtları görür. */
|
|
39
|
+
export const MANAGE_PERMISSION = '{{permissionPrefix}}Manage'
|
|
40
|
+
|
|
41
|
+
/** Bütün kayıtlar: yalnız resepsiyon okur ve yazar. */
|
|
42
|
+
export const visitors = createCrudHandler({ model: dataModel, entity: 'visitors', records: 'shared', canRead: MANAGE_PERMISSION, canWrite: MANAGE_PERMISSION })
|
|
43
|
+
/** Bugünkü ziyaretçiler: yalnız resepsiyon; gün süzgeci sunucuda ZORLANIR. */
|
|
44
|
+
export const todayVisitors = createCrudHandler({ model: dataModel, entity: 'visitors', records: 'shared', basePath: '/api/today', canRead: MANAGE_PERMISSION, canWrite: MANAGE_PERMISSION })
|
|
45
|
+
/** Ziyaretçilerim: herkes; "ziyaret edilen = ben" süzgeci sunucuda ZORLANIR ve yazma yoktur. */
|
|
46
|
+
export const myVisitors = createCrudHandler({ model: dataModel, entity: 'visitors', records: 'shared', basePath: '/api/mine' })
|
|
47
|
+
|
|
48
|
+
/** Resepsiyonun yazabildiği alanlar; durum, giriş/çıkış zamanı, ziyaret günü ve kaydeden SUNUCUDAN gelir. */
|
|
49
|
+
const EDITABLE = ['full_name', 'company', 'purpose', 'host', 'planned_at']
|
|
50
|
+
const LIST_RESTS = new Set(['', '/export.xlsx', '/export.csv', '/report.html'])
|
|
51
|
+
|
|
52
|
+
async function myName(input: RouteInput<Env>): Promise<string | null> {
|
|
53
|
+
try {
|
|
54
|
+
return (await getMyOrgContext(input.api()))?.fullName ?? null
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logError(input.ctx, 'kişi adı okunamadı', error)
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ziyaret edilen kişi zorunludur (gelişi ona bildirilir); kişi seçicinin değeri iki kolona ayrılır. */
|
|
62
|
+
function hostColumns(raw: unknown): { host_org_id: string; host_name: string | null } {
|
|
63
|
+
const person = personValue(raw, 'host')
|
|
64
|
+
if (!person) throw fieldError('host', 'Ziyaret edilen çalışanı seçin.')
|
|
65
|
+
return { host_org_id: person.id, host_name: person.name || null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function createVisitor(input: RouteInput<Env>): Promise<Response> {
|
|
69
|
+
const { request, env, ctx } = input
|
|
70
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
71
|
+
const { host, planned_at, ...rest } = onlyFields(await readJsonObject(request), EDITABLE)
|
|
72
|
+
const planned = checkPlannedAt(planned_at, new Date())
|
|
73
|
+
if (!planned.ok) throw fieldError('planned_at', planned.message)
|
|
74
|
+
const record = {
|
|
75
|
+
...rest,
|
|
76
|
+
...hostColumns(host),
|
|
77
|
+
planned_at: planned.iso,
|
|
78
|
+
visit_day: planned.day,
|
|
79
|
+
status: 'expected',
|
|
80
|
+
registered_by_name: await myName(input),
|
|
81
|
+
}
|
|
82
|
+
const res = (await visitors.handle(forward(request, visitors.path, 'POST', record), env.DB, ctx))!
|
|
83
|
+
if (res.status === 201) {
|
|
84
|
+
const saved = (await res.clone().json()) as { id: string }
|
|
85
|
+
logAudit(ctx, 'visitor.register', saved.id)
|
|
86
|
+
}
|
|
87
|
+
return res
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface VisitorState {
|
|
91
|
+
status: string
|
|
92
|
+
anonymized_at: string | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function findState(env: Env, id: string): Promise<VisitorState | null> {
|
|
96
|
+
return env.DB.prepare('SELECT status, anonymized_at FROM "visitors" WHERE id = ? AND deleted_at IS NULL').bind(id).first<VisitorState>()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ANONYMIZED = 'Bu ziyaretin kişisel bilgileri saklama süresi dolduğu için silindi; kayıt değiştirilemez.'
|
|
100
|
+
|
|
101
|
+
async function updateVisitor(input: RouteInput<Env>, id: string): Promise<Response> {
|
|
102
|
+
const { request, env, ctx } = input
|
|
103
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
104
|
+
refuseActingSession(ctx)
|
|
105
|
+
const body = onlyFields(await readJsonObject(request), EDITABLE)
|
|
106
|
+
const current = await findState(env, id)
|
|
107
|
+
if (!current) throw new OneError(404, 'APP-E-404', 'Ziyaret bulunamadı.')
|
|
108
|
+
if (current.anonymized_at) throw new OneError(409, 'APP-E-022', ANONYMIZED)
|
|
109
|
+
if (current.status !== 'expected') throw new OneError(409, 'APP-E-021', 'Yalnız henüz gelmemiş ziyaretin bilgileri düzenlenebilir.')
|
|
110
|
+
const columns: Record<string, string | null> = {}
|
|
111
|
+
for (const name of VISITOR_TEXT_FIELDS) if (name in body) columns[name] = visitorText(body[name], name)
|
|
112
|
+
if ('host' in body) Object.assign(columns, hostColumns(body.host))
|
|
113
|
+
if ('planned_at' in body) {
|
|
114
|
+
const planned = checkPlannedAt(body.planned_at, new Date())
|
|
115
|
+
if (!planned.ok) throw fieldError('planned_at', planned.message)
|
|
116
|
+
columns.planned_at = planned.iso
|
|
117
|
+
columns.visit_day = planned.day
|
|
118
|
+
}
|
|
119
|
+
const names = Object.keys(columns)
|
|
120
|
+
if (names.length === 0) throw fieldError('', 'Güncellenecek alan gönderilmedi.')
|
|
121
|
+
// ⛔ Tek koşullu güncelleme: yalnız ziyaret hâlâ BEKLENİYORSA ve kişisel bilgileri silinmemişse yazılır. Durum yukarıda
|
|
122
|
+
// okunduktan sonra araya giriş, iptal ya da gece temizliği girerse düzenleme 409 alır; aksi hâlde temizlenmiş bir
|
|
123
|
+
// ziyaretin adı ve firması geri yazılabilirdi. Değişen metin alanının arama kolonu (`<alan>_fold`) NULL yapılır:
|
|
124
|
+
// arama boş kolonda metni kendisi katlar.
|
|
125
|
+
const sets = names.flatMap((n) => (n === 'planned_at' ? [`${n} = ?`] : [`${n} = ?`, `${n}_fold = NULL`]))
|
|
126
|
+
const user = requireUser(ctx)
|
|
127
|
+
const res = await env.DB.prepare(
|
|
128
|
+
`UPDATE "visitors" SET ${sets.join(', ')}, updated_at = ?, updated_by = ? WHERE id = ? AND status = 'expected' AND anonymized_at IS NULL AND deleted_at IS NULL`,
|
|
129
|
+
)
|
|
130
|
+
.bind(...names.map((n) => columns[n] ?? null), nowIso(), user.sub, id)
|
|
131
|
+
.run()
|
|
132
|
+
if (Number(res.meta.changes ?? 0) !== 1) {
|
|
133
|
+
const now = await findState(env, id)
|
|
134
|
+
if (!now) throw new OneError(404, 'APP-E-404', 'Ziyaret bulunamadı.')
|
|
135
|
+
if (now.anonymized_at) throw new OneError(409, 'APP-E-022', ANONYMIZED)
|
|
136
|
+
throw new OneError(409, 'APP-E-021', 'Yalnız henüz gelmemiş ziyaretin bilgileri düzenlenebilir.')
|
|
137
|
+
}
|
|
138
|
+
logAudit(ctx, 'visitor.update', id)
|
|
139
|
+
// Yanıt, kaydın veri katmanındaki gösterimidir (liste ve ayrıntı ekranıyla aynı biçim).
|
|
140
|
+
return (await visitors.handle(forward(request, `${visitors.path}/${id}`, 'GET'), env.DB, ctx))!
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resepsiyonun düzenleyebildiği metin alanları; kuralları (zorunluluk, karakter sınırı) veri modelinden okunur. */
|
|
144
|
+
const VISITOR_TEXT_FIELDS = ['full_name', 'company', 'purpose'] as const
|
|
145
|
+
const SINGLE_LINE_BAD = /[\u0000-\u001f\u007f]/
|
|
146
|
+
|
|
147
|
+
/** Veri katmanının metin kuralıyla aynı: boş = değer yok, tek satır, denetim karakteri yok, karakter sınırı. */
|
|
148
|
+
function visitorText(raw: unknown, name: (typeof VISITOR_TEXT_FIELDS)[number]): string | null {
|
|
149
|
+
const field = dataModel.entities.visitors!.fields[name]!
|
|
150
|
+
const label = `"${field.title.tr}"`
|
|
151
|
+
if (raw === null || raw === undefined || (typeof raw === 'string' && raw.trim() === '')) {
|
|
152
|
+
if (field.required) throw fieldError(name, `${label} zorunlu.`)
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
if (typeof raw !== 'string' || SINGLE_LINE_BAD.test(raw)) throw fieldError(name, `${label} tek satır olmalı ve denetim karakteri içermemeli.`)
|
|
156
|
+
if (field.maxLength !== undefined && [...raw].length > field.maxLength) throw fieldError(name, `${label} en çok ${field.maxLength} karakter olabilir.`)
|
|
157
|
+
return raw
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Eylem uçları gövde ALMAZ: zaman ve durum sunucudandır; gövdede gelen her alan (ör. `checked_in_at`) 400 ile reddedilir. */
|
|
161
|
+
async function rejectBodyFields(request: Request): Promise<void> {
|
|
162
|
+
if (!request.headers.has('content-type') && request.body === null) return
|
|
163
|
+
onlyFields(await readJsonObject(request), [])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function transition(input: RouteInput<Env>, id: string, action: VisitAction): Promise<Response> {
|
|
167
|
+
const { request, env, ctx } = input
|
|
168
|
+
const user = requirePermission(ctx, MANAGE_PERMISSION)
|
|
169
|
+
refuseActingSession(ctx)
|
|
170
|
+
await rejectBodyFields(request)
|
|
171
|
+
const { from, to } = TRANSITIONS[action]
|
|
172
|
+
const now = nowIso()
|
|
173
|
+
const set =
|
|
174
|
+
action === 'check-in'
|
|
175
|
+
? { sql: 'status = ?, checked_in_at = ?, visit_day = ?, visit_day_fold = NULL', params: [to, now, istanbulDay(new Date(now))] }
|
|
176
|
+
: action === 'check-out'
|
|
177
|
+
? { sql: 'status = ?, checked_out_at = ?', params: [to, now] }
|
|
178
|
+
: { sql: 'status = ?', params: [to] }
|
|
179
|
+
// ⛔ Geçiş tek koşullu güncellemedir: yalnız kayıt BEKLENEN durumdaysa uygulanır. İki resepsiyon görevlisi aynı anda
|
|
180
|
+
// "Giriş" derse yalnız biri yazar, öteki 409 alır; giriş zamanı ilk yazıldığı gibi kalır.
|
|
181
|
+
const res = await env.DB.prepare(
|
|
182
|
+
`UPDATE "visitors" SET ${set.sql}, updated_at = ?, updated_by = ? WHERE id = ? AND status = ? AND anonymized_at IS NULL AND deleted_at IS NULL`,
|
|
183
|
+
)
|
|
184
|
+
.bind(...set.params, now, user.sub, id, from)
|
|
185
|
+
.run()
|
|
186
|
+
if (Number(res.meta.changes ?? 0) !== 1) {
|
|
187
|
+
const current = await findState(env, id)
|
|
188
|
+
if (!current) throw new OneError(404, 'APP-E-404', 'Ziyaret bulunamadı.')
|
|
189
|
+
if (current.anonymized_at) throw new OneError(409, 'APP-E-022', ANONYMIZED)
|
|
190
|
+
throw new OneError(409, 'APP-E-021', transitionConflict(action, current.status))
|
|
191
|
+
}
|
|
192
|
+
logAudit(ctx, `visitor.${action}`, id)
|
|
193
|
+
if (action !== 'check-in') return json({ id, status: to, ...(action === 'check-out' ? { checked_out_at: now } : {}) })
|
|
194
|
+
|
|
195
|
+
const row = await env.DB.prepare('SELECT full_name, company, host_org_id FROM "visitors" WHERE id = ?')
|
|
196
|
+
.bind(id)
|
|
197
|
+
.first<{ full_name: string; company: string | null; host_org_id: string | null }>()
|
|
198
|
+
const who = row ? (row.company ? `${row.full_name} (${row.company})` : row.full_name) : 'Ziyaretçiniz'
|
|
199
|
+
const notification = await notifyHost(
|
|
200
|
+
ctx,
|
|
201
|
+
input.api(),
|
|
202
|
+
systemNotice({ appName: APP_NAME.tr, title: `Ziyaretçiniz geldi: ${who}`, message: `${who} resepsiyonda giriş yaptı ve sizi bekliyor.` }),
|
|
203
|
+
row?.host_org_id ?? null,
|
|
204
|
+
)
|
|
205
|
+
return json({ id, status: to, checked_in_at: now, notification })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function myOrgId(input: RouteInput<Env>): Promise<string> {
|
|
209
|
+
const me = await getMyOrgContext(input.api())
|
|
210
|
+
if (!me) throw new OneError(409, 'APP-E-010', 'Portalda kişi kaydınız bulunamadı; ziyaretçileriniz gösterilemiyor.')
|
|
211
|
+
return me.orgUserId.toLowerCase()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Ziyaretçilerim: liste ve dışa aktarma; istemcinin gönderdiği "ziyaret edilen" süzgeci YOK SAYILIR. */
|
|
215
|
+
async function mine(input: RouteInput<Env>): Promise<Response | null> {
|
|
216
|
+
const { request, env, ctx, url, method } = input
|
|
217
|
+
requireUser(ctx)
|
|
218
|
+
if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Ziyaretçilerim yalnız görüntülenir.')
|
|
219
|
+
const rest = url.pathname.slice(myVisitors.path.length)
|
|
220
|
+
const id = matchId(url.pathname, myVisitors.path)
|
|
221
|
+
if (id) {
|
|
222
|
+
// Tek kayıt: yalnız ziyaret edilen kişi okur; başkasının ziyaretçisi "yok" yanıtı alır (kimlik yoklanamaz).
|
|
223
|
+
const me = await myOrgId(input)
|
|
224
|
+
const own = await env.DB.prepare('SELECT 1 AS ok FROM "visitors" WHERE id = ? AND host_org_id = ? AND deleted_at IS NULL').bind(id, me).first<{ ok: number }>()
|
|
225
|
+
if (!own) throw new OneError(404, 'APP-E-404', 'Ziyaret bulunamadı.')
|
|
226
|
+
return myVisitors.handle(request, env.DB, ctx)
|
|
227
|
+
}
|
|
228
|
+
if (!LIST_RESTS.has(rest)) throw new OneError(404, 'APP-E-404', 'Bulunamadı.')
|
|
229
|
+
const me = await myOrgId(input)
|
|
230
|
+
const scoped = withQuery(request, url.pathname, (params) => {
|
|
231
|
+
params.delete('host_org_id')
|
|
232
|
+
params.set('host_org_id', me)
|
|
233
|
+
})
|
|
234
|
+
return myVisitors.handle(scoped, env.DB, ctx)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Bugünkü ziyaretçiler: gün sunucuda İstanbul saatiyle hesaplanır; istemcinin gün süzgeci YOK SAYILIR. */
|
|
238
|
+
async function today(input: RouteInput<Env>): Promise<Response | null> {
|
|
239
|
+
const { request, env, ctx, url, method } = input
|
|
240
|
+
requirePermission(ctx, MANAGE_PERMISSION)
|
|
241
|
+
if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bugünkü liste yalnız görüntülenir; işlemler ziyaret kaydının uçlarındadır.')
|
|
242
|
+
const rest = url.pathname.slice(todayVisitors.path.length)
|
|
243
|
+
if (!LIST_RESTS.has(rest)) throw new OneError(404, 'APP-E-404', 'Bulunamadı.')
|
|
244
|
+
const day = istanbulDay(new Date())
|
|
245
|
+
const scoped = withQuery(request, url.pathname, (params) => {
|
|
246
|
+
params.delete('visit_day')
|
|
247
|
+
params.set('visit_day', day)
|
|
248
|
+
})
|
|
249
|
+
return todayVisitors.handle(scoped, env.DB, ctx)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function summary({ env, ctx }: RouteInput<Env>): Promise<Response> {
|
|
253
|
+
requireUser(ctx)
|
|
254
|
+
const [counts, retention] = await Promise.all([dayCounts(env), lastRetentionRun(env)])
|
|
255
|
+
return json({ counts, retentionDays: RETENTION_DAYS, lastRetention: retention })
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ACTIONS: readonly VisitAction[] = ['check-in', 'check-out', 'cancel']
|
|
259
|
+
|
|
260
|
+
async function routes(input: RouteInput<Env>): Promise<Response | null> {
|
|
261
|
+
const { url, method, env, ctx } = input
|
|
262
|
+
const path = url.pathname
|
|
263
|
+
if (path === '/api/summary' && method === 'GET') return summary(input)
|
|
264
|
+
if (path === myVisitors.path || path.startsWith(`${myVisitors.path}/`)) return mine(input)
|
|
265
|
+
if (path === todayVisitors.path || path.startsWith(`${todayVisitors.path}/`)) return today(input)
|
|
266
|
+
for (const action of ACTIONS) {
|
|
267
|
+
const id = matchId(path, visitors.path, action)
|
|
268
|
+
if (id && method === 'POST') return transition(input, id, action)
|
|
269
|
+
}
|
|
270
|
+
if (path === visitors.path && method === 'POST') return createVisitor(input)
|
|
271
|
+
const id = matchId(path, visitors.path)
|
|
272
|
+
if (id && method === 'PATCH') return updateVisitor(input, id)
|
|
273
|
+
if (id && (method === 'PUT' || method === 'DELETE'))
|
|
274
|
+
throw new OneError(405, 'APP-E-405', 'Ziyaret silinmez; gelmeyecekse iptal edin. Kişisel bilgiler saklama süresi dolunca kendiliğinden silinir.')
|
|
275
|
+
if (path === visitors.path || path.startsWith(`${visitors.path}/`)) {
|
|
276
|
+
if (method !== 'GET' && method !== 'HEAD') throw new OneError(405, 'APP-E-405', 'Bu işlem yapılamaz.')
|
|
277
|
+
return visitors.handle(input.request, env.DB, ctx)
|
|
278
|
+
}
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export const definition: Omit<AppDefinition<Env>, 'guide'> = {
|
|
283
|
+
manifest,
|
|
284
|
+
tools: agentTools,
|
|
285
|
+
jobs,
|
|
286
|
+
page: renderShell,
|
|
287
|
+
script: renderShellScript,
|
|
288
|
+
routes,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function buildApp(guide: string) {
|
|
292
|
+
return createApp<Env>({ ...definition, guide })
|
|
293
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Zamanlanmış görevler (manifest `jobs`). Zamanlanmış iş KULLANICISIZDIR: Devoplus servislerini kullanmaz, yalnız
|
|
2
|
+
// uygulamanın kendi veritabanını değiştirir.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ KVKK: her gece 02:30'da (İstanbul) son olayı `RETENTION_DAYS` günden eski ziyaretlerin kişisel bilgileri silinir —
|
|
5
|
+
// ad soyad, firma ve ziyaret amacı ile bu alanların arama kolonları (`<alan>_fold`). Arama kolonu da silinir: yalnız
|
|
6
|
+
// asıl alanı boşaltmak kişinin adını arama kolonunda bırakırdı. Kayıt satırı kalır (zaman, durum, ziyaret edilen
|
|
7
|
+
// çalışan): ziyaret sayıları bozulmaz, ama kişi artık tanınamaz. Silinmiş (yumuşak silme) satırlar da kapsanır.
|
|
8
|
+
// Aynı iş birden çok kez gelebilir: güncelleme yalnız henüz silinmemiş satırlara uygulanır, kayıt gün anahtarıyla tekildir.
|
|
9
|
+
import { defineJobs } from '@devoplus/one-sdk'
|
|
10
|
+
import type { Env } from './app.ts'
|
|
11
|
+
import { istanbulDay, retentionCutoff } from './visitor.ts'
|
|
12
|
+
|
|
13
|
+
/** Sistemin yazdığı kayıtlarda `updated_by` / `created_by` değeri (kişi kimliği değildir). */
|
|
14
|
+
export const SYSTEM_ACTOR = 'system:visitor-data-retention'
|
|
15
|
+
|
|
16
|
+
/** Kişisel bilgileri silinen alanlar (arama kolonlarıyla birlikte). Yeni bir kişisel alan eklerseniz buraya da ekleyin. */
|
|
17
|
+
export const PERSONAL_FIELDS_SQL =
|
|
18
|
+
"full_name = '', full_name_fold = NULL, company = NULL, company_fold = NULL, purpose = '', purpose_fold = NULL"
|
|
19
|
+
|
|
20
|
+
export async function clearExpiredPersonalData(env: Env, now: Date): Promise<{ cutoff: string; cleared: number }> {
|
|
21
|
+
const cutoff = retentionCutoff(now)
|
|
22
|
+
const stamp = now.toISOString()
|
|
23
|
+
const res = await env.DB.prepare(
|
|
24
|
+
// Saklama süresini aşmış AÇIK ziyaret (çıkışı unutulmuş "içeride" ya da hiç gelmemiş "bekleniyor") aynı güncellemede
|
|
25
|
+
// kapanış durumuna geçer: anonim satır uygulamadan düzeltilemediği için açık kalsaydı "şu an içeride" sayısını kalıcı
|
|
26
|
+
// olarak şişirirdi. Çıkış zamanı bilinmediği için yazılmaz.
|
|
27
|
+
`UPDATE "visitors" SET ${PERSONAL_FIELDS_SQL}, status = CASE status WHEN 'inside' THEN 'left' WHEN 'expected' THEN 'cancelled' ELSE status END, ` +
|
|
28
|
+
'anonymized_at = ?, updated_at = ?, updated_by = ? ' +
|
|
29
|
+
'WHERE anonymized_at IS NULL AND coalesce(checked_out_at, checked_in_at, planned_at) < ?',
|
|
30
|
+
)
|
|
31
|
+
.bind(stamp, stamp, SYSTEM_ACTOR, cutoff)
|
|
32
|
+
.run()
|
|
33
|
+
return { cutoff, cleared: Number(res.meta.changes ?? 0) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const jobs = defineJobs<Env>({
|
|
37
|
+
schedules: {
|
|
38
|
+
'visitor-data-retention': async (job, env) => {
|
|
39
|
+
const at = job.scheduledTime ? new Date(job.scheduledTime) : new Date()
|
|
40
|
+
const { cutoff, cleared } = await clearExpiredPersonalData(env, at)
|
|
41
|
+
const now = at.toISOString()
|
|
42
|
+
await env.DB.prepare(
|
|
43
|
+
'INSERT OR IGNORE INTO "retention_runs" (id, created_at, created_by, updated_at, updated_by, run_key, cutoff, cleared_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
44
|
+
)
|
|
45
|
+
.bind(crypto.randomUUID(), now, SYSTEM_ACTOR, now, SYSTEM_ACTOR, istanbulDay(at), cutoff, cleared)
|
|
46
|
+
.run()
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Ziyaretçi geldi bildirimi: ziyaret edilen çalışana e-posta (ortak `sendMailSafely`) ve Devoplus Portal mobil
|
|
2
|
+
// uygulamasına bildirim. Bildirim asıl işin sonucudur, parçası değildir: giriş kaydı yazıldıktan SONRA gönderilir ve
|
|
3
|
+
// düşerse giriş geri alınmaz — sonuç kanal başına döner, arayüz kullanıcıya söyler.
|
|
4
|
+
// Mobil bildirim (`push.execute`) e-posta gibi kullanıcının Portal'daki ayrı ve süreli onayına bağlıdır. Ayrıca platformda
|
|
5
|
+
// her ortamda açık olmayabilir: kapalıysa sunucu reddeder (`ONE-APP-E-029`) ve sonuç `unavailable` olur.
|
|
6
|
+
import { awaitsUserConsent, logError, notify, type BackendClient, type OneContext, type Recipients, type ScenarioMessage } from '@devoplus/one-sdk'
|
|
7
|
+
import { sendMailSafely, type MailOutcome } from './lib/mail.ts'
|
|
8
|
+
|
|
9
|
+
export type PushOutcome = 'sent' | 'consent-needed' | 'unavailable' | 'failed' | 'skipped'
|
|
10
|
+
/** Platformun "mobil bildirim bu ortamda/portalda kapalı" kodu. */
|
|
11
|
+
export const PUSH_DISABLED_CODE = 'ONE-APP-E-029'
|
|
12
|
+
|
|
13
|
+
export async function sendPushSafely(ctx: OneContext, api: BackendClient, message: ScenarioMessage, recipients: Recipients): Promise<PushOutcome> {
|
|
14
|
+
if ((recipients.toSubjects?.length ?? 0) + (recipients.toOrgUserIds?.length ?? 0) === 0) return 'skipped'
|
|
15
|
+
if (awaitsUserConsent(ctx, 'push.execute')) return 'consent-needed'
|
|
16
|
+
try {
|
|
17
|
+
const result = await notify(api, message, { ...recipients, channels: ['push'] })
|
|
18
|
+
const push = result.push
|
|
19
|
+
if (push && 'error' in push) {
|
|
20
|
+
if (push.error.code === PUSH_DISABLED_CODE) return 'unavailable'
|
|
21
|
+
logError(ctx, 'mobil bildirim gönderilemedi', push.error)
|
|
22
|
+
return 'failed'
|
|
23
|
+
}
|
|
24
|
+
// Sunucu isteği kabul edip HİÇBİR alıcıyı kuyruğa almadıysa (pasif kişi, adres yok, sınır…) ileti kimseye gitmedi:
|
|
25
|
+
// ekranda "ev sahibine bildirildi" denmez.
|
|
26
|
+
if (!push || push.queued === 0) {
|
|
27
|
+
logError(ctx, 'mobil bildirim hiçbir alıcıya ulaşmadı', push?.skipped ?? [])
|
|
28
|
+
return 'failed'
|
|
29
|
+
}
|
|
30
|
+
return 'sent'
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logError(ctx, 'mobil bildirim gönderilemedi', error)
|
|
33
|
+
return 'failed'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HostNotice {
|
|
38
|
+
mail: MailOutcome
|
|
39
|
+
push: PushOutcome
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** İki kanal birbirinden bağımsızdır: biri düşerse öteki yine denenir. */
|
|
43
|
+
export async function notifyHost(ctx: OneContext, api: BackendClient, message: ScenarioMessage, hostOrgId: string | null): Promise<HostNotice> {
|
|
44
|
+
const to: Recipients = hostOrgId ? { toOrgUserIds: [hostOrgId] } : {}
|
|
45
|
+
const mail = await sendMailSafely(ctx, api, message, to)
|
|
46
|
+
const push = await sendPushSafely(ctx, api, message, to)
|
|
47
|
+
return { mail, push }
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Veritabanı okumaları — ekran, zamanlanmış iş ve Devoplus yapay zekâsı araçları aynı sorguları kullanır. Bu dosyadaki
|
|
2
|
+
// sayımlar KİŞİSEL BİLGİ döndürmez (ad, firma, amaç yok): yapay zekâ aracı da bunları kullanır.
|
|
3
|
+
import type { BaseEnv } from './lib/server.ts'
|
|
4
|
+
import { istanbulDay } from './visitor.ts'
|
|
5
|
+
|
|
6
|
+
export interface DayCounts {
|
|
7
|
+
day: string
|
|
8
|
+
/** Bugün için planlanmış, henüz gelmemiş. */
|
|
9
|
+
expected: number
|
|
10
|
+
/** Bugün giriş yapmış (içeride ya da ayrılmış). */
|
|
11
|
+
checkedIn: number
|
|
12
|
+
/** Bugün giriş yapıp ayrılmış. */
|
|
13
|
+
left: number
|
|
14
|
+
/** Şu an içeride (önceki günlerden çıkışı yapılmamış olanlar dahil). */
|
|
15
|
+
insideNow: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function dayCounts(env: BaseEnv, now = new Date()): Promise<DayCounts> {
|
|
19
|
+
const day = istanbulDay(now)
|
|
20
|
+
const row = await env.DB.prepare(
|
|
21
|
+
'SELECT ' +
|
|
22
|
+
"SUM(CASE WHEN status = 'expected' AND visit_day = ? THEN 1 ELSE 0 END) AS expected, " +
|
|
23
|
+
"SUM(CASE WHEN status IN ('inside', 'left') AND visit_day = ? THEN 1 ELSE 0 END) AS checked_in, " +
|
|
24
|
+
"SUM(CASE WHEN status = 'left' AND visit_day = ? THEN 1 ELSE 0 END) AS left_count, " +
|
|
25
|
+
"SUM(CASE WHEN status = 'inside' THEN 1 ELSE 0 END) AS inside_now " +
|
|
26
|
+
'FROM "visitors" WHERE deleted_at IS NULL',
|
|
27
|
+
)
|
|
28
|
+
.bind(day, day, day)
|
|
29
|
+
.first<{ expected: number | null; checked_in: number | null; left_count: number | null; inside_now: number | null }>()
|
|
30
|
+
return {
|
|
31
|
+
day,
|
|
32
|
+
expected: row?.expected ?? 0,
|
|
33
|
+
checkedIn: row?.checked_in ?? 0,
|
|
34
|
+
left: row?.left_count ?? 0,
|
|
35
|
+
insideNow: row?.inside_now ?? 0,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function lastRetentionRun(env: BaseEnv) {
|
|
40
|
+
return env.DB.prepare('SELECT run_key, cutoff, cleared_count FROM "retention_runs" WHERE deleted_at IS NULL ORDER BY run_key DESC LIMIT 1').first<{
|
|
41
|
+
run_key: string
|
|
42
|
+
cutoff: string
|
|
43
|
+
cleared_count: number
|
|
44
|
+
}>()
|
|
45
|
+
}
|