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,36 @@
|
|
|
1
|
+
# {{nameTr}} — Devoplus yapay zekâsı için rehber
|
|
2
|
+
|
|
3
|
+
Bu dosyayı **Devoplus yapay zekâsı** okur: kullanıcı Devoplus'ta bir soru sorduğunda bu uygulamanın araçlarını ne zaman
|
|
4
|
+
ve nasıl kullanacağını buradan öğrenir (uygulama adresinde `/.well-known/one-agent.md`). Bir aracı eklerken,
|
|
5
|
+
değiştirirken ya da kaldırırken bu dosyayı AYNI değişiklikte güncelleyin; yayın kapısı dört başlığı ve her aracın adını
|
|
6
|
+
arar. Teknik olmayan bir okur için değil, yapay zekâ için yazılır: kısa, kesin, örnekli.
|
|
7
|
+
|
|
8
|
+
## Uygulama ne yapar
|
|
9
|
+
|
|
10
|
+
{{nameTr}}, kullanıcının kişisel notlarını tutar. Kullanıcı "notlarım", "son yazdığım not" gibi şeyler sorduğunda bu
|
|
11
|
+
uygulamaya başvurun. (Uygulamanız büyüdükçe bu bölümü güncelleyin: hangi iş sorularının bu uygulamaya ait olduğunu ve
|
|
12
|
+
hangilerinin AİT OLMADIĞINI yazın.)
|
|
13
|
+
|
|
14
|
+
## Veri
|
|
15
|
+
|
|
16
|
+
- **Not** (`notes` tablosu): `id`, `text` (en çok 500 karakter), `created_at` (UTC). Her not bir kullanıcıya aittir.
|
|
17
|
+
- Her kullanıcı yalnız KENDİ notlarını görür; başka bir kişinin notu hiçbir araçla okunamaz.
|
|
18
|
+
- Veri yalnız bu portalın kurulumundadır.
|
|
19
|
+
|
|
20
|
+
## Araçlar
|
|
21
|
+
|
|
22
|
+
### `list-notes` — notları listele (salt okunur)
|
|
23
|
+
|
|
24
|
+
- **Ne zaman:** kullanıcı kendi notlarını ya da son notunu sorduğunda.
|
|
25
|
+
- **Girdi:** `limit` — 1-50 arası tam sayı, verilmezse 10. Başka alan göndermeyin; reddedilir.
|
|
26
|
+
- **Çıktı:** `{ "notes": [{ "id": 3, "text": "…", "createdAt": "2026-10-18 09:12:00" }, …] }` — en yenisi önce. Boş
|
|
27
|
+
liste "notunuz yok" demektir.
|
|
28
|
+
- **İzin:** uygulamayı kullanma izni. Hiçbir şeyi değiştirmez; kullanıcıdan onay istemeye gerek yoktur.
|
|
29
|
+
|
|
30
|
+
Not EKLEMEK ya da SİLMEK için araç yoktur — bu işler uygulama ekranından yapılır.
|
|
31
|
+
|
|
32
|
+
## Örnek istekler
|
|
33
|
+
|
|
34
|
+
- "Son üç notumu göster." → `list-notes` (`limit: 3`)
|
|
35
|
+
- "Hiç notum var mı?" → `list-notes` (`limit: 1`) → liste boşsa "Henüz notunuz yok."
|
|
36
|
+
- "Bir not ekle." → araç yok; uygulamayı Devoplus Portal'da açmasını söyleyin.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# {{nameTr}} — Devoplus One uygulaması
|
|
2
|
+
|
|
3
|
+
`create-devoplus-one-app` ile üretilmiş başlangıç noktası. Devoplus Portal içinde iframe ile çalışır; kimlik,
|
|
4
|
+
kiracı ve servisler `@devoplus/one-sdk`'dan gelir. Uygulamayı geliştirecek AI aracı için kurallar **`AGENTS.md`**'dedir.
|
|
5
|
+
|
|
6
|
+
## İçerik
|
|
7
|
+
|
|
8
|
+
| Dosya | Amaç |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `one.manifest.json` | Uygulamanın platforma beyanı: slug, ad, sürüm, scope'lar, servisler, menü, izin kodu (`{{permissionCode}}`), sınırlar |
|
|
11
|
+
| `src/index.ts` | Giriş noktası: kabuk + `/api/me` + veritabanında notlar{{#files}} + dosyalar{{/files}}{{#cache}} + önbellek{{/cache}} |
|
|
12
|
+
| `src/shell.ts` | Tek sayfalık kabuk: TR/EN metinler, açık/koyu tema, Portal köprüsü (`one:ready` / `one:token` / `one:context` / `one:resize`) |
|
|
13
|
+
| `src/shell.test.ts` | Kabuk testleri (dil anahtarı eşitliği, köprü sözleşmesi) |
|
|
14
|
+
| `migrations/0001_init.sql` | Veritabanı şeması; şema değişikliği yeni numaralı dosyayla gelir |
|
|
15
|
+
| `AGENTS.md` | Uygulamayı yazacak AI için SDK sözleşmesi ve yasaklar |
|
|
16
|
+
| `src/agent.ts` | Devoplus yapay zekâsının çağırabileceği araçların işleyicileri (beyan: manifest `agent.tools`) |
|
|
17
|
+
| `AGENT_GUIDE.md` | Devoplus yapay zekâsının okuduğu kullanım rehberi (`/.well-known/one-agent.md`); araç beyanı varken ZORUNLU |
|
|
18
|
+
|
|
19
|
+
## Geliştirme
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm install
|
|
23
|
+
pnpm type-check && pnpm test && pnpm build # build = one build: yükleme provası, hiçbir yere göndermez
|
|
24
|
+
pnpm migrate # one migrate — migrations/ altındaki SQL dosyalarını yerel veritabanına uygular (ilk çalıştırmada ve her yeni migration'da)
|
|
25
|
+
pnpm dev # one dev — uygulamayı yerelde çalıştırır; Portal başlıklarını (X-One-*) elle göndermeniz gerekir
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> `one` komutu `@devoplus/one-sdk` ile gelir. Yerel yapılandırma, yerel veri ve paket çıktısı `.one/` altındadır
|
|
29
|
+
> (depoya girmez); her komutta `one.manifest.json`'dan yeniden üretilir. Migration uygulanmadan `notes` tablosu
|
|
30
|
+
> yoktur ve `/api/notes` hata döner. Gerçek yüklemede migration'ları platform sürümle birlikte uygular.
|
|
31
|
+
|
|
32
|
+
> `@devoplus/one-sdk` npm'den kurulur. Kararlı sürüm çıkana kadar bağımlılık `^0.2.0-next.0` aralığıyla `next` ön
|
|
33
|
+
> sürümlerini de kapsar; kararlı sürüme geçerken aralığı değiştirmeniz gerekmez (`0.2.x` aynı aralıktadır).
|
|
34
|
+
|
|
35
|
+
## Kayıt → kurulum → yayın
|
|
36
|
+
|
|
37
|
+
1. **Kayıt:** Devoplus Console › *Devoplus One Uygulamaları* ekranında uygulama kaydı açılır (slug `{{slug}}`, ad, scope üst
|
|
38
|
+
sınırı, izin kodu `{{permissionCode}}`). Kayıt kimlik sağlayıcıda uygulamanın client kaydını açar.
|
|
39
|
+
2. **Kurulum:** aynı ekrandan uygulama bir portala kurulur (portal anahtarı). Kurulum = uygulama × portal; adres
|
|
40
|
+
`{{slug}}-<portalAnahtarı>.<ortam tabanı>` olur.
|
|
41
|
+
3. **Yayın:** kod Devoplus GitLab'ına push edilir. Hat **platformundur** (proje ayarı "CI/CD configuration file" platform
|
|
42
|
+
projesindeki `one-app.gitlab-ci.yml`'yi gösterir); bu depoya `.gitlab-ci.yml` YAZILMAZ — yazılsa da okunmaz. Her MR ve
|
|
43
|
+
`test`/`uat`/`main` itişinde `prepare` koşar: `one-ci validate` (manifest · README/AGENTS · sır taraması) · bağımlılık
|
|
44
|
+
taraması · `pnpm install` · `type-check` · `test`. Dağıtım dal = ortamdır ve elle başlatılır (`deploy:<ortam>` →
|
|
45
|
+
"Run job", `ONE_PORTAL_KEY` + `ONE_PORTAL_ID`); client kimliği `one-{{slug}}`'dur. Geliştirici makinesinden doğrudan
|
|
46
|
+
yükleme yoktur. `pnpm-lock.yaml` depoya girer (hat `--frozen-lockfile` kurar). AI ile yapılan değişiklikler
|
|
47
|
+
`ai/<konu>` dalında ve `AI-Assisted:` trailer'ıyla gelir; one-ci son yayından bu yana gelen AI commit'lerini ve
|
|
48
|
+
dallarını sürüm defterine yazar (`one-ci releases`).
|
|
49
|
+
4. Portal menüsünde uygulama, `{{permissionCode}}` izni atanmış kullanıcılara görünür.
|
|
50
|
+
|
|
51
|
+
## Yeni yetenek eklerken
|
|
52
|
+
|
|
53
|
+
- Backend verisi gerekiyorsa önce `one.manifest.json` → `scopes` (katalogdaki ad), sonra kodda `requireScope`.
|
|
54
|
+
- Dosya → `services.files: true` + `FILES` + SDK `OneFiles`; önbellek → `services.cache: true` + `CACHE` + SDK `OneCache`.
|
|
55
|
+
- Şema → `migrations/0002_*.sql` (var olan dosyaya dokunulmaz).
|
|
56
|
+
- Kullanıcıya görünen metin iki dile birden (`STRINGS`); Türkçe, resmî "siz".
|
|
57
|
+
|
|
58
|
+
Sahip: {{ownerEmail}}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
### Yazım kuralı: denetlenen her ön koşul yazım ifadesindedir (bütün şablonlar)
|
|
2
|
+
|
|
3
|
+
Bir uç yazmadan önce bir şeyi denetliyorsa (kaydın durumu, kişinin sorumluluğu ya da sahipliği, üst kaydın silinmemiş
|
|
4
|
+
olması, okunup yazıma taşınan bir değer, alanlar arası bir kural) o denetim yazımla AYNI SQL ifadesinin koşulunda da
|
|
5
|
+
durur. Önce okuyup sonra yazmak iki ayrı adımdır: araya giren bir istek (kapatma, silme, başkasına verme, aynı kaydın
|
|
6
|
+
başka bir sekmeden düzenlenmesi) koşulu bozar ve yazım yine olur.
|
|
7
|
+
|
|
8
|
+
- Yazımı `src/lib/record.ts` ile yapın: `recordWrite` değerleri veri modelinin kuralıyla doğrular; `insertWhen` /
|
|
9
|
+
`updateWhen` / `removeWhen` koşulu (`when`) zorunlu ister. Etkilenen satır yoksa koşul tutmamıştır: nedeni yeniden
|
|
10
|
+
okuyup söyleyin (404 / 409), sessizce geçmeyin.
|
|
11
|
+
- Birden çok satır birlikte değişiyorsa tek toplu yazım (`env.DB.batch`) kullanın ve sonraki yazımları ilkinin gerçekten
|
|
12
|
+
olduğuna bağlayın (`EXISTS …`).
|
|
13
|
+
- Alanlar arası kural (ör. "bitiş başlangıçtan önce olamaz") yazımdan SONRAKİ satıra uygulanır: gövdede olmayan alan
|
|
14
|
+
satırın o anki değeridir.
|
|
15
|
+
- Zamanlanmış işin damgası (ör. "hatırlatıldı") satırı seçen koşulun tamamını yeniden ister.
|
|
16
|
+
- Bir koşulu bilerek yazıma koymuyorsanız nedenini kodun yanına yazın (ör. "sonuç, iki isteğin bir sırayla yapılmasıyla
|
|
17
|
+
aynıdır").
|
|
18
|
+
|
|
19
|
+
### Liste kuralı: sabit sınır SESSİZCE kesmez (bütün şablonlar)
|
|
20
|
+
|
|
21
|
+
- Görünürlük (kimin kaydını görürüm), sayım (toplam, ertelenen, ekip büyüklüğü) ve dışa aktarma kesik bir listeden
|
|
22
|
+
hesaplanmaz: ekip ya da kişi listesine üst sınır koymayın; sayıyı ayrı bir `COUNT` ile alın.
|
|
23
|
+
- Yalnız gösterimi kısaltan bir sınır (ilk N kayıt, e-postaya sığan kalemler) kesildiğini SÖYLER: bir fazla satır okuyup
|
|
24
|
+
`truncated: true` döndürün ya da "ve N kayıt daha" yazın. Dışa aktarma sınırı aşınca eksik dosya üretmek yerine reddedin.
|
|
25
|
+
- **Sayfa betiğinde de aynı kural:** açılır listenin seçenekleri, süzgeç seçenekleri ve ekrandaki sayımlar TEK SAYFALIK bir
|
|
26
|
+
istekten kurulmaz. Hazır liste uçları sayfa başına en çok 200 kayıt verir ve fazlasını hata vermeden sonraki sayfaya
|
|
27
|
+
bırakır; `?_size=200` ile kurulan seçici 201. kaydı sessizce kaybeder, 200'den büyük bir `_size` ise 400 alır ve seçici
|
|
28
|
+
hiç kurulmaz. Bunun için `loadAll('/api/<varlık>?<süzgeç>')` kullanın: bütün sayfaları toplar; en çok 25 sayfa
|
|
29
|
+
(5.000 kayıt) okur, bir sayfa yeni kayıt getirmezse de durur ve bu durumlarda listenin eksik olduğunu ekranda söyler.
|
|
30
|
+
`loadAll` başarısız olursa (`ok: false`) hatayı gösterin; boş liste gibi okumayın. `loadAll` yalnız `_size` / `_page`
|
|
31
|
+
okuyan hazır liste uçlarıyla çalışır; `page` / `limit` okuyan elle yazılmış bir uca bağlamayın (her istekte ilk sayfa
|
|
32
|
+
döner, liste eksik kalır).
|
|
33
|
+
Yalnız sayfalı gösterim (`<one-table paging="page">`) ve sunucunun tam döndürdüğü yanıtlar (ör. sunucuda hesaplanan
|
|
34
|
+
bir sayı) tek sayfayla okunur; bu durumda gerekçeyi isteğin yanına yazın.
|
|
35
|
+
- `loadAll` **en iyi çabadır**: sayfalar ayrı isteklerdir ve sıra numarasıyla okunur. İki sayfa arasında kayıt eklenir,
|
|
36
|
+
silinir ya da sıralama alanı değişirse aynı kayıt iki sayfada gelebilir (kimliği aynı olan ikinci kopya atılır) ya da
|
|
37
|
+
hiçbir sayfada gelmeyebilir. Atlamayı bu yardımcı kapatamaz; kaydın kesinlikle listede olması gereken bir işte sayfa
|
|
38
|
+
betiğine güvenmeyin, kararı sunucuda verin.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
## Geliştirici notları
|
|
3
|
+
|
|
4
|
+
Bu bölüm uygulamayı yazan kişi ya da yapay zekâ aracı içindir; kurallar **`AGENTS.md`**'dedir.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm install
|
|
8
|
+
pnpm type-check && pnpm test && pnpm build # build = one build: yükleme provası, hiçbir yere göndermez
|
|
9
|
+
pnpm migrate # one migrate — migrations/ altındaki dosyaları yerel veritabanına uygular
|
|
10
|
+
pnpm dev # one dev — yerelde çalıştırır; Portal başlıklarını (X-One-*) elle göndermeniz gerekir
|
|
11
|
+
pnpm data:generate # one.data.json değişince: yeni migration + src/data/types.ts
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- Veri modeli `one.data.json`'dadır; `migrations/` ve `src/data/types.ts` ondan **üretilir**, elle yazılmaz.
|
|
15
|
+
- Kayıt: Devoplus Console › *Devoplus One Uygulamaları* (kısa ad `{{slug}}`, izin kodu `{{permissionCode}}`). Kurulum aynı
|
|
16
|
+
ekrandan portala yapılır. Yayın Devoplus GitLab hattıyla olur; geliştirici makinesinden doğrudan yükleme yoktur.
|
|
17
|
+
- `@devoplus/one-sdk` npm'den kurulur; kararlı sürüm çıkana kadar bağımlılık aralığı (`^0.2.0-next.0`) `next` ön
|
|
18
|
+
sürümlerini de kapsar.
|
|
19
|
+
|
|
20
|
+
Sahip: {{ownerEmail}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import manifest from '../one.manifest.json' with { type: 'json' }
|
|
3
|
+
import { buildApp, definition } from './app.ts'
|
|
4
|
+
|
|
5
|
+
// Üç yerin birbirini tutması: manifest beyanı ↔ koddaki işleyiciler ↔ sayfadaki görünümler. Biri değişip öteki
|
|
6
|
+
// unutulursa Devoplus yapay zekâsı aracı bulamaz (404), platformun işi işleyici bulamaz ya da sekme boş açılır.
|
|
7
|
+
const execution = { waitUntil: () => {} }
|
|
8
|
+
// Platformun her isteğe yazdığı bağlam (sayfa için oturum gerekmez).
|
|
9
|
+
const headers = { 'x-one-app': manifest.app.slug, 'x-one-portal-key': 'deneme', 'x-one-portal': 'a'.repeat(24), 'x-one-auth': 'anonymous' }
|
|
10
|
+
|
|
11
|
+
describe('uygulama ↔ manifest', () => {
|
|
12
|
+
it('Devoplus yapay zekâsı araçları: manifest beyanı ile işleyiciler birebir', () => {
|
|
13
|
+
expect(Object.keys(definition.tools).sort()).toEqual((manifest.agent?.tools ?? []).map((t) => t.name).sort())
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('zamanlanmış görev ve kuyruk işleyicileri manifest beyanıyla birebir', () => {
|
|
17
|
+
const declared = (manifest as { jobs?: { schedules?: { name: string }[]; queues?: { name: string }[] } }).jobs
|
|
18
|
+
expect(Object.keys(definition.jobs?.schedules ?? {}).sort()).toEqual((declared?.schedules ?? []).map((s) => s.name).sort())
|
|
19
|
+
expect(Object.keys(definition.jobs?.queues ?? {}).sort()).toEqual((declared?.queues ?? []).map((q) => q.name).sort())
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('menüdeki her yol sayfayı döndürür ve o görünüm açık çizilir', async () => {
|
|
23
|
+
const app = buildApp('# rehber')
|
|
24
|
+
for (const item of manifest.navigation ?? []) {
|
|
25
|
+
const res = await app.fetch(new Request(`https://uygulama.test${item.path}`, { headers }), {} as never, execution)
|
|
26
|
+
expect(res.status, item.path).toBe(200)
|
|
27
|
+
const html = await res.text()
|
|
28
|
+
expect(html, item.path).toContain(`<section data-view="${item.path}">`)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('tanımsız yol 404 (ayrıntı sızdırmadan)', async () => {
|
|
33
|
+
const res = await buildApp('# rehber').fetch(new Request('https://uygulama.test/yok', { headers }), {} as never, execution)
|
|
34
|
+
expect(res.status).toBe(404)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/// <reference path="./markdown.d.ts" />
|
|
2
|
+
// {{nameTr}} — Devoplus One uygulaması ({{templateName}} şablonu, create-devoplus-one-app). Giriş noktası İNCEDİR: istekleri
|
|
3
|
+
// `src/app.ts` karşılar. Burada yalnız Devoplus yapay zekâsının okuduğu rehber (`AGENT_GUIDE.md`) derlemede metin olarak
|
|
4
|
+
// gömülür — testler `src/app.ts`'i rehbersiz içe aktarabilsin diye ayrı dosyadadır.
|
|
5
|
+
import guide from '../AGENT_GUIDE.md' with { type: 'text' }
|
|
6
|
+
import { buildApp } from './app.ts'
|
|
7
|
+
|
|
8
|
+
export default buildApp(guide)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Sayfa betiğinin ortak çalışma zamanı (şablonların ORTAK parçası — create-devoplus-one-app üretir). `/shell.js` bu ön ek ve
|
|
2
|
+
// şablonun kendi gövdesinden oluşur; bundler yoktur, betik ES5 biçiminde yazılır ve sunucuda tek dosya olarak sunulur.
|
|
3
|
+
// Ön ekin verdiği yardımcılar (gövde bunları kullanır):
|
|
4
|
+
// t(anahtar, değişkenler?) STRINGS'ten dile göre metin ("{ad}" yer tutucuları doldurulur)
|
|
5
|
+
// L({ tr, en }) iki dilli değerden geçerli dilin metni
|
|
6
|
+
// $(kimlik) · el(etiket, nitelikler, çocuklar) DOM — metin her zaman textContent ile yazılır, innerHTML YOK
|
|
7
|
+
// api(yöntem, yol, gövde?) one.fetch ile JSON isteği → Promise<{ ok, status, body }> (belirteci köprü ekler)
|
|
8
|
+
// problem(yanıt) sunucunun RFC 9457 `detail` metni ya da genel hata metni
|
|
9
|
+
// loadAll(yol) hazır liste ucunun BÜTÜN sayfaları → Promise<{ ok, status, body: { items, total, truncated } }>
|
|
10
|
+
// (seçenek kümesi, süzgeç, sayım tek sayfadan kurulmaz — aşağıdaki not)
|
|
11
|
+
// go(yol) · onView(fn) görünüm değiştirme (Portal sekmesi de aynı yolu kullanır) ve görünüm açılınca çağrılan işlev
|
|
12
|
+
// onReady(fn) ilk belirteç geldiğinde (istek atmaya hazır) bir kez
|
|
13
|
+
// onLang(fn) dil değişince (dinamik metinleri yeniden çizmek için)
|
|
14
|
+
// canWrite() Vekalet / Yerine Görüntüle oturumunda yazma kapalıysa false
|
|
15
|
+
// can(izinKodu) · onSession(fn) oturumdaki kişinin uygulama izni (yalnız düğme göstermek için; asıl kapı sunucuda)
|
|
16
|
+
// İzinli sekmeler (manifest `navigation[].permission`) oturum okununca köprüye bildirilir (`one.setNavigationAccess`):
|
|
17
|
+
// izni olmayan kişi o sekmeyi menüde görmez. Adresi doğrudan açan kişi görünümün "yetkiniz yok" notunu görür.
|
|
18
|
+
// fmtDate · fmtDateTime · fmtNumber gg.aa.yyyy, gg.aa.yyyy SS:DD, tr-TR sayı biçimi
|
|
19
|
+
// Portal ile HER konuşma köprüden geçer; `postMessage` doğrudan yazılmaz.
|
|
20
|
+
import type { OneNavigationItem } from '@devoplus/one-sdk'
|
|
21
|
+
import type { Strings } from './page.ts'
|
|
22
|
+
|
|
23
|
+
export interface ClientScriptOptions {
|
|
24
|
+
strings: Strings
|
|
25
|
+
navigation: readonly OneNavigationItem[]
|
|
26
|
+
/** Manifest `actingSessions: "write"` ise true (Vekalet / Yerine Görüntüle oturumunda da yazılır). */
|
|
27
|
+
actingWrites?: boolean
|
|
28
|
+
/** Sayfaya özgü betik (ön ekin yardımcılarını kullanır). */
|
|
29
|
+
body: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ön ekin kendi metinleri. Anahtarlar `core` ile başlar ve sayfanın STRINGS'iyle KESİŞMEZ (şablon testi denetler):
|
|
34
|
+
* `t()` önce sayfanın metnine baktığı için aynı adlı bir sayfa metni ortak uyarının yerine geçerdi.
|
|
35
|
+
*/
|
|
36
|
+
export const CORE_STRINGS = {
|
|
37
|
+
tr: {
|
|
38
|
+
coreListTruncated: 'Liste eksik yüklendi',
|
|
39
|
+
coreListTruncatedDetail: 'Yalnız ilk {n} kayıt yüklendi; kalan kayıtlar bu seçimde görünmez.',
|
|
40
|
+
},
|
|
41
|
+
en: {
|
|
42
|
+
coreListTruncated: 'The list was loaded partially',
|
|
43
|
+
coreListTruncatedDetail: 'Only the first {n} records were loaded; the remaining records are not available in this selection.',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** JSON bir betik dosyasına gömülüyor; "</" dizisi kaçırılır. */
|
|
48
|
+
function embed(value: unknown): string {
|
|
49
|
+
return JSON.stringify(value).replace(/<\//g, '<\\/')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderClientScript(options: ClientScriptOptions): string {
|
|
53
|
+
return `(function () {
|
|
54
|
+
'use strict';
|
|
55
|
+
var one = window.one;
|
|
56
|
+
var S = ${embed(options.strings)};
|
|
57
|
+
var CORE = ${embed(CORE_STRINGS)};
|
|
58
|
+
var NAVIGATION = ${embed(options.navigation)};
|
|
59
|
+
// manifest actingSessions: "write" ise Vekalet / Yerine Görüntüle oturumunda da yazılır → salt-okunur uyarısı gösterilmez.
|
|
60
|
+
var ACTING_WRITES = ${embed(options.actingWrites === true)};
|
|
61
|
+
var VIEWS = NAVIGATION.map(function (n) { return n.path; });
|
|
62
|
+
var root = document.documentElement;
|
|
63
|
+
var lang = root.lang === 'en' ? 'en' : 'tr';
|
|
64
|
+
// Durum satırı bir metin ANAHTARI taşır; dil değişince o anahtar yeni dile çevrilir.
|
|
65
|
+
var statusKey = 'waiting', hadToken = false, currentView = null;
|
|
66
|
+
var langHooks = [], viewHooks = [], readyHooks = [], sessionHooks = [], SESSION = null;
|
|
67
|
+
function $(id) { return document.getElementById(id); }
|
|
68
|
+
function t(key, vars) {
|
|
69
|
+
var s = (S[lang] && S[lang][key]) || S.tr[key] || CORE[lang][key] || CORE.tr[key] || key;
|
|
70
|
+
if (vars) for (var n in vars) if (Object.prototype.hasOwnProperty.call(vars, n)) s = s.split('{' + n + '}').join(String(vars[n]));
|
|
71
|
+
return s;
|
|
72
|
+
}
|
|
73
|
+
function L(v) { return v == null ? '' : typeof v === 'string' ? v : (v[lang] || v.tr || ''); }
|
|
74
|
+
function showStatus(key) { statusKey = key; var s = $('status'); if (s) s.textContent = key ? t(key) : ''; }
|
|
75
|
+
function translate() {
|
|
76
|
+
var nodes = document.querySelectorAll('[data-i18n]');
|
|
77
|
+
for (var i = 0; i < nodes.length; i++) nodes[i].textContent = t(nodes[i].getAttribute('data-i18n'));
|
|
78
|
+
var phs = document.querySelectorAll('[data-i18n-ph]');
|
|
79
|
+
for (var j = 0; j < phs.length; j++) phs[j].setAttribute('placeholder', t(phs[j].getAttribute('data-i18n-ph')));
|
|
80
|
+
var labels = document.querySelectorAll('[data-i18n-label]');
|
|
81
|
+
for (var k = 0; k < labels.length; k++) labels[k].setAttribute('aria-label', t(labels[k].getAttribute('data-i18n-label')));
|
|
82
|
+
showStatus(statusKey);
|
|
83
|
+
}
|
|
84
|
+
function onLang(fn) { langHooks.push(fn); }
|
|
85
|
+
function onView(fn) { viewHooks.push(fn); }
|
|
86
|
+
function onReady(fn) { readyHooks.push(fn); if (hadToken) fn(); }
|
|
87
|
+
function showView(p) {
|
|
88
|
+
var target = VIEWS.indexOf(p) >= 0 ? p : (VIEWS[0] || '/');
|
|
89
|
+
var views = document.querySelectorAll('[data-view]');
|
|
90
|
+
for (var i = 0; i < views.length; i++) {
|
|
91
|
+
if (views[i].getAttribute('data-view') === target) views[i].removeAttribute('hidden'); else views[i].setAttribute('hidden', '');
|
|
92
|
+
}
|
|
93
|
+
currentView = target;
|
|
94
|
+
one.setTitle(null);
|
|
95
|
+
for (var h = 0; h < viewHooks.length; h++) viewHooks[h](target);
|
|
96
|
+
}
|
|
97
|
+
function go(p) { one.setRoute(p); showView(p); }
|
|
98
|
+
function canWrite() { return !(one.actor && !ACTING_WRITES); }
|
|
99
|
+
function can(code) { return !!SESSION && (SESSION.admin === true || (SESSION.permissions || []).indexOf(code) >= 0); }
|
|
100
|
+
function onSession(fn) { sessionHooks.push(fn); if (SESSION) fn(SESSION); }
|
|
101
|
+
function el(tag, attrs, children) {
|
|
102
|
+
var node = document.createElement(tag);
|
|
103
|
+
for (var k in (attrs || {})) {
|
|
104
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, k) || attrs[k] == null || attrs[k] === false) continue;
|
|
105
|
+
if (k === 'text') node.textContent = String(attrs[k]);
|
|
106
|
+
else node.setAttribute(k, attrs[k] === true ? '' : String(attrs[k]));
|
|
107
|
+
}
|
|
108
|
+
(children || []).forEach(function (c) { if (c) node.appendChild(c); });
|
|
109
|
+
return node;
|
|
110
|
+
}
|
|
111
|
+
function api(method, path, body) {
|
|
112
|
+
var init = { method: method, headers: { accept: 'application/json' } };
|
|
113
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
114
|
+
return one.fetch(path, init).then(function (r) {
|
|
115
|
+
return r.text().then(function (text) {
|
|
116
|
+
var data = null;
|
|
117
|
+
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
118
|
+
if (statusKey === 'networkError') showStatus(hadToken ? '' : 'waiting');
|
|
119
|
+
return { ok: r.ok, status: r.status, body: data };
|
|
120
|
+
});
|
|
121
|
+
}, function () { showStatus('networkError'); return { ok: false, status: 0, body: null }; });
|
|
122
|
+
}
|
|
123
|
+
function problem(r) {
|
|
124
|
+
if (r && r.body && typeof r.body === 'object' && typeof r.body.detail === 'string' && r.body.detail) return r.body.detail;
|
|
125
|
+
return t(r && r.status === 0 ? 'networkError' : 'requestFailed');
|
|
126
|
+
}
|
|
127
|
+
// ⛔ Seçenek kümesi (açılır liste, süzgeç, sayım) TEK SAYFADAN KURULMAZ: hazır liste uçları sayfa başına en çok
|
|
128
|
+
// LOAD_PAGE kayıt verir ve fazlasını hata VERMEDEN sonraki sayfaya bırakır — tek istekle kurulan liste 201. kaydı
|
|
129
|
+
// sessizce kaybederdi. loadAll bütün sayfaları "nextCursor" ile toplar. İmleç olsa da üç durumda durur, kesildiğini
|
|
130
|
+
// ekranda AÇIKÇA söyler ve "truncated: true" döner: LOAD_ALL_MAX kayda ulaşınca, LOAD_MAX_PAGES sayfa okuyunca (istek
|
|
131
|
+
// sınırı) ve bir sayfa YENİ kayıt getirmeyince (boş ya da tamamı yinelenen sayfa — "_page"i okumayan bir uç her
|
|
132
|
+
// istekte aynı sayfayı döndürür). Bir sayfa düşerse ya da yanıt liste değilse yarım liste DÖNMEZ: "ok: false" döner
|
|
133
|
+
// (çağıran hatayı gösterir). Yalnız "_size" / "_page" okuyan hazır liste uçları içindir ("nextCursor" orada sayfa
|
|
134
|
+
// numarasıdır); "page" / "limit" okuyan elle yazılmış bir uç ona bağlanmaz. Yol "_size" / "_page" taşımaz.
|
|
135
|
+
// ⚠️ EN İYİ ÇABA: sayfalar ayrı isteklerdir ve uç sıra numarasıyla (OFFSET) sayfalar. İki sayfa arasında kayıt
|
|
136
|
+
// eklenir, silinir ya da sıralama alanı değişirse bir kayıt iki sayfada gelebilir — "id"si aynı olan ikinci kopya
|
|
137
|
+
// atılır — ya da HİÇBİR sayfada gelmeyebilir. Atlamayı bu yardımcı kapatamaz; imleç tabanlı bir uç gerekir.
|
|
138
|
+
var LOAD_PAGE = 200, LOAD_ALL_MAX = 5000, LOAD_MAX_PAGES = Math.ceil(LOAD_ALL_MAX / LOAD_PAGE);
|
|
139
|
+
function loadAll(path) {
|
|
140
|
+
var items = [], seen = {}, sep = path.indexOf('?') >= 0 ? '&' : '?';
|
|
141
|
+
function page(n, done) {
|
|
142
|
+
return api('GET', path + sep + '_size=' + LOAD_PAGE + '&_page=' + encodeURIComponent(n)).then(function (r) {
|
|
143
|
+
if (!r.ok) return r;
|
|
144
|
+
if (!r.body || typeof r.body !== 'object' || !Array.isArray(r.body.items)) return { ok: false, status: r.status, body: r.body };
|
|
145
|
+
var added = 0;
|
|
146
|
+
for (var i = 0; i < r.body.items.length; i++) {
|
|
147
|
+
var row = r.body.items[i], id = row && row.id != null ? String(row.id) : null;
|
|
148
|
+
if (id !== null) { if (Object.prototype.hasOwnProperty.call(seen, id)) continue; seen[id] = true; }
|
|
149
|
+
items.push(row);
|
|
150
|
+
added++;
|
|
151
|
+
}
|
|
152
|
+
var next = r.body.nextCursor;
|
|
153
|
+
// İki koruma birlikte: yeni kayıt getirmeyen sayfa tekrarlayan sayfayı, sayfa tavanı ilerleyen ama bitmeyen
|
|
154
|
+
// imleci keser. İkisi olmadan istek döngüsü bitmezdi.
|
|
155
|
+
if (next && added > 0 && items.length < LOAD_ALL_MAX && done < LOAD_MAX_PAGES) return page(next, done + 1);
|
|
156
|
+
var truncated = !!next;
|
|
157
|
+
if (truncated) one.toast({ severity: 'warn', summary: t('coreListTruncated'), detail: t('coreListTruncatedDetail', { n: fmtNumber(items.length) }) });
|
|
158
|
+
return { ok: true, status: r.status, body: { items: items, total: r.body.total, truncated: truncated } };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return page(1, 1);
|
|
162
|
+
}
|
|
163
|
+
function toastResult(r, okKey) {
|
|
164
|
+
if (r.ok) one.toast({ severity: 'success', summary: t(okKey) });
|
|
165
|
+
else one.toast({ severity: 'error', summary: t('requestFailed'), detail: problem(r) });
|
|
166
|
+
return r.ok;
|
|
167
|
+
}
|
|
168
|
+
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
|
169
|
+
function fmtDate(v) {
|
|
170
|
+
if (!v) return '';
|
|
171
|
+
var m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(String(v));
|
|
172
|
+
if (m) return m[3] + '.' + m[2] + '.' + m[1];
|
|
173
|
+
var d = new Date(String(v));
|
|
174
|
+
return isNaN(d.getTime()) ? String(v) : pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear();
|
|
175
|
+
}
|
|
176
|
+
function fmtTime(v) { var d = new Date(String(v)); return isNaN(d.getTime()) ? '' : pad(d.getHours()) + ':' + pad(d.getMinutes()); }
|
|
177
|
+
function fmtDateTime(v) { return v ? fmtDate(v) + ' ' + fmtTime(v) : ''; }
|
|
178
|
+
function fmtNumber(n) { return n == null || n === '' ? '' : new Intl.NumberFormat(lang === 'en' ? 'en-US' : 'tr-TR').format(Number(n)); }
|
|
179
|
+
|
|
180
|
+
// ── Sayfaya özgü betik ─────────────────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
${options.body}
|
|
182
|
+
// ── Ortak bağlama ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
one.onToken(function () {
|
|
185
|
+
var first = !hadToken;
|
|
186
|
+
hadToken = true;
|
|
187
|
+
if (statusKey === 'waiting') showStatus('');
|
|
188
|
+
if (!first) return;
|
|
189
|
+
for (var i = 0; i < readyHooks.length; i++) readyHooks[i]();
|
|
190
|
+
api('GET', '/api/session').then(function (r) {
|
|
191
|
+
if (!r.ok || !r.body) return;
|
|
192
|
+
SESSION = r.body;
|
|
193
|
+
// İzinli sekmeler (manifest navigation[].permission, S10) yalnız izni olana görünür; yönetici hepsini görür.
|
|
194
|
+
one.setNavigationAccess({ permissions: SESSION.permissions || [], admin: SESSION.admin === true });
|
|
195
|
+
for (var j = 0; j < sessionHooks.length; j++) sessionHooks[j](SESSION);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
one.onContext(function (c) {
|
|
199
|
+
lang = String(c.lang || '').toLowerCase().indexOf('en') === 0 ? 'en' : 'tr';
|
|
200
|
+
root.lang = lang;
|
|
201
|
+
root.setAttribute('data-theme', c.theme === 'dark' ? 'dark' : 'light');
|
|
202
|
+
translate();
|
|
203
|
+
// Vekalet / Yerine Görüntüle oturumunda platform yazma isteklerini reddeder; kullanıcıya baştan söylenir.
|
|
204
|
+
if (c.actor && !ACTING_WRITES) $('readOnly').removeAttribute('hidden'); else $('readOnly').setAttribute('hidden', '');
|
|
205
|
+
for (var i = 0; i < langHooks.length; i++) langHooks[i](lang);
|
|
206
|
+
});
|
|
207
|
+
one.onNavigate(showView);
|
|
208
|
+
translate();
|
|
209
|
+
showView(one.path);
|
|
210
|
+
one.start({ navigation: NAVIGATION });
|
|
211
|
+
})();
|
|
212
|
+
`
|
|
213
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Dosya yükleme ve indirme (şablonların ORTAK parçası — create-devoplus-one-app üretir; manifest `services.files` açık şablonlarda).
|
|
2
|
+
// Hazır `<one-file-upload>` bileşeni dosyayı `POST /api/files`'a gövde olarak gönderir (ad `x-file-name` başlığında);
|
|
3
|
+
// yanıt `{ key, name, size, contentType }`'tır. ⛔ Anahtar KULLANICININ ön ekindedir (`uploads/<sub>/<uuid>-<ad>`):
|
|
4
|
+
// her yükleme benzersizdir (aynı adla ikinci yükleme öncekini ezmez) ve bir kayda yalnız kendi ön ekindeki anahtar
|
|
5
|
+
// yazılabilir (`ownFileKey`). Dosya, kaydın kendisi okunabiliyorsa kaydın ucundan indirilir (`fileResponse`).
|
|
6
|
+
import { OneFiles, logAudit, readBoundedBody, requireUser, sanitizeFileName, type OneContext, type OneFilesBinding } from '@devoplus/one-sdk'
|
|
7
|
+
import { json } from './server.ts'
|
|
8
|
+
import { uploadPrefixFor } from './http.ts'
|
|
9
|
+
|
|
10
|
+
/** Yükleme tavanı: 5 MB (bileşenin varsayılanıyla aynı). */
|
|
11
|
+
export const UPLOAD_MAX_BYTES = 5 * 1024 * 1024
|
|
12
|
+
const UPLOAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/
|
|
13
|
+
|
|
14
|
+
export async function uploadFile(request: Request, files: OneFilesBinding, ctx: OneContext): Promise<Response> {
|
|
15
|
+
const user = requireUser(ctx)
|
|
16
|
+
const name = sanitizeFileName(request.headers.get('x-file-name') ?? 'dosya.bin')
|
|
17
|
+
const contentType = request.headers.get('content-type') ?? 'application/octet-stream'
|
|
18
|
+
// Sınır gövde belleğe alınmadan ÖNCE uygulanır (Content-Length), parçalı akışta okuma sırasında kesilir.
|
|
19
|
+
const body = await readBoundedBody(request, UPLOAD_MAX_BYTES)
|
|
20
|
+
const key = `${uploadPrefixFor(user.sub)}${crypto.randomUUID()}-${name}`
|
|
21
|
+
const stored = await new OneFiles(files).put(key, body, { contentType, maxBytes: UPLOAD_MAX_BYTES, metadata: { sub: user.sub } })
|
|
22
|
+
logAudit(ctx, 'file.upload', stored.key, { size: stored.size })
|
|
23
|
+
return json({ key: stored.key, name, size: stored.size, contentType }, 201)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Kayda bağlı dosyayı indirir. Erişim kararı ÇAĞIRANDADIR (kaydı okuyabilen indirebilir). */
|
|
27
|
+
export async function fileResponse(files: OneFilesBinding, key: string): Promise<Response> {
|
|
28
|
+
const object = await new OneFiles(files).get(key)
|
|
29
|
+
if (!object) return json({ detail: 'Dosya bulunamadı.' }, 404)
|
|
30
|
+
const headers = new Headers()
|
|
31
|
+
object.writeHttpMetadata(headers)
|
|
32
|
+
headers.set('etag', object.httpEtag)
|
|
33
|
+
const downloadName = (key.split('/').pop() ?? 'dosya').replace(UPLOAD_ID_RE, '')
|
|
34
|
+
headers.set('content-disposition', `attachment; filename="${downloadName}"`)
|
|
35
|
+
headers.set('cache-control', 'no-store')
|
|
36
|
+
return new Response(object.body, { headers })
|
|
37
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// İstek yardımcıları (şablonların ORTAK parçası — create-devoplus-one-app üretir): sınırlı JSON gövde okuma, izinli alan
|
|
2
|
+
// denetimi, hazır kayıt uçlarına (`createCrudHandler`) temizlenmiş gövdeyle iletme ve hazır bileşenlerin değerlerini
|
|
3
|
+
// (kişi, dosya) saklanacak biçime çevirme. Kurallar sunucudadır: istemciden gelen değer DOĞRULANMADAN yazılmaz.
|
|
4
|
+
import { DataValidationError, OneError, readBoundedBody, type FieldIssue } from '@devoplus/one-sdk'
|
|
5
|
+
|
|
6
|
+
/** Uygulamanın kendi uçlarında JSON gövde tavanı (bayt). Kayıt uçlarının kendi tavanı 1 MB'dir. */
|
|
7
|
+
export const JSON_BODY_MAX_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
/** Gövdeyi SINIRLI okur ve JSON nesnesi ister (`request.json()` gövdenin tamamını belleğe aldıktan SONRA ayrıştırırdı). */
|
|
10
|
+
export async function readJsonObject(request: Request, maxBytes = JSON_BODY_MAX_BYTES): Promise<Record<string, unknown>> {
|
|
11
|
+
const type = (request.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase()
|
|
12
|
+
if (type !== 'application/json') throw new OneError(415, 'APP-E-415', 'Gövde application/json türünde olmalı.')
|
|
13
|
+
const buffer = await readBoundedBody(request, maxBytes)
|
|
14
|
+
let parsed: unknown
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(new TextDecoder().decode(buffer))
|
|
17
|
+
} catch {
|
|
18
|
+
throw new OneError(400, 'APP-E-400', 'Gövde geçerli JSON değil.')
|
|
19
|
+
}
|
|
20
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new OneError(400, 'APP-E-400', 'Gövde bir JSON nesnesi olmalı.')
|
|
21
|
+
return parsed as Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Alan hatası (form alanın altında gösterir). */
|
|
25
|
+
export function fieldError(field: string, message: string, status = 400): DataValidationError {
|
|
26
|
+
return new DataValidationError(status, status === 409 ? 'APP-E-409' : 'APP-E-400', message, [{ field, message }])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Gövdede yalnız izinli alanlar olabilir; sunucunun yazdığı alan (durum, onaylayan …) istemciden ALINMAZ. */
|
|
30
|
+
export function onlyFields(body: Record<string, unknown>, allowed: readonly string[]): Record<string, unknown> {
|
|
31
|
+
const issues: FieldIssue[] = []
|
|
32
|
+
const out: Record<string, unknown> = {}
|
|
33
|
+
for (const [key, value] of Object.entries(body)) {
|
|
34
|
+
if (allowed.includes(key)) out[key] = value
|
|
35
|
+
else issues.push({ field: key, message: `"${key}" bu işlemde gönderilemez.` })
|
|
36
|
+
}
|
|
37
|
+
if (issues.length > 0) throw new DataValidationError(400, 'APP-E-400', 'Kayıt doğrulanamadı.', issues)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Aynı başlıklarla (kimlik platformdan gelir) başka bir uca giden istek — hazır kayıt uçlarına temizlenmiş gövde vermek için. */
|
|
42
|
+
export function forward(request: Request, path: string, method: string, body?: unknown): Request {
|
|
43
|
+
const headers = new Headers(request.headers)
|
|
44
|
+
headers.delete('content-length')
|
|
45
|
+
if (body !== undefined) headers.set('content-type', 'application/json')
|
|
46
|
+
const init: RequestInit = { method, headers }
|
|
47
|
+
if (body !== undefined) init.body = JSON.stringify(body)
|
|
48
|
+
return new Request(new URL(path, request.url), init)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Aynı başlıklarla, sorgusu sunucuda değiştirilmiş bir okuma isteği (ör. "yalnız bana atananlar" süzgecini zorlamak). */
|
|
52
|
+
export function withQuery(request: Request, path: string, change: (params: URLSearchParams) => void): Request {
|
|
53
|
+
const url = new URL(request.url)
|
|
54
|
+
const target = new URL(path, request.url)
|
|
55
|
+
target.search = url.search
|
|
56
|
+
change(target.searchParams)
|
|
57
|
+
return new Request(target, { method: request.method, headers: request.headers })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ORG_ID_RE = /^[0-9a-f]{24}$/i
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Kişi seçicinin değeri (`{ id, name }`) → org kimliği + ad. ⚠️ `id` Organization Schema kimliğidir, oturum kimliği (`sub`)
|
|
64
|
+
* DEĞİLDİR; bildirimde `toOrgUserIds` alanına yazılır. Boş değer `null`.
|
|
65
|
+
*/
|
|
66
|
+
export function personValue(raw: unknown, field: string): { id: string; name: string } | null {
|
|
67
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
68
|
+
const value = raw as { id?: unknown; name?: unknown }
|
|
69
|
+
if (typeof value !== 'object' || typeof value.id !== 'string' || !ORG_ID_RE.test(value.id))
|
|
70
|
+
throw fieldError(field, 'Kişi seçiciden geçerli bir kişi seçin.')
|
|
71
|
+
const name = typeof value.name === 'string' ? value.name.trim().slice(0, 120) : ''
|
|
72
|
+
return { id: value.id.toLowerCase(), name }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Kullanıcının dosya ön eki: her kullanıcı yalnız KENDİ ön ekine yükler (`uploads/<sub>/`). */
|
|
76
|
+
export function uploadPrefixFor(sub: string): string {
|
|
77
|
+
return `uploads/${encodeURIComponent(sub)}/`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dosya yükleyicinin değeri (`{ key, name }` ya da anahtar) → anahtar. ⛔ Anahtar KULLANICININ ön ekinde olmalı:
|
|
82
|
+
* başkasının dosya anahtarını kayda yazıp sonra kaydın ekini indirerek o dosyayı okumak böyle engellenir.
|
|
83
|
+
*/
|
|
84
|
+
export function ownFileKey(raw: unknown, sub: string, field: string): string | null {
|
|
85
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
86
|
+
const key = typeof raw === 'string' ? raw : typeof raw === 'object' && typeof (raw as { key?: unknown }).key === 'string' ? (raw as { key: string }).key : null
|
|
87
|
+
if (key === null || !key.startsWith(uploadPrefixFor(sub)) || key.includes('..')) throw fieldError(field, 'Dosyayı bu formdan yükleyin.')
|
|
88
|
+
return key
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Yolun `/<önek>/<kimlik>/<eylem>` parçaları (kimlik kayıt uçlarının ürettiği uuid'dir). */
|
|
92
|
+
export function matchId(pathname: string, prefix: string, action?: string): string | null {
|
|
93
|
+
const rest = pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length + 1) : null
|
|
94
|
+
if (rest === null) return null
|
|
95
|
+
const [id, act, extra] = rest.split('/')
|
|
96
|
+
if (extra !== undefined || !/^[0-9a-f-]{36}$/.test(id ?? '')) return null
|
|
97
|
+
if ((action ?? undefined) !== (act ?? undefined)) return null
|
|
98
|
+
return id!
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const nowIso = (): string => new Date().toISOString()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// E-posta bildirimi (şablonların ORTAK parçası — create-devoplus-one-app üretir; manifest `mail.execute` beyan eden şablonlarda).
|
|
2
|
+
// Bildirim asıl işin bir PARÇASI DEĞİL, sonucudur: kayıt yazıldıktan sonra gönderilir ve düşerse kayıt geri alınmaz —
|
|
3
|
+
// sonuç döner, arıza olay günlüğüne yazılır. `mail.execute` kullanıcının portaldaki ayrı ve süreli onayına bağlıdır
|
|
4
|
+
// (yoksa belirteçte bulunmaz): o durumda gönderim denenmez, `consent-needed` döner ve arayüz kullanıcıya
|
|
5
|
+
// "portaldaki İzinleri incele düğmesiyle izin verin" der. Alıcı yalnız kimlikle verilir (`toSubjects` oturum kimliği,
|
|
6
|
+
// `toOrgUserIds` kişi kaydı kimliği); serbest e-posta adresi YAZILMAZ.
|
|
7
|
+
import { awaitsUserConsent, logError, notify, type BackendClient, type OneContext, type Recipients, type ScenarioMessage } from '@devoplus/one-sdk'
|
|
8
|
+
|
|
9
|
+
export type MailOutcome = 'sent' | 'consent-needed' | 'failed' | 'skipped'
|
|
10
|
+
|
|
11
|
+
export async function sendMailSafely(ctx: OneContext, api: BackendClient, message: ScenarioMessage, recipients: Recipients): Promise<MailOutcome> {
|
|
12
|
+
if ((recipients.toSubjects?.length ?? 0) + (recipients.toOrgUserIds?.length ?? 0) === 0) return 'skipped'
|
|
13
|
+
if (awaitsUserConsent(ctx, 'mail.execute')) return 'consent-needed'
|
|
14
|
+
try {
|
|
15
|
+
const result = await notify(api, message, { ...recipients, channels: ['mail'] })
|
|
16
|
+
const mail = result.mail
|
|
17
|
+
if (mail && 'error' in mail) {
|
|
18
|
+
logError(ctx, 'e-posta bildirimi gönderilemedi', mail.error)
|
|
19
|
+
return 'failed'
|
|
20
|
+
}
|
|
21
|
+
return 'sent'
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logError(ctx, 'e-posta bildirimi gönderilemedi', error)
|
|
24
|
+
return 'failed'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Veri modelinden arayüz tanımları (şablonların ORTAK parçası — create-devoplus-one-app üretir). Seçeneklerin ve alan adlarının
|
|
2
|
+
// tek kaynağı `one.data.json`'dır (`src/data/types.ts` içindeki `dataModel`): sayfa onları buradan alır, elle tekrar
|
|
3
|
+
// yazmaz — seçenek adı modelde değişince liste, süzgeç ve form birlikte değişir.
|
|
4
|
+
import type { OneDataModel } from '@devoplus/one-sdk'
|
|
5
|
+
|
|
6
|
+
export interface Localized {
|
|
7
|
+
tr: string
|
|
8
|
+
en: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChoiceOption {
|
|
12
|
+
value: string
|
|
13
|
+
label: Localized
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function field(model: OneDataModel, entity: string, name: string) {
|
|
17
|
+
const f = model.entities[entity]?.fields[name]
|
|
18
|
+
if (!f) throw new Error(`veri modelinde ${entity}.${name} yok`)
|
|
19
|
+
return f
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Seçim alanının seçenekleri (`<one-table>` kolonu, `<one-filter>` ve `<one-form>` alanı için). */
|
|
23
|
+
export function choices(model: OneDataModel, entity: string, name: string): ChoiceOption[] {
|
|
24
|
+
return (field(model, entity, name).choices ?? []).map((c) => ({ value: c.value, label: { tr: c.title.tr, en: c.title.en } }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Alanın görünen adı. */
|
|
28
|
+
export function label(model: OneDataModel, entity: string, name: string): Localized {
|
|
29
|
+
const t = field(model, entity, name).title
|
|
30
|
+
return { tr: t.tr, en: t.en }
|
|
31
|
+
}
|