Compare commits

...

91 commits
1.2.9 ... main

Author SHA1 Message Date
Sedat Öztürk
d99ac59cdc LangugesText dosya düzenlemesi 2026-10-21 23:40:03 +03:00
Sedat ÖZTÜRK
23667314ee changeLog sayfasına yönlendirme 2026-10-20 13:53:21 +03:00
Sedat ÖZTÜRK
df3294e02a application-configuration içerisine Menus alt kısım eklendi 2026-10-20 12:20:27 +03:00
Sedat ÖZTÜRK
679f8fcb89 WizardFileManager düzenlemesi 2026-10-20 11:52:23 +03:00
Sedat ÖZTÜRK
702dffe50e Fable5 düzenlemesi 2026-10-19 17:35:08 +03:00
Sedat Öztürk
f6ee88baf3 /setup yönlendirmesi 2026-10-19 00:47:36 +03:00
Sedat Öztürk
20cbd50dc8 Bakım İş Emirleri 2026-10-19 00:22:40 +03:00
Sedat Öztürk
c72881622d Bakım Talimatları 2026-10-18 02:59:45 +03:00
Sedat Öztürk
569fc80f12 Ürünlere Ekipman Tab eklendi 2026-10-18 02:15:46 +03:00
Sedat Öztürk
cd41d9dff3 mcp hatası düzeltildi. 2026-10-18 01:31:09 +03:00
Sedat Öztürk
5e8ee03fc8 Fable5 bakışı ile düzenlemeler 2026-10-17 21:51:10 +03:00
Sedat Öztürk
a56bdc3e79 Tailwind 4 geçişi 2026-10-17 00:13:31 +03:00
Sedat ÖZTÜRK
48e7d34026 Bakım Modülü Tanımlamaları 2026-10-15 01:42:19 +03:00
Sedat ÖZTÜRK
29f4bc8b33 Listformlardan eksik olan IsBranch yeteneği getirildi. 2026-10-15 00:12:14 +03:00
Sedat ÖZTÜRK
c0c90174d5 Genel düzenlemeler -> Satınalma ve Inventory 2026-10-14 22:26:03 +03:00
Sedat ÖZTÜRK
60992800a8 Inventory Planlama 2026-10-14 12:07:11 +03:00
Sedat ÖZTÜRK
52b6e1201d Inventory menüsü 2026-10-13 00:43:05 +03:00
Sedat ÖZTÜRK
fed76687e3 Satınalma Siparişleri Ürünleri 2026-10-12 14:07:18 +03:00
Sedat ÖZTÜRK
55bd2626b4 Satınalma Siparişleri düzenlemeleri 2026-10-12 13:27:54 +03:00
Sedat ÖZTÜRK
6db67e121d Satınalma Talepleri düzeltme 2026-10-11 22:57:23 +03:00
Sedat ÖZTÜRK
8a3a00ae62 Satınalma Siparişleri 2026-10-11 17:01:11 +03:00
Sedat ÖZTÜRK
dae0a55e16 Mcp hataları düzeltildi 2026-10-11 01:03:36 +03:00
Sedat ÖZTÜRK
d3d0fa0985 OperationType 2026-10-10 23:56:36 +03:00
Sedat ÖZTÜRK
2d48626219 Satınalma Talepleri düzenlemeleri 2026-10-10 23:11:12 +03:00
Sedat ÖZTÜRK
6f472b15f0 Satınalma Talepleri 2026-10-09 22:57:57 +03:00
Sedat ÖZTÜRK
f44308756c Devexpress Grid kısmına Rating komponenti eklendi 2026-10-09 22:40:51 +03:00
Sedat ÖZTÜRK
f341730b4a Satınalma Talepleri düzenlemeleri 2026-10-09 21:28:48 +03:00
Sedat ÖZTÜRK
eb57bcd5ca ApplicationSuite 2026-10-09 20:54:08 +03:00
Sedat ÖZTÜRK
94ae8ed7ac Satınalma Talepleri 2026-10-09 20:02:27 +03:00
Sedat ÖZTÜRK
1e3c66913d Mcp problemleri giderildi 2026-10-09 17:01:18 +03:00
Sedat ÖZTÜRK
4323e87572 Intranet -> Dashboard Diary 2026-10-09 15:24:53 +03:00
Sedat ÖZTÜRK
78ecb82b34 Wizard dosya isimlerinde çakışması problemi giderildi 2026-10-09 14:33:26 +03:00
Sedat ÖZTÜRK
c2e6ef8a37 Administration -> Onaycılar 2026-10-09 14:23:24 +03:00
Sedat ÖZTÜRK
2a449a158d Envanter -> Tanımlamalar kısmına Depolar, Konumlar 2026-10-09 11:53:38 +03:00
Sedat ÖZTÜRK
372c83e52d Inventory ve Reçete yapısı 2026-10-09 10:48:29 +03:00
Sedat ÖZTÜRK
ba565a8b31 Menu yapısı ve style değiştirildi. 2026-10-08 14:20:54 +03:00
Sedat ÖZTÜRK
0e97c4e081 Project Management 2026-10-08 13:51:14 +03:00
Sedat ÖZTÜRK
ddff672eac Production menü ve yer değişiklikleri 2026-10-08 09:24:59 +03:00
Sedat ÖZTÜRK
1ede045bac EditorScript düzenlemeleri 2026-10-08 01:07:28 +03:00
Sedat ÖZTÜRK
dea283e7cf Inventory -> Definitions Menüsü 2026-10-07 23:27:23 +03:00
Sedat ÖZTÜRK
3af82ecf94 Mcp üzerinden hazırlanan listelerin Default Value değeri
TRY, true/false, 60, Adet
2026-10-07 22:34:49 +03:00
Sedat ÖZTÜRK
ce5d7764d1 mcp ve wizard yetenekleri eşitlendi 2026-10-07 21:46:45 +03:00
Sedat ÖZTÜRK
3922c46539 Grid, Tree, FormDevexpress komponentlerin yetenekleri eşitlendi 2026-10-07 21:08:10 +03:00
Sedat ÖZTÜRK
7e5322d831 Listform için GetDataFolderPath veya GetDataFilePath 2026-10-07 17:48:16 +03:00
Sedat ÖZTÜRK
b9b26eff7c CustomEndpoints klasör yapısına taşındı 2026-10-07 17:34:49 +03:00
Sedat ÖZTÜRK
135a85e682 WizardFileManager CustomEndpoints silme prosedürüne alındı 2026-10-07 16:53:08 +03:00
Sedat ÖZTÜRK
02b8d3abb4 Mcp problemi giderildi 2026-10-07 15:45:18 +03:00
Sedat ÖZTÜRK
53e26ce787 Mcp problemleri giderildi 2026-10-07 11:17:46 +03:00
Sedat ÖZTÜRK
c1454ab818 Ülkeye göre Phone Mask ayarlandı 2026-10-06 15:36:01 +03:00
Sedat ÖZTÜRK
35118ab2d9 Postggre güncellemeleri ve colorbox 2026-10-06 14:24:30 +03:00
Sedat ÖZTÜRK
68dd9b28e6 Supplier yerine Kontak şeklinde değiştirildi 2026-10-06 12:35:09 +03:00
Sedat ÖZTÜRK
9e83d6dabe Satınalma Modülü için Tanımlamalar 2026-10-06 10:19:26 +03:00
Sedat ÖZTÜRK
5ded5d1229 SchedulerView düzenlemesi 2026-10-05 22:18:20 +03:00
Sedat ÖZTÜRK
a09f71521f local-mcp problemi giderildi. 2026-10-05 22:17:29 +03:00
Sedat ÖZTÜRK
2d59f7116a My Profile düzenlemesi 2026-10-05 17:11:06 +03:00
Sedat ÖZTÜRK
10abf9c08d seeds klasörü konum değiştirildi 2026-10-05 15:58:59 +03:00
Sedat ÖZTÜRK
da98513c42 Final Seeder Time out 10 minutes 2026-10-05 00:31:43 +03:00
Sedat ÖZTÜRK
e6db756de3 Puantaj ve DEvam Takibi 2026-10-05 00:18:13 +03:00
Sedat ÖZTÜRK
98eeea8dae İzin Yönetimi 2026-10-04 22:17:16 +03:00
Sedat ÖZTÜRK
7ecb45a53f Performans 360 2026-10-04 18:04:16 +03:00
Sedat ÖZTÜRK
16fd5df0c7 İşe Alım modülü 2026-10-04 11:14:38 +03:00
Sedat ÖZTÜRK
130e23bc67 Disiplin Dosyaları, Çalışma Takvimi 2026-10-04 00:04:29 +03:00
Sedat ÖZTÜRK
b6797950b2 Kullanıcı tanımlamaları genişletildi ayrıca Intranet Hr taşındı 2026-10-03 02:11:39 +03:00
Sedat ÖZTÜRK
fbaedd08df Mappers ve nullable warning mesajları giderildi. 2026-10-02 16:58:22 +03:00
Sedat ÖZTÜRK
e05c44f295 Event Log komponenti ve mcp hataları düzeltildi 2026-10-02 15:46:52 +03:00
Sedat ÖZTÜRK
021a8a346f Human Resources -> Araçlar düzenlemesi 2026-10-02 12:29:50 +03:00
Sedat ÖZTÜRK
5e6e3970c5 ListFormun birden fazla SubFormların FormReport raporu hatası giderildi. 2026-10-02 12:29:22 +03:00
Sedat ÖZTÜRK
d331fb62d4 ListFormEdit ekranında sütunların sıralaması 2026-10-02 10:18:23 +03:00
Sedat ÖZTÜRK
7be0b4ed88 Human Resources -> Araçlar 2026-10-02 10:09:23 +03:00
Sedat ÖZTÜRK
6c669d4c14 ListFormEdit database AddField selectBox hale getirildi. 2026-10-02 10:08:46 +03:00
Sedat ÖZTÜRK
36546d0c2d Human Resources -> Eğitim bilgileri 2026-10-02 09:07:01 +03:00
Sedat ÖZTÜRK
021ddd03d1 Cascading sistem AI tarafından elde geçirildi. 2026-10-01 16:58:07 +03:00
Sedat ÖZTÜRK
81b2202df0 mcp Lookup Cascading özelliği eklendi. 2026-10-01 16:42:56 +03:00
Sedat ÖZTÜRK
c1bdc47d28 mcp üzerinden Report Templates ekleme yeteneği eklendi 2026-10-01 15:01:57 +03:00
Sedat ÖZTÜRK
c4e90a459f MenuManager komponenti json dosyası güncellenebiliyor. 2026-10-01 12:37:30 +03:00
Sedat ÖZTÜRK
61706220b1 Human Resources -> Health Cards 2026-10-01 11:50:16 +03:00
Sedat ÖZTÜRK
e2a63f210f Kullanıcılardaki HealthCard güncellemesi 2026-10-01 10:01:21 +03:00
Sedat ÖZTÜRK
2430a3bf4a WizardFile Manager düzenlemeleri 2026-10-01 08:20:28 +03:00
Sedat ÖZTÜRK
54a0131d6e AbpUsers tablosuna Gender eklendi 2026-10-01 01:54:21 +03:00
Sedat ÖZTÜRK
0bc44e726c WizardFileManager güncellemesi 2026-09-30 23:52:33 +03:00
Sedat ÖZTÜRK
6081131f95 Bazı hatalar giderildi 2026-09-30 17:57:25 +03:00
Sedat ÖZTÜRK
c62e2c8586 IsLookupSelfSourced ve Widget tazelendi 2026-09-30 16:45:44 +03:00
Sedat ÖZTÜRK
6d5071237a Visitors 2026-09-30 16:00:01 +03:00
Sedat ÖZTÜRK
9bcebb97fb Human resources -> Definitions yenilikleri 2026-09-30 12:42:56 +03:00
Sedat ÖZTÜRK
ef2d42d469 DevRequest ile WizardFileManager entegre edildi 2026-09-30 10:28:12 +03:00
Sedat ÖZTÜRK
24ae2b2811 Uom ve UomCategory TenantId eklendi 2026-09-30 08:55:23 +03:00
Sedat ÖZTÜRK
019f1ddab4 mcp yetki problemi 2026-09-29 17:55:13 +03:00
Sedat ÖZTÜRK
a46e12f986 Entity klasör düzenlemesi 2026-09-29 17:18:47 +03:00
Sedat ÖZTÜRK
acc836c293 Core sisteme Hr menüsü eklendi 2026-09-29 14:46:39 +03:00
Sedat ÖZTÜRK
677a62bd79 Md dosyaları düzenleme ve ölü kodlar kaldırıldı 2026-09-29 09:14:07 +03:00
Sedat ÖZTÜRK
ae226d359b wizard 3 sekmeli sistem ve timesheet çalışması 2026-09-28 15:50:38 +03:00
1851 changed files with 1293007 additions and 126189 deletions

View file

@ -19,10 +19,11 @@ Companion documents (this file wins on conflict):
| Document | Scope | | Document | Scope |
| --- | --- | | --- | --- |
| `lowcode.instructions.md` | **Authoring reference**: exact schemas, enum values and worked examples for producing wizard screens, editor scripts, custom components, SQL objects and endpoints. Read it before writing any artifact. | | `lowcode.instructions.md` | **Authoring reference**: exact schemas, enum values and worked examples for producing wizard screens, editor scripts, custom components, SQL objects and endpoints. Read it before writing any artifact. |
| `lowcode-reference.instructions.md` | **Capability inventory**: field-by-field contract of every ListForm/ListFormField JSON column, chart and pivot options, SQL command hooks, Visual Designer toolbox, the scheduled-mail module and (§12) every ready-made screen under the Saas and Administration menus. Sweep its §0 checklist before producing a screen or component; a request whose answer already exists as a platform screen (§12.5) is configured there, not rebuilt. | | `lowcode-reference.instructions.md` | **Capability inventory**: field-by-field contract of every ListForm/ListFormField JSON column, chart and pivot options, SQL command hooks, Visual Designer toolbox, the scheduled-mail module and (§12) every ready-made screen under the Saas, Administration and HR (`Hr`) menus. Sweep its §0 checklist before producing a screen or component; a request whose answer already exists as a platform screen (§12.6) is configured there, not rebuilt. |
| `dotnet.instructions.md` | Binding code standard for everything under `api/` | | `dotnet.instructions.md` | Binding code standard for everything under `api/` |
| `list.instructions.md` | Step-by-step procedure for adding a module / list through seeders | | `list.instructions.md` | Step-by-step procedure for adding a module / list through seeders |
| `../../README.md` | Technical map: what exists, where it lives, how it is wired | | `../../README.md` | Technical map: what exists, where it lives, how it is wired |
| `../../configs/seeds/docs/modules/{Menu}.md` | **How a generated module works**: discovery answers, approved recipe, screens, formulas, status rules, permissions, FAQ. Read it first when asked how a module/component behaves; write or update it with every module/component change (`lowcode.instructions.md` §0.13). Index: `README.md` §10.5 |
| `../../CLAUDE.md` | Operating rules and standing defaults for AI agents in this repo | | `../../CLAUDE.md` | Operating rules and standing defaults for AI agents in this repo |
--- ---
@ -52,26 +53,28 @@ System nature:
3. Every proposal must include tenant and permission design. 3. Every proposal must include tenant and permission design.
4. Never bypass platform authorization patterns. 4. Never bypass platform authorization patterns.
5. Never hardcode secrets, tenant ids, or connection strings. 5. Never hardcode secrets, tenant ids, or connection strings.
6. **Stay inside the framework; ask before stepping out.** The platform may extend itself through 6. **No new Core feature without the command.** Core = all source code under `api/` and `ui/`;
its own mechanisms (seed artifacts; adding a missing event, prop, or small backward-compatible the frame = artifacts generated under `configs/seeds/` (wizard, SQL objects, `crud/`, custom
capability to an existing UI component — when Developer Kit tooling builds ~70% of the components, Custom Endpoints, Dynamic Services, workers, notifications, language/permission
requested component, the rest is completed with such small additions, never with a new rows, SEO, module docs). A new Core feature — however small or backward compatible: a DTO
component; backward-compatible additions to script recipes / editor options / tokens). field, a prop/event on an existing UI component, a script recipe, an editor option key, a
Anything beyond that — a new React component or page, a new toolbox family, core engine changes (ListForm default-value token, a new React component/page, engine/seeder/MCP changes, new entity/
runtime, seeders, code generator, auth, DataSourceManager, dispatcher/compiler), new ABP migration, platform table schema, NuGet/npm packages, `Sas.Menus.Routes.json` rows (except the
entity/module/migration, platform table schema changes, new NuGet/npm dependencies, the `Seo*` columns of an existing row — lowcode §0.9), external integrations — is written **only
`App.Menus.Routes.json` seed data (except the `Seo*` columns of an existing row, which are part when the user gives the command "core sisteme ekle"** (or an equivalent that names the Core
of ordinary production — lowcode §0.9), new external integrations — is **out of frame**: finish everything that change). Before concluding Core is needed, scan the full ListForm capability inventory
fits inside the frame first, then ask the user with a single question (why configuration is (`lowcode-reference.instructions.md` §0–§9, lowcode §4–§9). Without the command: finish what
insufficient, the smallest change that works, files touched, rollback) and proceed **only with fits in the frame and end with a single "Core gerektiren kısım" note (missing capability, why
explicit approval**. Approval is per request and does not carry over. Full lists: the inventory falls short, smallest change, files, rollback) — do not offer Core as an
option to pick. Bug fixes that restore documented behaviour without new settings are not new
features. The command covers only the named change and does not carry over. Full lists:
`lowcode.instructions.md` §0.8. `lowcode.instructions.md` §0.8.
Exception policy: Exception policy:
- Code-level React or backend development can be considered only if user explicitly requests code implementation and configuration path is insufficient. - Code-level React or backend development happens only on the user's explicit command ("core sisteme ekle" or a request naming the code change).
- If exception is required, AI must explain why configuration-based options are not enough. - When Core would be needed, explain in a sentence or two why the configuration inventory is not enough.
- Never say "cannot be done": the answer is either an in-frame build or an approval question. - Never say "cannot be done": the answer is an in-frame build plus, if needed, a "Core gerektiren kısım" note.
--- ---
@ -292,8 +295,8 @@ the Wizard's `Custom` path.
per-command Form permissions (`selectPermission`, `insertPermission`, `updatePermission`, per-command Form permissions (`selectPermission`, `insertPermission`, `updatePermission`,
`deletePermission`). In `Otomatik` mode the base is the read permission the Wizard created for `deletePermission`). In `Otomatik` mode the base is the read permission the Wizard created for
the component and the commands get `''` / `.Create` / `.Update` / `.Delete` suffixes. the component and the commands get `''` / `.Create` / `.Update` / `.Delete` suffixes.
- List screen permissions: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`. - List screen permissions: `Sas.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`.
- Every save/delete is mirrored into `configs/seeds/{scope}/data/App.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are - Every save/delete is mirrored into `configs/seeds/{scope}/data/Sas.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are
referenced through `crud/{Entity}.json`. A component that is not seed-backed is not done. referenced through `crud/{Entity}.json`. A component that is not seed-backed is not done.
### 10.2 SQL View Designer ### 10.2 SQL View Designer
@ -307,9 +310,13 @@ designer. Aggregations available in the Group By column: `GroupBy`, `Where`, `SU
`COUNT_DISTINCT`, `AVG`, `MIN`, `MAX`. `COUNT_DISTINCT`, `AVG`, `MIN`, `MAX`.
Persistence: only the **table designer's** deploy writes a seed file automatically Persistence: only the **table designer's** deploy writes a seed file automatically
(`{sql|postgres}/object/{TableName}.sql`, full CREATE script, via `save-table-script`). View (`sql/object/{TableName}.sql`, full CREATE script, via `save-table-script`; its PostgreSQL counterpart
`postgres/object/{TableName}.sql` is generated from it by `PostgreSqlTableScript`). View
Designer deploys and any DDL run from the SQL editor (views, procedures, functions) reach the Designer deploys and any DDL run from the SQL editor (views, procedures, functions) reach the
database only — their seed files under `{sql|postgres}/object/` must be written explicitly. database only — their seed files under `{sql|postgres}/object/` must be written explicitly.
Every seed script is written for **both** providers: a `sql/…/X.sql` change always comes with its
`postgres/…/X.sql` counterpart (schema `public`, same name and parameter order) — the platform runs on
SQL Server and PostgreSQL alike (`lowcode.instructions.md` §8.3.1, CLAUDE.md standing default 16).
Full mechanics and the SP/function production patterns: `lowcode.instructions.md` §8.1–§8.2.1 Full mechanics and the SP/function production patterns: `lowcode.instructions.md` §8.1–§8.2.1
and §9.5. and §9.5.
@ -360,10 +367,12 @@ AI workflow integration:
MCP (`/mcp`, `Sozsoft.Mcp` module) — expose ListForm data/queries to an external AI agent MCP (`/mcp`, `Sozsoft.Mcp` module) — expose ListForm data/queries to an external AI agent
(Claude, ChatGPT, or an OAuth-registered MCP client) without new code: configure a server row (Claude, ChatGPT, or an OAuth-registered MCP client) without new code: configure a server row
on the **MCP Servers** screen (`App.DeveloperKit.McpServers`, §12.2 of on the **MCP Servers** screen (`Sas.DeveloperKit.McpServers`, §12.2 of
`lowcode-reference.instructions.md`) with its allowed tools and tenant scope; queries always run `lowcode-reference.instructions.md`) with its allowed tools and tenant scope; queries always run
on the calling tenant's own database. Tenant users are read-only over MCP (schema tools, on the calling tenant's own database. Tenant users read and write data over MCP but cannot develop
`query_list`, `run_select`); writing and development tools are host-only. A (`McpToolNames.TenantTools`: schema tools, `query_list`, `run_select`, `insert_row`/`update_row`/`delete_row`
— row writes go through the screen's C/U/D contract and need `.Write`); development tools,
`list_queries` and `run_query` are host-only. A
request to "let an agent query/update X" is a Decision Flow §1 (ListForm) request first — MCP request to "let an agent query/update X" is a Decision Flow §1 (ListForm) request first — MCP
only gates which of the existing screens/queries an already-authorized client may reach; it only gates which of the existing screens/queries an already-authorized client may reach; it
never grants access beyond the calling user's own ListForm/query permissions. Ayrıntı: never grants access beyond the calling user's own ListForm/query permissions. Ayrıntı:
@ -454,8 +463,8 @@ When configuration cannot satisfy requirement:
1. Try SQL-based design 1. Try SQL-based design
2. Try Custom Endpoint 2. Try Custom Endpoint
3. Try Dynamic Service 3. Try Dynamic Service
4. Propose minimal code change with explicit justification — this step is out of frame: 4. Describe the minimal code change with explicit justification — this step is Core:
**ask and wait for approval before touching code** (rule 3.6). **do not touch code until the user says "core sisteme ekle"** (rule 3.6).
--- ---
@ -622,6 +631,8 @@ A proposal is complete only if all are present:
4. Menu-route-component mapping included. 4. Menu-route-component mapping included.
5. Validation checklist included. 5. Validation checklist included.
6. Risk/rollback notes included. 6. Risk/rollback notes included.
7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Menu}.md` is completed (analysis,
decisions, behaviour) and indexed in `README.md` §10.5.
--- ---
@ -659,8 +670,8 @@ There are **two seed surfaces**. AI must not mix them.
Platform-owned screens, menus, permissions and language texts. Changing them requires a build and Platform-owned screens, menus, permissions and language texts. Changing them requires a build and
a deploy. AI must learn and teach implementation flow primarily from these assets: a deploy. AI must learn and teach implementation flow primarily from these assets:
- ListFormSeeder_Saas.cs / ListFormSeeder_Administration.cs / ListFormSeeder_{Modul}.cs - ListFormSeeder_Sas.cs / ListFormSeeder_Adm.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
- configs/seeds/host/data/App.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data) - configs/seeds/data/Sas.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data)
- WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read - WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read
surface B below surface B below
- ListFormSeedDataApplier, CrudEndpointSeedApplier (Domain/Data/) — the single home of the apply - ListFormSeedDataApplier, CrudEndpointSeedApplier (Domain/Data/) — the single home of the apply
@ -673,11 +684,14 @@ a deploy. AI must learn and teach implementation flow primarily from these asset
**B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through **B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through
`SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer, the CRUD `SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer, the CRUD
Endpoint Manager and every ListForm screen with a `SeedFilePath` write here; the Endpoint Manager and every ListForm screen with a `SeedFilePath` write here; the
seeders above restore them when the database is recreated. Scope folders mirror the CDN layout: seeders above restore them when the database is recreated. There is a single folder and no
tenant scope: the host and every tenant with its own database are seeded from the same files
(`SeedFileScope`; a tenant sharing the host database is skipped — the host seed already wrote
there). Files are written only in host context; a tenant's record never reaches a seed file.
``` ```
configs/seeds/host/{wizard,data,crud,sql/{object,execute},postgres/{object,execute}} configs/seeds/{wizard,data,crud,docs,sql/{object,execute},postgres/{object,execute}}
configs/seeds/tenants/{tenantId}/… same layout, tenant scope configs/seeds/{sql,postgres}/host/{object,execute} host database only (e.g. scripts over HangFire)
configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned) configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned)
``` ```
@ -717,13 +731,13 @@ AI must prioritize metadata-based field behavior instead of suggesting custom Re
### 31.3 Step 3 - Add menu and route mapping ### 31.3 Step 3 - Add menu and route mapping
From the `configs/seeds/host/data/App.Menus.*.json` patterns, AI must define both: From the `configs/seeds/data/Sas.Menus.*.json` patterns, AI must define both:
- `App.Menus.Routes.json` row: - `Sas.Menus.Routes.json` row:
- `Id`, `Key`, `Path`, `ComponentPath`, `RouteType`, `Authority` - `Id`, `Key`, `Path`, `ComponentPath`, `RouteType`, `Authority`
- for a visitor-facing route, also its `Seo*` columns — there is no separate SEO table - for a visitor-facing route, also its `Seo*` columns — there is no separate SEO table
(lowcode §0.9) (lowcode §0.9)
- `App.Menus.Menu.json` row: - `Sas.Menus.Menu.json` row:
- `ParentCode`, `Code`, `DisplayName`, `Url`, `Icon`, `RequiredPermissionName`, `Order` - `ParentCode`, `Code`, `DisplayName`, `Url`, `Icon`, `RequiredPermissionName`, `Order`
For dynamic list screens, base route pattern is: For dynamic list screens, base route pattern is:
@ -734,7 +748,7 @@ AI must ensure menu URL and route path point to same screen contract.
### 31.4 Step 4 - Add permissions ### 31.4 Step 4 - Add permissions
From the `configs/seeds/host/data/Abp.Identity.Permission*.json` patterns, AI must define: From the `configs/seeds/data/Abp.Identity.Permission*.json` patterns, AI must define:
- Permission group (if missing) - Permission group (if missing)
- Permission definitions for feature root and actions - Permission definitions for feature root and actions
@ -754,7 +768,7 @@ AI must never propose menu/route without permission contract.
### 31.5 Step 5 - Add settings/integration dependencies ### 31.5 Step 5 - Add settings/integration dependencies
From the `configs/seeds/host/data/App.SettingDefinitions.json` / `App.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services: From the `configs/seeds/data/Sas.SettingDefinitions.json` / `Sas.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services:
- Sender credentials and endpoints (sms/mail/whatsapp/rocket) - Sender credentials and endpoints (sms/mail/whatsapp/rocket)
- AiBot integration endpoint and activation state - AiBot integration endpoint and activation state
@ -855,6 +869,10 @@ Rules:
- Do not require user to explicitly request these columns each time. - Do not require user to explicitly request these columns each time.
- If user explicitly says "no tenant" or "no audit", then respect that override. - If user explicitly says "no tenant" or "no audit", then respect that override.
- A non-definition (transaction/document) table also gets `BranchId uniqueidentifier NOT NULL`
next to `TenantId`, and its screens (including views reading it) use `IsBranch: true`; detail/line
tables carry the column too: Branch is picked first and the header lookup cascades on it (`CascadeParentFields: BranchId`), subform relation `BranchId → BranchId`. Override only on "no branch". Details:
`lowcode.instructions.md` §8.1 (İşlem tablosu → `BranchId`).
- Primary key strategy must remain compatible with existing index/key policy. - Primary key strategy must remain compatible with existing index/key policy.
### 32.2 Wizard menu parent fallback behavior ### 32.2 Wizard menu parent fallback behavior
@ -879,7 +897,7 @@ Default is `List`, so older seed files that lack it keep working.
| Kind | Produces | Menu URL | Child permissions | | Kind | Produces | Menu URL | Child permissions |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `List` | ListForm + ListFormFields + Route + Menu + Permissions + language keys | `/admin/list/{ListFormCode}` | `.Create`, `.Update`, `.Delete`, `.Export`, `.Import`, `.Note` | | `List` | ListForm + ListFormFields + Menu + Permissions + language keys (no Route record; the generic `/admin/list/{code}` route serves it) | `/admin/list/{ListFormCode}` | `.Create`, `.Update`, `.Delete`, `.Export`, `.Import`, `.Note` |
| `Custom` | Menu + Permissions + language keys; the screen is the selected Custom Component | the component's `RoutePath` | `.Create`, `.Update`, `.Delete` only | | `Custom` | Menu + Permissions + language keys; the screen is the selected Custom Component | the component's `RoutePath` | `.Create`, `.Update`, `.Delete` only |
`Export` / `Import` / `Note` exist only in the ListForm ecosystem. Never propose them on the `Export` / `Import` / `Note` exist only in the ListForm ecosystem. Never propose them on the
@ -891,7 +909,7 @@ embedded elsewhere as a SubGrid/part. Default is `true`.
### 32.4 Permission group selection ### 32.4 Permission group selection
The wizard's permission group comes from a select list backed by `GetWizardPermissionGroups`. The wizard's permission group comes from a select list backed by `ListFormWizardAppService.GetPermissionGroups` (`/api/app/list-form-wizard/permission-groups`).
Display names are read from the language key whose name equals the group name. Display names are read from the language key whose name equals the group name.
- Selecting an existing group fills `PermissionGroupEn/Tr` (seed file: `Language.PermissionGroupEn/Tr`); - Selecting an existing group fills `PermissionGroupEn/Tr` (seed file: `Language.PermissionGroupEn/Tr`);
@ -902,15 +920,20 @@ Display names are read from the language key whose name equals the group name.
### 32.5 Wizard export/import is a package, not a file ### 32.5 Wizard export/import is a package, not a file
Exporting a wizard produces a zip whose paths are relative to the scope folder Exporting a wizard produces a zip whose paths are relative to the seed folder and which carries
(`host` / `tenants/{tenantId}`) and which carries every dependency: every dependency:
The closure is **recursive** (`WizardExportCollector`): a sub-form's wizard file is queued and its The closure is **recursive** (`WizardExportCollector`): a sub-form's wizard file is queued and its
own dependencies are collected under the same rules, so the archive stands on its own. own dependencies are collected under the same rules, so the archive stands on its own.
A whole development request exports the same way: every wizard file whose `DevRequest` block
(written by MCP `deploy_wizard` from the active request) points at the request goes into one
closure (`export-dev-request`), shared files once with merged rows. Wizards added by hand carry
no `DevRequest` and are exported one by one.
- `wizard/{file}.json` — the screen itself plus the wizard files of the sub-forms it references - `wizard/{file}.json` — the screen itself plus the wizard files of the sub-forms it references
through `SubFormsDto[].Code`, recursively through `SubFormsDto[].Code`, recursively
- `data/App.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the - `data/Sas.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the
exported component and its dependency chain, never exported whole exported component and its dependency chain, never exported whole
- `data/{ListFormCode}.json` — the screen's own data mirror when `SeedFilePath` is set - `data/{ListFormCode}.json` — the screen's own data mirror when `SeedFilePath` is set
(an extensionless `SeedFilePath` is the folder form: `data/{ListFormCode}/{key}.json`, one file per row) (an extensionless `SeedFilePath` is the folder form: `data/{ListFormCode}/{key}.json`, one file per row)
@ -942,7 +965,7 @@ up so `RollbackImport` can undo the whole batch), closed by `CompleteImport`.
`AnalyzeImport` also **validates** before anything is written (`WizardImportValidator`). With at `AnalyzeImport` also **validates** before anything is written (`WizardImportValidator`). With at
least one `Error` finding the session is marked `blocked` on the server and `ApplyImport` refuses, least one `Error` finding the session is marked `blocked` on the server and `ApplyImport` refuses,
so the check cannot be bypassed by calling the endpoint directly. Errors: malformed JSON; a wizard so the check cannot be bypassed by calling the endpoint directly. Errors: malformed JSON; a wizard
file with no `Wizard`/`ListForm` section; a screen's or a `crud/{Entity}.json`'s table missing in file with no `Wizard` section or, on the List path, no `Data` heading (`SelectCommand`/`KeyFieldName`); a screen's or a `crud/{Entity}.json`'s table missing in
the target **and** absent from the archive's scripts; a `data/{code}.json` whose ListForm exists the target **and** absent from the archive's scripts; a `data/{code}.json` whose ListForm exists
neither in the archive nor in the target; an unresolvable custom-component `Dependencies` / neither in the archive nor in the target; an unresolvable custom-component `Dependencies` /
`DataSources[].SeedFile`; an empty component name or route on the `Custom` path; a sub-form `DataSources[].SeedFile`; an empty component name or route on the `Custom` path; a sub-form
@ -988,7 +1011,8 @@ later steps would fail on it; (b) columns missing from existing tables are added
through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows, except the platform files that hold through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows, except the platform files that hold
the wizards' menu/permission/language rows (`WizardSeedRowStore.ListFormCodes`); (e) `wizard/*.json` the wizards' menu/permission/language rows (`WizardSeedRowStore.ListFormCodes`); (e) `wizard/*.json`
— the screens are **not** installed: like a deploy, the file is marked `Pending` and the next DB — the screens are **not** installed: like a deploy, the file is marked `Pending` and the next DB
Migrate installs it (`WizardDataSeeder`) from the file's `ListForm`/`Fields` contract as-is, even Migrate installs it (`WizardDataSeeder`) from the file's `Wizard` section as-is
(`Wizard.BuildListForm()/BuildFields()`; the file has no separate `ListForm`/`Fields` sections), even
when the target already has the screen. when the target already has the screen.
A wizard deploy (wizard screen, MCP `deploy_wizard`, edit) never writes to the database: it only A wizard deploy (wizard screen, MCP `deploy_wizard`, edit) never writes to the database: it only
@ -998,7 +1022,7 @@ grants the permissions to a role (admin included); roles get them from the role
Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` / Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` /
`execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries. `execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries.
Permissions: `App.Listforms.Wizard.Export` / `.Import`. Permissions: `Sas.Listforms.Wizard.Export` / `.Import`.
AI must not propose moving a screen between environments by copying a single json file. AI must not propose moving a screen between environments by copying a single json file.

View file

@ -31,7 +31,7 @@ Kural:
- Nullable, proje bazında ve bilinçli olarak açılır (`<Nullable>enable</Nullable>`). - Nullable, proje bazında ve bilinçli olarak açılır (`<Nullable>enable</Nullable>`).
Şu an açık olanlar: `Sozsoft.Platform.Application`, `Sozsoft.Notifications.*`, Şu an açık olanlar: `Sozsoft.Platform.Application`, `Sozsoft.Notifications.*`,
test projeleri. `Sozsoft.Mcp.*`, test projeleri.
- **Entity taşıyan projelerde** (`*.Domain`) nullable açılacaksa, aynı PR'da - **Entity taşıyan projelerde** (`*.Domain`) nullable açılacaksa, aynı PR'da
`dotnet ef migrations add` çıktısı gözden geçirilir; şema değişikliği isteniyorsa `dotnet ef migrations add` çıktısı gözden geçirilir; şema değişikliği isteniyorsa
kabul edilir, istenmiyorsa kolon zorunluluğu `PlatformDbContext` içinde kabul edilir, istenmiyorsa kolon zorunluluğu `PlatformDbContext` içinde
@ -122,11 +122,38 @@ Ek kurallar:
- **Repository**: özel sorgu gerekiyorsa Domain'de arayüz + EntityFrameworkCore'da - **Repository**: özel sorgu gerekiyorsa Domain'de arayüz + EntityFrameworkCore'da
implementasyon. AppService içinde `DbContext` doğrudan kullanılmaz. implementasyon. AppService içinde `DbContext` doğrudan kullanılmaz.
- **Dinamik SQL**: parametreli çalıştırılır; string birleştirme ile sorgu kurulmaz. - **Dinamik SQL**: parametreli çalıştırılır; string birleştirme ile sorgu kurulmaz.
- **İki sağlayıcı**: uygulama SQL Server ve PostgreSQL'de çalışır (`DefaultDatabaseProvider`).
EF modeli SQL Server sözdizimiyle yazılır (`HasColumnType("nvarchar(max)")`,
`HasFilter("[IsDeleted] = 0")`, `HasCheckConstraint`, `HasDefaultValueSql("NEWID()")`);
PostgreSQL'de `PlatformDbContext.ConfigureProviderSpecificModel` bunları `PostgreSqlModelSyntax`
ile çevirir. Çeviride karşılığı olmayan bir sözdizimi kullanılırsa `PostgreSqlModelSyntax`'a
eklenir; immutable olmayan hesaplanan kolon (`GETDATE()`'e bağlı) PG'de yazılamaz, sağlayıcıya
göre dallanır. C#'ta üretilen SQL tırnaklı tanımlayıcı ve ANSI ifadelerle yazılır ya da
`DataSourceType`'a göre dallanır; yalnızca T-SQL'de çalışan metin eklenmez. Tanımlayıcı
doğrulama/tırnaklama ve varsayılan şema tek yerdedir: `Sozsoft.Platform.Data.SqlIdentifier`
(Domain.Shared; `IsSafe`, `Quote`, `QuoteQualified`, `DefaultSchema`), nesne kataloğu sorgusu
`SqlCatalogQueries`; ikinci bir regex/`"public" : "dbo"` kopyası yazılmaz. Dapper deposu tek
gerçeklemedir (`DynamicDataRepository`, sağlayıcı `DbProviderFactory` ile seçilir; `Ms`/`Pg`
anahtarlı sınıflar yalnızca DI kaydıdır). `PropertyNameCaseInsensitive` JSON okuması
`PlatformJsonOptions.CaseInsensitive` ile yapılır, çağrı başına `JsonSerializerOptions` üretilmez.
- **Mapping**: Mapperly (`[Mapper]` partial class) kullanılır; AutoMapper eklenmez. - **Mapping**: Mapperly (`[Mapper]` partial class) kullanılır; AutoMapper eklenmez.
Mapper'lar servis klasöründeki `{Alan}Mappers.cs` dosyasındadır (modülde
`{Modül}ApplicationMappers.cs`), ABP'nin `MapperBase<TSource, TDest>` /
`TwoWayMapperBase`'inden türer ve `RequiredMappingStrategy.Target` ile işaretlenir.
RMG012 uyarısı "hedefte karşılığı olmayan alan" demektir; yeni mapper eklerken bu RMG012 uyarısı "hedefte karşılığı olmayan alan" demektir; yeni mapper eklerken bu
uyarı bırakılmaz. uyarı bırakılmaz: kaynakta karşılığı olmayan, servisin sonradan doldurduğu hedef alan
(`User`, `IsLiked`, `*Dto` alt nesneleri, audit alanları) her `Map` overload'unda
`[MapperIgnoreTarget(nameof(...))]` ile açıkça yazılır; iç içe koleksiyon için ayrı
`private partial` eşleme metodu tanımlanır.
- **Localization**: kullanıcıya görünen metin koda gömülmez, `PlatformResource` - **Localization**: kullanıcıya görünen metin koda gömülmez, `PlatformResource`
anahtarı üzerinden verilir. anahtarı üzerinden verilir. Bu her tür exception mesajı, uyarı listesi, DTO açıklaması ve
kayda yazılan sabit değer (ör. sistem onay açıklaması) için geçerlidir; Türkçe ya da
İngilizce statik metin backend'den dönmez. Servislerde `L["Sas.X.Y", arg]`; localizer
alamayan statik/yardımcı sınıflarda `new BusinessException(PlatformConsts.AppName + ":Sas.X.Y").WithData("name", v)`
(ABP kodu `Platform:` ad alanıyla `PlatformResource`'a eşler, dil anahtarı kodun tamamıdır,
yer tutucu `{name}`). Log satırları ve geliştiriciye dönük `[Obsolete]` metinleri İngilizcedir,
yerelleştirilmez. MCP araç açıklamaları ve `McpProblem` metinleri modele dönüktür, bu kuralın
dışındadır; `ModuleDocRenderer`'ın ürettiği modül dokümanı Türkçe proje belgesidir.
- **Çok değerli alan taşıyan girdi DTO'su**: ekranda `dxTagBox` (ya da çoklu seçimli - **Çok değerli alan taşıyan girdi DTO'su**: ekranda `dxTagBox` (ya da çoklu seçimli
`dxGridBox`) ile doldurulan bir alan tipli bir DTO'ya bağlanıyorsa property `string` `dxGridBox`) ile doldurulan bir alan tipli bir DTO'ya bağlanıyorsa property `string`
kalır ve `[JsonConverter(typeof(MultiValueStringJsonConverter))]` ile işaretlenir — kalır ve `[JsonConverter(typeof(MultiValueStringJsonConverter))]` ile işaretlenir —

View file

@ -16,9 +16,9 @@ This document summarizes the rules, standards, and step-by-step instructions for
- **Modularization:** Each module (e.g., {Modul}) must have its own seeder, permission group, and localization entries. - **Modularization:** Each module (e.g., {Modul}) must have its own seeder, permission group, and localization entries.
- **Naming Conventions:** - **Naming Conventions:**
- Table names: `{Modul}_T_{Entity}` (e.g., `Mrp_T_Order`) - Table names: `{Modul}_T_{Entity}` (e.g., `Mrp_T_Order`)
- Menu/permission keys: `App.{Modul}`, `App.{Modul}.{Liste}` - Menu/permission keys: `{Modul}`, `{Modul}.{Liste}`
- **Tenant Isolation:** All data, forms, and permissions must be tenant-aware. - **Tenant Isolation:** All data, forms, and permissions must be tenant-aware.
- **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/host/data/App.Languages.LanguageText.json`. - **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json`.
- **No Redundant Code:** Avoid duplicating permission grants or seeding logic across modules. - **No Redundant Code:** Avoid duplicating permission grants or seeding logic across modules.
- **SeedConsts Rule:** For every new module/entity, add a constant to `SeedConsts` (e.g., `public static class {Modul}` and `public const string {Liste}`) if it does not already exist. Never duplicate existing constants. - **SeedConsts Rule:** For every new module/entity, add a constant to `SeedConsts` (e.g., `public static class {Modul}` and `public const string {Liste}`) if it does not already exist. Never duplicate existing constants.
- **DeleteCommand Rule:** In all `ListFormSeeder_{Modul}.cs` files, the `DeleteCommand` property **must** use the `DefaultDeleteCommand` function, e.g., `DeleteCommand = DefaultDeleteCommand("{TabloAdı}")`. Never use a raw SQL string directly for `DeleteCommand`. - **DeleteCommand Rule:** In all `ListFormSeeder_{Modul}.cs` files, the `DeleteCommand` property **must** use the `DefaultDeleteCommand` function, e.g., `DeleteCommand = DefaultDeleteCommand("{TabloAdı}")`. Never use a raw SQL string directly for `DeleteCommand`.
@ -27,27 +27,30 @@ This document summarizes the rules, standards, and step-by-step instructions for
## Special File Handling Rules ## Special File Handling Rules
- **configs/seeds/host/data/App.Menus.*.json:** - **configs/seeds/data/Sas.Menus.*.json:**
- Never add, remove or re-path a route in `App.Menus.Routes.json`. The only field set you may edit on an existing row is its `Seo*` columns (lowcode §0.9). Only `App.Menus.Module.json` and `App.Menus.Menu.json` can be changed for menu additions or updates. All menu additions must use the platform's configuration mechanisms or be added to those two files as required by platform design. - Never add, remove or re-path a route in `Sas.Menus.Routes.json`. The only field set you may edit on an existing row is its `Seo*` columns (lowcode §0.9). Only `Sas.Menus.Module.json` and `Sas.Menus.Menu.json` can be changed for menu additions or updates. All menu additions must use the platform's configuration mechanisms or be added to those two files as required by platform design.
- **configs/seeds/host/data/App.Languages.LanguageText.json:** - **configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json:**
- When adding a new key, always check if the key already exists. Only add the key if it does not exist to prevent duplicates. - When adding a new key, always check if the key already exists. Only add the key if it does not exist to prevent duplicates.
- Each key is two rows (`CultureName` `en` and `tr`). `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID. - The data lives in the folder `configs/seeds/data/Sas.Languages.LanguageText/`: one file per language (`ar, de-DE, en, es, fi, fr, hi, hr, hu, it, pt-BR, ru, sk, sl, tr, zh-Hans`, i.e. every `CultureName` of `Sas.Languages.Language.json`). The file name is the language's `CultureName` (`{CultureName}.json`: `tr.json`, `de-DE.json`) and its `Rows` hold all rows of that language.
- Wizard, MCP and every component/screen/menu addition follow the same rule: the generated EN + TR rows are written by the deploy/seeder, the producer then writes the translated rows for all other languages (`lowcode.instructions.md` §4.4.3, CLAUDE.md standing default 27). Deleting a key removes its row from every file.
- Every new key is added to **all** language files (one row per file). `en` and `tr` carry the authored text; the other 14 files get its translation. When a language is added to `Sas.Languages.Language.json`, create its file and translate every key.
- Each row's `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID.
--- ---
## File-by-File Change Summary ## File-by-File Change Summary
### 1. configs/seeds/host/data/App.Menus.Menu.json ### 1. configs/seeds/data/Sas.Menus.Menu.json
- **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `App.Menus.Module.json`. - **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `Sas.Menus.Module.json`.
- **{Liste}** added as a child menu under {Modul}. - **{Liste}** added as a child menu under {Modul}.
### 2. configs/seeds/host/data/Abp.Identity.Permissions.json ### 2. configs/seeds/data/Abp.Identity.Permissions.json
- **{Modul} permissions** grouped under `App.{Modul}`. - **{Modul} permissions** grouped under `{Modul}`.
- **{Liste} permissions** nested under {Modul} group. - **{Liste} permissions** nested under {Modul} group.
### 3. ListFormSeeder_Administration.cs ### 3. ListFormSeeder_Adm.cs
- **Removed** {Liste} seeding logic (moved to {Modul} seeder). - **Removed** {Liste} seeding logic (moved to {Modul} seeder).
### 4. ListFormSeeder_{Modul}.cs ### 4. ListFormSeeder_{Modul}.cs
@ -61,7 +64,7 @@ This document summarizes the rules, standards, and step-by-step instructions for
### 6. PlatformIdentityDataSeeder.cs ### 6. PlatformIdentityDataSeeder.cs
- **Removed** redundant {Modul} permission grant logic (now handled by the permission seed data). - **Removed** redundant {Modul} permission grant logic (now handled by the permission seed data).
### 7. App.Languages.LanguageText.json ### 7. Sas.Languages.LanguageText/{CultureName}.json
- **Added** localization entries for: - **Added** localization entries for:
- {Modul} menu and {Liste} list - {Modul} menu and {Liste} list
- {Liste} list fields (e.g., `FullName`, `Phone`) - {Liste} list fields (e.g., `FullName`, `Phone`)
@ -74,11 +77,11 @@ This document summarizes the rules, standards, and step-by-step instructions for
### 1. Add a New Module (e.g., {Modul}) ### 1. Add a New Module (e.g., {Modul})
``` ```
Yeni bir modül ekle (ör: {Modul}): Yeni bir modül ekle (ör: {Modul}):
- configs/seeds/host/data/App.Menus.Menu.json'a kök menü, App.Menus.Module.json'a modül kaydı olarak ekle - configs/seeds/data/Sas.Menus.Menu.json'a kök menü, Sas.Menus.Module.json'a modül kaydı olarak ekle
- configs/seeds/host/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur - configs/seeds/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur
- ListFormSeeder_{Modul}.cs dosyası oluştur ve list-form seed'ini buraya taşı - ListFormSeeder_{Modul}.cs dosyası oluştur ve list-form seed'ini buraya taşı
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak adlandır - SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak adlandır
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına menü, liste ve alan çevirilerini ekle - configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına menü, liste ve alan çevirilerini ekle
``` ```
### 2. Add a New List Under a Module ### 2. Add a New List Under a Module
@ -87,8 +90,8 @@ Yeni bir liste ekle (ör: {Liste}):
- {Modul} ana menüsünün altına ekle - {Modul} ana menüsünün altına ekle
- ListFormSeeder_{Modul}.cs dosyasına seed kodunu ekle - ListFormSeeder_{Modul}.cs dosyasına seed kodunu ekle
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak oluştur - SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak oluştur
- configs/seeds/host/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle - configs/seeds/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına liste ve alan çevirilerini ekle - configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına liste ve alan çevirilerini ekle
``` ```
### 3. Enforce Tenant Isolation and Full Audit ### 3. Enforce Tenant Isolation and Full Audit
@ -101,10 +104,10 @@ Tablo ve list-form için tenant izolasyonu ve full-audit alanlarını ekle:
### 4. Add/Update Localization ### 4. Add/Update Localization
``` ```
Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.LanguageText.json dosyasına (EN + TR satırı) şu şekilde ekle: Yeni menü, liste veya alan eklediğinde configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json klasöründeki tüm dil dosyalarına (EN + TR yazılan metin, diğer diller çeviri) şu şekilde ekle:
- App.{Modul} - {Modul}
- App.{Modul}.{Liste} - {Modul}.{Liste}
- App.Listform.ListformField.{Alan} - Sas.Listform.ListformField.{Alan}
``` ```
### 5. SeedConsts and DeleteCommand Rules ### 5. SeedConsts and DeleteCommand Rules
@ -128,7 +131,7 @@ Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.L
- [ ] Menü ve izinler doğru hiyerarşide mi? - [ ] Menü ve izinler doğru hiyerarşide mi?
- [ ] List-form seed'leri doğru modül dosyasında mı? - [ ] List-form seed'leri doğru modül dosyasında mı?
- [ ] Tablo isimleri ve referansları standartlara uygun mu? (örn: {Modul}_T_{Entity}) - [ ] Tablo isimleri ve referansları standartlara uygun mu? (örn: {Modul}_T_{Entity})
- [ ] App.Languages.LanguageText.json'da tüm yeni menü, liste ve alanlar var mı? - [ ] Sas.Languages.LanguageText/{CultureName}.json'da tüm yeni menü, liste ve alanlar var mı?
- [ ] Redundant kod veya izin grant'ı var mı? - [ ] Redundant kod veya izin grant'ı var mı?
- [ ] Seed ve migration sonrası UI'da çeviriler doğru görünüyor mu? - [ ] Seed ve migration sonrası UI'da çeviriler doğru görünüyor mu?
- [ ] SeedConsts.cs dosyasında ilgili sabitler var mı, tekrar eklenmemiş mi? - [ ] SeedConsts.cs dosyasında ilgili sabitler var mı, tekrar eklenmemiş mi?

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

751
CLAUDE.md Normal file
View file

@ -0,0 +1,751 @@
# Sozsoft Platform - Claude Instructions
This file provides Claude-specific operating rules for this repository.
Primary source of truth for platform behavior is:
- `.github/instructions/ai.instructions.md`
If there is any conflict, follow `.github/instructions/ai.instructions.md`.
## Communication Rules (apply to every response)
1. **Never introduce or summarize the platform.** Assume the user knows what this
application is, how it is built, and which technologies it uses. Do not open a response
with "Sozsoft Platform is a multi-tenant low-code engine…" or any equivalent framing.
2. **Answer the request, nothing more.** No unsolicited architecture overviews, no
restating the decision order, no re-explaining ListForm/DeveloperKit concepts unless
the user actually asked about them.
3. **No preamble, no epilogue.** Skip "Great question", "I reviewed the codebase",
summaries of what you are about to do, and closing recaps of what you just did.
4. **Match the question's size.** A yes/no question gets a sentence. A config question
gets the config. Only a full feature request gets a full proposal.
5. **Respond in the language the user writes in** (Turkish → Turkish).
6. Mention a rule from this file only when it changes the answer — never as boilerplate.
## Purpose
- Maximize delivery through runtime configuration.
- Minimize custom code.
- Preserve platform consistency, security, and tenant isolation.
Primary principle: Configuration first, code last.
## Mandatory Decision Order
For every request, evaluate in this order — internally. State the chosen step only when the
user is asking how to build something, and state it in one line, not as a walkthrough of the
options you rejected.
1. Dynamic configuration with existing ListForm ecosystem
2. SQL Query Manager + Custom Endpoint
3. Dynamic Service
4. Code change (last resort, justification required)
## Non-Negotiable Rules
1. Do not propose new custom React component/page development for standard feature requests.
2. Build new screens using platform configuration mechanisms.
3. Every implementation proposal must include tenant and permission design.
4. Never bypass platform authorization patterns.
5. Never hardcode secrets, tenant IDs, or connection strings.
6. Never add a new Core feature (`api/`, `ui/` code) without the command "core sisteme ekle" (standing default 11).
Exception:
- Custom React/backend code is written only on the user's command ("core sisteme ekle" or a
request that names the code change) — never because configuration seemed insufficient.
- When Core would be needed, say why the ListForm inventory is not enough — in a sentence or two.
## Architecture Guardrails
- Backend: Respect ABP module boundaries and explicit, auditable permissions.
- Frontend: Use existing dynamic view infrastructure and metadata-driven behavior.
- Data: Use parameterized SQL patterns and enforce tenant-safe access.
## Dynamic Platform Expectations
- Menus and routes are database-driven.
- Dynamic List/Form/Component infrastructure is the default solution path.
- Keep route, menu, permission, and datasource mappings coherent.
## Security and Compliance
- Enforce RBAC and permission-driven visibility in all layers.
- Never output real credentials, tokens, keys, or secrets.
- Use placeholders in examples.
- Maintain tenant isolation in every query and action.
## Response Contract
The list below applies **only** to a full implementation proposal for a new screen, module,
or integration. It is not a template for questions, debugging, code review, refactors,
explanations, or small changes.
Even for a full proposal: include only the items that carry real content for that request,
and drop the rest. An empty or obvious heading is noise.
1. Goal
2. Decision flow result (which step used)
3. Artifacts to configure
4. SQL/query/endpoint design (if needed)
5. Menu + route + component mapping
6. Permission and role mapping
7. Tenant isolation notes
8. Validation and test checklist
9. Rollback strategy
---
## Standing Context (read this before proposing anything)
These sections encode expectations the user will **not** repeat in every prompt.
`README.md` is the technical map of the platform; this file is how to act on it.
### Where the truth lives
| Konu | Kaynak |
| --- | --- |
| Platform davranışı, karar sırası, çıktı sözleşmesi | `.github/instructions/ai.instructions.md` |
| **Artefakt üretimi**: seed şemaları, enum değerleri, çalışan örnekler | `.github/instructions/lowcode.instructions.md` |
| **Yetenek envanteri**: JSON kolonlarının alan alan sözleşmesi, grafik/pivot/tasarımcı/mail modülü, Saas + Administration + İK menüsündeki hazır ekranlar | `.github/instructions/lowcode-reference.instructions.md` |
| `api/` kod standardı (sürüm, nullable, ölü kod, modern C#, ABP katmanları) | `.github/instructions/dotnet.instructions.md` |
| Modül/liste ekleme prosedürü, seeder dosya sorumlulukları | `.github/instructions/list.instructions.md` |
| Ne nerede yaşıyor, hangi ekran ne yapıyor | `README.md` |
| Bir modülün nasıl çalıştığı, analizi ve kararları | `configs/seeds/docs/modules/{Menü}.md` (dizin: `README.md` §10.5) |
| Yetki kodları | `PlatformConsts.AppCodes` (backend) + `constants/permission.constant.ts` (UI) |
| Seed yolları | `SeedPathResolver` |
| SQL Server → PostgreSQL çeviri kuralları | `.github/instructions/lowcode.instructions.md` §8.3.1 |
Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → bu dosya → `README.md`.
### Standing defaults — do not ask again
1. **Yeni tablo** → tenant + tam audit kolonları varsayılan gelir (`Id`, `TenantId`,
`CreationTime`, `CreatorId`, `LastModificationTime`, `LastModifierId`, `IsDeleted`,
`DeletionTime`, `DeleterId`). Kullanıcı açıkça "tenant yok / audit yok" demedikçe çıkarma.
Tanım **olmayan** (işlem/belge) yeni tablo `TenantId` gibi `BranchId uniqueidentifier NOT NULL`
(PG `uuid NOT NULL`) da taşır; ekranı `IsBranch: true`. **Şube alanı her ekranda aynı sözleşmeyle
gelir** (`BranchId` kolonu taşıyan tanım ekranları dahil): seçenekler kullanıcıya atanmış aktif
şubelerdir (`Sas_T_BranchUsers`, Kullanıcılar › Yetkiler › Şube Yönetimi; `LookupQueryValues.UserBranchValues`),
yeni kayıt bu listenin **ilk seçeneğiyle** açılır (`DbQuery`, `LookupQueryValues.UserDefaultBranchValue`
ile aynı metin). Departman şubesi, sabit şube ya da ekrana özel sorgu yazılmaz.
Bu tablodan okuyan view/rapor ekranı da `BranchId`'yi taşır ve `IsBranch: true` olur. Ana kayda
bağlı tablo da kolonu taşır; formda önce Şube seçilir, ana kayıt lookup'ı şubeye göre süzülür
(`CascadeParentFields: BranchId`) + alt form ilişkisi `BranchId → BranchId`. Ayrıntı:
`lowcode.instructions.md` §8.1 (İşlem tablosu → `BranchId`).
Yeni **tanım** tablosu (tanım olup olmadığına talebin içeriğine göre analizde karar verilir;
sabit ad listesi yoktur) buna ek olarak `IsActive bit NOT NULL DEFAULT 1` ile gelir; liste + form alanı, varsayılan
`true`, Aktif/Pasif widget'ı. Tanım **olmayan** tabloya (işlem/belge tablosu, onun alt formları:
satır, malzeme, süre, duruş, not) `IsActive` eklenmez; yalnızca talep aktif/pasif bayrağını açıkça
isterse aynı varsayılanla gelir: tabloda `NOT NULL DEFAULT 1` (`create_table`'da
`isNullable: false`, `defaultValue: "1"`), formda yeni kayıt `true` ile açılır
(`FormFieldsDefaultValueJson`). Ayrıntı: `lowcode.instructions.md` §8.1.
Görsel tutan her kolon (fotoğraf, logo, avatar kopyası) `nvarchar(MAX)` + editör `dxImageUpload`;
değer base64 saklanır, sınırlı boy ve `dxImageViewer` kullanılmaz (deploy ikisini de reddeder).
2. **Yeni ekran** → Wizard yolu. `Definitions` altına otomatik yerleştirme yok; modüle ait yeni
bir kök menü oluştur, `Order = max(Order) + 1`. Yetki ağacı menü ağacını izler: ekranın okuma
yetkisi menüdeki dalın ara yetkisinin altına yazılır; menüsüz alt formun yetkisi ise listenin
yetkisine bağlanır (`ParentName` = listenin yetki kodu) (`lowcode.instructions.md` §0.12 Menü yeri).
3. **Yeni ekran/menü** → yetki sözleşmesi olmadan önerilmez. Menü ve route aynı ekran
sözleşmesini göstermeli.
4. **Kullanıcıya görünen her metin** → dil anahtarı üzerinden, EN + TR birlikte. Koda gömülü
metin önerme. Wizard, MCP ya da herhangi bir bileşen/ekran/menü eklemesinin yazdığı **her** anahtar
`Sas.Languages.LanguageText/` klasöründeki bütün dil dosyalarına da yazılır (standing default 27).
**Kullanıcıya görünen her tarih ve sayı** → aktif kültürle: UI'da `localeFormat.ts`
(`formatLocaleDate/Time/DateTime/Number/Currency/Fixed`), API'de `CultureFormat` + `CultureInfo.CurrentCulture`;
DevExtreme kolon/editörü adlı biçim kullanır (`shortDate`, `shortDateShortTime`, `shortTime`, `decimal`,
`fixedPoint`, `currency`). Sabit desen (`DD.MM.YYYY`, `dd/MM/yyyy`), `'tr-TR'` sabiti, `toLocaleString()` ve
görüntülenen ondalık için `toFixed` yazılmaz; makine biçimi (ISO yük, `<input type=date>`) `ui/README.md`'de.
5. **Runtime'da üretilen her artefakt** (wizard, custom component, crud endpoint) → ilgili seed
dosyası da yazılır/güncellenir. Veritabanı sıfırlandığında geri gelmeyen bir şey üretme.
6. **Yeni ekran/komponent talebi — yazılı ya da görsel** → dosya yazmadan önce **keşif turu**:
toplu sorularla (tur başına en fazla 4, seçenekli, önerilen varsayılanlı) ekran türü, veri
kaynağı, modül, kapsam ve yolun soru setini al; tarifi kur ve **onaylat**, sonra
`configs/seeds/` altına üret. Şüphede Wizard. Kullanıcı "sorma / sen seç" derse
varsayılanlarla üret ve sonunda listele. Depodan okunabilen şeyi sorma; tabloya yazma hedefi,
onaycı ve hesap formülü asla varsayılmaz. Ekran görüntüsü piksel sözleşmesi değildir;
görsel Tur 2'nin çoğunu cevaplar, yalnızca görselden okunamayanlar sorulur.
Bu kural yalnızca **yeni üretim** içindir; düzeltme/alan ekleme/debug doğrudan yapılır.
Ayrıntı: `lowcode.instructions.md` §0.6–§0.7.
7. **Ziyaretçiye açık yeni yol** (public `RoutePath` taşıyan custom component, `Pub` menüsü,
yeni public sayfa) → SEO alanları da doldurulur. Ayrı bir SEO tablosu yoktur: meta, yolu
tanımlayan kaydın `Seo*` kolonlarındadır — `data/Sas.Menus.Routes.json` ya da
`data/Sas.DeveloperKit.CustomComponents/{Id}.json`. Metin `::Anahtar` ile dil anahtarı taşır.
Kullanıcı SEO bilgisi girmez; üreten yazar. Genel varsayılanlar kayıtta değil
`Adm.SiteManagement.Seo.*` ayarlarındadır. Ayrıntı: `lowcode.instructions.md` §0.9.
8. **Ekran içi hesap/koşul ihtiyacı** → önce `EditorScript` (Script Builder tarifleri), sonra
`EditorOptions`; kod yazmak son çare.
9. **SQL** → her zaman parametreli. String birleştirme ile sorgu kurma.
10. **Silme** → `DeleteCommand = DefaultDeleteCommand("{Tablo}")`; ham SQL string'i yazma.
11. **Core'a yeni özellik yalnızca komutla** → Core = `api/` + `ui/` kaynak kodu; çerçeve =
`configs/seeds/` artefaktları. Kullanıcı **"core sisteme ekle"** (ya da Core değişikliğini adıyla
isteyen eşdeğer komut) demedikçe Core'a yeni özellik yazılmaz — küçük/geriye dönük uyumlu olması
fark etmez: DTO alanı, var olan bileşene prop/olay, tarif, `EditorOptions` anahtarı, token, yeni
React bileşeni/sayfası, çekirdek motor, entity/migration, platform tablosu şeması, paket,
`Routes`, dış entegrasyon. "Core gerekiyor" demeden önce ListForm'un bütün yetenekleri taranır
(`lowcode-reference.instructions.md` §0–§9, `lowcode` §4–§9). Komut yoksa çerçeve içindeki kısım
bitirilir, son mesajda tek bir "Core gerektiren kısım" notu yazılır (soru kutusuyla Core önerilmez).
Bildirilen hatanın yeni ayar eklemeden düzeltilmesi yeni özellik değildir. Komut yalnızca adı
geçen değişikliği kapsar, sonraki talebe taşınmaz; eklenen yetenek envantere yazılır. "Yapılamaz"
cevabı yoktur. Ayrıntı: `lowcode.instructions.md` §0.8.
12. **Liste ekranı görünümleri** → prompt/görsel/tarifle üretilen her liste ekranı `Grid` + `Card` +
`Pivot` + `Chart` açık, `DefaultLayout: "grid"` ile gelir; "liste/grid" demek diğerlerini
kapatmak değildir. Bir görünüm yalnızca istek onu açıkça dışarıda bıraktığında `false` yazılır.
Tree/Gantt/Scheduler/Todo yalnızca istenince açılır. Ayrıntı: `lowcode.instructions.md` §9.8.
13. **Liste ekranı widget'ları** → Wizard ya da MCP `deploy_wizard` ile üretilen her List ekranı
sorulmadan **en az 3 kartlık** `Widgets` ile gelir; kartlar tablonun durum / boolean bayrak /
tarih / tutar kolonlarından seçilir, başlıklar dil anahtarıdır (`Sas.Widget.*` önce). Kiracı
tablosunda her kart `"TenantId" = '@TENANTID'` taşır; `IsDeleted` koşulu yazılmaz. Form/Chart,
alt form ve basit tanım listeleri istisnadır. **Alt form oluşturulurken widget oluşturulmaz**:
başka bir ekranın `SubForms` sekmesi olarak üretilen her ekran — menüsüz ya da kendi menüsü olan —
`Widgets: []` ile deploy edilir. Deploy menüsüz (`CreateMenu: false`) wizard'ın `Widgets`'ını
ayrıca düşürür; ekran alt form sekmesinde açıldığında şerit de çizilmez
(`useListFormWidgets(…, isSubForm)`) — kart sorgusu ana kayda göre süzülmez. Ayrıntı:
`lowcode.instructions.md` §4.6.1.
Alt form ekranının menü/yetki/düzenleme kuralları (menüsüz alt formun yetkisi ana ekranın altında,
`Form` sekmesi tek kayıt + `AllowAdding`/`AllowUpdating` açık): `lowcode.instructions.md` §4.6.
14. **Modül dokümanı** → MCP ya da wizard ile oluşturulan her modül çalışma şeklini
`configs/seeds/docs/modules/{Menü}.md` dosyasında taşır: dosya kök menü başına tektir ve adı kök
menünün kodudur (`Hr.md`, `Prc.md`, `Adm.md`),
menünün altındaki her dal dosyada bir `module-doc:section` bölümüdür. Bölüm keşif cevapları ve
onaylanan tarifi (analiz), ekranları, veri modelini, hesapları/durum geçişlerini, yetki dağılımını,
entegrasyonları, sık sorulanları taşır. Dosyayı, dalın bölümünü ve "Ek — Ekran envanteri"ni deploy
kendisi açar/yeniler; anlatım alt bölümlerini (`module-doc:todo`) üreten doldurur — MCP'de
`save_module_doc` (ekran/dal koduyla yalnızca o bölüm), Claude Code'da dosyaya doğrudan. MCP talebi
bölümü tamamlanmadan `Testing`'e alınamaz. Wizard dışı modülün bölümü elle yazılır. README'ye
yalnızca §10.5 dizin satırı/bölüm adı ve ilgili bölümde tek cümlelik bağlantı yazılır.
"X nasıl çalışıyor?" sorusunda önce bu dosya okunur. Ayrıntı: `lowcode.instructions.md` §0.13.
15. **Ekran açıklaması** → wizard/MCP ile üretilen her liste ekranının (alt formlar dahil)
`DescTr`/`DescEn` metni ayrıntılıdır; başlıktaki ⓘ simgesinde gösterilir: ne işe yaradığı,
menüde nerede / hangi ekranın sekmesi olduğu, ana alanlar, otomatik hesap ve geçişler, ilişkili
ekranlar (`RichText` biçimi). MCP `deploy_wizard` 3 satır / 150 karakterin altını reddeder.
Ayrıntı: `lowcode.instructions.md` §4.4.2.
16. **İki veritabanı sağlayıcısı** → uygulama SQL Server ile olduğu gibi PostgreSQL ile de çalışır
(`SettingsConsts.DefaultDatabaseProvider`). Veritabanına dokunan her değişiklik ikisi için birlikte
düşünülür ve yazılır; kullanıcı bunu her istekte hatırlatmaz:
- `configs/seeds/sql/{object,execute,host/…}/X.sql` yazılan/değişen her dosyanın
`configs/seeds/postgres/…/X.sql` eşi aynı işte yazılır/güncellenir (aynı ad, aynı parametre sırası,
şema PG'de `public`). Biri olmadan diğeri commit edilmez; "PG sonra" yoktur.
- EF modeli SQL Server sözdizimiyle yazılır (kolon tipi, index filtresi, check, varsayılan); PG
karşılığı `PostgreSqlModelSyntax` üzerinden `PlatformDbContext.ConfigureProviderSpecificModel`'de
üretilir. Çeviriye girmeyen yeni bir sözdizimi eklenirse çeviri de eklenir.
- C#'ta üretilen SQL (Dapper, `QueryManager`, seeder komutları, ekran `*Command`/`LookupQuery`
metinleri) ya iki sağlayıcıda da çalışan tırnaklı/ANSI biçimde yazılır ya da `DataSourceType`'a göre
dallanır.
- SQL değişikliği doğrulanırken PG eşi de bir PostgreSQL veritabanında çalıştırılır (iki kez —
idempotent). Ayrıntı ve dönüşüm tablosu: `lowcode.instructions.md` §8.3.1.
17. **Her prompt sonunda optimizasyon + ölü kod temizliği** → kullanıcı "optimize et, ölü kodları
kaldır" yazmaz; her iş bitmeden, kullanıcı istemese de o işte dokunulan kod üzerinde yapılır:
kullanılmayan `using`/üye/parametre/tip/dil anahtarı silinir (tip silmeden önce repo geneli
tarama, "Definition of done"), aynı mantığın ikinci kopyası ortak metoda indirilir, gereksiz
tekrar sorgu/dosya okuması kaldırılır, kod/değer **statik yazılmaz** — tablo adı
`TableNameResolver`'dan, değer tablo/seed/ayar verisinden dinamik bulunur. Son mesajda ne
temizlendiği tek satırla söylenir.
Kapsam seed artefaktlarını da içerir: yeni bir yordam/view/ekran eskisinin işini devralınca eskisi
aynı işte silinir (iki sağlayıcının dosyası, wizard'ı, menü/yetki/dil satırları). Kullanımı biten
SQL nesnesi (tablo, view, yordam, fonksiyon), menüsüz ve referanssız wizard, ekranı olmayan `data/`
dosyası ve hiçbir ekran öğesinin göstermediği dil anahtarı bırakılmaz; tarama kuralı
`lowcode.instructions.md` §8.6.
18. **Yeni kaydın form varsayılanı** → Wizard ve MCP ile üretilen her listede form varsayılanı
kolonun veritabanındaki sabit `DEFAULT` değeridir; deploy onu tablodan (katalog ya da seed
scripti) okuyup `FormFieldsDefaultValueJson`'a yazar, değer koda gömülmez. Bu yüzden tablo
şu `DEFAULT`'larla kurulur: `IsActive` → `1` (true), ölçü birimi (`*UomId`) → `'Adet'`, para birimi
(`*CurrencyId`/`*Currency`) → `'TRY'` (lookup'ın anahtarı neyse); talepte başka sabit varsayılan
varsa (Süre = 60) o da kolonun `DEFAULT`'udur. Alana özel değer wizard öğesinin `DefaultValue`'su ile
verilir. Ayrıntı: `lowcode.instructions.md` §4.3.5.1.
19. **Sabit lookup seçenekleri** → RadioGroup/SelectBox/TagBox/Autocomplete'in `StaticData` seçeneklerinde
`name` düz metin değil `Sas.StaticLookup.*` dil anahtarıdır; EN + TR satırı dil dosyasına yazılır.
Deploy düz metni kendisi anahtara çevirir (aynı TR metinli anahtar varsa onu kullanır) —
`ListFormWizardAppService.LocalizeStaticLookupsAsync`, `lowcode.instructions.md` §4.3.4.1.
20. **EditorScript** → MCP/wizard script'i elle yazmaz, Script Builder kurallarını verir; deploy kodu
kurallardan üretir (`EditorScript.Rebuild`). `str/num/bool` alan **adı** alır (`str('X')`, değeri
değil). `setValue`'da `true`/`false` mantıksal yazılır (onay kutusu alanında `0`/`1` deploy'da çevrilir).
MCP ve Script Builder aynı kuralları aynı koda çevirir; `set` zinciri derinlik sınırsızdır, döngü ve
script hatası kullanıcıya bildirilir. Ayrıntı: `lowcode.instructions.md` §4.4, §5.3, §5.6.
21. **Demo veri ≠ seed senkronu** → `SeedData: true` yalnızca `SeedFilePath` açar;
`SeedSyncInsert/Update/Delete` istekte açıkça istenmedikçe `false`'tur (wizard cevabı `SeedSync`).
22. **Wizard adı modül önekli** → yeni wizard'ın adı (= seed dosyasının adı) `{ModuleId}_{Ad}`'dır
(`Prc_RejectReason.json`, `Hr_RejectionReason.json`); aynı ekran adı başka modülde de geçebildiği
için öneksiz ad dosya çakışması (`'X' adında bir Wizard zaten var`) üretir. Önek deploy'da
`MenuParentModuleId`'den eklenir (`ListFormWizardDto.ResolveWizardName`); analiz ve üretim planında
dosya adı önekli yazılır. `configs/seeds/wizard/` altındaki bütün dosyalar bu düzendedir; öneksiz
dosya yalnızca eski bir export arşivinden gelebilir ve düzenlenirken adı korunur. Ayrıntı:
`lowcode.instructions.md` §4.10 (Adım 2 — Menü).
23. **Otomatik numara Sequence'tan** → wizard/MCP üretiminde talep otomatik numara istediğinde ya da
analiz bir alanın (`ProjectNo`, `OrderNo`, `RequestNo` …) otomatik numaralanmasına karar verdiğinde
alan `Adm.Definitions.Sequence` numaratörüne bağlanır; elle girilen numara/kod alanı bu kurala
girmez. Numaratör
`data/Adm.Definitions.Sequence.json`'a satır olarak yazılır (uygun olan varsa yeniden kullanılır),
alanın varsayılanı `FormFieldsDefaultValueDto`'da `CustomValueType: 5`, `Value` = numaratörün `Name`'i.
`@AUTONUMBER`, `MAX + 1` sorgusu ya da SQL ile numara üretilmez (boşluksuz numara açıkça istenmedikçe).
Ayrıntı: `lowcode.instructions.md` §4.3.5.2.
24. **İş akışlı liste** → tablo, tasarımcının Workflow şablonundaki dört kolonla kurulur ve `WorkflowDto`
yalnız bunlara bağlanır: `ApprovalUserName` nvarchar(256), `ApprovalStatus` nvarchar(50),
`ApprovalDate` datetime, `ApprovalDescription` nvarchar(200) (PG: varchar/timestamp). Ayrı durum
kolonu (`Status`), Guid onaycı kolonu (`ApproverId`) ve red nedeni kolonu açılmaz: durum akışın
düğüm başlığıdır, red nedeni onay düğümünün `RejectReason` listesinden seçilip açıklamaya yazılır.
Bu dört alan popup düzenleme formunda yer almaz (akış yazar), listede kolon olarak görünür.
Ayrıntı: `lowcode.instructions.md` §4.7.
25. **Not yalnızca Table kaynağında** → kayıt kaynağı (`SelectCommandType`) Table olmayan listede (View, Query,
StoredProcedure, TableValuedFunction) Not yoktur: `PermissionJson.N` boş, `ShowNote` kapalı, `{Kod}.Note` yetki satırı ve
satırdaki Not butonu üretilmez; view/sorgu satırının notun bağlanacağı kalıcı kaydı yoktur. Wizard/MCP bunu kendisi uygular
(`ListFormWizardDto.HasNote`), C# seeder'da `DefaultPermissionJson(name, hasNote: false)` yazılır. Ayrıntı:
`lowcode.instructions.md` §4.2 (Üretilen yetkiler).
26. **Özel komutun parametreleri boş bırakılmaz** → MCP/prompt/wizard ile üretilen ya da düzenlenen ekranda yazılan her
`InsertCommand`/`UpdateCommand`/`DeleteCommand` ve `*BeforeCommand`/`*AfterCommand` ile aynı işte
`Insert/Update/DeleteFieldsDefaultValueJson` da tamamlanır: komuttaki her `@Parametre` gönderilen alana, `IsTenant`'ın
eklediği `@TenantId`'ye ya da listedeki bir satıra (`Id=@ID`, `LastModifierId=@USERID`, `DeleterId=@USERID`,
`CreatorId=@USERID`, `*Time=@NOW`) bağlanır; kaynağı olmayan parametre "Must declare the scalar variable" verir.
Wizard'ın audit varsayılanları view/sorgu kaynaklı ekranda ve özel komutta eksik kalır, bu yüzden kontrol üretendedir.
`SelectFieldsDefaultValueJson` çalışma anında okunmaz; okuma koşulu `DefaultFilter`'a yazılır. Ayrıntı:
`lowcode.instructions.md` §4.3.5.3.
27. **Çok dilli metin: her anahtar bütün dil dosyalarında** → Wizard (`deploy_wizard`, wizard ekranı), MCP (`save_component`,
`save_report_*`, `create_table` dil satırları, satır araçları), Custom Component/Visual Designer, menü, yetki grubu,
widget, sabit lookup seçeneği (`Sas.StaticLookup.*`) ya da elle yeni ekran — dil anahtarı üreten her iş şunu yapar:
1. Anahtarın `LanguageKey`'i ve **EN + TR** metni üretilen yolda yazılır (deploy `Language.Texts` / `Language.*En|Tr`,
seeder, elle satır); metni EN + TR veren kullanıcıdır ya da analizdir.
2. Aynı işte `Sas.Languages.Language.json`'daki **diğer her dil** (şu an `ar, de-DE, es, fi, fr, hi, hr, hu, it, pt-BR,
ru, sk, sl, zh-Hans`) için aynı `ResourceName` + `Key` ile `CultureName` = dil kodu olan satır, o dilin dosyasında
(`data/Sas.Languages.LanguageText/{CultureName}.json`, dosya adı dilin `CultureName`'i) çeviriyle yazılır; sabit liste yoktur, dil listesi
`Sas.Languages.Language.json`'dan okunur. Yer tutucular (`{0}`), HTML ve kod aynen korunur. `Id` =
`Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}` v5 GUID.
3. MCP'de EN + TR satırını deploy yazar; diğer dillerin satırları aynı çağrı dizisinde `Sas.Languages.LanguageText` ekranına
`insert_row` ile (her dil için bir satır; `CultureName`, `ResourceName`, `Key`, `Value`) eklenir. Claude Code'da
dil dosyalarına doğrudan yazılır.
4. Anahtarı silen iş (ekran/wizard silme, bileşen silme) bütün dillerin satırını da siler.
5. Son mesajda hangi anahtarların kaç dile yazıldığı tek satırla söylenir; çevirisi eksik dil bırakılmaz.
Ayrıntı: `lowcode.instructions.md` §4.4.3.
### Cross-cutting: touching one thing means touching these too
- **Modül dokümanı** → `ModuleDocManager.RefreshAllAsync` wizard'ı yazan her yoldan çağrılır (deploy,
silme, `WizardSeedSynchronizer`, import kapanışı); wizard dosyasını yazan yeni bir yol eklenirse
çağrı da eklenir, yoksa envanter bayatlar. Wizard → doküman/bölüm eşlemesi tek yerdedir
(`ModuleDocCatalog`: dosya = kök menü, bölüm = beyan → alt formun ana ekranı → menü dalı; kök ve
dal `data/Sas.Menus.Menu.json`'dan okunur, sabit liste yoktur); envanter biçimi
`ModuleDocRenderer`'dadır. Export kapanışı dokümanı taşır (`WizardExportCollector`), import
`docs/modules/*.md` yolunu kabul eder.
- **`scriptRecipes.ts` (TS) ↔ `Domain.Shared/Editors/*.cs`** — biri değişirse diğeri de değişir.
İkisinin çıktısı **birebir aynı** olmak zorundadır; aksi hâlde seeder ile basılan script
dialogda "elle düzenlenmiş" sayılır ve kural editörü kapanır.
- **`phoneFormat.ts` ↔ `DynamicReportPhoneFormatter.cs`** — telefon token'larının (`phoneGlobal`, `phoneTR`,
`phoneIntl`, `phoneCountry`) görüntü kuralı iki yerde yazılır: liste/kart istemcide, dinamik rapor sunucuda.
Biri değişirse diğeri de değişir; yoksa liste ile rapor aynı numarayı farklı yazar.
- **`Sequence.GetNextSequence` (C#) ↔ `Adm_P_SequenceNext.sql` (SQL Server + PG)** — numaratörün biçim
(`FormatTemplate` token'ları, `PaddingSize`) ve sıfırlama (`ResetPeriod`/`LastPeriodKey`) kuralı iki yerde yazılır:
formdan açılan kayıt C#'tan, yordamın açtığı belge (tekliften sipariş, alternatif teklif) SQL'den numara alır.
Biri değişirse diğeri de değişir; yoksa aynı numaratör iki farklı biçimde numara üretir. Eşzamanlılık korumaları
ayrıdır ve ikisi de kalır: yordam satır kilidi (`UPDLOCK`/`FOR UPDATE`), C# karşılaştır-ve-yaz
(`ISequenceRepository.TryAdvanceAsync`, `SequenceManager` yeniden dener) — entity izlenerek okunup `UpdateAsync`
ile yazılmaz, iki form aynı numarayı alırdı.
- **`LanguageTextInsertCommand` (ListFormSeeder_Sas) ↔ `LanguageTextSeedBulkInserter`** — dil metninin ekleme kuralı
(eksik `LanguageKey`'i metnin `Id`'siyle üretmek, aynı kültür + kaynak + anahtar varsa eklememek) iki yerde yazılır:
ekrandan gelen kayıt komuttan, migrate'teki seed satırları toplu yazıcıdan geçer (112 bin satır satır başına ~115 sn,
toplu ~2 sn). Biri değişirse diğeri de değişir. Satır sayısı büyüyen başka bir ekran aynı yolu
`IListFormSeedBulkInserter` ile alır.
- **Liste popup'ı (Grid/Tree) ↔ form sayfası (`FormDevExpress`)** — editör yetenekleri ortak yardımcılardadır
(`list/shared/editingForm.ts`, `editingFormScripts.ts`, `cascade.ts`); TagBox/GridBox/ImageUpload/ImageViewer/Rating'in
tek uygulaması `form/editors/*`, liste hücresi yalnızca `list/editors/cellEditors.tsx` adaptörüdür. Bir tarafa
yetenek eklenirken ortak yardımcıya eklenir; tek tarafa yazılan yetenek eşliği bozar. Tablo:
`lowcode.instructions.md` §4.3.4.
- **`htmlEditorOptions.ts` ↔ `EditorOptions.Html`** — HtmlEditor varsayılanı iki yerde yazılır ve
bloklar birebir aynıdır. İstemci iç içe blokları tek seviye birleştirdiği için seed edilen
`toolbar` varsayılanın yerine geçer; biri değişip diğeri değişmezse seed'li ekranlar eski araç
çubuğunu gösterir.
- **Wizard seed dosyası tek `Wizard` bölümüdür** → dosyada `ListForm`/`Fields` bölümü yoktur; ekranın
bütün sözleşmesi `Wizard` bölümündedir. ListForm detayının her sekmesi orada bir bloktur (`Editing`,
`Insert`/`Update`/`Delete`, `FormDefaults`, `Permission`, `FilterRow` … `PivotOption`, görünüm option
blokları, `ExtraFilters`, `CommandColumns`, `SubForms`, `Widgets`, `WorkflowDto`, `ChartOptions`), alan
detayının her sekmesi `Groups[].Items[]` öğesinin bir alanıdır (`WizardColumnItemInputDto`: `Filter`,
`Header`, `Grouping`, `TotalSummary`/`GroupSummary`, `Join`, `ValidationRules`, `Styling`, `Permission`,
`PivotSettings`, `ListOrderNo` …). Wizard alanları `Menu`, `Language`, `Data`, `Details`, `Layout`
başlıklarına toplanır ve `ListFormCode` → `WizardCode`, `MenuParentModuleId` → `ModuleId`,
`LanguageTexts` → `Language.Texts` adıyla yazılır; varsayılanı olan alanlar (`CreateMenu: true`, boş
`DataSourceConnectionString`, boş `MenuParentPath` / `LanguageTexts`) hiç yazılmaz. Kısayol cevaplar
(`AllowAdding/AllowUpdating/AllowDeleting/AllowDetail/OpenDetailAfterInsert/ConfirmDelete`,
`SeedData`/`SeedSync`, öğedeki `IsRequired`) **dosyaya yazılmaz**: `Editing`, `Data.SeedFilePath`/`SeedSync*`
ve `ValidationRules`'tan türetilir (`SyncDerivedAnswers`); deploy girdisinde yazılırsa bloğu ezer. Bu yalnızca
dosya biçimidir; C# sözleşmesi düzdür, dönüşüm `WizardSeedFileDto.ToSeedJson` / `FromSeedJson` içindedir.
**Wizard seed dosyası her zaman bu iki metotla okunup yazılır**; düz `JsonSerializer` çağrısı başlıkları
ve türetilen kısayolları sessizce bozar. `FromSeedJson` eski biçimin `ListForm`/`Fields` bölümlerini
okuyup `RefreshFrom` ile `Wizard`'a aktarır; bir sonraki yazımda düşerler.
- **Wizard ile üretilen ekran** → ListForm detayından yapılan her kayıt (`ListFormsAppService`,
`ListFormFieldsAppService`, `ListFormJsonRowAppService`) seed dosyasının `Wizard` bölümünü
`ListFormWizardDto.RefreshFrom(listForm, fields)` ile geri yazar (`WizardSeedSynchronizer`). Bu üçlüden
birine yeni bir kayıt yolu eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
**Eşlik kuralı:** `GridOptionsEditDto`/`ColumnFormatEditDto`'ya eklenen her alanın
`ListFormWizardDto`/`WizardColumnItemInputDto`'da karşılığı + `RefreshFrom` + `BuildListForm/BuildFields`
eşlemesi olmak zorundadır; yoksa tasarımcıda yapılan değişiklik migrate'te kaybolur.
- **Wizard girdisinden ekran üretimi** → tek hat `ListFormWizardDto.ApplyDefaults(isDeleted, isCreated,
isModified)` (boş blokları `WizardDefaults` + `WizardConsts` ile doldurur; yetki adları, popup başlığı,
durum anahtarı ekran kodundan yeniden türetilir) → `BuildListForm()` / `BuildFields()` (salt eşleme,
parametresiz) → `RefreshFrom` (üretilen ekran cevaplara geri yazılır, dosya öyle kaydedilir); entity'ye
yazma `GridOptionsEditDto.ApplyTo` / `ColumnFormatEditDto.ApplyTo`. Deploy, seeder (`WizardDataSeeder`
dosyadan `Wizard.BuildListForm()/BuildFields()`) ve senkronizasyon aynı metotları çağırır; ikinci bir
üretim kopyası yazma.
- **Katman sınırı (DDD)** → `DbMigrator` Application'a referans vermez. Seeder ile AppService'in
paylaştığı dönüşüm `Application.Contracts`'ta DTO metodudur; entity'ye yazma Domain.Shared'deki
`IListFormArtifact` / `IListFormFieldArtifact` sözleşmeleri üzerinden yapılır.
Ayrıntı: `dotnet.instructions.md` §5.
- **Yeni bir yetki** → `PlatformConsts.AppCodes` + `data/Abp.Identity.Permissions.json` + (UI'da kullanılacaksa)
`permission.constant.ts` + `data/Sas.Languages.LanguageText/{CultureName}.json`. Yetki ekranındaki sıra `AbpPermissions.Order`
(grup için `AbpPermissionGroups.Order`) sütunundan gelir ve seed satırındaki `Order` alanıyla
yazılır; sıra kardeşler arasındadır — her üst yetkinin altında 1'den başlar, yeni satırı menüdeki
yerine denk gelen numarayla ekle.
- **`/mcp` ucuna yeni bir araç** → üç yer birlikte değişir: `McpToolNames` (ad kayıt defteri),
ilgili `Mcp*Tools` sınıfı (`Sozsoft.Mcp.HttpApi`) ve arkasındaki AppService sözleşmesi
(`Sozsoft.Mcp.Application.Contracts`). Arac adı `McpToolNames.All` içinde yoksa araç listesi
süzgecinden geçmez ve hiç görünmez; MCP Servers ekranındaki `AllowedTools` seçenekleri de
`SelectableTools`'dan üretildiği için kendiliğinden güncellenir. Sunucu kaydında tek anahtar
`AllowedTools`'tur: boşsa tüm araçlar, doluysa yalnızca seçilenler açıktır; araç tipine göre ayrı
bir bayrak yoktur, sınırı kullanıcı yetkisi çizer. **İki MCP yetkisi vardır:** `.Invoke` uca
erişim ve okumadır (MCP servislerinin sınıf düzeyinde aranır); `.Write` MCP üzerinden yapılan her değişikliktir ve
değişiklik yapan her araç `RequiredPermissions`'ta `.Write`'a bağlanır — okuma/yazma ayrımı için
yeni yetki açılmaz. **Kiracı veri okur ve yazar, geliştirme yapamaz**: kiracı bağlamında açık
araçlar `McpToolNames.TenantTools` ile sınırlıdır (şema, `query_list`, `run_select`,
`insert_row`/`update_row`/`delete_row`) ve `AllowedTools` bu kümeyi yalnızca daraltır. Satır
yazma ekranın C/U/D sözleşmesinden geçtiği için kiracıya açıktır; ekran yetkisinden geçmeden
yazabilen araç (procedure, Custom Endpoint) ve geliştirme araçları (`DevelopTools`) `TenantTools`'a
eklenmez. Geliştirme işlemi yine Wizard / Custom Components AppService'inden geçer.
**`/mcp` istek düzeyindeki unit of work'ün dışındadır** (`AbpAspNetCoreUnitOfWorkOptions.IgnoredUrls`):
cevap SSE ile aktığı için istek UoW'si sonuç istemciye gittikten sonra commit edilir ve bağlantı
kapanınca geri alınır — araç başarılı görünür, EF kayıtları kaybolur. Her MCP servis çağrısı
kendi UoW'sini sonuç dönmeden tamamlar; bu istisna kaldırılmaz.
Kapı iki yerdedir: `AddListToolsFilter` listeyi süzer, `McpAppService.EnsureToolAllowedAsync`
çağrıyı reddeder — süzme tek başına yetki sınırı değildir, ikisi birlikte yazılır. `Invoke`
uçta değil her MCP servisinin sınıf düzeyindeki `[Authorize]`'dadır; yeni bir MCP AppService'i de
bu attribute'la açılır.
**MCP'nin kendi reddi istisna değildir, modelle konuşmadır**: eksik/hatalı girdi, durum kuralı,
kapalı araç ve eksik yetki `McpProblem` olarak döner — MCP AppService'leri `McpOutcome<T>` /
`McpProblem?` döndürür, domain servisleri (`McpSqlGuard`, `McpTableCreator`, `McpSqlObjectWriter`)
de öyle; araç katmanı bunu `McpToolResult.From` ile hata sonucuna (`CallToolResult.IsError`) çevirir,
model mesajı okuyup tekrar dener. `throw new UserFriendlyException` ve `AuthorizationService.CheckAsync`
MCP kodunda kullanılmaz (debugger'ı durdurur, SDK'da yığın izli `ERR` üretir); yetki
`McpAppService.CheckPermissionAsync` ile adıyla istenir. İstisna yalnızca gerçek hatadır (kod hatası,
beklenmeyen durum). Arkadaki platform servisinin (wizard, custom component, ListForm verisi) attığı
`UserFriendlyException`/yetki reddi ile sınıf düzeyindeki `[Authorize]` reddi `McpToolErrorTranslator`
(çağrı filtresi) ile mesaja çevrilir. Yeni aracın arkasındaki servis bir yetkiyi `[Authorize]`
attribute'uyla istiyorsa (ret mesajı yetki adını taşımaz) o yetki `McpToolNames.ServicePermissions`'a
eklenir; ekran yetkisi gereken araç yetkiyi `IMcpListFormAccessFilter.GetPermissionNameAsync` +
`CheckPermissionAsync` ile adıyla önceden ister.
Araç → yetki tablosu `README.md` §13.9'dadır; araç ya da yetki değişirse tablo da değişir.
Hazır prompt'lar (`McpPromptNames` + `McpDevelopPrompts`) yalnızca metindir; adımları tekrar
etmez, `get_dev_guide` bölümüne yönlendirir. Bir kural bağlayıcıysa prompt'a değil araca yazılır
(örnek: `Ready to Start` şartı `start_dev_request`'tedir).
- **Wizard deploy veritabanına yazmaz** → wizard ekranı, MCP `deploy_wizard` ve düzenleme aynı
yoldan (`ListFormWizardAppService.Create`) geçer ve **yalnızca seed dosyası yazar**: `wizard/{Ad}.json`
+ menü/modül/izin grubu/yetki/dil satırları (`data/Sas.Menus.{Menu,Module}.json`,
`data/Abp.Identity.Permission{Groups,s}.json`, `data/Sas.Languages.LanguageText/{CultureName}.json`;
`WizardSeedRowStore`, anahtarlar `WizardSeedRowKeys`). Kurulum yalnızca DB Migrate'tedir:
`WizardDataSeeder` ekrana özgü kayıtları (veri kaynağı, ListForm, alan, iş akışı) kurar,
`ListFormSeedDataApplier` satırları ekler. Deploy/import dosyaya `Pending` yazar; Wizard Manager
onu "Migrate bekliyor" gösterir (`WizardFileInfoDto.IsPending`), migrate önceki sürümün kayıtlarını
(`Pending.Replaced`) kaldırıp ekranı yeniden kurar ve `Pending`'i siler. Yetkiler **hiçbir role
otomatik atanmaz** (admin dahil); rol ekranından verilir. Deploy'a veritabanı yazan ya da silen bir
adım ekleme. Ayrıntı: `lowcode.instructions.md` §4.0.
- **MCP ile demo veri** → geliştirme isteği örnek satır taşıyorsa (prompt'taki tablo/liste, ekran
görüntüsü) ekran `deploy_wizard`'a `SeedData: true` ile gider (`SeedFilePath = data/{ListFormCode}.json`
; `SeedSync*` bayrakları **kapalı** kalır — yalnızca istek ekrandan girilen kayıtların dosyaya
yansımasını açıkça isterse `SeedSync: true`, wizard ekranında da aynı; `ListFormWizardDto.BuildListForm`) ve satırlar aynı çağrıda `seedRows` ile
`configs/seeds/data/{ListFormCode}.json` dosyasına yazılır (`McpDevelopAppService`; alan adları
ekrana göre doğrulanır, `Guid` anahtarı sunucu üretir). Migrate ekranı kurar, ardından satırları
ekler; MCP migrate'i beklemez. Talep döngüsü `Testing`'de biter, `Staging`'e kullanıcı alır. Dosya elle yazılmaz, görselden okunamayan değer uydurulmaz.
`SeedData` wizard ekranında sorulmaz ama düzenlemede korunur (`RefreshFrom` + UI modeli).
Ayrıntı: `lowcode.instructions.md` §0.10.1.
- **Talepteki görsel** → `Sas.DevRequests` açıklamasına yapıştırılan görsel base64 `data:` URI olarak
kalır, hiçbir yere yüklenmez. `start_dev_request` / `get_dev_request` onu metinden ayıklar
(`McpDevRequestImages`) ve MCP resim içeriği (`ImageContentBlock`) olarak döndürür; metinde
`[image-N]` kalır. Sonucun tamamı istemcinin araç sonucu sınırına (Claude Desktop 1 MB) sığar:
kalan pay görsellere bölünür, payını aşan görsel `IMcpImageShrinker` (Host'ta SkiaSharp) ile küçültülüp
JPEG'e çevrilir, yine sığmayan gönderilmez ve metinde bildirilir; kaynak açıklama değişmez.
Açıklama bu yüzden `update_row` ile yeniden yazılmaz. `SubTasks` de
`add_dev_request_subtasks` ile yazılır: atanan kişi bağlanan kullanıcı, bitiş tarihi bugün —
sunucu doldurur, modele bırakılmaz. `Timesheets`'i tamamen sunucu yazar
(`IMcpDevRequestTimesheetRecorder`): `get_dev_request`/`start_dev_request` talebi etkin talep yapar,
sonraki her üretim/veri yazma işlemi (`create_table`, `save_sql_object`, `generate_crud_endpoints`,
`deploy_wizard`, `delete_wizard`,
`save_component`, `delete_component`, `save_report_category`, `save_report_template`,
`delete_report_template`, satır araçları, `save_dev_request_analysis`,
`append_dev_request_note`, `add_dev_request_subtasks`) ona `Sas.Mcp.Timesheet.*` anahtarından Türkçe
açıklama ve ölçülen süreyle bir satır ekler; yalnızca talebi açan `start_dev_request` satır yazmaz. Yeni bir üretim/veri yazma aracı eklenirse kaydedici çağrısı ve dil anahtarı da eklenir.
Satır aracın UoW'si commit edildikten sonra ayrı UoW'de yazılır; aynı UoW'de yazmak Dapper
transaction'ının `Sas_H_DevRequest` kilidini EF'in ayrı bağlantısından bekletip zaman aşımına düşürür.
- **Talep analizi** → `Description` talebin yazıldığı hâliyle kalır; MCP ona yazmaz. Analiz ve
üretim notları `Analysis` kolonundadır (Todo panosunda `AnalysisExpr`, Açıklama yanında sekme).
Durum sırası `Backlog → Analysis → Ready to Start → …`: "Analiz et" `save_dev_request_analysis`
ile yalnızca üretimi başlamamış (`Backlog`/`Analysis`/`Ready to Start`) talebi analiz eder (ekran/tablo üretmez,
modül dokümanına yazmaz — dosya ve dal bölümü üretimde, deploy ile açılır; onaylanan analiz bölümün
"2. Analiz ve kararlar" alt bölümüne üretimde `save_module_doc` ile yazılır). Durumu sunucu
`productionReady` ile belirler: "Üretime hazır: Evet" → `Ready to Start`, şartlı ya da Hayır →
`Analysis` (kullanıcı kararı bekler). Üretim notu `append_dev_request_note` ile `Analysis`'e eklenir.
Analiz standardı tek yerdedir: `lowcode.instructions.md` §0.12; prompt'lar oraya yönlendirir.
- **MCP şema araçları** → tablo/sütun bilgisi canlı katalogdan değil `Sas_H_DbTableColumn`
sözlüğünden okunur (`IMcpSchemaDictionary`). Sözlüğün alan adları
`dbo.Sas_H_ListFormFieldsDictionaryJson` prosedürünün ürettiği JSON ile birebir aynıdır;
prosedür değişirse `McpDictionaryListForm`/`McpDictionaryField` de değişir. Sözlük tablo kataloğunu
değil ekranların alanlarını döker: bir tablonun ekranda alan olarak tanımlanmamış kolonunu MCP göremez.
Modelin okuması gereken kolon (ör. `AbpUsers`'ın kişisel bilgileri) ilgili ekranda gizli alan olarak durur.
- **MCP OAuth** → bilinen sağlayıcılar (Claude, ChatGPT) seed edilmiş `Platform_Mcp` istemcisini,
yerel `mcp-remote` proxy'si açık onaylı `Platform_Mcp_Local` istemcisini,
diğer MCP istemcileri `/connect/register` ile üretilen `mcp_` önekli açık onaylı istemcileri
kullanır (`McpOAuthClientManager`; seed `McpOAuthClientDataSeedContributor`). Bilinen sağlayıcı
eklemek = DbMigrator'daki `Platform_Mcp:RedirectUris` listesine callback eklemek. MCP
istemcisi kararı tek yerdedir (`McpConsts.IsMcpClient`): `resource` parametresi yok sayılır ve
token kullanıcı oturumuna bağlanmaz; token ucuna yeni bir oturum yolu eklenirse bu istisna
korunur, yoksa connector web oturumunu düşürür.
- **MCP yalnızca seed dosyası yazar** → Wizard gibi: geliştirme araçları (`create_table`,
`save_sql_object`, `generate_crud_endpoints`, `deploy_wizard`) yalnızca `configs/seeds/` altına dosya
yazar, veritabanına DB Migrate yansıtır. Yeni bir geliştirme aracı veritabanına yazan AppService'i
çağırmaz, seed dosyasını üreten ortak metodu kullanır (ör. `CrudEndpointSeedFileDto.Generate/Write`;
diyaloğun servisi de aynı metottan geçer). Hata kodu yalnızca migrate'i bozacak girdi içindir;
düzeltilebilen girdi (bilinmeyen seçenek, boş/tekrar eden index) sessizce normalize edilir.
Kolon adları tahmin edilmez: şema araçları geliştiriciye migrate bekleyen wizard ekranlarını ve
seed-only tabloları da döner (`McpSchemaAppService.GetSeedOnlySourcesAsync`). `deploy_wizard`
girdisi dosya biçimi (`{"Wizard": {...}}`, başlıklı) ya da düz cevaptır (`WizardSeedFileDto.FromDeployJson`);
bloklar isteğe bağlıdır (yazılmayan blok `ApplyDefaults` ile dolar), kısayol cevap bloktan üstündür,
eski biçimin `ListForm`/`Fields` bölümleri reddedilir (`Sas.ListForm.WizardError.LegacySectionsNotAccepted`);
`get_wizard` yeni biçimi döner.
- **MCP yetki vermez** → yeni bir MCP aracı kullanıcının uygulamadaki yetkisini aşamaz: liste verisi
ekranın sorgusundan (`IListFormSelectAppService`/`IListFormDataAppService`) geçer; liste
yetkisini aşan bir yetenek (ham SQL gibi) `McpToolNames.RequiredPermissions`'a uygulamadaki
karşılık gelen yetkiyle eklenir. `create_table` bu yüzden `.Write`'a ek olarak `Sas.SqlQueryManager`
ister; tablo scriptini tasarımcının `generateCreateTableSql` çıktısıyla aynı biçimde üretir
(`McpTableCreator`) ve **veritabanında çalıştırmaz** — MCP veritabanı kurmaz, tabloyu da ekran gibi
kullanıcının çalıştırdığı DB Migrate kurar; wizard deploy'u kurulmamış tablonun kolonlarını seed
scriptinden okur (`PostgreSqlTableScript.ReadSeedColumns`) — tasarımcının şablon kolonları ya da script biçimi değişirse o da değişir. Tasarımcı
(`SaveTableScriptAsync`) ve `create_table` T-SQL dosyanın PostgreSQL eşini `PostgreSqlTableScript` ile
üretip `postgres/object/` altına yazar; script biçimine yeni bir deyim eklenirse çevirisi de eklenir,
yoksa PG eşi yazılmaz (log uyarısı). Düşürülen nesnenin dosyası iki klasörden de silinir.
- **Çalışan (persona) ekranları ve profil** → çalışanın/yöneticinin kendi listeleri İK menüsüne
konmaz: menüsüz `Hr.My*`/`Team*` wizard + Intranet panosu widget'ı. Profil sayfasının İzinlerim /
Puantajım / Performansım sekmeleri persona ekranlarını salt okunur gösteren çekirdek bileşenlerdir
(düzenle/sil yok, tek aksiyon izin talebi oluşturma); yeni persona
ekranını profile eklemek kod + rota değişikliğidir (onay). Çalışan Bilgileri sekmesi ve Genel sekmesi
kullanıcının kendi alanlarını `my-profile` ek alanlarından okur (`PlatformProfileAppService.ProfileExtraProperties`);
sağlık, ücret ve işten çıkış alanları bu listeye girmez. Ayrıntı: `lowcode.instructions.md` §0.12.1.
- **Bildirim kapsamı** → bildirim tipi, kuralı ve kuyruğu kiracıya aittir (`Adm_T_NotificationTemplate`,
`Adm_T_NotificationType`, `Adm_T_NotificationRule`, `Adm_T_Notification`; dördü de `IMultiTenant`) ve ekranların tamamı
Administration › Bildirimler altındadır. Ayrı bir manuel gönderim tablosu yoktur: **Bildirimler**
ekranının ekleme formu elle gönderimdir, uç alıcı × kanal açılımını kuyruğa yazar
(`list-form-dynamic-api/notification-insert`). Yeni kiracıya varsayılan tür/kural kümesi
gelir: kendi veritabanı olan kiracı seed dosyasından alır, host veritabanını paylaşana
`NotificationTenantDefaultsSeedContributor` aynı dosyalardan yeni kimliklerle kopyalar; iki yol
aynı kiracıda çalışmaz. Kiracı verisine dokunan zamanlanmış işin
`Scope` alanı `Tenant` ya da `Host ve Kiracılar` olmalıdır — `Host` kapsamında çalışan bir iş
kiracı satırlarını göremez (`WorkerScopeEnum`, `PlatformBackgroundWorker`).
- **Bildirim bayrakları** → sıra değişmez: `IsSent` (iletildi) olmadan `IsRead` yazılmaz, `IsRead`
ile `ReadTime` birlikte yazılır. `UiActivity` kayıt oluşurken iletilmiş sayılır, `UiToast`/`Desktop`
tarayıcıda gösterildiğinde (`NotificationChannels.BrowserChannels`; `sent-many` ucu yalnızca bu
kümeyi işaretler), kalan kanalları `NotificationWorker` işaretler — gönderim okuma değildir.
Kullanıcı tercihi (`IsCustomized` + `RecipientType = User`) kural değildir, yalnızca sahibini
kapatır; alıcıyı kurallar belirler. Yeni bildirim üretildiği anda `/notificationhub` ile
duyurulur (`INotificationRealtimeNotifier`; sözleşme bildirim modülünün Domain'inde, SignalR
gerçeklemesi barındıran uygulamada — modül katmanı ASP.NET Core'a bağlanmaz) — duyuru en iyi çabadır, istemcideki yedek yoklama kaldırılmaz.
Ayrıntı: `README.md` §13.3–§13.5.
- **SEO** → **ayrı bir SEO tablosu yoktur**; meta, yolu tanımlayan kaydın üzerinde yaşar. Üç
parça birlikte çalışır: genel varsayılanlar `Adm.SiteManagement.Seo.*` ayarları, yol bazlı
alanlar `Route.Seo*` ve `CustomComponent.Seo*` (ikisinin de `SeedSync*` bayrakları açık),
istemci tarafı `SeoHead`. Üçü de uygulama konfigürasyonunun `seo` alanından beslenir
(`PlatformApplicationConfigurationContributor` → `SeoAppService.GetConfigAsync`); `sitemap.xml`
ve `robots.txt` aynı servisten `SeoController` ile yayınlanır. Aynı yolu iki kaynak da
tanımlıyorsa bileşen kazanır — çalışma zamanında da bileşen fiziksel rotayı gölgeler.
**Ziyaretçiye açık yeni bir yol üreten her iş SEO alanlarını da doldurur** (standing default 7).
Sitemap'e giren kaynaklar (`Pub` menüleri, aktif custom component rotaları, yayınlanmış blog
yazıları) `SeoAppService` içindedir; yeni bir public kaynak türü eklenirse toplayıcıya da
eklenir, yoksa sayfa sitemap'te görünmez. Yeni bir meta alanı = iki entity + `SeoRouteDto` +
`SeoFields` (seeder) + `SeoHead` birlikte.
- **Yeni bir dil anahtarı** → `configs/seeds/data/Sas.Languages.LanguageText/` klasörünün **bütün** dil dosyalarına
(`ar, de-DE, en, es, fi, fr, hi, hr, hu, it, pt-BR, ru, sk, sl, tr, zh-Hans` — `Sas.Languages.Language.json`'daki
her dil bir dosyadır; **dosya adı dilin `CultureName`'idir** (`{CultureName}.json`: `tr.json`, `de-DE.json`), dosya
o dilin bütün satırlarını `Rows` içinde taşır), dil dosyasına anahtar başına bir satır; EN + TR yazılan metindir,
diğer 14 dil bu iki metinden çevrilir. Yeni bir dil `Sas.Languages.Language.json`'a eklenirse klasöre
`{CultureName}.json` açılır ve bütün anahtarlar çevrilir. Ekranın seed hedefi gruplu klasördür
(`SeedFilePath` = `data/Sas.Languages.LanguageText/{CultureName}.json`): ekrandan insert/update/delete yalnızca
satırın kültür dosyasına yazar, satır başına dosya açılmaz.
`Id` = `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}` adından URL isim alanlı v5 GUID; eklemeden önce anahtarın
zaten var olup olmadığını kontrol et.
- **Custom component sınıfları** → bileşen kodu veritabanında yaşar, Tailwind derlemede yalnızca
seed dosyasını görür. Seed dosyası `ui/` dışında olduğu için aynı sınıflar `ui/safelist.txt`
dosyasına da yazılır (`ui/twSafelistGenerator.ts` Vite eklentisi, derlemede otomatik); yoksa
uygulama sunucuda stilsiz kalır. İkinci bir safelist dosyası açma. Çalışma anında veriden üretilen
sınıf (`bg-${renk}-500`) dosyaya değil `src/assets/styles/tailwind/index.css`'teki `@source inline`
satırına eklenir. Sınıf adları Tailwind 4'tür (`shadow-xs`, `rounded-sm`, `bg-black/50`,
`shrink-0`, `x!`); tema `tailwind.config.ts`'te değil aynı dosyanın `@theme` bloğundadır.
- **Custom component kaydı/silinmesi** → `data/Sas.DeveloperKit.CustomComponents/{Id}.json`
(Custom Components ekranının seed verisi); ekran klasör modundadır, her bileşen kendi satır
dosyasındadır. Hem listeden hem Visual Designer'dan (`CustomComponentAppService`) yapılan kayıt
aynı klasöre yansır; kullandığı endpoint'ler `crud/{Entity}.json`.
- **Wizard dosyası** → tek başına taşınmaz; export zip'i **özyinelemeli bağımlılık kapanışını**
taşır (`WizardExportCollector`): alt formların wizard dosyaları, ekranın `SeedFilePath` veri
aynası, lookup sorgularının işaret ettiği nesnelerin scriptleri, `crud/` ve
`{sql|postgres}/{object|execute}/` dosyaları. `Custom` yolunda `data/` girdisi yalnızca ilgili
bileşen ve bağımlılık zincirine süzülür; import'ta hedef dosyanın üstüne yazılmaz, satırlar
anahtar üzerinden birleştirilir. Kapanışa yeni bir bağımlılık türü eklenirse
`WizardImportValidator`'daki karşılığı da eklenir — biri toplar, diğeri eksikliğini yakalar.
- **Talep bağı (`DevRequest`)** → MCP `deploy_wizard` wizard dosyasına etkin talebi
(`IMcpDevRequestTimesheetRecorder.GetActiveRequestIdAsync`) `DevRequest { Id, Title }` olarak
yazar; bağ modelin girdisinden gelmez (`ListFormWizardDto.DevRequestId` `[JsonIgnore]`). Elle
yapılan kayıt önceki bağı korur, talepsiz wizard'da bölüm hiç yazılmaz. Wizard Manager talebi
gösterir, kaydına bağlar ve talebin bütün wizard'larını tek kapanışta export eder
(`export-dev-request`; `WizardExportCollector.CollectAsync` birden çok dosya alır). Wizard
dosyasını yeniden yazan yeni bir yol eklenirse `DevRequest` korunur — dosyayı DTO olarak okuyup
yazan yollar bunu kendiliğinden yapar.
- **Şema değişikliği** → ayrım **kayıp riskine** göredir. Eksik kolon **eklenir** (kayıpsız;
`ALTER TABLE ADD`), var olan kolon **hiçbir zaman değiştirilmez**, hiçbir kolon düşürülmez,
hiçbir tablo `DROP` edilmez. Ölçü farkında (uzunluk/kesinlik/nullability) import durdurulur ve
fark somut olarak bildirilir; varsayılanı olmayan `NOT NULL` bir kolonun dolu tabloya eklenmesi
de durdurulur — hangi değerin yazılacağı iş kararıdır. Karşılaştırma tek yerdedir
(`SqlTableSchemaComparer`), analiz ve kapanış aynı sonucu kullanır.
Karşılaştırmada tip **adı** kullanılmaz — sağlayıcı katalogları farklı kanonik ad raporlar ve
metin karşılaştırması sahte fark üretir. Genel kural: **sağlayıcıya göre değişen bir değeri
metin olarak karşılaştırma, katalogdan dinamik oku.**
- **Veritabanına dokunan her import ucu** → `EnsureImportNotBlocked` ile başlar. Analizdeki
engelleme kararı sunucuda `blocked` dosyasında durur; yeni bir uç eklenirse kontrolü de eklenir,
yoksa karar istemci tarafından atlanabilir hâle gelir.
- **Wizard import'u** → analiz dosyalar yazılmadan **önce** doğrular (`WizardImportValidator`);
`Error` seviyesinde bir bulgu varsa oturuma `blocked` işareti düşer ve `ApplyImport` reddeder.
Kapanış (`CompleteImport`) bağımlılık sırasıyla çalışır: `sql/object` → `sql/execute` → `crud`
→ `data` → `wizard`. Tablo eksikse önce scripti çalışır; sıra değiştirilmez. Ekran import'ta
**kurulmaz**: wizard dosyasına `Pending` yazılır ve ekran deploy'daki gibi DB Migrate'te kurulur;
dosyanın `Wizard` bölümündeki bloklar olduğu gibi uygulanır (`BuildListForm/BuildFields`), varsayılanlar
yeniden üretilmez — tasarımcı değişiklikleri bloklarda durduğu için korunur. Wizard'ın menü/yetki/dil satırlarını
taşıyan platform dosyaları `data` adımında uygulanmaz (`WizardSeedRowStore.ListFormCodes`).
Engelleyici bulgu iki sınıftır: **arşiv kaynaklı** (`RequiresReexport` — bozuk/eksik dosya,
karşılığı olmayan referans) hedef ortamda giderilemez, export tekrarlanır; **ortam kaynaklı**
(eksik tablo, şema farkı) ekrandan giderilir. Yeni bir `Error` eklerken hangisi olduğu işaretlenir.
Engelleme **oturum genelidir, dosya bazlı değildir** —
import ya bütün olarak çalışır ya da hiç. Bunun bedeli, her engelin ekran içinde çözülebilir
olmasıdır: tabloya dair bir bulgu üretirken `TableName` (+ tablo hedefte yoksa `TableMissing`)
doldurulur, istemci tablo tasarımcısını buna göre açar.
- **`ListForm.SeedFilePath` dolu bir ekran** → `SeedSyncInsert/SeedSyncUpdate/SeedSyncDelete`
bayraklarından işaretli olan işlemler seed klasöründeki hedefe yansır
(`ListFormSeedDataSynchronizer`); yalnızca host bağlamındaki kayıt yansır, kiracının kaydı
veritabanında kalır. Hedef üç biçimdedir ve ayrım yoldadır: `.json` ile biten
değer **tek dosyadır** (`data/{ListFormCode}.json`, bütün satırlar orada), uzantısız değer
**klasördür** (`data/{ListFormCode}/{anahtar}.json`, her satır kendi dosyasında), `{Alan}.json` ile
biten değer **gruplu klasördür** (`data/{ListFormCode}/{Alan}.json` → aynı alan değerini taşıyan satırlar
`{değer}.json`'da; dil metinleri `{CultureName}`) ve klasör yoksa ilk kayıtta oluşturulur. Klasör modu
satırı büyük ya da sık yazılan, gruplu mod satır başına dosyanın on binlerce dosya üreteceği ekranlar
içindir (varsayılan tek dosya). **Biçimin tek kaynağı ekranın tanımıdır**; diskte ne bulunduğu kararı
değiştirmez ve tanım kod içinde ekran adıyla sabitlenmez. Hedefi okuyan/yazan her yol
(senkronizasyon, export/import, temizleme, wizard satır deposu, MCP demo satırları, bildirim
varsayılanları) `ListFormSeedTarget` üzerinden gider; paylaşılan ekranların tanımı
`ListFormWizardAppService.ReadSeedFilePathsAsync` ile okunup bileşenlere verilir. Applier `data/`
altını özyinelemeli tarar ve **bir klasörün tamamını tek unit of work'te** uygular — ekran
tanımı, alan listesi ve veri kaynağı satır dosyası başına değil ekran başına bir kez çözülür;
dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Ekran bir hedef tanımlıyorsa
yalnızca o hedef uygulanır; tanımın dışında kalan dosya (biçimi değişmiş ekranın eski dosyası)
uyarıyla atlanır. Biçimi değiştirmek tanımı (ListForm detayı ya da seeder) değiştirmektir;
yeniden wizard deploy'u tanımlı hedefi korur.
Migrate/seed sırasında ve wizard import'unun kapanışında
(`CompleteImport`) `ListFormSeedDataApplier` dosyayı geri uygular. Migrate/seed'de kayıt yoksa
eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki
değerlerle güncellenir ve soft delete izleri (`IsDeleted`/`DeletionTime`/`DeleterId`) temizlenir. Satırı veritabanından okuyan senkronizasyon açık UoW varsa commit sonrasında kendi UoW'sinde çalışır (EF ile yazan çağıran, ör. `MenuAppService`, aksi hâlde eski değeri dosyaya yazardı); yalnızca dosyaya dokunan `RemoveRowsByFieldAsync` hemen çalışır. Yeni bir kayıt yolu (yeni endpoint, toplu işlem, tipli AppService) eklenirse
senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
- **Seed kapsamı** → `configs/seeds/` tek klasördür; `host/` ya da `tenants/{tenantId}/` yoktur.
Host ve kendi veritabanı olan her tenant aynı dosyalarla kurulur, host veritabanını paylaşan
tenant'ta dosyalar yeniden uygulanmaz (`SeedFileScope`; migrate kararı `DataSeedContext`'e yazar).
Dosyayı okuyan yeni bir seeder `SeedFileScope.IsEnabled` ile başlar. Dosyalar yalnızca host
bağlamında yazılır; dosyaya yazan yeni bir yol tenant bağlamında yazmaz. Bekleyen wizard kurulumu
(`Pending`) dosyaları uygulayan son veritabanında kapanır (`SeedFileScope.CompletesPending`).
Tek istisna `{sql|postgres}/host/{object,execute}`: yalnızca host'ta çalışır; tenant veritabanında
karşılığı olmayan nesneye (HangFire tabloları) dayanan script buraya konur, tablo önekine bakılmaz.
Kendi veritabanı olan tenant'ta EF ile yalnızca `IMultiTenant` varlıklar o veritabanına gider;
ListForm, menü ve yetki tanımını ABP her zaman host'ta okur. Kiracı tablosunun satırları kiracı
adıyla kayıtlı `DataSource` üzerinden gider. Kiracı yalnızca kimliğiyle değiştirildiğinde ad boş
kalır; bu durumda adı `DataSourceManager` kiracı kaydından okur. Yeni tenant yöneticisi ilk
seed'de kiracı tarafı bütün yetkileri alır (`PlatformIdentityDataSeeder`). Bunun nedeni,
DbMigrator'da dinamik yetki deposunun kapalı olmasıdır: ABP'nin yetki seed'i seed dosyasından
gelen yetkileri göremez.
- **Seed dosyası yazmak** → veri dosyası `ListFormSeedDataFileDto.Serialize`, wizard dosyası
`WizardSeedFileDto.ToSeedJson` ile yazılır; düz `JsonSerializer.Serialize` + `File.WriteAllText`
kullanılmaz. System.Text.Json emojiyi her zaman vekil çift kaçışına çevirir; dosya baştan
yazıldığında bütün emojiler sahte fark üretir. İkisi de `SeedJsonText.RestoreSurrogatePairs`'ten
geçer. Veri dosyasının `GeneratedAt` değeri oluşturma anıdır: `Serialize` yalnızca boşsa doldurur,
hiçbir yazma yolu onu güncellemez — ekleyip geri kaldıran işlem (wizard deploy + silme) dosyayı
birebir önceki hâline döndürür. `crud/` dosyalarının damgası ayrıdır ve karar verir (aşağıda).
- **`crud/{Entity}.json`** → uygulama kuralı `CrudEndpointSeedApplier`'dadır ve damga
karşılaştırmalıdır: eksik endpoint eklenir, mevcut kayıt yalnızca dosyanın `GeneratedAt` değeri
kaydın `LastModificationTime`/`CreationTime` değerinden yeniyse güncellenir/silinir. Migrate
kullanıcının runtime revizyonunu ezmez. Yeni bir seeder yazarken bu kuralı taklit et: **migrate
varsayılanı eklemektir, ezmek değil.**
- **Modül = menü = tablo öneki = yetki kökü** → platformun C# seeder'lı kök menüleri `Sas`,
`Adm`, `Hr` (İK) ve `Pub` (kök menü `Pub.Header`) modüllerindedir
(`TableNameResolver.MenuPrefix` yalnızca bunları bilir); wizard ile üretilen modüller kendi kök menüsü ve
tablo önekiyle gelir — `Prj`, `Prc`, `Stk` (`Stk_`),
`Mnt` (`Mnt_`), `Prd` (`Prd_`); tam liste `data/Sas.Menus.Menu.json`
kök satırlarıdır. Bir ekranı modüller arası
taşımak birlikte yapılır: `TableNameResolver` (`MenuPrefix`) + `AppCodes` (Seed/Platform) +
`ListFormSeeder_{Sas,Adm,Hr}.cs` + `data/{ListFormCode}.json` adı + menü/yetki/dil
satırları + route `Authority`. Yetki `Modules` değeri: Sas/Adm `Sas|Adm`, İK `Sas|Hr`
(`Sas` host, `Adm`/`Hr` kiracı görünürlüğü). İK departman/pozisyon `BranchId` zorunludur; seed
satırı host şubesine (`PlatformConsts.Branches.BranchIdDefaultValue`) bağlanır.
- **Menü ağacı ve yetkiler** → `configs/seeds/data/Sas.Menus.{Routes,Module,Menu}.json` ve
`Abp.Identity.Permission{Groups,s}.json`; platformun kendi menü/yetki kayıtları da diğer liste
verisiyle aynı mekanizmadan (`ListFormSeedDataApplier`) geçer, ayrı bir seeder yoktur. `Order`
alanı bağımlılık sırasını kurar: route → modül → menü, yetki grubu → yetki, ülke grubu → ülke →
şehir → ilçe (`Sas.Definitions.{CountryGroup,Country,City,District}.json`), departman → onaycı (`Hr.Department` → `Adm.Definitions.Approver`), bildirim tipi → kuralı
(`Adm.Notifications.Notification{Types,Rules}.json`). Para birimi, hitap, AI bot
(`Sas.Definitions.{Currency,ContactTitle,AiBot}.json`), ayar tanımları (`Sas.SettingDefinitions.json`)
ve zamanlanmış işler (`Sas.BackgroundWorkers.RecurringJobs.json`), dil → metin
(`Sas.Languages.{Language,LanguageText}.json`; `LanguagesData.json` + `LanguageDataSeeder` kaldırıldı) da buradadır; `HostData.json`
kaldırıldı, `HostDataSeeder` yalnızca bağlantı cümlesi appsettings'ten gelen varsayılan veri kaynağını kurar. Dosyalar ekranın
anahtarını (`KeyFieldName`) kullanır; `Guid` anahtarlı satırın `Id`'si iş anahtarından türetilmiş
sabit bir GUID'dir, satır eklerken aynı kuralla üret. `Sas.Menus.Routes.json` dosyasına
yalnızca var olan satırın `Seo*` alanları için dokunulur (standing default 7); rota eklemek,
silmek ya da yolunu değiştirmek çerçeve dışıdır.
- **Public site** → menü `Menu` tablosunda `ModuleId = "Pub"` altında (`PlatformConsts.PublicSite`);
`Pub.Header` hem üst menüyü hem alt bilgideki hızlı bağlantıları besler (ikisi de
`PublicNav` düğümü) ve ziyaretçiye uygulama konfigürasyonunun `menus.public` alanıyla taşınır
(menü ucu `[Authorize]`). Sayfa ise Custom Component'tir: `/admin/` ile
başlamayan `RoutePath` otomatik public route olur ve aynı yolu paylaşan fiziksel route'u gölgeler;
`RoutePath` boş bırakılabilir (rotasız bileşen). Yeni public sayfa =
`data/Sas.DeveloperKit.CustomComponents/{Id}.json` satırı + `Pub` menü kaydı; ikisi birlikte yazılır.
Üst ve alt bilgi rotasız bileşenlerdir (`PublicHeader`, `PublicFooter`), layout ikisini de
`renderComponent` ile çizer; rotasız bileşende `RoutePath` **NULL** yazılır (tekil indeks yalnızca
dolu değerleri kapsar). Üst bilginin davranışı `components/publicSite` katalog düğümlerinde,
yerleşimi tasarımcıdadır.
**Public bileşenlerin hepsi tasarımcı belgesidir** (`Props.visualDesigner`, `sourceMode: visual`);
kod belgeden üretilir (`generateDesignerCode`), elle yazılmış koda dönülmez. Runtime kapsamında
`react-icons`/`react-router` yoktur — ikon `PlatformIcon` düğümü, gezinme `a` düğümü. Kanvasın
doğrudan çizdiği katalog düğümleri tek sözlüktedir (`componentEditor/runtimeNodes.ts`); yeni bir
düğüm eklerken bileşen + bu sözlük + `catalog.ts` tanımı + dil anahtarı birlikte yazılır. Elle
yazılmış bir bileşen tasarımcıda kod modunda açılır, kanvasa çevrilmez. Ayrıntı: `README.md` §10.4.
- **Çok değerli alan (`dxTagBox` / çoklu `dxGridBox`)** → tek metin kolonunda `|` ile saklanır.
Ekran varsayılan `list-form-data/*` ucundan kaydediyorsa birleştirmeyi `QueryHelper` yapar;
tipli DTO alan bir uca (`list-form-dynamic-api/...`, Custom Endpoint, Dynamic Service)
bağlıysa DTO alanına `[JsonConverter(typeof(MultiValueStringJsonConverter))]` eklenir —
yoksa istek `400` ile düşer. Ayrıntı: `lowcode.instructions.md` §4.3.2.1.
### Definition of done
- Ölü kod bırakma: kullanılmayan `using`, private üye, alan, DTO, hook, servis, tip. Bir tip
silinmeden önce **repo geneli** (api + ui + configs) referans taraması yapılır; DI ile çözülen
tipler düz metin aramasında referanssız görünür.
- Yorum satırına alınmış kod bloğu bırakılmaz.
- Seed tarafında ölü artefakt yok (`lowcode.instructions.md` §8.6): yerini yenisine bırakan SQL nesnesi,
wizard ve veri dosyası silinmiş; her `sql/` dosyasının `postgres/` eşi var; kullanılmayan dil anahtarı
wizard'ın `Language.Texts`/`InsertedRecords`'undan ve dil dosyasından çıkarılmış.
- Modül/ekran/bileşen ekleyen ya da davranışını değiştiren iş modül dokümanını
(`configs/seeds/docs/modules/{Menü}.md`) yazar veya günceller; liste ekranının açıklaması
ayrıntılıdır (standing default 14–15).
- `api/` değişikliğinden sonra: `dotnet build -p:EnforceCodeStyleInBuild=true` — 0 hata, yeni
`IDE00xx` yok.
- `ui/` değişikliğinden sonra: `npm run typecheck` ve `npm run lint`.
- Yorumlar **neden**i anlatır, ne yaptığını değil. Kod zaten ne yaptığını söylüyor.
- **Dokümantasyon kodla birlikte güncellenir.** Platformun davranışını değiştiren her değişiklik
(yeni/kaldırılan seeder, seed klasörü ya da dosya biçimi, ListForm sözleşmesine alan ekleme,
yeni yetki/dil anahtarı, katman sınırı, yeni mekanizma) aynı commit içinde ilgili `.md`
dosyasına yansıtılır: `README.md` (ne nerede yaşıyor, klasör düzeni, tablolar),
`.github/instructions/lowcode*.instructions.md` (artefakt/yetenek sözleşmesi),
`dotnet.instructions.md` (kod standardı, katman kuralı), `CLAUDE.md` (çalışma kuralı).
Değişiklik hangi md'de karşılığı olduğu bilinmiyorsa önce grep ile eski anlatım aranır ve
düzeltilir; bayat kalan bir satır bırakma. Doküman güncellemesi "sonraya" bırakılmaz.
### Tone
- Türkçe soruya Türkçe cevap.
- Platformu tanıtma, mimariyi özetleme, ne yapacağını anlatıp sonra bir de ne yaptığını özetleme.
- Cevabın boyutu sorunun boyutu kadar olsun.

793
README.md

File diff suppressed because it is too large Load diff

View file

@ -6,4 +6,15 @@
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<!--
ABP paketleri System.Security.Cryptography.Pkcs 10.0.0'ı geçişli olarak getiriyor (GHSA-846f-f764-76mx,
NU1903). Paket çerçeveden budanmadığı için yamalı sürüm bütün projelere tek yerden verilir; ABP yamalı
sürümü kendisi çekince satır kaldırılır (dotnet list package - -vulnerable - -include-transitive).
Projelerin kendi doğrudan referansı olan paketler (MessagePack, Cryptography.Xml) kendi csproj'larında güncel tutulur.
-->
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
</ItemGroup>
</Project> </Project>

View file

@ -55,11 +55,11 @@ username=system%40sozsoft.com
- WhatsApp -> API Setup kısmından "From" kısmında yeni telefon numarası eklenir. - WhatsApp -> API Setup kısmından "From" kısmında yeni telefon numarası eklenir.
- WhatsApp -> API Setup kısmından Generate Access Token oluşturulur. - WhatsApp -> API Setup kısmından Generate Access Token oluşturulur.
- token: EAASM83pDJf0BO0ZCYBr3Fx4SES0ox8XiZCgP3FIteprYpwawZCkGYjpMlKk4OZCWgRNZC2Ttofgf8amMf929ZBuDcgYIOsXkPMGBNmVxm2czipzq63LmtbYfyCWsKVy0q1jtay0nRoeDuO2FKhqCfLLtNn9cSWjINVLcz26ptK8a2Oko83cZBzrNsp3cpIECnzqRfEjHdEz1U73y3ZB1MZBOhHilax7yZALf4ZA1Tmk - token: <access token>
- WhatsApp telefon numarasını Register etmek için aşağıdaki postman Curl gönderilir. - WhatsApp telefon numarasını Register etmek için aşağıdaki postman Curl gönderilir.
``` ```
curl --location 'https://graph.facebook.com/v21.0/521106361082067/register' \ curl --location 'https://graph.facebook.com/v21.0/<phone-number-id>/register' \
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
--header 'Authorization: Bearer <access token>' \ --header 'Authorization: Bearer <access token>' \
--data '{ --data '{
@ -68,7 +68,7 @@ username=system%40sozsoft.com
}' }'
``` ```
- Message göndermek için url : https://graph.facebook.com/v21.0/521106361082067/messages - Message göndermek için url : https://graph.facebook.com/v21.0/<phone-number-id>/messages
``` ```
{ {

View file

@ -55,7 +55,6 @@ COPY "src/Sozsoft.Platform.Domain/Sozsoft.Platform.Domain.csproj" "src/Sozsoft.P
COPY "src/Sozsoft.Platform.Domain.Shared/Sozsoft.Platform.Domain.Shared.csproj" "src/Sozsoft.Platform.Domain.Shared/" COPY "src/Sozsoft.Platform.Domain.Shared/Sozsoft.Platform.Domain.Shared.csproj" "src/Sozsoft.Platform.Domain.Shared/"
COPY "src/Sozsoft.Platform.EntityFrameworkCore/Sozsoft.Platform.EntityFrameworkCore.csproj" "src/Sozsoft.Platform.EntityFrameworkCore/" COPY "src/Sozsoft.Platform.EntityFrameworkCore/Sozsoft.Platform.EntityFrameworkCore.csproj" "src/Sozsoft.Platform.EntityFrameworkCore/"
COPY "src/Sozsoft.Platform.HttpApi/Sozsoft.Platform.HttpApi.csproj" "src/Sozsoft.Platform.HttpApi/" COPY "src/Sozsoft.Platform.HttpApi/Sozsoft.Platform.HttpApi.csproj" "src/Sozsoft.Platform.HttpApi/"
COPY "src/Sozsoft.Platform.HttpApi.Client/Sozsoft.Platform.HttpApi.Client.csproj" "src/Sozsoft.Platform.HttpApi.Client/"
COPY "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" "src/Sozsoft.Platform.HttpApi.Host/" COPY "src/Sozsoft.Platform.HttpApi.Host/Sozsoft.Platform.HttpApi.Host.csproj" "src/Sozsoft.Platform.HttpApi.Host/"
COPY "src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj" "src/Sozsoft.Platform.DbMigrator/" COPY "src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj" "src/Sozsoft.Platform.DbMigrator/"
COPY "test/Sozsoft.Platform.EntityFrameworkCore.Tests/Sozsoft.Platform.EntityFrameworkCore.Tests.csproj" "test/Sozsoft.Platform.EntityFrameworkCore.Tests/" COPY "test/Sozsoft.Platform.EntityFrameworkCore.Tests/Sozsoft.Platform.EntityFrameworkCore.Tests.csproj" "test/Sozsoft.Platform.EntityFrameworkCore.Tests/"

View file

@ -17,8 +17,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.Applicatio
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi", "src\Sozsoft.Platform.HttpApi\Sozsoft.Platform.HttpApi.csproj", "{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi", "src\Sozsoft.Platform.HttpApi\Sozsoft.Platform.HttpApi.csproj", "{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi.Client", "src\Sozsoft.Platform.HttpApi.Client\Sozsoft.Platform.HttpApi.Client.csproj", "{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.DbMigrator", "src\Sozsoft.Platform.DbMigrator\Sozsoft.Platform.DbMigrator.csproj", "{AA94D832-1CCC-4715-95A9-A483F23A1A5D}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.DbMigrator", "src\Sozsoft.Platform.DbMigrator\Sozsoft.Platform.DbMigrator.csproj", "{AA94D832-1CCC-4715-95A9-A483F23A1A5D}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi.Host", "src\Sozsoft.Platform.HttpApi.Host\Sozsoft.Platform.HttpApi.Host.csproj", "{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi.Host", "src\Sozsoft.Platform.HttpApi.Host\Sozsoft.Platform.HttpApi.Host.csproj", "{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D}"
@ -186,18 +184,6 @@ Global
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x64.Build.0 = Release|Any CPU {4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x64.Build.0 = Release|Any CPU
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.ActiveCfg = Release|Any CPU {4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.ActiveCfg = Release|Any CPU
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.Build.0 = Release|Any CPU {4164BDF7-F527-4E85-9CE6-E3C2D7426A27}.Release|x86.Build.0 = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x64.ActiveCfg = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x64.Build.0 = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x86.ActiveCfg = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Debug|x86.Build.0 = Debug|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|Any CPU.Build.0 = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x64.ActiveCfg = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x64.Build.0 = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x86.ActiveCfg = Release|Any CPU
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC}.Release|x86.Build.0 = Release|Any CPU
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU {AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|x64.ActiveCfg = Debug|Any CPU {AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -593,7 +579,6 @@ Global
{42F719ED-8413-4895-B5B4-5AB56079BC66} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0} {42F719ED-8413-4895-B5B4-5AB56079BC66} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{520659C8-C734-4298-A3DA-B539DB9DFC0B} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0} {520659C8-C734-4298-A3DA-B539DB9DFC0B} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{4164BDF7-F527-4E85-9CE6-E3C2D7426A27} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0} {4164BDF7-F527-4E85-9CE6-E3C2D7426A27} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{3B5A0094-670D-4BB1-BFDD-61B88A8773DC} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{AA94D832-1CCC-4715-95A9-A483F23A1A5D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0} {AA94D832-1CCC-4715-95A9-A483F23A1A5D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0} {748584B1-BA69-4F6A-81AA-F4BDE6BCE29D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
{4D886EEF-D4CF-4879-A10D-249D762E4ED9} = {03E1C8DA-035E-4882-AF81-F392139FCF38} {4D886EEF-D4CF-4879-A10D-249D762E4ED9} = {03E1C8DA-035E-4882-AF81-F392139FCF38}

View file

@ -1,22 +0,0 @@
using System.Threading.Tasks;
using Volo.Abp;
namespace Sozsoft.Languages;
[IntegrationService]
public interface ILanguageKeyIntegrationService
{
Task CreateKeysForSettingsAsync(
string NameKey,
string DescriptionKey,
string MainGroupKey,
string SubGroupKey
);
Task DeleteKeysForSettingsAsync(
string NameKey,
string DescriptionKey,
string MainGroupKey,
string SubGroupKey);
}

View file

@ -6,6 +6,25 @@ public class LanguagesPermissions
{ {
public const string GroupName = "Languages"; public const string GroupName = "Languages";
// Yetki tanımları seed'dedir (data/Abp.Identity.Permissions.json); burada yalnızca adları durur.
// Değerler PlatformConsts.AppCodes.Languages ile aynıdır — modül platforma referans vermez.
public static class Language
{
public const string Default = "Sas.Languages.Language";
public const string Create = Default + ".Create";
public const string Update = Default + ".Update";
public const string Delete = Default + ".Delete";
}
/// <summary>Dil anahtarları ayrı bir ekranda değil, dil metinleriyle birlikte yönetilir.</summary>
public static class LanguageText
{
public const string Default = "Sas.Languages.LanguageText";
public const string Create = Default + ".Create";
public const string Update = Default + ".Update";
public const string Delete = Default + ".Delete";
}
public static string[] GetAll() public static string[] GetAll()
{ {
return ReflectionHelper.GetPublicConstantsRecursively(typeof(LanguagesPermissions)); return ReflectionHelper.GetPublicConstantsRecursively(typeof(LanguagesPermissions));

View file

@ -2,8 +2,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Sozsoft.Languages.Entities; using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Localization; using Sozsoft.Languages.Localization;
using Sozsoft.Languages.Permissions;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Entities;
@ -12,6 +14,7 @@ using Volo.Abp.Validation;
namespace Sozsoft.Languages; namespace Sozsoft.Languages;
[Authorize]
public class LanguageAppService : CrudAppService< public class LanguageAppService : CrudAppService<
Language, Language,
LanguageDto, LanguageDto,
@ -23,6 +26,11 @@ public class LanguageAppService : CrudAppService<
{ {
LocalizationResource = typeof(LanguagesResource); LocalizationResource = typeof(LanguagesResource);
ObjectMapperContext = typeof(LanguagesApplicationModule); ObjectMapperContext = typeof(LanguagesApplicationModule);
GetPolicyName = LanguagesPermissions.Language.Default;
GetListPolicyName = LanguagesPermissions.Language.Default;
CreatePolicyName = LanguagesPermissions.Language.Create;
UpdatePolicyName = LanguagesPermissions.Language.Update;
DeletePolicyName = LanguagesPermissions.Language.Delete;
} }
public override async Task<LanguageDto> CreateAsync(LanguageDto input) public override async Task<LanguageDto> CreateAsync(LanguageDto input)
@ -40,6 +48,7 @@ public class LanguageAppService : CrudAppService<
return await base.CreateAsync(input); return await base.CreateAsync(input);
} }
[Authorize(LanguagesPermissions.Language.Default)]
public async Task<LanguageDto> GetLanguageByCultureNameAsync(string CultureName) public async Task<LanguageDto> GetLanguageByCultureNameAsync(string CultureName)
{ {
var item = await Repository.FirstOrDefaultAsync(a => a.CultureName == CultureName); var item = await Repository.FirstOrDefaultAsync(a => a.CultureName == CultureName);

View file

@ -2,8 +2,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Sozsoft.Languages.Entities; using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Localization; using Sozsoft.Languages.Localization;
using Sozsoft.Languages.Permissions;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
@ -11,6 +13,7 @@ using Volo.Abp.Validation;
namespace Sozsoft.Languages; namespace Sozsoft.Languages;
[Authorize]
public class LanguageKeyAppService : CrudAppService< public class LanguageKeyAppService : CrudAppService<
LanguageKey, LanguageKey,
LanguageKeyDto, LanguageKeyDto,
@ -26,6 +29,11 @@ public class LanguageKeyAppService : CrudAppService<
{ {
LocalizationResource = typeof(LanguagesResource); LocalizationResource = typeof(LanguagesResource);
ObjectMapperContext = typeof(LanguagesApplicationModule); ObjectMapperContext = typeof(LanguagesApplicationModule);
GetPolicyName = LanguagesPermissions.LanguageText.Default;
GetListPolicyName = LanguagesPermissions.LanguageText.Default;
CreatePolicyName = LanguagesPermissions.LanguageText.Create;
UpdatePolicyName = LanguagesPermissions.LanguageText.Update;
DeletePolicyName = LanguagesPermissions.LanguageText.Delete;
_repository = repository; _repository = repository;
} }

View file

@ -1,64 +0,0 @@
using System;
using System.Threading.Tasks;
using Sozsoft.Languages.Entities;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace Sozsoft.Languages;
[IntegrationService]
public class LanguageKeyIntegrationService : ILanguageKeyIntegrationService, ITransientDependency
{
private readonly IRepository<LanguageKey, Guid> _repository;
public LanguageKeyIntegrationService(IRepository<LanguageKey, Guid> repository)
{
_repository = repository;
}
public async Task CreateKeysForSettingsAsync(
string NameKey,
string DescriptionKey,
string MainGroupKey,
string SubGroupKey
)
{
//NameKey
if (!await _repository.AnyAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName))
{
await _repository.InsertAsync(new LanguageKey { Key = NameKey, ResourceName = LanguagesConsts.AppName });
}
//DescriptionKey
if (!await _repository.AnyAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName))
{
await _repository.InsertAsync(new LanguageKey { Key = DescriptionKey, ResourceName = LanguagesConsts.AppName });
}
//MainGroupKey
if (!await _repository.AnyAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName))
{
await _repository.InsertAsync(new LanguageKey { Key = MainGroupKey, ResourceName = LanguagesConsts.AppName });
}
//SubGroupKey
if (!await _repository.AnyAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName))
{
await _repository.InsertAsync(new LanguageKey { Key = SubGroupKey, ResourceName = LanguagesConsts.AppName });
}
}
public async Task DeleteKeysForSettingsAsync(
string NameKey,
string DescriptionKey,
string MainGroupKey,
string SubGroupKey)
{
await _repository.DeleteAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName);
await _repository.DeleteAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName);
await _repository.DeleteAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName);
await _repository.DeleteAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName);
}
}

View file

@ -4,11 +4,12 @@ using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Linq.Dynamic.Core; using System.Linq.Dynamic.Core;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Sozsoft.Languages.Caching;
using Sozsoft.Languages.Entities; using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Localization; using Sozsoft.Languages.Localization;
using StackExchange.Redis; using Sozsoft.Languages.Permissions;
using Volo.Abp; using Volo.Abp;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
@ -19,6 +20,11 @@ using Volo.Abp.Validation;
namespace Sozsoft.Languages; namespace Sozsoft.Languages;
/// <summary>
/// Dil metinlerinin yönetim uçları. Çeviri okuma bu servisten geçmez — public sayfalar dahil bütün
/// istemciler metinleri ABP'nin application-localization ucundan (DatabaseResourceLocalizer) alır.
/// </summary>
[Authorize]
public class LanguageTextAppService : CrudAppService< public class LanguageTextAppService : CrudAppService<
LanguageText, LanguageText,
LanguageTextDto, LanguageTextDto,
@ -29,20 +35,25 @@ public class LanguageTextAppService : CrudAppService<
{ {
private readonly IRepository<LanguageText, Guid> _repositoryText; private readonly IRepository<LanguageText, Guid> _repositoryText;
private readonly IRepository<LanguageKey, Guid> _repositoryKey; private readonly IRepository<LanguageKey, Guid> _repositoryKey;
private readonly IConfiguration _configuration; private readonly DistributedCacheCleaner _cacheCleaner;
public LanguageTextAppService( public LanguageTextAppService(
IRepository<LanguageText, Guid> repositoryText, IRepository<LanguageText, Guid> repositoryText,
IRepository<LanguageKey, Guid> repositoryKey, IRepository<LanguageKey, Guid> repositoryKey,
IConfiguration configuration DistributedCacheCleaner cacheCleaner
) )
: base(repositoryText) : base(repositoryText)
{ {
LocalizationResource = typeof(LanguagesResource); LocalizationResource = typeof(LanguagesResource);
ObjectMapperContext = typeof(LanguagesApplicationModule); ObjectMapperContext = typeof(LanguagesApplicationModule);
GetPolicyName = LanguagesPermissions.LanguageText.Default;
GetListPolicyName = LanguagesPermissions.LanguageText.Default;
CreatePolicyName = LanguagesPermissions.LanguageText.Create;
UpdatePolicyName = LanguagesPermissions.LanguageText.Update;
DeletePolicyName = LanguagesPermissions.LanguageText.Delete;
_repositoryText = repositoryText; _repositoryText = repositoryText;
_repositoryKey = repositoryKey; _repositoryKey = repositoryKey;
_configuration = configuration; _cacheCleaner = cacheCleaner;
} }
public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input) public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input)
@ -63,6 +74,7 @@ public class LanguageTextAppService : CrudAppService<
return await base.CreateAsync(input); return await base.CreateAsync(input);
} }
[Authorize(LanguagesPermissions.LanguageText.Update)]
public async Task UpdateLanguageTextAsync(LanguageTextCreateUpdateDto input) public async Task UpdateLanguageTextAsync(LanguageTextCreateUpdateDto input)
{ {
var entity = await _repositoryText.GetAsync( var entity = await _repositoryText.GetAsync(
@ -77,6 +89,7 @@ public class LanguageTextAppService : CrudAppService<
} }
} }
[Authorize(LanguagesPermissions.LanguageText.Default)]
public async Task<PagedResultDto<LanguageTextTranslatedDto>> GetLanguageTextTranslatedAsync(LanguageTextTranslatedRequestDto input) public async Task<PagedResultDto<LanguageTextTranslatedDto>> GetLanguageTextTranslatedAsync(LanguageTextTranslatedRequestDto input)
{ {
var query = await _repositoryKey.GetQueryableAsync(); var query = await _repositoryKey.GetQueryableAsync();
@ -109,6 +122,7 @@ public class LanguageTextAppService : CrudAppService<
return new PagedResultDto<LanguageTextTranslatedDto>(totalCount, dtoLanguageTextTranslate); return new PagedResultDto<LanguageTextTranslatedDto>(totalCount, dtoLanguageTextTranslate);
} }
[Authorize(LanguagesPermissions.LanguageText.Default)]
public async Task<LanguageTextDto> GetLanguageTextAsync(LanguageTextFilteredDto input) public async Task<LanguageTextDto> GetLanguageTextAsync(LanguageTextFilteredDto input)
{ {
var entity = await _repositoryText.FirstOrDefaultAsync(a => var entity = await _repositoryText.FirstOrDefaultAsync(a =>
@ -119,6 +133,7 @@ public class LanguageTextAppService : CrudAppService<
return ObjectMapper.Map<LanguageText, LanguageTextDto>(entity); return ObjectMapper.Map<LanguageText, LanguageTextDto>(entity);
} }
[Authorize(LanguagesPermissions.LanguageText.Default)]
public async Task<List<LanguageTextDto>> GetLanguageTextByCultureNameAsync(string CultureName) public async Task<List<LanguageTextDto>> GetLanguageTextByCultureNameAsync(string CultureName)
{ {
var item = await _repositoryText.GetListAsync(a => a.CultureName == CultureName); var item = await _repositoryText.GetListAsync(a => a.CultureName == CultureName);
@ -130,11 +145,11 @@ public class LanguageTextAppService : CrudAppService<
return ObjectMapper.Map<List<LanguageText>, List<LanguageTextDto>>(item); return ObjectMapper.Map<List<LanguageText>, List<LanguageTextDto>>(item);
} }
// Dil Metinleri listesindeki "Redis önbelleğini temizle" butonu da bu yetkiyle görünür.
[Authorize(LanguagesPermissions.LanguageText.Default)]
public async Task ClearRedisCacheAsync() public async Task ClearRedisCacheAsync()
{ {
var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!); await _cacheCleaner.ClearAsync();
var db = redis.GetDatabase();
await db.ExecuteAsync("FLUSHALL");
} }
protected virtual IQueryable<LanguageKey> ApplySorting(IQueryable<LanguageKey> query, LanguageTextTranslatedRequestDto input) protected virtual IQueryable<LanguageKey> ApplySorting(IQueryable<LanguageKey> query, LanguageTextTranslatedRequestDto input)

View file

@ -11,14 +11,6 @@ public partial class LanguageToLanguageInfoMapper : MapperBase<Language, Languag
public override partial LanguageInfo Map(Language source); public override partial LanguageInfo Map(Language source);
public override partial void Map(Language source, LanguageInfo destination); public override partial void Map(Language source, LanguageInfo destination);
public override void BeforeMap(Language source)
{
}
public override void AfterMap(Language source, LanguageInfo destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -27,14 +19,6 @@ public partial class LanguageToLanguageDtoMapper : MapperBase<Language, Language
public override partial LanguageDto Map(Language source); public override partial LanguageDto Map(Language source);
public override partial void Map(Language source, LanguageDto destination); public override partial void Map(Language source, LanguageDto destination);
public override void BeforeMap(Language source)
{
}
public override void AfterMap(Language source, LanguageDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -43,14 +27,6 @@ public partial class LanguageKeyToLanguageKeyDtoMapper : MapperBase<LanguageKey,
public override partial LanguageKeyDto Map(LanguageKey source); public override partial LanguageKeyDto Map(LanguageKey source);
public override partial void Map(LanguageKey source, LanguageKeyDto destination); public override partial void Map(LanguageKey source, LanguageKeyDto destination);
public override void BeforeMap(LanguageKey source)
{
}
public override void AfterMap(LanguageKey source, LanguageKeyDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -59,14 +35,6 @@ public partial class LanguageTextToLanguageTextDtoMapper : MapperBase<LanguageTe
public override partial LanguageTextDto Map(LanguageText source); public override partial LanguageTextDto Map(LanguageText source);
public override partial void Map(LanguageText source, LanguageTextDto destination); public override partial void Map(LanguageText source, LanguageTextDto destination);
public override void BeforeMap(LanguageText source)
{
}
public override void AfterMap(LanguageText source, LanguageTextDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -75,14 +43,6 @@ public partial class LanguageTextToLanguageTextEtoMapper : MapperBase<LanguageTe
public override partial LanguageTextEto Map(LanguageText source); public override partial LanguageTextEto Map(LanguageText source);
public override partial void Map(LanguageText source, LanguageTextEto destination); public override partial void Map(LanguageText source, LanguageTextEto destination);
public override void BeforeMap(LanguageText source)
{
}
public override void AfterMap(LanguageText source, LanguageTextEto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -93,14 +53,6 @@ public partial class LanguageTextCreateUpdateDtoToLanguageTextMapper : MapperBas
[MapperIgnoreTarget(nameof(LanguageText.LanguageKey))] [MapperIgnoreTarget(nameof(LanguageText.LanguageKey))]
public override partial void Map(LanguageTextCreateUpdateDto source, LanguageText destination); public override partial void Map(LanguageTextCreateUpdateDto source, LanguageText destination);
public override void BeforeMap(LanguageTextCreateUpdateDto source)
{
}
public override void AfterMap(LanguageTextCreateUpdateDto source, LanguageText destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -133,12 +85,4 @@ public partial class LanguageKeyToLanguageTextTranslatedDtoMapper : MapperBase<L
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreationTime))] [MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreationTime))]
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreatorId))] [MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreatorId))]
public override partial void Map(LanguageKey source, LanguageTextTranslatedDto destination); public override partial void Map(LanguageKey source, LanguageTextTranslatedDto destination);
public override void BeforeMap(LanguageKey source)
{
}
public override void AfterMap(LanguageKey source, LanguageTextTranslatedDto destination)
{
}
} }

View file

@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Sozsoft.Languages.Localization;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
namespace Sozsoft.Languages.Caching;
/// <summary>
/// Uygulamanın dağıtık önbelleğini boşaltır. <c>FLUSHALL</c> yerine yalnızca bu uygulamanın anahtarlarını
/// siler: Redis başka bir uygulama ya da ortamla paylaşılıyorsa onların verisine dokunulmaz.
/// Bağlantı süreç başına tektir; her çağrıda yeni bağlantı açmak bağlantı sızdırıyordu.
/// </summary>
public class DistributedCacheCleaner : ISingletonDependency, IDisposable
{
private const int BatchSize = 500;
private readonly Lazy<ConnectionMultiplexer> _connection;
private readonly string[] _keyPatterns;
private readonly IDatabaseResourceLocalizer _localizer;
public DistributedCacheCleaner(
IConfiguration configuration,
IOptions<AbpDistributedCacheOptions> cacheOptions,
IDatabaseResourceLocalizer localizer)
{
_localizer = localizer;
_connection = new Lazy<ConnectionMultiplexer>(
() => ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]!));
// ABP iki biçimde yazar: IDistributedCache<T> anahtarı [t:{kiracı},]c:{önbellek},k:{KeyPrefix}{anahtar},
// bellek içi tanım önbelleklerinin damgası ise doğrudan {KeyPrefix}_Abp...Stamp. Host "Platform",
// DbMigrator "Platform:" öneki kullanır; ':' atılınca desenler iki sürecin anahtarlarını da kapsar.
var prefix = (cacheOptions.Value.KeyPrefix ?? string.Empty).TrimEnd(':');
_keyPatterns = [$"{prefix}*", $"*,k:{prefix}*"];
}
/// <returns>Silinen anahtar sayısı.</returns>
public async Task<long> ClearAsync()
{
var connection = _connection.Value;
var database = connection.GetDatabase();
long removed = 0;
foreach (var endpoint in connection.GetEndPoints())
{
var server = connection.GetServer(endpoint);
if (server.IsReplica || !server.IsConnected)
{
continue;
}
foreach (var pattern in _keyPatterns)
{
var batch = new List<RedisKey>(BatchSize);
await foreach (var key in server.KeysAsync(database.Database, pattern, BatchSize))
{
batch.Add(key);
if (batch.Count == BatchSize)
{
removed += await DeleteAsync(database, batch);
batch.Clear();
}
}
removed += await DeleteAsync(database, batch);
}
}
// Dil sözlüğünün süreç içi kopyası Redis'te değildir; temizlik onu da kapsar.
_localizer.ClearLocalCache();
return removed;
}
// Anahtarlar tek tek silinir: çok anahtarlı DEL, cluster'da farklı slot'lara düşen anahtarları reddeder.
private static async Task<long> DeleteAsync(IDatabase database, List<RedisKey> keys)
{
var results = await Task.WhenAll(keys.Select(key => database.KeyDeleteAsync(key)));
return results.Count(deleted => deleted);
}
public void Dispose()
{
if (_connection.IsValueCreated)
{
_connection.Value.Dispose();
}
GC.SuppressFinalize(this);
}
}

View file

@ -1,5 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Languages.Entities; using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Localization;
using Volo.Abp.Caching; using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities.Events; using Volo.Abp.Domain.Entities.Events;
@ -12,10 +13,12 @@ public class LanguageTextCacheItemInvalidator :
ITransientDependency ITransientDependency
{ {
private readonly IDistributedCache<LanguageTextCacheItem> cache; private readonly IDistributedCache<LanguageTextCacheItem> cache;
private readonly IDatabaseResourceLocalizer localizer;
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache) public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache, IDatabaseResourceLocalizer localizer)
{ {
this.cache = cache; this.cache = cache;
this.localizer = localizer;
} }
public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData) public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData)
@ -23,7 +26,7 @@ public class LanguageTextCacheItemInvalidator :
//distributedCacheOptions.Value.KeyPrefix //distributedCacheOptions.Value.KeyPrefix
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.Entity.ResourceName, eventData.Entity.CultureName); var key = LanguageTextCacheItem.CalculateCacheKey(eventData.Entity.ResourceName, eventData.Entity.CultureName);
await cache.RemoveAsync(key); await cache.RemoveAsync(key);
return; localizer.ClearLocalCache();
} }
} }

View file

@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Languages.Localization;
using Volo.Abp.Caching; using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities.Events.Distributed; using Volo.Abp.Domain.Entities.Events.Distributed;
@ -13,11 +14,14 @@ public class LanguageTextCacheItemInvalidatorDistributed :
ITransientDependency ITransientDependency
{ {
private readonly IDistributedCache<LanguageTextCacheItem> cache; private readonly IDistributedCache<LanguageTextCacheItem> cache;
private readonly IDatabaseResourceLocalizer localizer;
public LanguageTextCacheItemInvalidatorDistributed( public LanguageTextCacheItemInvalidatorDistributed(
IDistributedCache<LanguageTextCacheItem> cache) IDistributedCache<LanguageTextCacheItem> cache,
IDatabaseResourceLocalizer localizer)
{ {
this.cache = cache; this.cache = cache;
this.localizer = localizer;
} }
public async Task HandleEventAsync(EntityCreatedEto<LanguageTextEto> eventData) public async Task HandleEventAsync(EntityCreatedEto<LanguageTextEto> eventData)
@ -39,6 +43,7 @@ public class LanguageTextCacheItemInvalidatorDistributed :
{ {
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.ResourceName, eventData.CultureName); var key = LanguageTextCacheItem.CalculateCacheKey(eventData.ResourceName, eventData.CultureName);
await cache.RemoveAsync(key); await cache.RemoveAsync(key);
localizer.ClearLocalCache();
} }
} }

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -14,8 +15,14 @@ namespace Sozsoft.Languages.Localization;
public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonDependency public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonDependency
{ {
// Her metin aramasında sözlüğün tamamı Redis'ten çekilip çözülüyordu. Süreç içi katman bunu önler.
// Kısa ömürlüdür: metinlerin bir kısmı ham SQL ile yazılır (olay üretmez) ve diğer API örnekleri ile
// DbMigrator ayrı süreçtir; onların temizliği buraya en geç bu süre sonunda yansır.
private static readonly long LocalCacheDurationMs = (long)TimeSpan.FromSeconds(30).TotalMilliseconds;
private readonly IRepository<LanguageText, Guid> languageTextRepository; private readonly IRepository<LanguageText, Guid> languageTextRepository;
private readonly IDistributedCache<LanguageTextCacheItem> cache; private readonly IDistributedCache<LanguageTextCacheItem> cache;
private readonly ConcurrentDictionary<string, (LanguageTextCacheItem Item, long ExpiresAt)> localCache = new();
public DatabaseResourceLocalizer( public DatabaseResourceLocalizer(
IRepository<LanguageText, Guid> languageTextRepository, IRepository<LanguageText, Guid> languageTextRepository,
@ -27,27 +34,52 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name) public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name)
{ {
var item = GetCacheItem(resource, cultureName); var value = GetCacheItem(resource, cultureName)?.Dictionary?.GetOrDefault(name);
var value = item.Dictionary?.GetOrDefault(name); return value == null ? null : new LocalizedString(name, value);
if (item == null || value == null)
{
return null;
}
return new LocalizedString(name, value);
} }
/// <summary>Süreç içi katmanı boşaltır; dağıtık önbellek temizlendiğinde ve metin değiştiğinde çağrılır.</summary>
public void ClearLocalCache() => localCache.Clear();
protected LanguageTextCacheItem GetCacheItem(LocalizationResourceBase resource, string cultureName) protected LanguageTextCacheItem GetCacheItem(LocalizationResourceBase resource, string cultureName)
{ {
// culture, resource, key // culture, resource, key
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName); var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
return cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName)); if (TryGetLocal(key, out var local))
{
return local;
}
return SetLocal(key, cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName)));
}
private bool TryGetLocal(string key, out LanguageTextCacheItem item)
{
if (localCache.TryGetValue(key, out var entry) && entry.ExpiresAt > Environment.TickCount64)
{
item = entry.Item;
return true;
}
item = null;
return false;
}
private LanguageTextCacheItem SetLocal(string key, LanguageTextCacheItem item)
{
if (item != null)
{
localCache[key] = (item, Environment.TickCount64 + LocalCacheDurationMs);
}
return item;
} }
public void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary) public void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
{ {
var item = GetCacheItem(resource, cultureName); var item = GetCacheItem(resource, cultureName);
foreach (var kvp in item.Dictionary) foreach (var kvp in item?.Dictionary ?? [])
{ {
dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value); dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value);
} }
@ -67,9 +99,12 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
public async Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary) public async Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
{ {
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName); var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
var item = await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName)); if (!TryGetLocal(key, out var item))
{
item = SetLocal(key, await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName)));
}
foreach (var kvp in item.Dictionary) foreach (var kvp in item?.Dictionary ?? [])
{ {
dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value); dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value);
} }

View file

@ -10,5 +10,6 @@ public interface IDatabaseResourceLocalizer
void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary); void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary);
Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary); Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary);
LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name); LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name);
void ClearLocalCache();
} }

View file

@ -7,8 +7,8 @@ public abstract class MailQueueAppService : ApplicationService
{ {
protected MailQueueAppService() protected MailQueueAppService()
{ {
LocalizationResource = typeof(ErpMailQueueResource); LocalizationResource = typeof(SozsoftMailQueueResource);
ObjectMapperContext = typeof(ErpMailQueueModule); ObjectMapperContext = typeof(SozsoftMailQueueModule);
} }
} }

View file

@ -1 +0,0 @@
namespace Sozsoft.MailQueue.Application;

View file

@ -3,7 +3,7 @@
namespace Sozsoft.MailQueue.Domain.Shared; namespace Sozsoft.MailQueue.Domain.Shared;
[LocalizationResourceName(MailQueueConsts.AppName)] [LocalizationResourceName(MailQueueConsts.AppName)]
public class ErpMailQueueResource public class SozsoftMailQueueResource
{ {
} }

View file

@ -1,64 +0,0 @@
using Sozsoft.MailQueue.MailGeneration.Models;
using Volo.Abp.DependencyInjection;
namespace Sozsoft.MailQueue.Domain.MailGeneration;
public interface IAttachmentExtractor
{
IList<AttachmentModel> Extract(string attachment, string attachmentParametre);
}
public class AttachmentExtractor : IAttachmentExtractor, ITransientDependency
{
protected readonly IParameterExtractor ParameterExtractor;
public AttachmentExtractor(
IParameterExtractor parameterExtractor)
{
ParameterExtractor = parameterExtractor;
}
public IList<AttachmentModel> Extract(string attachment, string attachmentParametre)
{
var result = new List<AttachmentModel>();
if (attachment.IsNullOrWhiteSpace())
return result;
var parameters = ParameterExtractor.Extract(attachmentParametre);
/*
Dict<
0, Dict<
Tarih, 1232
No 4334
>
1, Dict<
QUERY, ghghg
QUERYSQL select ffddf
*/
//Attachment:
//{0:MRP:rpt_DepoCikisCekiListesi:PDF:MailEkteGozukecekAd};{1:CRM:rpt_DepoCikisCekiListesiCarpet:XLS:MailEkteGozukecekAd}
//AttachmentParametre:
//Filtre değerleri olabilir {0:Tarih=1232&No=4334};{1:Tarih=213}
//Veya query olabilir {0:QUERY=…&QUERYSQL=..};{1:QUERY=…&QUERYSQL=..}
foreach (var item in attachment.Split("};{"))
{
var keyValue = item.Trim('{').Trim('}').Split(":");
if (keyValue.Length == 5)
{
result.Add(new AttachmentModel
{
Index = keyValue[0],
DbAdi = keyValue[1],
RaporAdi = keyValue[2],
ExportDosyaTipi = keyValue[3],
Filtreler = parameters.GetOrDefault(keyValue[0]),
MailEkAdi = keyValue[4]
});
}
}
return result;
}
}

View file

@ -2,85 +2,36 @@
using Sozsoft.MailQueue.FileGeneration; using Sozsoft.MailQueue.FileGeneration;
using Sozsoft.MailQueue.MailGeneration.Models; using Sozsoft.MailQueue.MailGeneration.Models;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Guids;
using Volo.Abp.TextTemplating;
namespace Sozsoft.MailQueue.Domain.MailGeneration; namespace Sozsoft.MailQueue.Domain.MailGeneration;
public interface IAttachmentGenerator public interface IAttachmentGenerator
{ {
Task<Dictionary<string, string>> GenerateAsync(
Guid kuyrukId,
string attachment,
string attachmentParametre,
string jobId = null);
Task<Dictionary<string, string>> GenerateAsync(List<MailTemplateTableModel> tables, Guid? kuyrukId = null, string jobId = null); Task<Dictionary<string, string>> GenerateAsync(List<MailTemplateTableModel> tables, Guid? kuyrukId = null, string jobId = null);
} }
/// <summary>
/// Kuyruk satırındaki tablo şablonlarından (<c>DosyaTipi</c> PDF/XLS/TXT) mail eki üretir.
/// Ek üretiminin tek yolu budur; eski <c>Attachment</c>/<c>AttachmentParameter</c> kolonlarıyla
/// MS Access raporundan ek üretme yolu hiç gerçeklenmediği için kaldırıldı — kolon dolu olsa da
/// satır artık atlanmaz, tablo ekleriyle gönderilir.
/// </summary>
public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
{ {
public IParameterExtractor ParameterExtractor { get; }
public IAttachmentExtractor AttachmentExtractor { get; }
public ITemplateRenderer TemplateRenderer { get; }
public IGuidGenerator GuidGenerator { get; }
public IServiceProvider ServiceProvider { get; } public IServiceProvider ServiceProvider { get; }
protected ILogManager LogManager { get; } protected ILogManager LogManager { get; }
private readonly IConfiguration Configuration; private readonly IConfiguration Configuration;
public AttachmentGenerator( public AttachmentGenerator(
IAttachmentExtractor attachmentExtractor,
ITemplateRenderer templateRenderer,
IGuidGenerator guidGenerator,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogManager logManager, ILogManager logManager,
IConfiguration configuration IConfiguration configuration)
)
{ {
AttachmentExtractor = attachmentExtractor;
TemplateRenderer = templateRenderer;
GuidGenerator = guidGenerator;
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
LogManager = logManager; LogManager = logManager;
Configuration = configuration; Configuration = configuration;
} }
/// <summary>
/// Verilen parametreler (attachment parametreleri) ile KIM raporlari ile istenilen formatta dosya uretir
/// </summary>
/// <param name="attachment">Export edilecek olan attachmentlar</param>
/// <param name="attachmentParametre">Her attachment için parametreler</param>
/// <returns>Uretilen ekleri Key: MailEkAdi, Value: Base64 encode edilmis dosya olan dictionary olarak döner</returns>
public async Task<Dictionary<string, string>> GenerateAsync(
Guid kuyrukId,
string attachment,
string attachmentParametre,
string jobId)
{
var result = new Dictionary<string, string>();
var models = AttachmentExtractor.Extract(attachment, attachmentParametre);
var attachmentFolder = $"{kuyrukId}-{jobId}";
foreach (var item in models)
{
var file = GenerateAttachment(item, attachmentFolder);
if (file.IsNullOrEmpty())
{
LogManager.LogError($"Attachment oluşturulamadı. Index: {item.Index}", "ATTACHMENT", "KUYRUK", kuyrukId, jobId);
continue;
}
var base64 = await ConvertFileToBase64Async(file);
var key = item.MailEkAdi;
if (result.ContainsKey(key))
{
key = result.Count + "-" + item.MailEkAdi;
}
result.Add(key, base64);
}
return result;
}
/// <summary> /// <summary>
/// Verilan tablo modeli ile istenilen dosya tipinde (PDF, XLS, TXT) ek üretir /// Verilan tablo modeli ile istenilen dosya tipinde (PDF, XLS, TXT) ek üretir
/// </summary> /// </summary>
@ -108,7 +59,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
if (fileGenerator == null) if (fileGenerator == null)
{ {
LogManager.LogError($"File Generator oluşturulamadı. Index: {table.Index}, Dosya Tipi: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"File generator could not be created. Index: {table.Index}, FileType: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
continue; continue;
} }
@ -124,7 +75,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
catch (Exception ex) catch (Exception ex)
{ {
LogManager.LogError($"Dosya oluşturulamadı. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"File could not be generated. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
continue; continue;
} }
} }
@ -132,70 +83,6 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
return attachments; return attachments;
} }
private string GenerateAttachment(AttachmentModel model, string attachmentFolder)
{
if (model.Filtreler.IsNullOrEmpty() || model.RaporAdi.IsNullOrWhiteSpace())
{
return null;
}
return null;
// if (model.Filtreler.ContainsKey("QUERY") && model.Filtreler.ContainsKey("QUERYSQL"))
// {
// /*
//Parametrelerden asagidaki sekilde gelen queryleri en alttaki formata donusturuyoruz
//params
//0 =>
// QUERY -> qryDepo_Cikis_CekiListesi_Detayli
// QUERYSQL -> Ksp_Depo_Cikis_CekiListesi_Detayli 252993
// QUERY2 -> qryDepo_Cikis_CekiListesi_Sub
// QUERY2SQL -> KSp_Depo_Cikis_CekiListesi_Sub 252993
//SQL olan keyvalue'lari cikar ve sadece querylerde foreach yap
// QUERY -> qryDepo_Cikis_CekiListesi_Detayli
// dict.add(item.Value, params[item.Key + "SQL"].Value)
// QUERY2 -> qryDepo_Cikis_CekiListesi_Sub
// dict.add(item.Value, params[item.Key + "SQL"].Value)
//Bunlari yeni dict'e ekle
//dict
//qryDepo_Cikis_CekiListesi_Detayli -> Ksp_Depo_Cikis_CekiListesi_Detayli 252993
//qryDepo_Cikis_CekiListesi_Sub -> KSp_Depo_Cikis_CekiListesi_Sub 252993
// */
// //dict -> key (name), value (sql)
// var queries = model.Filtreler
// .Where(a => a.Key.StartsWith("QUERY"))
// .Where(a => !a.Key.EndsWith("SQL"))
// .Where(a => !a.Value.IsNullOrWhiteSpace())
// .ToDictionary(x => x.Value, x => model.Filtreler.GetValueOrDefault(x.Key + "SQL"));
// return MsAccessReportManager.GenerateAccessReport(
// model.DbAdi,
// model.Index,
// model.RaporAdi,
// model.ExportDosyaTipi,
// queries,
// attachmentFolder);
// }
// else
// {
// return MsAccessReportManager.GenerateAccessReport(
// model.DbAdi,
// model.Index,
// model.RaporAdi,
// string.Join(" AND ", model.Filtreler.Select(a => $"{a.Key}={a.Value}")),
// model.ExportDosyaTipi,
// attachmentFolder);
// }
}
private async Task<string> ConvertFileToBase64Async(string filePath)
{
var bytes = await File.ReadAllBytesAsync(filePath);
return Convert.ToBase64String(bytes);
}
private void LogToFile(Guid id, byte[] content, string fileName) private void LogToFile(Guid id, byte[] content, string fileName)
{ {
if (!Configuration.GetValue<bool>("App:LogMailToFile")) if (!Configuration.GetValue<bool>("App:LogMailToFile"))
@ -224,4 +111,3 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
} }
} }

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Sozsoft.MailQueue.MailGeneration.Models; using Sozsoft.MailQueue.MailGeneration.Models;
using Volo.Abp.Timing;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.TextTemplating; using Volo.Abp.TextTemplating;
@ -23,17 +24,20 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
public IParameterExtractor ParameterExtractor { get; } public IParameterExtractor ParameterExtractor { get; }
public ITemplateRenderer TemplateRenderer { get; } public ITemplateRenderer TemplateRenderer { get; }
protected ILogManager LogManager { get; } protected ILogManager LogManager { get; }
protected IClock Clock { get; }
public MailBodyGenerator( public MailBodyGenerator(
IParameterExtractor parameterExtractor, IParameterExtractor parameterExtractor,
ITemplateRenderer templateRenderer, ITemplateRenderer templateRenderer,
IConfiguration configuration, IConfiguration configuration,
ILogManager logManager) ILogManager logManager,
IClock clock)
{ {
ParameterExtractor = parameterExtractor; ParameterExtractor = parameterExtractor;
TemplateRenderer = templateRenderer; TemplateRenderer = templateRenderer;
this.configuration = configuration; this.configuration = configuration;
LogManager = logManager; LogManager = logManager;
Clock = clock;
} }
/// <summary> /// <summary>
@ -66,7 +70,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
} }
catch (Exception) catch (Exception)
{ {
LogManager.LogError($"Template Render Başarısız: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"Template render failed: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
throw; throw;
} }
} }
@ -84,7 +88,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
return; return;
} }
path = Path.Combine(path, id.ToString()); path = Path.Combine(path, id.ToString());
var date = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss"); var date = Clock.Now.ToString("yyyy-MM-dd-HH-mm-ss");
var dosyaAdi = $"mail-{id}-{date}.html "; var dosyaAdi = $"mail-{id}-{date}.html ";
try try
{ {

View file

@ -1,12 +0,0 @@
namespace Sozsoft.MailQueue.MailGeneration.Models;
public class AttachmentModel
{
public string Index { get; set; }
public string DbAdi { get; set; } //MRP.mdb, CRM.mdb
public string RaporAdi { get; set; }
public string ExportDosyaTipi { get; set; } //PDF, TXT, XLS, ..
public Dictionary<string, string> Filtreler { get; set; }
public string MailEkAdi { get; set; }
}

View file

@ -1,4 +1,6 @@
namespace Sozsoft.MailQueue.MailGeneration.Models; using Sozsoft.Platform.Localization;
namespace Sozsoft.MailQueue.MailGeneration.Models;
public class MailTemplateTableColumnModel public class MailTemplateTableColumnModel
{ {
@ -15,5 +17,10 @@ public class MailTemplateTableColumnModel
public string AltToplam { get; set; } public string AltToplam { get; set; }
public int Genislik { get; set; } public int Genislik { get; set; }
/// <summary>
/// Hucre metni: <c>DataFormat</c> deseni aktif kulturde uygulanir (HTML/PDF/TXT ortak yol).
/// XLS bu yoldan gecmez; orada desen Excel bicim kodu olarak hucreye yazilir.
/// </summary>
public string FormatValue(object value) => CultureFormat.Value(value, VeriFormati);
} }

View file

@ -1,11 +1,9 @@
using Microsoft.Extensions.Configuration; using Sozsoft.MailQueue.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Sozsoft.MailQueue.Domain.MailGeneration; using Sozsoft.MailQueue.Domain.MailGeneration;
using Sozsoft.MailQueue.Domain.Shared; using Sozsoft.MailQueue.Domain.Shared;
using Sozsoft.Sender.Mail; using Sozsoft.Sender.Mail;
using Volo.Abp.BackgroundWorkers; using Volo.Abp.BackgroundWorkers;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Guids;
using Volo.Abp.Timing; using Volo.Abp.Timing;
using Volo.Abp.Uow; using Volo.Abp.Uow;
@ -18,195 +16,105 @@ public class MailQueueWorker : BackgroundWorkerBase
public MailQueueWorkerOptions Options { get; set; } public MailQueueWorkerOptions Options { get; set; }
protected ISozsoftEmailSender ErpEmailSender { get; } protected ISozsoftEmailSender ErpEmailSender { get; }
protected IRepository<Entities.BackgroundWorker_MailQueue> Repository { get; } protected IRepository<BackgroundWorker_MailQueue> Repository { get; }
public IClock Clock { get; } public IClock Clock { get; }
public IConfiguration Configuration { get; }
public ILogManager LogManager { get; } public ILogManager LogManager { get; }
public ITableGenerator TableGenerator { get; } public ITableGenerator TableGenerator { get; }
public IMailBodyGenerator MailBodyGenerator { get; } public IMailBodyGenerator MailBodyGenerator { get; }
public IAttachmentGenerator AttachmentGenerator { get; } public IAttachmentGenerator AttachmentGenerator { get; }
public IGuidGenerator GuidGenerator { get; }
public MailQueueWorker( public MailQueueWorker(
ISozsoftEmailSender erpEmailSender, ISozsoftEmailSender erpEmailSender,
IRepository<Entities.BackgroundWorker_MailQueue> repository, IRepository<BackgroundWorker_MailQueue> repository,
IClock clock, IClock clock,
IConfiguration configuration,
ILogManager logManager, ILogManager logManager,
ITableGenerator tableGenerator, ITableGenerator tableGenerator,
IMailBodyGenerator mailBodyGenerator, IMailBodyGenerator mailBodyGenerator,
IAttachmentGenerator attachmentGenerator, IAttachmentGenerator attachmentGenerator)
IGuidGenerator guidGenerator
)
{ {
ErpEmailSender = erpEmailSender; ErpEmailSender = erpEmailSender;
Repository = repository; Repository = repository;
Clock = clock; Clock = clock;
Configuration = configuration;
LogManager = logManager; LogManager = logManager;
TableGenerator = tableGenerator; TableGenerator = tableGenerator;
MailBodyGenerator = mailBodyGenerator; MailBodyGenerator = mailBodyGenerator;
AttachmentGenerator = attachmentGenerator; AttachmentGenerator = attachmentGenerator;
GuidGenerator = guidGenerator;
} }
/// <summary> /// <summary>
/// ID'si verilen Template satırının kuyruk kayıtlarını proses ederek bildirimleri yapar /// Şablonun (TemplateId) gönderilmemiş kuyruk kayıtlarını işler: her kayıt için tablo, gövde ve
/// ekler üretilir, mail gönderilir, sonuç kayda yazılır. Akış: tablo → gövde → ekler → gönderim.
/// </summary> /// </summary>
public override async Task StartAsync(CancellationToken cancellationToken = default) public override async Task StartAsync(CancellationToken cancellationToken = default)
{ {
/*
* TemplateId'den ilgili template satırını al
* TemplateId'den ilgili kuyruk kayıtlarını al
* Bu kurallar inclusive'dir.
* Yani birden fazla kural aynı kuyruk kaydı için işletilebilir.
* BeforeSP varsa çalıştır.
* SP görevini yaptıktan sonra devam et
* Template:
* Dosya Tipi: NULL, PDF, XLS, IN
* NULL: Sadece mail body
* PDF, XLS, TXT, XML, HTML, RTF: Bu durumda ilgili rapor hazırlanıp eklenir
* IN: Tablo, mail body'e gömülür
* Tablo:
* Mail Body metni oluşturulacak
* Kuyruk tablosu MAILPARAMETRE alanı ile
* MAILTEMPLATE'de belirtilen HTML üretilerek
* mail body oluşturulur
* Tablo Alanı dolu ise;
* Belirtilen tablodan kayıtları al ve Kuyruk'taki
* TABLOPARAMETRE ve TABLO alanlarına göre tabloyu oluştur
* ve Dosya Tipi'ne göre bildirime ekle.
* Bu durumda ilgili Tabloda belirtilen css ve başlıklar kullanılmalı
* Kuyruk - ATTACHMENT ve ATTACHMENTPARAMETRE dolu ise;
* Bu alanlarda belirtilen raporlar Access API ile
* oluşturulup DOSYA TIPI'ne göre bildirime eklenecek
* Gönderimi Yap
* KUYRUK - GATEWAYTEMPLATEID alanında belirtilen template ile
* mail gateway'de ilgili template gönderimi yapılır
* KIMDEN ve KIME alanları kullanılır.
* Gateway'e gönderim emri verildikten sonra
* GonderilmeTarihi alanı setlenir.
* AFTERSP'de belirtilen SP'yi veritabanında çalıştır
*
*/
/*
Options:
- Mail gidecek kişiler listesi
- Mail subject
- Mail lojik türü
- Kayıt kaynağı
- RS Filtre (id, vs)
-
1- Template Id'ye göre, Kuyruk tablosunu filtrele
2- Her kayıt için mail gönder
*/
using var scope = ServiceProvider.CreateScope();
var uowManager = LazyServiceProvider.LazyGetRequiredService<IUnitOfWorkManager>(); var uowManager = LazyServiceProvider.LazyGetRequiredService<IUnitOfWorkManager>();
using var uow = uowManager.Begin(); using var uow = uowManager.Begin();
var jobId = $"{TemplateId}-{Clock.Now:yyyy-MM-dd-HH-mm-ss}"; var jobId = $"{TemplateId}-{Clock.Now:yyyy-MM-dd-HH-mm-ss}";
var attachmentsBasePath = Configuration.GetValue<string>("App:AttachmentsPath");
//var template = await RepositoryTemplate.GetAsync(a => a.Id == templateId);
var queues = await Repository.GetListAsync(a => a.TemplateId == TemplateId && !a.SendStatus, cancellationToken: cancellationToken); var queues = await Repository.GetListAsync(a => a.TemplateId == TemplateId && !a.SendStatus, cancellationToken: cancellationToken);
foreach (var queue in queues) foreach (var queue in queues)
{ {
// Her kayıt kendi UoW'sinde: bir kaydın hatası diğerlerinin gönderimini ve kaydını düşürmez.
using var uow2 = uowManager.Begin(requiresNew: true, isTransactional: false); using var uow2 = uowManager.Begin(requiresNew: true, isTransactional: false);
try try
{ {
// Tablo oluştur await ProcessQueueAsync(queue, jobId, cancellationToken);
var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id); await uow2.CompleteAsync(cancellationToken);
// Mail Body oluştur
var mailBody = await MailBodyGenerator.GenerateAsync(
TemplateName,
queue.MailParameter,
queue.Id,
tables,
jobId);
// Attachment oluştur
// Iki attachment türü var:
// 1- ATTACHMENT alanı dolu olanlar
var attachments = await AttachmentGenerator.GenerateAsync(queue.Id, queue.Attachment, queue.AttachmentParameter, jobId);
if (!queue.Attachment.IsNullOrWhiteSpace() && attachments.IsNullOrEmpty())
{
//Kuyruk uzerinde Attachment var fakat hicbiri olusturulamis, devam etmeyelim
LogManager.LogWarning("Attachmentlar oluşturulamadı", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
continue;
}
// 2- Tablo (IN olmayanlar-PDF,XLS,TXT)
var attachmentsTable = await AttachmentGenerator.GenerateAsync(tables.Values.ToList(), queue.Id, jobId);
// IN olmayan herhangi bir tablo belirtilmisse fakat bu tablo attachment olarak uretilememisse, hata vardir
if (tables.Values.Where(a => a.DosyaTipi != "IN").Any() && attachmentsTable.IsNullOrEmpty())
{
//NULL ise hata olmustur, devam etmeyelim
LogManager.LogWarning("Tablo Attachmentlar oluşturulamadı", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
continue;
}
foreach (var attachmentTable in attachmentsTable)
{
var key = attachmentTable.Key;
if (attachments.ContainsKey(key))
{
key += "-" + GuidGenerator.Create();
}
attachments.Add(key, attachmentTable.Value);
}
// Gönderimi yap
var senderKeyValue = queue.From.Split(":");
var sender = new KeyValuePair<string, string>(
senderKeyValue.ElementAtOrDefault(0),
senderKeyValue.ElementAtOrDefault(1));
var result = await ErpEmailSender.SendEmailAsync(
queue.To.Split(";").Select(a => a.Trim()).ToArray(),
sender,
null,
mailBody,
Options.MailSubject,
attachments,
true);
if (result.Success)
{
queue.AwsMessageId = result.MessageId;
}
else
{
LogManager.LogWarning($"Mail gönderilemedi. Gelen Mesaj: {result.ErrorMessage}", "MAILSEND", "KUYRUK", queue.Id, jobId);
queue.AwsMessageId = result.ErrorMessage;
}
queue.SendStatus = result.Success;
queue.SendTime = Clock.Now;
await Repository.UpdateAsync(queue, cancellationToken: cancellationToken);
// Attachmentlari silebiliriz
try
{
var attachmentsPath = Path.Combine(attachmentsBasePath, $"{queue.Id}-{jobId}");
if (Directory.Exists(attachmentsPath))
{
Directory.Delete(attachmentsPath, true);
}
}
catch (Exception ex)
{
LogManager.LogError($"Attachments klasörü silinemedi. Kuyruk Id: {queue.Id}. {ex}", "HATA", "ATTACHMENT", queue.Id, jobId);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
LogManager.LogError($"Hata. Kuyruk Id: {queue.Id}. {ex}", "HATA", "KUYRUK", queue.Id, jobId); LogManager.LogError($"Hata. Kuyruk Id: {queue.Id}. {ex}", "HATA", "KUYRUK", queue.Id, jobId);
} }
await uow2.SaveChangesAsync(cancellationToken);
} }
await uow.CompleteAsync(cancellationToken);
}
await uow.SaveChangesAsync(cancellationToken); private async Task ProcessQueueAsync(BackgroundWorker_MailQueue queue, string jobId, CancellationToken cancellationToken)
{
var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id);
var mailBody = await MailBodyGenerator.GenerateAsync(
TemplateName,
queue.MailParameter,
queue.Id,
tables,
jobId);
// Ekler tablo şablonlarından (IN olmayanlar: PDF, XLS, TXT) üretilir.
var attachments = await AttachmentGenerator.GenerateAsync(tables.Values.ToList(), queue.Id, jobId);
// IN olmayan bir tablo belirtilmiş ama hiçbiri üretilememişse hata vardır, gönderilmez.
if (tables.Values.Any(a => a.DosyaTipi != "IN") && attachments.IsNullOrEmpty())
{
LogManager.LogWarning("Table attachments could not be generated", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
return;
}
var senderKeyValue = queue.From.Split(":");
var sender = new KeyValuePair<string, string>(
senderKeyValue.ElementAtOrDefault(0),
senderKeyValue.ElementAtOrDefault(1));
var result = await ErpEmailSender.SendEmailAsync(
queue.To.Split(";").Select(a => a.Trim()).ToArray(),
sender,
null,
mailBody,
Options.MailSubject,
attachments,
true);
if (result.Success)
{
queue.AwsMessageId = result.MessageId;
}
else
{
LogManager.LogWarning($"Mail could not be sent. Message: {result.ErrorMessage}", "MAILSEND", "KUYRUK", queue.Id, jobId);
queue.AwsMessageId = result.ErrorMessage;
}
queue.SendStatus = result.Success;
queue.SendTime = Clock.Now;
await Repository.UpdateAsync(queue, cancellationToken: cancellationToken);
} }
} }

View file

@ -1,6 +1,7 @@
using System.Data; using System.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Sozsoft.MailQueue.Domain.Entities; using Sozsoft.MailQueue.Domain.Entities;
using Sozsoft.Platform.Data;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
@ -20,34 +21,50 @@ public class QueryRepository : IQueryRepository, ITransientDependency
Repository = repository; Repository = repository;
} }
/// <summary>
/// Mail şablonunun tablo kaynağını okur. Tablo ve kolon adları şablon kaydından gelir, parametre
/// olarak bağlanamaz; bu yüzden beyaz listeden geçirilip ANSI tırnakla yazılır (iki sağlayıcıda da
/// geçerli), değerler ise parametredir.
/// </summary>
public async Task<DataTable> QueryAsync(string tabloAdi, Dictionary<string, string> where) public async Task<DataTable> QueryAsync(string tabloAdi, Dictionary<string, string> where)
{ {
if (!SqlIdentifier.IsSafe(tabloAdi))
throw new ArgumentException($"Invalid table name: {tabloAdi}", nameof(tabloAdi));
var table = new DataTable(); var table = new DataTable();
var ctx = await Repository.GetDbContextAsync(); var ctx = await Repository.GetDbContextAsync();
var cmd = ctx.Database.GetDbConnection().CreateCommand(); var connection = ctx.Database.GetDbConnection();
var query = $"SELECT * FROM {tabloAdi} WHERE "; await using var cmd = connection.CreateCommand();
var wheres = new List<string>(); var wheres = new List<string>();
var index = 0;
foreach (var item in where) foreach (var item in where)
{ {
var parameterName = $"@p{item.Key}"; if (!SqlIdentifier.IsSafe(item.Key))
wheres.Add($"{item.Key}={parameterName}"); throw new ArgumentException($"Invalid column name: {item.Key}", nameof(where));
var parameterName = $"@p{index++}";
wheres.Add($"{SqlIdentifier.QuoteAnsi(item.Key)} = {parameterName}");
var prm = cmd.CreateParameter(); var prm = cmd.CreateParameter();
prm.ParameterName = parameterName; prm.ParameterName = parameterName;
prm.Value = item.Value; prm.Value = item.Value;
cmd.Parameters.Add(prm); cmd.Parameters.Add(prm);
} }
query += string.Join(" AND ", wheres);
cmd.CommandText = query; cmd.CommandText = $"SELECT * FROM {SqlIdentifier.QuoteAnsi(tabloAdi)}"
+ (wheres.Count > 0 ? " WHERE " + string.Join(" AND ", wheres) : string.Empty);
await connection.OpenAsync();
try try
{ {
cmd.Connection.Open(); await using var reader = await cmd.ExecuteReaderAsync();
table.Load(await cmd.ExecuteReaderAsync()); table.Load(reader);
} }
finally finally
{ {
cmd.Connection.Close(); await connection.CloseAsync();
} }
return table; return table;
} }
} }

View file

@ -16,7 +16,7 @@ public class TXTFileGenerator : IFileGenerator, ITransientDependency
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
table.TabloDegerleri.ForEach(row => table.TabloDegerleri.ForEach(row =>
sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => row.GetValueOrDefault(col.SutunAdi))))); sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => col.FormatValue(row.GetValueOrDefault(col.SutunAdi))))));
return await Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString())); return await Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString()));
} }

View file

@ -119,7 +119,7 @@ table.tablo tbody tr:hover {
{ {
<tr> <tr>
@foreach (var sutun in tablo0.TabloSutunlari){ @foreach (var sutun in tablo0.TabloSutunlari){
<td>@satir.GetValueOrDefault(sutun.SutunAdi)</td> <td>@sutun.FormatValue(satir.GetValueOrDefault(sutun.SutunAdi))</td>
} }
</tr> </tr>
} }

View file

@ -1,4 +1,7 @@
using ClosedXML.Excel; using System.Globalization;
using ClosedXML.Excel;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Sozsoft.MailQueue.MailGeneration.Models; using Sozsoft.MailQueue.MailGeneration.Models;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -6,6 +9,8 @@ namespace Sozsoft.MailQueue.FileGeneration;
public class XLSFileGenerator : IFileGenerator, ITransientDependency public class XLSFileGenerator : IFileGenerator, ITransientDependency
{ {
public ILogger<XLSFileGenerator> Logger { get; set; } = NullLogger<XLSFileGenerator>.Instance;
string sheetPassword = "Erp123"; string sheetPassword = "Erp123";
public async Task<byte[]> Generate(MailTemplateTableModel table) public async Task<byte[]> Generate(MailTemplateTableModel table)
@ -87,7 +92,8 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
switch (dataType) switch (dataType)
{ {
case XLDataType.Number: case XLDataType.Number:
cell.SetValue(Convert.ToInt32(value)); // Int32'ye cevirmek ondaligi atardi; hucre sayisal kalir, gosterimi Excel bicim kodu verir.
cell.SetValue(Convert.ToDecimal(value, CultureInfo.InvariantCulture));
break; break;
case XLDataType.DateTime: case XLDataType.DateTime:
cell.SetValue(Convert.ToDateTime(value)); cell.SetValue(Convert.ToDateTime(value));
@ -166,7 +172,11 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin); cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin);
} }
} }
catch { } catch (Exception ex)
{
// Geçersiz CSS değeri hücreyi biçimsiz bırakır; dosya üretimi sürer.
Logger.LogWarning(ex, "Cell CSS could not be applied: {Css}", string.Join(";", css.Select(a => a.Key + ":" + a.Value)));
}
} }
} }

View file

@ -42,6 +42,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Sozsoft.Sender\Sozsoft.Sender.csproj" /> <ProjectReference Include="..\Sozsoft.Sender\Sozsoft.Sender.csproj" />
<ProjectReference Include="..\..\src\Sozsoft.Platform.Domain.Shared\Sozsoft.Platform.Domain.Shared.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -26,22 +26,22 @@ namespace Sozsoft.MailQueue;
typeof(AbpAuthorizationModule), typeof(AbpAuthorizationModule),
typeof(AbpEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreModule),
typeof(AbpTextTemplatingRazorModule), typeof(AbpTextTemplatingRazorModule),
typeof(ErpSenderModule) typeof(SozsoftSenderModule)
)] )]
public class ErpMailQueueModule : AbpModule public class SozsoftMailQueueModule : AbpModule
{ {
public override void ConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context)
{ {
context.Services.AddMapperlyObjectMapper<ErpMailQueueModule>(); context.Services.AddMapperlyObjectMapper<SozsoftMailQueueModule>();
Configure<AbpLocalizationOptions>(options => Configure<AbpLocalizationOptions>(options =>
{ {
options.Resources.Add<ErpMailQueueResource>("en"); options.Resources.Add<SozsoftMailQueueResource>("en");
}); });
Configure<AbpExceptionLocalizationOptions>(options => Configure<AbpExceptionLocalizationOptions>(options =>
{ {
options.MapCodeNamespace("MailQueue", typeof(ErpMailQueueResource)); options.MapCodeNamespace("MailQueue", typeof(SozsoftMailQueueResource));
}); });
context.Services.AddAbpDbContext<MailQueueDbContext>(options => context.Services.AddAbpDbContext<MailQueueDbContext>(options =>
@ -51,7 +51,7 @@ public class ErpMailQueueModule : AbpModule
Configure<AbpRazorTemplateCSharpCompilerOptions>(options => Configure<AbpRazorTemplateCSharpCompilerOptions>(options =>
{ {
options.References.Add(MetadataReference.CreateFromFile(typeof(ErpMailQueueModule).Assembly.Location)); options.References.Add(MetadataReference.CreateFromFile(typeof(SozsoftMailQueueModule).Assembly.Location));
}); });
Configure<AbpCompiledViewProviderOptions>(options => Configure<AbpCompiledViewProviderOptions>(options =>
@ -69,7 +69,7 @@ public class ErpMailQueueModule : AbpModule
// FileGenerationModule // FileGenerationModule
Configure<AbpVirtualFileSystemOptions>(options => Configure<AbpVirtualFileSystemOptions>(options =>
{ {
options.FileSets.AddEmbedded<ErpMailQueueModule>(); options.FileSets.AddEmbedded<SozsoftMailQueueModule>();
}); });
context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa"); context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa");

View file

@ -71,6 +71,32 @@ public class McpCreateTableRequestDto
public bool FullAudited { get; set; } = true; public bool FullAudited { get; set; } = true;
public bool MultiTenant { get; set; } = true; public bool MultiTenant { get; set; } = true;
public List<McpTableIndexDto> Indexes { get; set; } = [];
public List<McpTableForeignKeyDto> ForeignKeys { get; set; } = [];
}
/// <summary>Index ya da unique kisit; ad sunucuda <c>IX_/UQ_{Tablo}_{Kolonlar}</c> olarak uretilir.</summary>
public class McpTableIndexDto
{
public List<string> Columns { get; set; } = [];
public bool Unique { get; set; }
}
/// <summary>FK; ad <c>FK_{Tablo}_{Kolon}</c>.</summary>
public class McpTableForeignKeyDto
{
public string Column { get; set; } = string.Empty;
public string ReferencedTable { get; set; } = string.Empty;
/// <summary>Bos ise <c>Id</c>.</summary>
public string? ReferencedColumn { get; set; }
/// <summary><c>NoAction</c> (varsayilan), <c>Cascade</c> ya da <c>SetNull</c>.</summary>
public string? OnDelete { get; set; }
} }
public class McpTableColumnDto public class McpTableColumnDto
@ -99,6 +125,30 @@ public class McpCreateTableResultDto
public string SeedFilePath { get; set; } = string.Empty; public string SeedFilePath { get; set; } = string.Empty;
} }
/// <summary>View, procedure ya da function scriptinin iki saglayici icin yazildigi seed dosyalari.</summary>
public class McpSqlObjectResultDto
{
public string Name { get; set; } = string.Empty;
/// <summary><c>VIEW</c>, <c>PROCEDURE</c> ya da <c>FUNCTION</c>.</summary>
public string Kind { get; set; } = string.Empty;
/// <summary>Seed kokune gore goreli yollar: <c>sql/object/{Ad}.sql</c>, <c>postgres/object/{Ad}.sql</c>.</summary>
public List<string> SeedFilePaths { get; set; } = [];
}
/// <summary>Yazilan <c>crud/{Entity}.json</c> dosyasi ve icindeki endpoint'ler.</summary>
public class McpCrudEndpointsResultDto
{
public string EntityName { get; set; } = string.Empty;
/// <summary>Seed kokune gore goreli yol.</summary>
public string SeedFilePath { get; set; } = string.Empty;
/// <summary><c>{Method} {Path}</c>; pasif baslayanlar isaretlidir.</summary>
public List<string> Endpoints { get; set; } = [];
}
/// <summary> /// <summary>
/// Deploy edilmis bir wizard'in salt-okur dogrulamasi. <see cref="Passed"/> yalnizca /// Deploy edilmis bir wizard'in salt-okur dogrulamasi. <see cref="Passed"/> yalnizca
/// <see cref="McpVerifyCheckDto.Blocking"/> bir kontrol kaldiginda false olur; engellemeyen /// <see cref="McpVerifyCheckDto.Blocking"/> bir kontrol kaldiginda false olur; engellemeyen

View file

@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
namespace Sozsoft.Mcp.Dtos;
/// <summary>Rapor kategorisi (<c>Adm.Reports.Categories</c>).</summary>
public class McpReportCategoryDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Icon { get; set; }
}
/// <summary>Rapor sablonunun listede donen ozeti; HTML icerigi tasimaz.</summary>
public class McpReportTemplateSummaryDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string Category { get; set; } = string.Empty;
public string? Status { get; set; }
}
/// <summary>Kategoriler ve sablonlar birlikte; sablon kaydederken kategori adi buradan secilir.</summary>
public class McpReportCatalogDto
{
public List<McpReportCategoryDto> Categories { get; set; } = [];
public List<McpReportTemplateSummaryDto> Templates { get; set; } = [];
}
/// <summary>
/// Rapor sablonunun tamami. <see cref="Placeholders"/> HTML icindeki <c>{{AlanAdi}}</c> yer
/// tutuculardir; sablon bir ekran kaydiyla acildiginda kaydin alanlariyla doldurulur.
/// </summary>
public class McpReportTemplateDto : McpReportTemplateSummaryDto
{
public Guid CategoryId { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public List<string> Placeholders { get; set; } = [];
/// <summary>Sablonun kayitsiz goruntuleme adresi.</summary>
public string ViewUrl { get; set; } = string.Empty;
/// <summary>
/// Kaydetme istegi bir ekran verdiyse o ekranin satir butonu (<c>CommandColumnJson</c>
/// elemani); ekranin mevcut butonlarina eklenir.
/// </summary>
public McpReportRowButtonDto? RowButton { get; set; }
}
/// <summary>Sablonu bir ekran kaydiyla acan satir butonu; <c>CommandColumnDto</c> ile ayni alanlar.</summary>
public class McpReportRowButtonDto
{
public string Text { get; set; } = string.Empty;
public string Hint { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string UrlTarget { get; set; } = "_blank";
public string AuthName { get; set; } = string.Empty;
public bool IsVisible { get; set; } = true;
}
public class McpReportCategorySaveRequestDto
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? Icon { get; set; }
}
public class McpReportTemplateSaveRequestDto
{
/// <summary>Sablonun adi; kiraci icinde tekildir ve kimligidir.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Kategori adi ya da Id'si. Yeni sablonda zorunlu.</summary>
public string? Category { get; set; }
/// <summary>Basilacak HTML. Yeni sablonda zorunlu; guncellemede bos ise mevcut icerik korunur.</summary>
public string? HtmlContent { get; set; }
public string? Description { get; set; }
/// <summary><c>active</c> ya da <c>passive</c>; yeni sablonda varsayilan <c>active</c>.</summary>
public string? Status { get; set; }
/// <summary>
/// Sablonun basilacagi ekran. Verilirse yer tutucular ekranin alanlarina gore dogrulanir ve
/// cevapta o ekranin satir butonu doner.
/// </summary>
public string? ListFormCode { get; set; }
/// <summary>Satir butonunun dil anahtari; verilmezse <c>Sas.Platform.View</c>.</summary>
public string? ButtonText { get; set; }
}

View file

@ -63,7 +63,11 @@ public class McpListSummaryDto
/// <summary>Tablo adi ya da SELECT metni — listenin gercek SQL kaynagi.</summary> /// <summary>Tablo adi ya da SELECT metni — listenin gercek SQL kaynagi.</summary>
public string? SqlSourceName { get; set; } public string? SqlSourceName { get; set; }
/// <summary><c>table</c>, <c>selectSql</c> ya da <c>unknown</c>.</summary> /// <summary>
/// <c>table</c>, <c>selectSql</c> ya da <c>unknown</c>; gelistiricide ayrica <c>pending</c> (wizard dosyasi
/// var, DB Migrate bekliyor) ve <c>pendingTable</c> (yalnizca seed scripti olan tablo). Kolon adlari
/// dogrudur; query_list migrate'ten once calismaz.
/// </summary>
public string? SourceKind { get; set; } public string? SourceKind { get; set; }
public string? KeyFieldName { get; set; } public string? KeyFieldName { get; set; }

View file

@ -11,9 +11,9 @@ namespace Sozsoft.Mcp;
/// </summary> /// </summary>
public interface IMcpDataAppService : IApplicationService public interface IMcpDataAppService : IApplicationService
{ {
Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input); Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input);
Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input); Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input);
Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input); Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input);
} }

View file

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
using Sozsoft.Platform.DeveloperKit; using Sozsoft.Platform.DeveloperKit;
@ -14,52 +14,68 @@ namespace Sozsoft.Mcp;
/// </summary> /// </summary>
public interface IMcpDevelopAppService : IApplicationService public interface IMcpDevelopAppService : IApplicationService
{ {
Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section); Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section);
Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input); Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input);
Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search); /// <summary>View/procedure/function scriptini iki saglayicinin seed klasorune yazar; veritabaninda calistirmaz.</summary>
Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript);
Task<McpWizardFileDto> GetWizardAsync(string fileName); /// <summary>Tablo ya da view icin CRUD endpoint'lerini <c>crud/{Entity}.json</c> seed dosyasina yazar; kaydi DB Migrate ekler.</summary>
Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType);
Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search);
Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName);
/// <summary>Deploy edilen wizard'in ozeti.</summary> /// <summary>Deploy edilen wizard'in ozeti.</summary>
Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input); Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input);
/// <summary>Wizard'i siler; <paramref name="dropTables"/> acikken ekranin secilebilir tablosunu da dusurur.</summary> /// <summary>Wizard'i siler; <paramref name="dropTables"/> acikken ekranin secilebilir tablosunu da dusurur.</summary>
Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables); Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables);
/// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary> /// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary>
Task<McpVerifyResultDto> VerifyWizardAsync(string fileName); Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName);
/// <summary>Id ya da basliga gore bulunan talebi, yalnizca Ready to Start durumundaysa In Progress'e alir.</summary> /// <summary>Id ya da basliga gore bulunan talebi, yalnizca Ready to Start durumundaysa In Progress'e alir.</summary>
Task<McpDevRequestDto> StartDevRequestAsync(string request); Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request);
/// <summary>Talebi durumuna dokunmadan okur; yarim kalan bir talebe donmek icindir.</summary> /// <summary>Talebi durumuna dokunmadan okur; yarim kalan bir talebe donmek icindir.</summary>
Task<McpDevRequestDto> GetDevRequestAsync(string request); Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request);
/// <summary> /// <summary>
/// Talebin analizini yazar (var olani degistirir) ve talebi Analysis durumuna alir; yalnizca /// Talebin analizini yazar (var olani degistirir); analiz kosulsuz uretime hazirsa talebi
/// Backlog ya da Analysis durumundaki talep analiz edilir. /// Ready to Start'a, degilse Analysis durumuna alir. Yalnizca Backlog ya da Analysis durumundaki
/// talep analiz edilir.
/// </summary> /// </summary>
Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html); Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady);
/// <summary> /// <summary>
/// Analizin sonuna HTML ekler. Aciklama talebin yazildigi haliyle kalir; uretim notlari /// Analizin sonuna HTML ekler. Aciklama talebin yazildigi haliyle kalir; uretim notlari
/// analize yazilir. /// analize yazilir.
/// </summary> /// </summary>
Task AppendDevRequestNoteAsync(string request, string html); Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html);
/// <summary> /// <summary>
/// Talebin SubTasks listesine tamamlanmis satirlar ekler; atanan kisi cagiran kullanici, /// Talebin SubTasks listesine tamamlanmis satirlar ekler; atanan kisi cagiran kullanici,
/// bitis tarihi bugundur. /// bitis tarihi bugundur.
/// </summary> /// </summary>
Task AddDevRequestSubTasksAsync(string request, List<string> titles); Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles);
Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search); /// <summary>Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur.</summary>
Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name);
Task<CustomComponentDto?> GetComponentAsync(string name); /// <summary>
/// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden
/// uretilir.
/// </summary>
Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content);
Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input); Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search);
Task DeleteComponentAsync(string name); Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name);
Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input);
Task<McpProblem?> DeleteComponentAsync(string name);
} }

View file

@ -16,17 +16,17 @@ public interface IMcpQueryAppService : IApplicationService
/// okuma yetkisi, kiraci/sube filtreleri ve kullanici ozellestirmeleri uygulamadaki gibi /// okuma yetkisi, kiraci/sube filtreleri ve kullanici ozellestirmeleri uygulamadaki gibi
/// gecerlidir. Normal kullanicinin veri okuma yolu budur. /// gecerlidir. Normal kullanicinin veri okuma yolu budur.
/// </summary> /// </summary>
Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input); Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input);
/// <summary> /// <summary>
/// Tek bir SELECT ifadesi kosturur. Coklu ifade, DDL ve DML reddedilir; satir sayisi /// Tek bir SELECT ifadesi kosturur. Coklu ifade, DDL ve DML reddedilir; satir sayisi
/// sunucu kaydindaki tavana kirpilir. /// sunucu kaydindaki tavana kirpilir.
/// </summary> /// </summary>
Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input); Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input);
/// <summary>Veri kaynagindaki view/procedure/function nesneleri ve Custom Endpoint'ler.</summary> /// <summary>Veri kaynagindaki view/procedure/function nesneleri ve Custom Endpoint'ler.</summary>
Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null); Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null);
/// <summary>Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir.</summary> /// <summary>Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir.</summary>
Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input); Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input);
} }

View file

@ -0,0 +1,26 @@
using System.Threading.Tasks;
using Sozsoft.Mcp.Dtos;
using Volo.Abp.Application.Services;
namespace Sozsoft.Mcp;
/// <summary>
/// Rapor kategorisi ve rapor sablonu uretimi. Yazma <c>Adm.Reports.Categories</c> ve
/// <c>Adm.Reports.ReportTemplates</c> ekranlarinin C/U/D sozlesmesinden gecer; ekran yetkisi,
/// kiraci filtresi ve seed senkronizasyonu oradaki yolla ayni sekilde uygulanir.
/// </summary>
public interface IMcpReportAppService : IApplicationService
{
Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search);
/// <summary>Sablonu Id'si ya da adiyla, HTML icerigi ve yer tutuculariyla doner.</summary>
Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template);
/// <summary>Kategoriyi ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input);
/// <summary>Sablonu ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input);
Task<McpProblem?> DeleteReportTemplateAsync(string template);
}

View file

@ -26,13 +26,13 @@ public interface IMcpSchemaAppService : IApplicationService
/// <param name="search">Bos birakilirsa tum listeler doner.</param> /// <param name="search">Bos birakilirsa tum listeler doner.</param>
/// <param name="maxResults">Donecek en fazla liste sayisi.</param> /// <param name="maxResults">Donecek en fazla liste sayisi.</param>
Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200); Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200);
/// <summary>Tek bir listenin sutunlariyla birlikte tanimi; bulunamazsa null.</summary> /// <summary>Tek bir listenin sutunlariyla birlikte tanimi; bulunamazsa null.</summary>
Task<McpListDetailDto?> GetListAsync(string listFormCode); Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode);
/// <summary> /// <summary>
/// Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama. /// Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama.
/// </summary> /// </summary>
Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50); Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50);
} }

View file

@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services; using Sozsoft.Mcp.Services;
using Sozsoft.Platform.Localization; using Sozsoft.Platform.Localization;
using Volo.Abp;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
namespace Sozsoft.Mcp; namespace Sozsoft.Mcp;
@ -46,40 +45,49 @@ public abstract class McpAppService : ApplicationService
} }
/// <summary> /// <summary>
/// Kaydi zorunlu kilan islemler icin. Kayit yoksa istemcinin nedenini gorebilecegi bir /// Kapali bir araca yapilan cagriyi reddeder: sunucu kaydi yoksa, arac sunucu kaydinda kapaliysa
/// hata atar. /// ya da kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina
/// </summary>
protected async Task<McpServerConfigDto> RequireServerConfigAsync()
=> await ResolveServerConfigAsync()
?? throw new UserFriendlyException(L[McpErrorCodes.NoServerRecord]);
/// <summary>
/// Kapali bir araca yapilan cagriyi reddeder: arac sunucu kaydinda kapaliysa ya da
/// kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina
/// yeterli degildir — istemci listede olmayan bir araci da cagirabilir. /// yeterli degildir — istemci listede olmayan bir araci da cagirabilir.
/// </summary> /// </summary>
protected async Task<McpServerConfigDto> EnsureToolAllowedAsync(string toolName) protected async Task<McpOutcome<McpServerConfigDto>> EnsureToolAllowedAsync(string toolName)
{ {
var config = await RequireServerConfigAsync(); var config = await ResolveServerConfigAsync();
if (config is null)
{
return Problem(McpErrorCodes.NoServerRecord);
}
if (CurrentTenant.IsAvailable && !McpToolNames.TenantTools.Contains(toolName, StringComparer.Ordinal)) if (CurrentTenant.IsAvailable && !McpToolNames.TenantTools.Contains(toolName, StringComparer.Ordinal))
{ {
throw new UserFriendlyException(L[McpErrorCodes.TenantToolDisabled, toolName]); return Problem(McpErrorCodes.TenantToolDisabled, toolName);
} }
if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName)) if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName))
{ {
throw new UserFriendlyException(L[McpErrorCodes.ToolDisabled, toolName, config.Name]); return Problem(McpErrorCodes.ToolDisabled, toolName, config.Name);
} }
if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission)) if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission) &&
await CheckPermissionAsync(toolName, permission) is { } denied)
{ {
await AuthorizationService.CheckAsync(permission); return denied;
} }
return config; return config;
} }
/// <summary>
/// Yetkiyi adiyla ister; eksikse istemcinin rol ekraninda arayabilecegi ad ve kodla reddeder.
/// <c>AuthorizationService.CheckAsync</c> istisna atar, bu yuzden MCP'de kullanilmaz.
/// </summary>
protected async Task<McpProblem?> CheckPermissionAsync(string toolName, string permission)
=> await AuthorizationService.IsGrantedAsync(permission)
? null
: Problem(McpErrorCodes.PermissionDenied, toolName, McpPermissionText.Describe(L, permission));
protected McpProblem Problem(string name, params object[] arguments)
=> McpProblem.From(L, name, arguments);
/// <summary>Istenen satir sayisini sunucu kaydindaki tavana kirpar.</summary> /// <summary>Istenen satir sayisini sunucu kaydindaki tavana kirpar.</summary>
protected static int ResolveMaxRows(McpServerConfigDto config, int requested) protected static int ResolveMaxRows(McpServerConfigDto config, int requested)
{ {

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -7,6 +8,7 @@ using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services; using Sozsoft.Mcp.Services;
using Sozsoft.Platform; using Sozsoft.Platform;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.ListForms;
using Sozsoft.Platform.ListForms.Select; using Sozsoft.Platform.ListForms.Select;
using Volo.Abp; using Volo.Abp;
@ -21,38 +23,98 @@ namespace Sozsoft.Mcp;
public class McpDataAppService( public class McpDataAppService(
IMcpServerConfigProvider serverConfigProvider, IMcpServerConfigProvider serverConfigProvider,
IListFormDataAppService listFormDataAppService, IListFormDataAppService listFormDataAppService,
IMcpListFormAccessFilter accessFilter) IMcpListFormAccessFilter accessFilter,
IMcpDevRequestTimesheetRecorder timesheetRecorder,
IModuleDocAppService moduleDocAppService)
: McpAppService(serverConfigProvider), IMcpDataAppService : McpAppService(serverConfigProvider), IMcpDataAppService
{ {
public async Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input) private const string DevRequestStatusField = "Status";
private const string DevRequestTestingStatus = "Testing";
public async Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input)
{ {
var request = await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true); if (!(await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true)).TryGetValue(out var request, out var problem))
{
return problem;
}
var key = await listFormDataAppService.PostInsertAsync(request); var key = await listFormDataAppService.PostInsertAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode);
return new McpWriteResultDto { Key = ((object?)key)?.ToString() }; return new McpWriteResultDto { Key = ((object?)key)?.ToString() };
} }
public async Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input) public async Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input)
{ {
var request = await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true); if (!(await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true)).TryGetValue(out var request, out var problem))
{
return problem;
}
if (await CheckModuleDocsBeforeTestingAsync(input) is { } incomplete)
{
return incomplete;
}
var affected = await listFormDataAppService.PostUpdateAsync(request); var affected = await listFormDataAppService.PostUpdateAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.UpdateRow, input.ListFormCode, string.Join(", ", input.Values.Keys));
return new McpWriteResultDto { AffectedRows = affected }; return new McpWriteResultDto { AffectedRows = affected };
} }
public async Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input) public async Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input)
{ {
var request = await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false); if (!(await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false)).TryGetValue(out var request, out var problem))
{
return problem;
}
var affected = await listFormDataAppService.PostDeleteAsync(request); var affected = await listFormDataAppService.PostDeleteAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode);
return new McpWriteResultDto { AffectedRows = affected }; return new McpWriteResultDto { AffectedRows = affected };
} }
/// <summary>
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumani bolumleri
/// tamam olmalidir (lowcode §0.13): bolum var ve doldurulmamis (<c>module-doc:todo</c>) alt bolum yok.
/// Talebi Testing'e alan tek yol <c>update_row</c> oldugu icin kural burada uygulanir. Wizard
/// okuma yetkisi olmayan kullanici wizard uretemeyecegi icin kontrol ona uygulanmaz.
/// </summary>
private async Task<McpProblem?> CheckModuleDocsBeforeTestingAsync(McpRowRequestDto input)
{
if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase))
return null;
var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase))
return null;
if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
return null;
foreach (var key in input.Keys ?? [])
{
if (!Guid.TryParse(key?.ToString(), out var devRequestId))
continue;
var incomplete = await moduleDocAppService.GetIncompleteForDevRequestAsync(devRequestId);
if (incomplete.Count == 0)
continue;
var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})"));
return Problem(McpErrorCodes.DevRequestModuleDocsIncomplete, detail);
}
return null;
}
/// <summary> /// <summary>
/// Istegi ekranin bekledigi bicime cevirir. <c>Data</c> JSON metni olarak verilir; ayni /// Istegi ekranin bekledigi bicime cevirir. <c>Data</c> JSON metni olarak verilir; ayni
/// bicim ekranin kopyalama yolunda da kullanilir. /// bicim ekranin kopyalama yolunda da kullanilir.
/// </summary> /// </summary>
private async Task<DataRequestDto> BuildRequestAsync( private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
McpRowRequestDto input, McpRowRequestDto input,
string toolName, string toolName,
AuthorizationTypeEnum operation, AuthorizationTypeEnum operation,
@ -61,29 +123,32 @@ public class McpDataAppService(
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(toolName); if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.ListFormCode)) if (string.IsNullOrWhiteSpace(input.ListFormCode))
{ {
throw new UserFriendlyException(L[McpErrorCodes.ListFormCodeRequired]); return Problem(McpErrorCodes.ListFormCodeRequired);
} }
// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki // Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
// istemci hangi yetkinin eksik oldugunu gorsun. // istemci hangi yetkinin eksik oldugunu gorsun.
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, operation); var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, operation);
if (permission is not null) if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
{ {
await AuthorizationService.CheckAsync(permission); return denied;
} }
if (requireValues && (input.Values is null || input.Values.Count == 0)) if (requireValues && (input.Values is null || input.Values.Count == 0))
{ {
throw new UserFriendlyException(L[McpErrorCodes.ValuesRequired]); return Problem(McpErrorCodes.ValuesRequired);
} }
if (requireKeys && (input.Keys is null || input.Keys.Count == 0)) if (requireKeys && (input.Keys is null || input.Keys.Count == 0))
{ {
throw new UserFriendlyException(L[McpErrorCodes.KeysRequired]); return Problem(McpErrorCodes.KeysRequired);
} }
return new DataRequestDto return new DataRequestDto

View file

@ -0,0 +1,13 @@
using System;
namespace Sozsoft.Mcp;
/// <summary>
/// Kullanicinin MCP ile uzerinde calistigi talep ve son kaydedilen etkinlik ani; bir sonraki
/// timesheet satirinin suresi bu andan olculur.
/// </summary>
public class McpDevRequestActivityCacheItem
{
public Guid RequestId { get; set; }
public DateTime At { get; set; }
}

View file

@ -0,0 +1,150 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Localization;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Guids;
using Volo.Abp.Localization;
using Volo.Abp.Timing;
using Volo.Abp.Uow;
using Volo.Abp.Users;
namespace Sozsoft.Mcp;
/// <summary>
/// Gelistirme talebinin Timesheets listesini MCP islemlerinden doldurur. Talep
/// <c>get_dev_request</c> / <c>start_dev_request</c> ile acildiginda kullanicinin etkin talebi
/// olur; ardindan yapilan her yazma islemi o talebe bir satir ekler. Satir modelin dikkatine
/// birakilmaz: aciklama dil anahtarindan Turkce uretilir, sure son etkinlikten bu yana olculur.
/// </summary>
public interface IMcpDevRequestTimesheetRecorder
{
/// <summary>Talebi kullanicinin etkin talebi yapar ve olcumu baslatir.</summary>
Task BeginAsync(Guid devRequestId);
/// <summary>Kullanicinin etkin talebi; talep acilmadiysa ya da suresi dolduysa bos.</summary>
Task<Guid?> GetActiveRequestIdAsync();
/// <summary>
/// Etkin talebe bir satir yazar; etkin talep yoksa (islem bir talep dongusunun parcasi
/// degilse) hicbir sey yapmaz.
/// </summary>
Task RecordAsync(string descriptionKey, params object[] args);
}
public class McpDevRequestTimesheetRecorder(
IDistributedCache<McpDevRequestActivityCacheItem> activityCache,
IRepository<DevRequest, Guid> devRequestRepository,
IStringLocalizer<PlatformResource> localizer,
ICurrentUser currentUser,
IClock clock,
IGuidGenerator guidGenerator,
IUnitOfWorkManager unitOfWorkManager,
ILogger<McpDevRequestTimesheetRecorder> logger) : IMcpDevRequestTimesheetRecorder, ITransientDependency
{
/// <summary>Timesheet satirlarinin dili; talep ekrani Turkce kullanilir.</summary>
private const string TimesheetCulture = "tr";
/// <summary>Talep ekranindaki tarih kutusunun okudugu bicim.</summary>
private const string DateFormat = "yyyy-MM-dd";
/// <summary>
/// Etkin talebin omru bir calisma oturumunu kapsar; suresi dolan kayit, gunler once acilmis
/// bir talebe baska bir isin yazilmasini ve sahte uzun sureyi engeller.
/// </summary>
private static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(12);
public Task BeginAsync(Guid devRequestId) => StampAsync(devRequestId, clock.Now);
public async Task<Guid?> GetActiveRequestIdAsync() => (await activityCache.GetAsync(ActivityKey()))?.RequestId;
public async Task RecordAsync(string descriptionKey, params object[] args)
{
var activity = await activityCache.GetAsync(ActivityKey());
if (activity is null)
return;
string description;
using (CultureHelper.Use(TimesheetCulture))
{
description = localizer[descriptionKey, args];
}
var now = clock.Now;
var currentUow = unitOfWorkManager.Current;
if (currentUow is null)
{
await WriteAsync(activity, description, now);
return;
}
// Satir yazma araclari talebi Dapper baglantisindaki transaction ile gunceller; o kilit
// UoW bitene kadar surer ve EF'in ayri baglantidan yaptigi guncelleme onu bekleyip zaman
// asimina duser. Timesheet bu yuzden asil is commit edildikten sonra kendi UoW'sinde yazilir.
currentUow.OnCompleted(async () =>
{
try
{
using var uow = unitOfWorkManager.Begin(requiresNew: true);
await WriteAsync(activity, description, now);
await uow.CompleteAsync();
}
catch (Exception ex)
{
// Is commit edildi; timesheet hatasi araci basarisiz gostermemeli.
logger.LogWarning(ex, "Timesheet entry for development request {RequestId} could not be written.", activity.RequestId);
}
});
}
private async Task WriteAsync(McpDevRequestActivityCacheItem activity, string description, DateTime now)
{
var devRequest = await devRequestRepository.FindAsync(activity.RequestId);
if (devRequest is null)
return;
JsonArray timesheets;
try
{
timesheets = string.IsNullOrWhiteSpace(devRequest.Timesheets)
? []
: JsonNode.Parse(devRequest.Timesheets) as JsonArray ?? throw new JsonException();
}
catch (JsonException)
{
// Yapilan is (deploy, tablo) zaten tamamlandi; bozuk bir timesheet onu geri almamali.
logger.LogWarning("Timesheets of development request {RequestId} could not be read; entry skipped.", devRequest.Id);
return;
}
var minutes = Math.Max(1, (int)Math.Round((now - activity.At).TotalMinutes));
timesheets.Add(new JsonObject
{
["id"] = guidGenerator.Create().ToString(),
["date"] = now.ToString(DateFormat, CultureInfo.InvariantCulture),
["userName"] = currentUser.UserName ?? string.Empty,
["description"] = description,
// Ekranin sure kutusu saati ondalik sayi olarak okur.
["duration"] = Math.Round(minutes / 60d, 4)
});
devRequest.Timesheets = timesheets.ToJsonString();
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await StampAsync(devRequest.Id, now);
}
private string ActivityKey() => currentUser.Id?.ToString() ?? string.Empty;
private Task StampAsync(Guid devRequestId, DateTime at) =>
activityCache.SetAsync(ActivityKey(), new McpDevRequestActivityCacheItem { RequestId = devRequestId, At = at },
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ActivityLifetime });
}

View file

@ -2,7 +2,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@ -36,12 +35,15 @@ public class McpDevelopAppService(
IMcpServerConfigProvider serverConfigProvider, IMcpServerConfigProvider serverConfigProvider,
IMcpDevGuide devGuide, IMcpDevGuide devGuide,
IMcpTableCreator tableCreator, IMcpTableCreator tableCreator,
IMcpSqlObjectWriter sqlObjectWriter,
IListFormWizardAppService wizardAppService, IListFormWizardAppService wizardAppService,
ICustomComponentAppService componentAppService, ICustomComponentAppService componentAppService,
IListFormSelectAppService listFormSelectAppService, IListFormSelectAppService listFormSelectAppService,
IRepository<Menu, Guid> menuRepository, IRepository<Menu, Guid> menuRepository,
IRepository<PermissionDefinitionRecord, Guid> permissionRepository, IRepository<PermissionDefinitionRecord, Guid> permissionRepository,
IRepository<DevRequest, Guid> devRequestRepository, IRepository<DevRequest, Guid> devRequestRepository,
IMcpDevRequestTimesheetRecorder timesheetRecorder,
IModuleDocAppService moduleDocAppService,
IConfiguration configuration) IConfiguration configuration)
: McpAppService(serverConfigProvider), IMcpDevelopAppService : McpAppService(serverConfigProvider), IMcpDevelopAppService
{ {
@ -50,11 +52,18 @@ public class McpDevelopAppService(
/// <summary>Index cevabinda listelenen en derin baslik; daha derini bolum istenince gorunur.</summary> /// <summary>Index cevabinda listelenen en derin baslik; daha derini bolum istenince gorunur.</summary>
private const int IndexHeadingLevel = 3; private const int IndexHeadingLevel = 3;
/// <summary>Baslikla eslesmeyen terim icin listelenen en fazla bolum.</summary>
private const int GuideSearchMaxResults = 15;
/// <summary>Custom Components ekraninin tek sayfada dondurebildigi ust sinir.</summary> /// <summary>Custom Components ekraninin tek sayfada dondurebildigi ust sinir.</summary>
private const int ComponentPageSize = 1000; private const int ComponentPageSize = 1000;
private const string DevRequestUpdatePermission = PlatformConsts.AppCodes.DevRequests + ".Update"; private const string DevRequestUpdatePermission = PlatformConsts.AppCodes.DevRequests + ".Update";
/// <summary>Liste ekrani aciklamasinin (DescTr/DescEn) en az satir ve karakter sayisi.</summary>
private const int MinDescriptionLines = 3;
private const int MinDescriptionLength = 150;
/// <summary>Talep ekranindaki alt gorev tarih kutusunun okudugu bicim.</summary> /// <summary>Talep ekranindaki alt gorev tarih kutusunun okudugu bicim.</summary>
private const string SubTaskDateFormat = "yyyy-MM-dd"; private const string SubTaskDateFormat = "yyyy-MM-dd";
@ -70,16 +79,19 @@ public class McpDevelopAppService(
public const string SeedClosure = "seed_closure"; public const string SeedClosure = "seed_closure";
} }
public async Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section) public async Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section)
{ {
await EnsureToolAllowedAsync(McpToolNames.GetDevGuide); if (await EnsureToolAllowedAsync(McpToolNames.GetDevGuide) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(section)) if (string.IsNullOrWhiteSpace(section))
{ {
var headings = await devGuide.GetHeadingsAsync(); var headings = await devGuide.GetHeadingsAsync();
if (headings.Count == 0) if (headings.Count == 0)
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]); return Problem(McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey);
} }
return new McpDevGuideDto return new McpDevGuideDto
@ -91,10 +103,28 @@ public class McpDevelopAppService(
}; };
} }
var result = await devGuide.GetSectionAsync( var result = await FindGuideSectionAsync(document, section);
string.IsNullOrWhiteSpace(document) ? DefaultGuideDocument : document, if (result is null)
section) {
?? throw new UserFriendlyException(L[McpErrorCodes.DevGuideSectionNotFound, section]); // Baslik olmayan bir terim (editor, alan adi) istenebilir; hata yerine terimin gectigi
// bolumler, o da yoksa baslik listesi doner ki model bir sonraki istegi secebilsin.
var matches = await devGuide.SearchAsync(section, GuideSearchMaxResults);
if (matches.Count > 0)
{
return new McpDevGuideDto
{
Content = L[McpErrorCodes.DevGuideSearchMatches, section],
Sections = [.. matches.Select(ToDto)]
};
}
var headings = await devGuide.GetHeadingsAsync();
return new McpDevGuideDto
{
Content = L[McpErrorCodes.DevGuideSectionNotFound, section],
Sections = [.. headings.Where(h => h.Level <= IndexHeadingLevel).Select(ToDto)]
};
}
return new McpDevGuideDto return new McpDevGuideDto
{ {
@ -104,22 +134,56 @@ public class McpDevelopAppService(
}; };
} }
/// <summary>
/// Bolum once istenen belgede, sonra diger belgelerde numara ya da baslik metniyle aranir;
/// model terimi hangi belgede oldugunu bilmeden isteyebilir.
/// </summary>
private async Task<McpDevGuideSection?> FindGuideSectionAsync(string? document, string section)
{
var first = string.IsNullOrWhiteSpace(document) ? DefaultGuideDocument : document;
foreach (var candidate in McpDevGuide.Documents.Keys.OrderBy(key => !string.Equals(key, first, StringComparison.OrdinalIgnoreCase)))
{
var result = await devGuide.GetSectionAsync(candidate, section);
if (result is not null)
{
return result;
}
}
return null;
}
/// <summary> /// <summary>
/// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca /// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca
/// MCP kapisidir. /// MCP kapisidir.
/// </summary> /// </summary>
public async Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input) public async Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.CreateTable); if (await EnsureToolAllowedAsync(McpToolNames.CreateTable) is { Problem: { } problem })
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default); {
return problem;
}
var created = await tableCreator.CreateAsync(new McpTableDefinition( if (await CheckPermissionAsync(McpToolNames.CreateTable, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
{
return denied;
}
var outcome = await tableCreator.CreateAsync(new McpTableDefinition(
input.TableName, input.TableName,
[.. (input.Columns ?? []).Select(column => new McpTableColumn( [.. (input.Columns ?? []).Select(column => new McpTableColumn(
column.Name, column.Type, column.MaxLength, column.IsNullable, column.DefaultValue))], column.Name, column.Type, column.MaxLength, column.IsNullable, column.DefaultValue))],
input.FullAudited, input.FullAudited,
input.MultiTenant)); input.MultiTenant,
[.. (input.Indexes ?? []).Select(index => new McpTableIndex(index.Columns ?? [], index.Unique))],
[.. (input.ForeignKeys ?? []).Select(fk => new McpTableForeignKey(fk.Column, fk.ReferencedTable, fk.ReferencedColumn, fk.OnDelete))]));
if (!outcome.TryGetValue(out var created, out var invalid))
{
return invalid;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName);
return new McpCreateTableResultDto return new McpCreateTableResultDto
{ {
@ -129,9 +193,84 @@ public class McpDevelopAppService(
}; };
} }
public async Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search) /// <summary>
/// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function
/// kaydinin karsiligidir. Nesneyi DB Migrate kurar.
/// </summary>
public async Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript)
{ {
await EnsureToolAllowedAsync(McpToolNames.ListWizards); if (await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject) is { Problem: { } problem })
{
return problem;
}
if (await CheckPermissionAsync(McpToolNames.SaveSqlObject, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
{
return denied;
}
if (!sqlObjectWriter.Save(sqlServerScript, postgreSqlScript).TryGetValue(out var saved, out var invalid))
{
return invalid;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveSqlObject, saved.Name);
return new McpSqlObjectResultDto
{
Name = saved.Name,
Kind = saved.Kind,
SeedFilePaths = [saved.SqlServerPath, saved.PostgreSqlPath]
};
}
/// <summary>
/// SQL Query Manager'daki CRUD endpoint diyaloguyla ayni endpoint kumesini
/// (<see cref="CrudEndpointSeedFileDto.Generate"/>) yalnizca <c>crud/{Entity}.json</c> seed dosyasina
/// yazar; kayitlari DB Migrate ekler (<c>CrudEndpointSeedApplier</c>). Yetki diyalogunkiyle aynidir.
/// </summary>
public async Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType)
{
if (await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints) is { Problem: { } problem })
{
return problem;
}
if (await CheckPermissionAsync(McpToolNames.GenerateCrudEndpoints, PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints) is { } denied)
{
return denied;
}
// Tasarimcidaki gibi sema oneki atilir: endpoint yolu yalnizca nesne adini tasir.
var entity = entityName?.Split('.')[^1].Trim('[', ']', '"', ' ') ?? string.Empty;
if (CrudEndpointSeedFileDto.SafeFileName(entity) is null)
{
return Problem(McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty);
}
// Model listeyi JSON dizisi olarak da gonderebilir (["GetList","Create"]); metne cevrilen dizi
// virgullu listeye indirilir.
var operations = operationTypes is null
? null
: string.Join(',', operationTypes.Split(',').Select(item => item.Trim('[', ']', '"', ' ')));
var seedFile = CrudEndpointSeedFileDto.Generate(entity, operations, objectType, Clock.Now.ToUniversalTime());
seedFile.Write(configuration);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.GenerateCrudEndpoints, entity);
return new McpCrudEndpointsResultDto
{
EntityName = entity,
SeedFilePath = $"{SeedPathResolver.CrudFolder}/{CrudEndpointSeedFileDto.SafeFileName(entity)}.json",
Endpoints = [.. seedFile.Endpoints.Select(e => $"{e.Method} {e.Path}{(e.IsActive ? string.Empty : " [inactive]")}")]
};
}
public async Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search)
{
if (await EnsureToolAllowedAsync(McpToolNames.ListWizards) is { Problem: { } problem })
{
return problem;
}
var files = await wizardAppService.GetFiles(); var files = await wizardAppService.GetFiles();
if (string.IsNullOrWhiteSpace(search)) if (string.IsNullOrWhiteSpace(search))
@ -139,14 +278,17 @@ public class McpDevelopAppService(
return files; return files;
} }
return [.. files.Where(file => return files.Where(file =>
Contains(file.FileName, search) || Contains(file.WizardName, search) || Contains(file.FileName, search) || Contains(file.WizardName, search) ||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))]; Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search)).ToList();
} }
public async Task<McpWizardFileDto> GetWizardAsync(string fileName) public async Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName)
{ {
await EnsureToolAllowedAsync(McpToolNames.GetWizard); if (await EnsureToolAllowedAsync(McpToolNames.GetWizard) is { Problem: { } problem })
{
return problem;
}
var seed = await wizardAppService.GetFile(fileName); var seed = await wizardAppService.GetFile(fileName);
@ -157,39 +299,114 @@ public class McpDevelopAppService(
return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() }; return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() };
} }
public async Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input) public async Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.DeployWizard); if (await EnsureToolAllowedAsync(McpToolNames.DeployWizard) is { Problem: { } problem })
{
return problem;
}
if (!ParseWizard(input.Wizard).TryGetValue(out var wizard, out var invalid))
{
return invalid;
}
var wizard = ParseWizard(input.Wizard);
wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim(); wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim();
if (CheckDetailedDescription(wizard) is { } shortDescription)
{
return shortDescription;
}
// Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy
// bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir),
// guncelleme ekranin mevcut yerini korur.
var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard;
wizard.MenuOrder = previous?.MenuOrder ?? 0;
// deploy_wizard dosyayi tamamen degistirir. Duzenlemede WizardCode yazilmamissa kod sessizce
// Sas.Wizard.{Ad}'a duser: girdi kismidir ve deploy ekranin menusunu, yetkilerini ve alanlarini
// bosaltirdi. Kodu bilerek degistiren girdi WizardCode'u acikca yazar.
if (previous is not null && string.IsNullOrWhiteSpace(wizard.ListFormCode) &&
!string.IsNullOrWhiteSpace(previous.ListFormCode) &&
!string.Equals(previous.ListFormCode.Trim(), wizard.ResolveListFormCode(), StringComparison.OrdinalIgnoreCase))
{
return Problem(McpErrorCodes.WizardCodeMissing, wizard.EditFileName!, previous.ListFormCode.Trim());
}
// Alt form sekmesi ana kayit her degistiginde yeniden sorgulanmaz; MCP yeni baglanti
// acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi
// deger korunur.
var previousRefresh = (previous?.SubForms ?? [])
.Where(subForm => !string.IsNullOrWhiteSpace(subForm.Code))
.GroupBy(subForm => subForm.Code, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().IsRefresh, StringComparer.OrdinalIgnoreCase);
foreach (var subForm in wizard.SubForms ?? [])
subForm.IsRefresh = subForm.Code is not null && previousRefresh.GetValueOrDefault(subForm.Code);
// Tek gruplu duzenleme formunda grup basligi popup basligini tekrarlar; baslik yalnizca
// birden fazla grubu birbirinden ayirmak icin yazilir.
var editGroups = wizard.Groups
.Where(group => group.Items.Any(item => item.IncludeInEditingForm && item.FieldName != wizard.KeyFieldName))
.ToList();
if (editGroups.Count == 1)
editGroups[0].Caption = string.Empty;
// Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz. // Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz.
var seedRows = ParseSeedRows(input.SeedRows, wizard); if (!(await ParseSeedRowsAsync(input.SeedRows, wizard)).TryGetValue(out var seedRows, out invalid))
{
return invalid;
}
// Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin
// butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir.
wizard.DevRequestId = await timesheetRecorder.GetActiveRequestIdAsync();
await wizardAppService.Create(wizard); await wizardAppService.Create(wizard);
var files = await wizardAppService.GetFiles(); var files = await wizardAppService.GetFiles();
var file = files.FirstOrDefault(file => string.Equals(file.WizardName, wizard.WizardName?.Trim(), StringComparison.OrdinalIgnoreCase)); var file = files.FirstOrDefault(file => string.Equals(file.WizardName, wizard.WizardName?.Trim(), StringComparison.OrdinalIgnoreCase));
if (file != null && seedRows.Count > 0) if (file != null && seedRows.Count > 0 && await WriteSeedRowsAsync(file.FileName, seedRows) is { } rowsRejected)
await WriteSeedRowsAsync(file.FileName, seedRows); return rowsRejected;
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count);
return file; return file;
} }
/// <summary>
/// Liste ekraninin aciklamasi basliktaki bilgi simgesinde (ⓘ) gosterilir ve ekranin kullanim
/// kilavuzudur: ne ise yaradigi, menude/hangi ekranin sekmesinde durdugu, ana alanlari, otomatik
/// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi
/// icin MCP deploy'u kisa aciklamayi reddeder.
/// </summary>
private McpProblem? CheckDetailedDescription(ListFormWizardDto wizard)
{
if (wizard.ComponentKind == WizardComponentKindEnum.Custom)
return null;
static bool IsDetailed(string? text) =>
!string.IsNullOrWhiteSpace(text) &&
text.Trim().Length >= MinDescriptionLength &&
text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines;
return IsDetailed(wizard.DescTr) && IsDetailed(wizard.DescEn)
? null
: Problem(McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength);
}
/// <summary> /// <summary>
/// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik /// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik
/// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler. /// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler.
/// </summary> /// </summary>
private List<Dictionary<string, JsonElement>> ParseSeedRows(string? json, ListFormWizardDto wizard) private async Task<McpOutcome<List<Dictionary<string, JsonElement>>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
return []; return new List<Dictionary<string, JsonElement>>();
if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData) if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); return Problem(McpErrorCodes.SeedRowsNotAllowed);
List<Dictionary<string, JsonElement>>? rows; List<Dictionary<string, JsonElement>>? rows;
try try
@ -202,7 +419,7 @@ public class McpDevelopAppService(
} }
if (rows is null || rows.Any(row => row is null)) if (rows is null || rows.Any(row => row is null))
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]); return Problem(McpErrorCodes.SeedRowsInvalid);
var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName) var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName)
.Append(wizard.KeyFieldName) .Append(wizard.KeyFieldName)
@ -211,33 +428,74 @@ public class McpDevelopAppService(
var unknown = rows.SelectMany(row => row.Keys).Where(key => !fields.Contains(key)) var unknown = rows.SelectMany(row => row.Keys).Where(key => !fields.Contains(key))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (unknown.Count > 0) if (unknown.Count == 0)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), string.Join(", ", fields)]); return rows;
return rows; var screenFields = string.Join(", ", fields);
var owners = await FindSameTableScreensAsync(wizard, unknown);
if (owners.Count == 0)
return Problem(McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), screenFields);
// Ayni tablonun baska ekraninda (cogunlukla alt form sekmesi) alan olan kolon migrate'te
// bu ekranin veri dosyasindan yazilmaz; model kolonun nerede durdugunu gorurse alani ekrana
// ekleyip ya da satirdan cikarip tekrar dener.
var placement = string.Join("; ", owners.Select(owner => $"{owner.Key}: {string.Join(", ", owner.Value)}"));
return Problem(McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields);
} }
/// <summary> /// <summary>
/// Demo satirlarini ekranin veri dosyasina (<c>ListForm.SeedFilePath</c>) yazar; veritabanina /// Bilinmeyen kolonlari ayni tabloyu okuyan diger wizard ekranlarinin alanlarinda arar; sonuc
/// dokunmaz. Migrate ekrani kurduktan sonra <c>ListFormSeedDataApplier</c> satirlari ekler ve /// ekran kodundan o ekranda bulunan kolonlara eslemedir. Ekran listesi wizard dosyalarindan
/// kiraci/audit alanlarini doldurur. Guid anahtar burada uretilir: dosya her uygulandiginda ayni /// okunur, migrate bekleyen ekranlar da dahildir.
/// kayda karsilik gelir. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez.
/// </summary> /// </summary>
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows) private async Task<Dictionary<string, List<string>>> FindSameTableScreensAsync(ListFormWizardDto wizard, List<string> columns)
{ {
var listForm = (await wizardAppService.GetFile(fileName)).ListForm; var owners = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/'); var table = wizard.SelectCommand?.Trim();
if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath)) if (string.IsNullOrWhiteSpace(table))
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); return owners;
var path = SeedPathResolver.GetScopePath(configuration, CurrentTenant.Id, relativePath.Split('/')); var wizardName = wizard.WizardName?.Trim();
var seedFile = File.Exists(path) foreach (var file in await wizardAppService.GetFiles())
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions) {
: null; if (file.ComponentKind == WizardComponentKindEnum.Custom ||
seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName }; string.Equals(file.WizardName, wizardName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(file.FileName, wizard.EditFileName, StringComparison.OrdinalIgnoreCase))
continue;
var keyFieldName = seedFile.KeyFieldName; var seed = await wizardAppService.GetFile(file.FileName);
var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal); if (!string.Equals(seed.Wizard?.SelectCommand?.Trim(), table, StringComparison.OrdinalIgnoreCase))
continue;
var fieldNames = (seed.Wizard?.Groups ?? [])
.SelectMany(group => group.Items ?? [])
.Select(item => item.FieldName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var found = columns.Where(fieldNames.Contains).ToList();
if (found.Count > 0)
owners[seed.Wizard?.ResolveListFormCode() ?? file.ListFormCode ?? file.WizardName] = found;
}
return owners;
}
/// <summary>
/// Demo satirlarini ekranin veri seed hedefine (<c>Data.SeedFilePath</c>; tanima gore tek
/// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra
/// <c>ListFormSeedDataApplier</c> satirlari ekler ve kiraci/audit alanlarini doldurur. Guid anahtar
/// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir
/// varsa (tekrar deploy) yeniden eklenmez.
/// </summary>
private async Task<McpProblem?> WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
{
var wizard = (await wizardAppService.GetFile(fileName)).Wizard;
var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), wizard?.SeedFilePath);
if (wizard == null || target == null)
return Problem(McpErrorCodes.SeedRowsNotAllowed);
var keyFieldName = wizard.KeyFieldName;
var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
var added = new List<(object Key, Dictionary<string, object> Row)>();
foreach (var row in rows) foreach (var row in rows)
{ {
@ -245,14 +503,16 @@ public class McpDevelopAppService(
if (!existing.Add(RowContent(values, keyFieldName))) if (!existing.Add(RowContent(values, keyFieldName)))
continue; continue;
if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase)) if (wizard.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
values[keyFieldName] = GuidGenerator.Create(); values[keyFieldName] = GuidGenerator.Create();
seedFile.Rows.Add(values); added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values));
} }
Directory.CreateDirectory(Path.GetDirectoryName(path)!); // Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz.
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile)); return added.Count > 0 && (await target.UpsertRowsAsync(wizard.ResolveListFormCode(), keyFieldName, added)).Count > 0
? Problem(McpErrorCodes.SeedRowsInvalid)
: null;
} }
/// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary> /// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary>
@ -266,9 +526,12 @@ public class McpDevelopAppService(
/// Ekranin tablosu ancak <paramref name="dropTables"/> acikken ve Wizard Manager'daki onayla ayni /// Ekranin tablosu ancak <paramref name="dropTables"/> acikken ve Wizard Manager'daki onayla ayni
/// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir. /// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir.
/// </summary> /// </summary>
public async Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables) public async Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables)
{ {
await EnsureToolAllowedAsync(McpToolNames.DeleteWizard); if (await EnsureToolAllowedAsync(McpToolNames.DeleteWizard) is { Problem: { } problem })
{
return problem;
}
var input = new WizardDeleteInput { FileName = fileName }; var input = new WizardDeleteInput { FileName = fileName };
if (dropTables) if (dropTables)
@ -277,7 +540,10 @@ public class McpDevelopAppService(
input.DropTables = [.. analysis.Tables.Where(t => t.CanDrop).Select(t => t.TableName)]; input.DropTables = [.. analysis.Tables.Where(t => t.CanDrop).Select(t => t.TableName)];
} }
return await wizardAppService.DeleteFile(input); var result = await wizardAppService.DeleteFile(input);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteWizard, fileName);
return result;
} }
/// <summary> /// <summary>
@ -287,9 +553,12 @@ public class McpDevelopAppService(
/// iz birakirdi. Seed kapanisi engellemez: ekran bu ortamda calisir, eksik dosya yalnizca /// iz birakirdi. Seed kapanisi engellemez: ekran bu ortamda calisir, eksik dosya yalnizca
/// migrate ya da baska bir ortamda ortaya cikar. /// migrate ya da baska bir ortamda ortaya cikar.
/// </summary> /// </summary>
public async Task<McpVerifyResultDto> VerifyWizardAsync(string fileName) public async Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName)
{ {
await EnsureToolAllowedAsync(McpToolNames.VerifyWizard); if (await EnsureToolAllowedAsync(McpToolNames.VerifyWizard) is { Problem: { } problem })
{
return problem;
}
var result = new McpVerifyResultDto { FileName = fileName }; var result = new McpVerifyResultDto { FileName = fileName };
@ -356,57 +625,95 @@ public class McpDevelopAppService(
/// ayni unit of work'te In Progress'e alinir. MCP yetki vermez; ekranin guncelleme yetkisi /// ayni unit of work'te In Progress'e alinir. MCP yetki vermez; ekranin guncelleme yetkisi
/// ayrica istenir. /// ayrica istenir.
/// </summary> /// </summary>
public async Task<McpDevRequestDto> StartDevRequestAsync(string request) public async Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request)
{ {
await EnsureToolAllowedAsync(McpToolNames.StartDevRequest); if (await EnsureToolAllowedAsync(McpToolNames.StartDevRequest) is { Problem: { } problem })
await AuthorizationService.CheckAsync(DevRequestUpdatePermission); {
return problem;
}
var devRequest = await FindDevRequestAsync(request); if (await CheckPermissionAsync(McpToolNames.StartDevRequest, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
{
return notFound;
}
if (!string.Equals(devRequest.Status, McpConsts.DevRequestStatuses.ReadyToStart, StringComparison.Ordinal)) if (!string.Equals(devRequest.Status, McpConsts.DevRequestStatuses.ReadyToStart, StringComparison.Ordinal))
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]); return Problem(McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
} }
devRequest.Status = McpConsts.DevRequestStatuses.InProgress; devRequest.Status = McpConsts.DevRequestStatuses.InProgress;
await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.BeginAsync(devRequest.Id);
return ToDto(devRequest); return ToDto(devRequest);
} }
public async Task<McpDevRequestDto> GetDevRequestAsync(string request) public async Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request)
{ {
await EnsureToolAllowedAsync(McpToolNames.GetDevRequest); if (await EnsureToolAllowedAsync(McpToolNames.GetDevRequest) is { Problem: { } problem })
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests); {
return problem;
}
return ToDto(await FindDevRequestAsync(request)); if (await CheckPermissionAsync(McpToolNames.GetDevRequest, PlatformConsts.AppCodes.DevRequests) is { } denied)
{
return denied;
}
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
{
return notFound;
}
await timesheetRecorder.BeginAsync(devRequest.Id);
return ToDto(devRequest);
} }
/// <summary> /// <summary>
/// Durum kurali modelin dikkatine birakilmaz: yalnizca Backlog ya da Analysis durumundaki talep /// Durum kurali modelin dikkatine birakilmaz: uretimi baslamamis (Backlog, Analysis, Ready to Start)
/// analiz edilir. Analysis'teki talebin analizi begenilmediyse yeniden yazilir; Ready to Start'a /// talep analiz edilir; Ready to Start talebin analizi uretim oncesi yeniden yazilabilir. "Uretime hazir: Evet" analiz uretimin karar vermeden uygulayabilecegi analizdir,
/// kullanici alir. /// talep dogrudan Ready to Start'a gecer; sartli ya da Hayir olan analiz kullanicinin kararini
/// bekledigi icin Analysis'te kalir.
/// </summary> /// </summary>
public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html) public async Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady)
{ {
await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis); if (await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis) is { Problem: { } problem })
await AuthorizationService.CheckAsync(DevRequestUpdatePermission); {
return problem;
}
if (await CheckPermissionAsync(McpToolNames.SaveDevRequestAnalysis, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]); return Problem(McpErrorCodes.DevRequestAnalysisRequired);
} }
var devRequest = await FindDevRequestAsync(request); if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis))
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]); return notFound;
}
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis or McpConsts.DevRequestStatuses.ReadyToStart))
{
return Problem(McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
} }
devRequest.Analysis = html.Trim(); devRequest.Analysis = html.Trim();
devRequest.Status = McpConsts.DevRequestStatuses.Analysis; devRequest.Status = productionReady
? McpConsts.DevRequestStatuses.ReadyToStart
: McpConsts.DevRequestStatuses.Analysis;
await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis);
return ToDto(devRequest); return ToDto(devRequest);
} }
@ -415,19 +722,33 @@ public class McpDevelopAppService(
/// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize /// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize
/// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir. /// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir.
/// </summary> /// </summary>
public async Task AppendDevRequestNoteAsync(string request, string html) public async Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html)
{ {
await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote); if (await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote) is { Problem: { } problem })
await AuthorizationService.CheckAsync(DevRequestUpdatePermission); {
return problem;
}
if (await CheckPermissionAsync(McpToolNames.AppendDevRequestNote, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNoteRequired]); return Problem(McpErrorCodes.DevRequestNoteRequired);
}
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
{
return notFound;
} }
var devRequest = await FindDevRequestAsync(request);
devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim()); devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim());
await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote);
return null;
} }
/// <summary> /// <summary>
@ -435,29 +756,44 @@ public class McpDevelopAppService(
/// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur. /// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur.
/// Var olan satirlara dokunulmaz. /// Var olan satirlara dokunulmaz.
/// </summary> /// </summary>
public async Task AddDevRequestSubTasksAsync(string request, List<string> titles) public async Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles)
{ {
await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks); if (await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks) is { Problem: { } problem })
await AuthorizationService.CheckAsync(DevRequestUpdatePermission); {
return problem;
}
if (await CheckPermissionAsync(McpToolNames.AddDevRequestSubTasks, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
var items = (titles ?? []).Select(title => title?.Trim()).Where(title => !string.IsNullOrEmpty(title)).ToList(); var items = (titles ?? []).Select(title => title?.Trim()).Where(title => !string.IsNullOrEmpty(title)).ToList();
if (items.Count == 0) if (items.Count == 0)
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]); return Problem(McpErrorCodes.DevRequestSubTasksRequired);
} }
var devRequest = await FindDevRequestAsync(request); if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
{
return notFound;
}
JsonArray subTasks; JsonArray? subTasks;
try try
{ {
subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks) subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks)
? [] ? new JsonArray()
: JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException(); : JsonNode.Parse(devRequest.SubTasks) as JsonArray;
} }
catch (JsonException) catch (JsonException)
{ {
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]); subTasks = null;
}
if (subTasks is null)
{
return Problem(McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString());
} }
var assignee = CurrentUser.UserName ?? string.Empty; var assignee = CurrentUser.UserName ?? string.Empty;
@ -477,6 +813,32 @@ public class McpDevelopAppService(
devRequest.SubTasks = subTasks.ToJsonString(); devRequest.SubTasks = subTasks.ToJsonString();
await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count);
return null;
}
public async Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name)
{
if (await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc) is { Problem: { } problem })
{
return problem;
}
return await moduleDocAppService.GetByNameAsync(name);
}
public async Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content)
{
if (await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc) is { Problem: { } problem })
{
return problem;
}
var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content });
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name);
return doc;
} }
private static McpDevRequestDto ToDto(DevRequest devRequest) private static McpDevRequestDto ToDto(DevRequest devRequest)
@ -487,7 +849,7 @@ public class McpDevelopAppService(
{ {
Id = devRequest.Id, Id = devRequest.Id,
Title = devRequest.Title, Title = devRequest.Title,
Status = devRequest.Status, Status = devRequest.Status ?? string.Empty,
Description = description, Description = description,
Images = images, Images = images,
Analysis = devRequest.Analysis, Analysis = devRequest.Analysis,
@ -501,23 +863,23 @@ public class McpDevelopAppService(
} }
/// <summary>Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir.</summary> /// <summary>Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir.</summary>
private async Task<DevRequest> FindDevRequestAsync(string? request) private async Task<McpOutcome<DevRequest>> FindDevRequestAsync(string? request)
{ {
var value = request?.Trim() ?? string.Empty; var value = request?.Trim() ?? string.Empty;
if (Guid.TryParse(value, out var id)) if (Guid.TryParse(value, out var id))
{ {
return await devRequestRepository.FindAsync(id) var byId = await devRequestRepository.FindAsync(id);
?? throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]); return byId is null ? Problem(McpErrorCodes.DevRequestNotFound, value) : byId;
} }
var matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value); var matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value);
return matches.Count switch return matches.Count switch
{ {
0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]), 0 => Problem(McpErrorCodes.DevRequestNotFound, value),
1 => matches[0], 1 => matches[0],
_ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))]) _ => Problem(McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id)))
}; };
} }
@ -624,13 +986,16 @@ public class McpDevelopAppService(
private static McpVerifyCheckDto Failed(string name, string message) private static McpVerifyCheckDto Failed(string name, string message)
=> new() { Name = name, Passed = false, Message = message }; => new() { Name = name, Passed = false, Message = message };
public async Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search) public async Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search)
{ {
await EnsureToolAllowedAsync(McpToolNames.ListComponents); if (await EnsureToolAllowedAsync(McpToolNames.ListComponents) is { Problem: { } problem })
{
return problem;
}
var components = await GetAllComponentsAsync(); var components = await GetAllComponentsAsync();
return [.. components return components
.Where(component => string.IsNullOrWhiteSpace(search) .Where(component => string.IsNullOrWhiteSpace(search)
|| Contains(component.Name, search) || Contains(component.Name, search)
|| Contains(component.RoutePath, search) || Contains(component.RoutePath, search)
@ -643,24 +1008,31 @@ public class McpDevelopAppService(
Description = component.Description, Description = component.Description,
IsActive = component.IsActive, IsActive = component.IsActive,
IsVisualDesigner = IsVisualDesignerDocument(component.Props) IsVisualDesigner = IsVisualDesignerDocument(component.Props)
})]; })
.ToList();
} }
public async Task<CustomComponentDto?> GetComponentAsync(string name) public async Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name)
{ {
await EnsureToolAllowedAsync(McpToolNames.GetComponent); if (await EnsureToolAllowedAsync(McpToolNames.GetComponent) is { Problem: { } problem })
{
return problem;
}
return await FindComponentAsync(name); return await FindComponentAsync(name);
} }
public async Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input) public async Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.SaveComponent); if (await EnsureToolAllowedAsync(McpToolNames.SaveComponent) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name)) if (string.IsNullOrWhiteSpace(input.Name))
{ {
throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]); return Problem(McpErrorCodes.ComponentNameRequired);
} }
var existing = await FindComponentAsync(input.Name); var existing = await FindComponentAsync(input.Name);
@ -670,17 +1042,17 @@ public class McpDevelopAppService(
// kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir. // kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir.
if (IsVisualDesignerDocument(props)) if (IsVisualDesignerDocument(props))
{ {
throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]); return Problem(McpErrorCodes.VisualDesignerComponent, input.Name);
} }
if (existing is null) if (existing is null)
{ {
if (string.IsNullOrWhiteSpace(input.Code)) if (string.IsNullOrWhiteSpace(input.Code))
{ {
throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]); return Problem(McpErrorCodes.ComponentCodeRequired);
} }
return await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto
{ {
Name = input.Name.Trim(), Name = input.Name.Trim(),
Code = input.Code, Code = input.Code,
@ -690,9 +1062,12 @@ public class McpDevelopAppService(
Dependencies = input.Dependencies, Dependencies = input.Dependencies,
IsActive = input.IsActive ?? true IsActive = input.IsActive ?? true
}); });
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveComponent, created.Name);
return created;
} }
return await componentAppService.UpdateAsync(existing.Id, new CreateUpdateCustomComponentDto var updated = await componentAppService.UpdateAsync(existing.Id, new CreateUpdateCustomComponentDto
{ {
Name = existing.Name, Name = existing.Name,
Code = string.IsNullOrWhiteSpace(input.Code) ? existing.Code : input.Code, Code = string.IsNullOrWhiteSpace(input.Code) ? existing.Code : input.Code,
@ -702,53 +1077,49 @@ public class McpDevelopAppService(
Dependencies = input.Dependencies ?? existing.Dependencies, Dependencies = input.Dependencies ?? existing.Dependencies,
IsActive = input.IsActive ?? existing.IsActive IsActive = input.IsActive ?? existing.IsActive
}); });
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveComponent, updated.Name);
return updated;
} }
public async Task DeleteComponentAsync(string name) public async Task<McpProblem?> DeleteComponentAsync(string name)
{ {
await EnsureToolAllowedAsync(McpToolNames.DeleteComponent); if (await EnsureToolAllowedAsync(McpToolNames.DeleteComponent) is { Problem: { } problem })
{
return problem;
}
var component = await FindComponentAsync(name) var component = await FindComponentAsync(name);
?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]); if (component is null)
{
return Problem(McpErrorCodes.ComponentNotFound, name);
}
await componentAppService.DeleteAsync(component.Id); await componentAppService.DeleteAsync(component.Id);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name);
return null;
} }
/// <summary> /// <summary>
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>) ya da duz cevaplardan okur. /// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>, basliklar toplanmis) ya da duz
/// Iki bicim de dosyanin okuma kuralindan gecer; boylece Menu/Language bloklari ve /// cevaplardan okur; Menu/Language/Data/Details/Layout bloklari ve WizardCode/ModuleId adlari
/// WizardCode/ModuleId adlari dosyadakiyle ayni anlama gelir. /// dosyadakiyle ayni anlama gelir (<see cref="WizardSeedFileDto.FromDeployJson"/>).
/// </summary> /// </summary>
private ListFormWizardDto ParseWizard(string? json) private McpOutcome<ListFormWizardDto> ParseWizard(string? json)
{ {
JsonObject? root; ListFormWizardDto? wizard;
try try
{ {
root = string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json) as JsonObject; wizard = string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json);
} }
catch (JsonException) catch (JsonException ex)
{ {
root = null; // Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir.
return Problem(McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message);
} }
if (root is null) return wizard is null ? Problem(McpErrorCodes.InvalidWizard) : wizard;
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
var seedJson = root.ContainsKey(nameof(WizardSeedFileDto.Wizard))
? root.ToJsonString()
: new JsonObject { [nameof(WizardSeedFileDto.Wizard)] = root }.ToJsonString();
try
{
return WizardSeedFileDto.FromSeedJson(seedJson)?.Wizard
?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
catch (JsonException)
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
} }
private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync() private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync()

View file

@ -1,12 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data.Common;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services; using Sozsoft.Mcp.Services;
using Sozsoft.Platform; using Sozsoft.Platform;
using Sozsoft.Platform.Data;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.ListForms.Select; using Sozsoft.Platform.ListForms.Select;
@ -29,39 +34,52 @@ namespace Sozsoft.Mcp;
/// <see cref="IMcpQueryExecutor"/> icindedir. /// <see cref="IMcpQueryExecutor"/> icindedir.
/// </summary> /// </summary>
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)] [Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
public class McpQueryAppService( public partial class McpQueryAppService(
IMcpServerConfigProvider serverConfigProvider, IMcpServerConfigProvider serverConfigProvider,
IMcpSqlGuard sqlGuard, IMcpSqlGuard sqlGuard,
IMcpQueryExecutor queryExecutor, IMcpQueryExecutor queryExecutor,
IRepository<CustomEndpoint, Guid> customEndpointRepository, IRepository<CustomEndpoint, Guid> customEndpointRepository,
IListFormSelectAppService listFormSelectAppService, IListFormSelectAppService listFormSelectAppService,
IMcpListFormAccessFilter accessFilter) IMcpListFormAccessFilter accessFilter,
IConfiguration configuration)
: McpAppService(serverConfigProvider), IMcpQueryAppService : McpAppService(serverConfigProvider), IMcpQueryAppService
{ {
private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default; private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default;
private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get; private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get;
public async Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input) public async Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
var config = await EnsureToolAllowedAsync(McpToolNames.QueryList); if (!(await EnsureToolAllowedAsync(McpToolNames.QueryList)).TryGetValue(out var config, out var problem))
{
return problem;
}
var take = ResolveMaxRows(config, input.Take); var take = ResolveMaxRows(config, input.Take);
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read); var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read);
if (permission is not null) if (permission is not null && await CheckPermissionAsync(McpToolNames.QueryList, permission) is { } denied)
{ {
await AuthorizationService.CheckAsync(permission); return denied;
}
if (!ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort).TryGetValue(out var sort, out problem) ||
!ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup).TryGetValue(out var group, out problem) ||
!ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary).TryGetValue(out var groupSummary, out problem) ||
!ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary).TryGetValue(out var totalSummary, out problem))
{
return problem;
} }
var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
{ {
ListFormCode = input.ListFormCode, ListFormCode = input.ListFormCode,
Filter = NullIfEmpty(input.Filter), Filter = NullIfEmpty(input.Filter),
Sort = ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort), Sort = sort,
Group = ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup), Group = group,
GroupSummary = ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary), GroupSummary = groupSummary,
TotalSummary = ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary), TotalSummary = totalSummary,
Skip = Math.Max(input.Skip, 0), Skip = Math.Max(input.Skip, 0),
Take = take, Take = take,
RequireTotalCount = true, RequireTotalCount = true,
@ -79,7 +97,7 @@ public class McpQueryAppService(
} }
/// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary> /// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary>
private string? ToLoadOption(string parameter, string? value, Func<string?, string?> convert) private McpOutcome<string?> ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
{ {
try try
{ {
@ -87,30 +105,139 @@ public class McpQueryAppService(
} }
catch (Exception ex) when (ex is JsonException or InvalidOperationException) catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{ {
throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]); return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter));
} }
} }
public async Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input) public async Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect); if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem))
{
return problem;
}
var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim(); var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim();
sqlGuard.EnsureReadOnlyStatement(sql); if (sqlGuard.CheckReadOnlyStatement(sql) is { } invalid)
{
return invalid;
}
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
{
return problem;
}
var maxRows = ResolveMaxRows(config, input.MaxRows); var maxRows = ResolveMaxRows(config, input.MaxRows);
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
var context = await queryExecutor.ResolveDataSourceAsync(); var context = await queryExecutor.ResolveDataSourceAsync();
var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType); var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType);
return ToResult(await queryExecutor.ExecuteAsync(context, limitedSql, parameters, maxRows)); // Sorguyu model yazar ve tablonun kolonlarini tahmin eder (her tabloda IsDeleted yok).
// Saglayici hatasi cevrilmezse istemciye yalnizca genel hata gider ve model sorguyu
// duzeltemez. Arac zaten SQL yetkisi istedigi icin hata metni yeni bilgi sizdirmaz.
try
{
return ToResult(await queryExecutor.ExecuteAsync(context, limitedSql, parameters, maxRows));
}
catch (DbException ex)
{
var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null;
if (pending is not null)
return Problem(McpErrorCodes.SelectTableNotInstalled, ex.Message, pending);
var columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null;
return columns is null
? Problem(McpErrorCodes.SelectFailed, ex.Message)
: Problem(McpErrorCodes.SelectFailedColumns, ex.Message, columns);
}
} }
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null) private const int MaxDescribedTables = 5;
/// <summary>SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column).</summary>
private static bool IsUnknownColumnError(DbException ex) =>
ex.SqlState == "42703" || ex.Message.Contains("Invalid column name", StringComparison.OrdinalIgnoreCase);
/// <summary>SQL Server 208 ("Invalid object name"), PostgreSQL 42P01 (undefined_table).</summary>
private static bool IsUnknownObjectError(DbException ex) =>
ex.SqlState == "42P01" || ex.Message.Contains("Invalid object name", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Sorgunun andigi ve veritabaninda olmayan tablolardan seed scripti olanlar (<c>Tablo(A, B); …</c>).
/// <c>create_table</c> tabloyu yalnizca scripte yazar, kurulum DB Migrate'tedir; model ayni talepte
/// tabloyu sorgularsa kolon tahmini yuzunden degil kurulum beklendigi icin bos doner. Kolonlar
/// scriptten okunur, boylece model sema araci cagirmadan ilerler. Scripti olan tablo yoksa null.
/// </summary>
private string? DescribePendingTables(string sql)
{ {
await EnsureToolAllowedAsync(McpToolNames.ListQueries); var described = TableReferenceRegex().Matches(sql)
.Select(match => match.Groups["table"].Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxDescribedTables)
.Select(table => (Table: table, Columns: PostgreSqlTableScript.ReadSeedColumns(configuration, table)))
.Where(item => item.Columns.Count > 0)
.Select(item => $"{item.Table}({string.Join(", ", item.Columns.Keys)})")
.ToList();
return described.Count == 0 ? null : string.Join("; ", described);
}
/// <summary>
/// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (<c>Tablo(A, B, C); …</c>). Model
/// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir.
/// Kolonlar okunamazsa null doner; asil hata yine iletilir.
/// </summary>
private async Task<string?> DescribeTableColumnsAsync(McpDataSourceContext context, string sql)
{
var tables = TableReferenceRegex().Matches(sql)
.Select(match => match.Groups["table"].Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxDescribedTables)
.ToList();
if (tables.Count == 0)
return null;
var parameters = tables.Select((table, index) => (Name: $"t{index}", Table: table))
.ToDictionary(p => p.Name, p => (object)p.Table);
try
{
var rows = await queryExecutor.ExecuteAsync(
context,
$"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN ({string.Join(", ", parameters.Keys.Select(name => "@" + name))}) ORDER BY TABLE_NAME, ORDINAL_POSITION",
parameters,
int.MaxValue);
var described = rows.Rows
.Select(row => (Table: ReadIgnoreCase(row, "TABLE_NAME"), Column: ReadIgnoreCase(row, "COLUMN_NAME")))
.Where(item => item.Table is not null && item.Column is not null)
.GroupBy(item => item.Table!, StringComparer.OrdinalIgnoreCase)
.Select(group => $"{group.Key}({string.Join(", ", group.Select(item => item.Column))})")
.ToList();
return described.Count == 0 ? null : string.Join("; ", described);
}
catch (DbException)
{
return null;
}
}
private static string? ReadIgnoreCase(Dictionary<string, object?> row, string key) =>
row.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
/// <summary>FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir (<c>[dbo].[Tablo]</c> → <c>Tablo</c>).</summary>
[GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?<table>\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex TableReferenceRegex();
public async Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null)
{
if (await EnsureToolAllowedAsync(McpToolNames.ListQueries) is { Problem: { } problem })
{
return problem;
}
var results = new List<McpSavedQueryDto>(); var results = new List<McpSavedQueryDto>();
if (await AuthorizationService.IsGrantedAsync(SqlPermission)) if (await AuthorizationService.IsGrantedAsync(SqlPermission))
@ -130,17 +257,16 @@ public class McpQueryAppService(
|| (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))]; || (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))];
} }
return [.. results return results
.OrderBy(item => item.Kind, StringComparer.Ordinal) .OrderBy(item => item.Kind, StringComparer.Ordinal)
.ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)]; .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
} }
private async Task<List<McpSavedQueryDto>> GetDatabaseObjectsAsync() private async Task<List<McpSavedQueryDto>> GetDatabaseObjectsAsync()
{ {
var context = await queryExecutor.ResolveDataSourceAsync(); var context = await queryExecutor.ResolveDataSourceAsync();
var catalogSql = context.DataSourceType == DataSourceTypeEnum.Postgresql var catalogSql = SqlCatalogQueries.NativeObjects(context.DataSourceType);
? McpCatalogQueries.Postgres
: McpCatalogQueries.SqlServer;
// Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz. // Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz.
var catalog = await queryExecutor.ExecuteAsync(context, catalogSql, new Dictionary<string, object>(), int.MaxValue); var catalog = await queryExecutor.ExecuteAsync(context, catalogSql, new Dictionary<string, object>(), int.MaxValue);
@ -166,44 +292,58 @@ public class McpQueryAppService(
return results; return results;
} }
public async Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input) public async Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery); if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem))
if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint))
{ {
await AuthorizationService.CheckAsync(CustomEndpointPermission); return problem;
return await RunCustomEndpointAsync(input, config);
} }
await AuthorizationService.CheckAsync(SqlPermission); var isCustomEndpoint = IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint);
if (await CheckPermissionAsync(McpToolNames.RunQuery, isCustomEndpoint ? CustomEndpointPermission : SqlPermission) is { } denied)
{
return denied;
}
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
{
return problem;
}
var maxRows = ResolveMaxRows(config, input.MaxRows); var maxRows = ResolveMaxRows(config, input.MaxRows);
var parameters = sqlGuard.NormalizeParameters(input.Parameters); if (isCustomEndpoint)
{
return await RunCustomEndpointAsync(input, parameters, maxRows);
}
var context = await queryExecutor.ResolveDataSourceAsync(); var context = await queryExecutor.ResolveDataSourceAsync();
var schemaName = string.IsNullOrWhiteSpace(input.SchemaName) var schemaName = string.IsNullOrWhiteSpace(input.SchemaName)
? sqlGuard.DefaultSchemaName(context.DataSourceType) ? SqlIdentifier.DefaultSchema(context.DataSourceType)
: input.SchemaName; : input.SchemaName;
var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType); if (!sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType).TryGetValue(out var qualifiedName, out problem) ||
var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType); !BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType).TryGetValue(out var sql, out problem))
{
return problem;
}
return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows)); return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows));
} }
private async Task<McpQueryResultDto> RunCustomEndpointAsync( private async Task<McpOutcome<McpQueryResultDto>> RunCustomEndpointAsync(
McpSavedQueryRequestDto input, McpSavedQueryRequestDto input,
McpServerConfigDto config) Dictionary<string, object> parameters,
int maxRows)
{ {
var endpoints = await GetCallableEndpointEntitiesAsync(); var endpoints = await GetCallableEndpointEntitiesAsync();
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase)) var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase));
?? throw new UserFriendlyException(L[McpErrorCodes.CustomEndpointNotFound, input.Name]); if (endpoint is null)
{
var maxRows = ResolveMaxRows(config, input.MaxRows); return Problem(McpErrorCodes.CustomEndpointNotFound, input.Name);
var parameters = sqlGuard.NormalizeParameters(input.Parameters); }
// Eksik parametre Dapper tarafinda belirsiz bir hataya donusmesin diye burada yakalanir. // Eksik parametre Dapper tarafinda belirsiz bir hataya donusmesin diye burada yakalanir.
var missing = endpoint.Parameters var missing = endpoint.Parameters
@ -213,7 +353,7 @@ public class McpQueryAppService(
if (missing.Count > 0) if (missing.Count > 0)
{ {
throw new UserFriendlyException(L[McpErrorCodes.MissingParameters, string.Join(", ", missing)]); return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing));
} }
var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode); var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode);
@ -249,7 +389,7 @@ public class McpQueryAppService(
|| (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))]; || (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))];
} }
private string BuildSavedQuerySql( private McpOutcome<string> BuildSavedQuerySql(
string? kind, string? kind,
string qualifiedName, string qualifiedName,
Dictionary<string, object> parameters, Dictionary<string, object> parameters,
@ -281,7 +421,7 @@ public class McpQueryAppService(
: $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd(); : $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd();
} }
throw new UserFriendlyException(L[McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty]); return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty);
} }
private static string? NullIfEmpty(string? value) private static string? NullIfEmpty(string? value)

View file

@ -0,0 +1,392 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services;
using Sozsoft.Platform;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.ListForms.Select;
using Volo.Abp;
using Volo.Abp.Domain.Repositories;
namespace Sozsoft.Mcp;
/// <summary>
/// Rapor kategorisi ve <c>DynamicReport</c> sablonu uretimi. Okuma depodan, yazma ekranlarin
/// C/U/D sozlesmesinden (<see cref="IListFormDataAppService"/>) gecer; boylece ekran yetkisi,
/// kiraci filtresi ve seed senkronizasyonu ekrandaki kayitla ayni kalir.
/// </summary>
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
public partial class McpReportAppService(
IMcpServerConfigProvider serverConfigProvider,
IListFormDataAppService listFormDataAppService,
IMcpListFormAccessFilter accessFilter,
IRepository<ReportCategory, Guid> categoryRepository,
IRepository<ReportTemplate, Guid> templateRepository,
IRepository<ListForm, Guid> listFormRepository,
IRepository<ListFormField, Guid> listFormFieldRepository,
IMcpDevRequestTimesheetRecorder timesheetRecorder)
: McpAppService(serverConfigProvider), IMcpReportAppService
{
private const string CategoriesListFormCode = PlatformConsts.AppCodes.Reports.Categories;
private const string TemplatesListFormCode = PlatformConsts.AppCodes.Reports.ReportTemplates;
private const string ActiveStatus = "active";
private const string DefaultButtonText = "Sas.Platform.View";
[GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")]
private static partial Regex PlaceholderRegex();
public async Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search)
{
if (await EnsureCanReadAsync(McpToolNames.ListReportTemplates) is { } problem)
{
return problem;
}
var categories = await categoryRepository.GetListAsync();
var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name);
var templates = (await templateRepository.GetListAsync())
.Where(x => string.IsNullOrWhiteSpace(search) ||
x.Name.Contains(search, StringComparison.OrdinalIgnoreCase) ||
(x.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))
.OrderBy(x => x.Name)
.Select(x => new McpReportTemplateSummaryDto
{
Id = x.Id,
Name = x.Name,
Description = x.Description,
Category = categoryNames.GetValueOrDefault(x.CategoryId, string.Empty),
Status = x.Status
})
.ToList();
return new McpReportCatalogDto
{
Categories = [.. categories.OrderBy(x => x.Name).Select(ToDto)],
Templates = templates
};
}
public async Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template)
{
if (await EnsureCanReadAsync(McpToolNames.GetReportTemplate) is { } problem)
{
return problem;
}
var entity = await FindTemplateAsync(template);
if (entity is null)
{
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
}
return await ToDtoAsync(entity, null, null);
}
public async Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
{
Check.NotNull(input, nameof(input));
const string toolName = McpToolNames.SaveReportCategory;
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name))
{
return Problem(McpErrorCodes.ReportNameRequired);
}
var name = input.Name.Trim();
var existing = await categoryRepository.FirstOrDefaultAsync(x => x.Name == name);
var values = new Dictionary<string, object?>
{
["Name"] = name,
["Description"] = input.Description ?? existing?.Description,
["Icon"] = input.Icon ?? existing?.Icon
};
var saved = existing is null
? await InsertAsync(toolName, CategoriesListFormCode, values)
: await UpdateAsync(toolName, CategoriesListFormCode, existing.Id, values);
if (!saved.TryGetValue(out var id, out var rejected))
{
return rejected;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name);
var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
return category is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(category);
}
public async Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
{
Check.NotNull(input, nameof(input));
const string toolName = McpToolNames.SaveReportTemplate;
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name))
{
return Problem(McpErrorCodes.ReportNameRequired);
}
var name = input.Name.Trim();
var existing = await templateRepository.FirstOrDefaultAsync(x => x.Name == name);
var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent;
if (string.IsNullOrWhiteSpace(htmlContent))
{
return Problem(McpErrorCodes.ReportHtmlRequired);
}
var categoryId = existing?.CategoryId;
if (!string.IsNullOrWhiteSpace(input.Category))
{
categoryId = (await FindCategoryAsync(input.Category))?.Id;
if (categoryId is null)
{
return Problem(McpErrorCodes.ReportCategoryNotFound, input.Category);
}
}
if (categoryId is null)
{
return Problem(McpErrorCodes.ReportCategoryRequired);
}
if (!(await FindListFormAsync(input.ListFormCode)).TryGetValue(out var listForm, out var notFound))
{
return notFound;
}
if (listForm is not null && await CheckPlaceholdersExistAsync(listForm.ListFormCode, htmlContent) is { } unknown)
{
return unknown;
}
var values = new Dictionary<string, object?>
{
["Name"] = name,
["Description"] = input.Description ?? existing?.Description,
["HtmlContent"] = htmlContent,
["CategoryId"] = categoryId,
["Status"] = input.Status ?? existing?.Status ?? ActiveStatus
};
var saved = existing is null
? await InsertAsync(toolName, TemplatesListFormCode, values)
: await UpdateAsync(toolName, TemplatesListFormCode, existing.Id, values);
if (!saved.TryGetValue(out var id, out var rejected))
{
return rejected;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name);
var template = await templateRepository.FirstOrDefaultAsync(x => x.Id == id);
if (template is null)
{
return Problem(McpErrorCodes.ReportTemplateNotFound, name);
}
return await ToDtoAsync(template, listForm, input.ButtonText);
}
public async Task<McpProblem?> DeleteReportTemplateAsync(string template)
{
const string toolName = McpToolNames.DeleteReportTemplate;
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
var entity = await FindTemplateAsync(template);
if (entity is null)
{
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
}
if (!(await BuildRequestAsync(toolName, TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id)).TryGetValue(out var request, out var denied))
{
return denied;
}
await listFormDataAppService.PostDeleteAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteReportTemplate, entity.Name);
return null;
}
/// <summary>
/// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin
/// uygulamada goremedigi sablonu gostermez.
/// </summary>
private async Task<McpProblem?> EnsureCanReadAsync(string toolName)
{
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read);
return await CheckPermissionAsync(toolName, permission ?? TemplatesListFormCode);
}
private async Task<McpOutcome<Guid>> InsertAsync(string toolName, string listFormCode, Dictionary<string, object?> values)
{
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Create, values, null)).TryGetValue(out var request, out var problem))
{
return problem;
}
var key = await listFormDataAppService.PostInsertAsync(request);
// Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur.
if (Guid.TryParse(((object?)key)?.ToString(), out var id))
{
return id;
}
var name = (string)values["Name"]!;
return listFormCode == CategoriesListFormCode
? (await categoryRepository.FirstAsync(x => x.Name == name)).Id
: (await templateRepository.FirstAsync(x => x.Name == name)).Id;
}
private async Task<McpOutcome<Guid>> UpdateAsync(string toolName, string listFormCode, Guid id, Dictionary<string, object?> values)
{
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Update, values, id)).TryGetValue(out var request, out var problem))
{
return problem;
}
await listFormDataAppService.PostUpdateAsync(request);
return id;
}
/// <summary>
/// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
/// istemci hangi yetkinin eksik oldugunu gorsun.
/// </summary>
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
string toolName,
string listFormCode,
AuthorizationTypeEnum operation,
Dictionary<string, object?>? values,
Guid? key)
{
var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation);
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
{
return denied;
}
return new DataRequestDto
{
ListFormCode = listFormCode,
Data = JsonSerializer.Serialize(values ?? []),
Keys = key is null ? [] : [key.Value]
};
}
private async Task<ReportTemplate?> FindTemplateAsync(string template)
{
if (string.IsNullOrWhiteSpace(template))
{
return null;
}
var value = template.Trim();
return Guid.TryParse(value, out var id)
? await templateRepository.FirstOrDefaultAsync(x => x.Id == id)
: await templateRepository.FirstOrDefaultAsync(x => x.Name == value);
}
private async Task<ReportCategory?> FindCategoryAsync(string category)
{
var value = category.Trim();
return Guid.TryParse(value, out var id)
? await categoryRepository.FirstOrDefaultAsync(x => x.Id == id)
: await categoryRepository.FirstOrDefaultAsync(x => x.Name == value);
}
/// <summary>Kod verilmediyse null; verilen kodun ekrani yoksa sorun.</summary>
private async Task<McpOutcome<ListForm?>> FindListFormAsync(string? listFormCode)
{
if (string.IsNullOrWhiteSpace(listFormCode))
{
return default(ListForm);
}
var code = listFormCode.Trim();
var listForm = await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code);
return listForm is null ? Problem(McpErrorCodes.ReportListFormNotFound, code) : listForm;
}
/// <summary>
/// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin
/// diye kayitta reddedilir.
/// </summary>
private async Task<McpProblem?> CheckPlaceholdersExistAsync(string listFormCode, string htmlContent)
{
var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode))
.Select(x => x.FieldName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList();
return unknown.Count == 0
? null
: Problem(McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown));
}
private static List<string> GetPlaceholders(string htmlContent)
=> [.. PlaceholderRegex().Matches(htmlContent ?? string.Empty)
.Select(x => x.Groups[1].Value)
.Distinct(StringComparer.OrdinalIgnoreCase)];
private async Task<McpReportTemplateDto> ToDtoAsync(ReportTemplate entity, ListForm? listForm, string? buttonText)
{
var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == entity.CategoryId);
var viewUrl = $"/admin/reports/DynamicReport/view/{entity.Id}";
return new McpReportTemplateDto
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Category = category?.Name ?? string.Empty,
CategoryId = entity.CategoryId,
Status = entity.Status,
HtmlContent = entity.HtmlContent,
Placeholders = GetPlaceholders(entity.HtmlContent),
ViewUrl = viewUrl,
RowButton = listForm is null ? null : new McpReportRowButtonDto
{
Text = string.IsNullOrWhiteSpace(buttonText) ? DefaultButtonText : buttonText,
Hint = string.IsNullOrWhiteSpace(buttonText) ? DefaultButtonText : buttonText,
Url = $"{viewUrl}/{listForm.ListFormCode}?recordId=@{listForm.KeyFieldName}",
AuthName = listForm.ListFormCode
}
};
}
private static McpReportCategoryDto ToDto(ReportCategory category) => new()
{
Id = category.Id,
Name = category.Name,
Description = category.Description,
Icon = category.Icon
};
}

View file

@ -1,11 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services; using Sozsoft.Mcp.Services;
using Sozsoft.Platform; using Sozsoft.Platform;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.ListForms;
using Volo.Abp.Authorization.Permissions; using Volo.Abp.Authorization.Permissions;
namespace Sozsoft.Mcp; namespace Sozsoft.Mcp;
@ -20,9 +26,17 @@ public class McpSchemaAppService(
IMcpServerConfigProvider serverConfigProvider, IMcpServerConfigProvider serverConfigProvider,
IMcpSchemaDictionary dictionary, IMcpSchemaDictionary dictionary,
IMcpListFormAccessFilter accessFilter, IMcpListFormAccessFilter accessFilter,
IPermissionChecker permissionChecker) IPermissionChecker permissionChecker,
IListFormWizardAppService wizardAppService,
IConfiguration configuration)
: McpAppService(serverConfigProvider), IMcpSchemaAppService : McpAppService(serverConfigProvider), IMcpSchemaAppService
{ {
/// <summary>Wizard dosyasi olan ama sozlukte olmayan ekran (DB Migrate bekliyor).</summary>
private const string PendingSourceKind = "pending";
/// <summary>Yalnizca seed scripti olan, ekrani olmayan tablo.</summary>
private const string PendingTableSourceKind = "pendingTable";
public async Task<McpServerConfigDto?> GetServerConfigAsync() public async Task<McpServerConfigDto?> GetServerConfigAsync()
=> await ResolveServerConfigAsync(); => await ResolveServerConfigAsync();
@ -34,20 +48,9 @@ public class McpSchemaAppService(
CurrentTenant.IsAvailable, CurrentTenant.IsAvailable,
config?.AllowedTools ?? []); config?.AllowedTools ?? []);
var required = candidates var granted = await permissionChecker.GetGrantedNamesAsync(candidates
.Where(McpToolNames.RequiredPermissions.ContainsKey) .Where(McpToolNames.RequiredPermissions.ContainsKey)
.Select(tool => McpToolNames.RequiredPermissions[tool]) .Select(tool => McpToolNames.RequiredPermissions[tool]));
.Distinct(StringComparer.Ordinal)
.ToArray();
var granted = new HashSet<string>(StringComparer.Ordinal);
if (required.Length > 0)
{
var grants = await permissionChecker.IsGrantedAsync(required);
granted.UnionWith(grants.Result
.Where(result => result.Value == PermissionGrantResult.Granted)
.Select(result => result.Key));
}
return [.. candidates.Where(tool => return [.. candidates.Where(tool =>
!McpToolNames.RequiredPermissions.TryGetValue(tool, out var permission) || granted.Contains(permission))]; !McpToolNames.RequiredPermissions.TryGetValue(tool, out var permission) || granted.Contains(permission))];
@ -79,9 +82,12 @@ public class McpSchemaAppService(
}; };
} }
public async Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200) public async Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200)
{ {
await EnsureToolAllowedAsync(McpToolNames.ListLists); if (await EnsureToolAllowedAsync(McpToolNames.ListLists) is { Problem: { } problem })
{
return problem;
}
var forms = await GetReadableListFormsAsync(); var forms = await GetReadableListFormsAsync();
var normalizedSearch = dictionary.Normalize(search); var normalizedSearch = dictionary.Normalize(search);
@ -89,16 +95,19 @@ public class McpSchemaAppService(
var matches = forms.Where(form => normalizedSearch.Length == 0 var matches = forms.Where(form => normalizedSearch.Length == 0
|| dictionary.Normalize(form.SearchText).Contains(normalizedSearch, StringComparison.Ordinal)); || dictionary.Normalize(form.SearchText).Contains(normalizedSearch, StringComparison.Ordinal));
return [.. matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary)]; return matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary).ToList();
} }
public async Task<McpListDetailDto?> GetListAsync(string listFormCode) public async Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode)
{ {
await EnsureToolAllowedAsync(McpToolNames.DescribeList); if (await EnsureToolAllowedAsync(McpToolNames.DescribeList) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(listFormCode)) if (string.IsNullOrWhiteSpace(listFormCode))
{ {
return null; return default(McpListDetailDto);
} }
var forms = await GetReadableListFormsAsync(); var forms = await GetReadableListFormsAsync();
@ -107,7 +116,7 @@ public class McpSchemaAppService(
if (form is null) if (form is null)
{ {
return null; return default(McpListDetailDto);
} }
var detail = new McpListDetailDto(); var detail = new McpListDetailDto();
@ -117,14 +126,17 @@ public class McpSchemaAppService(
return detail; return detail;
} }
public async Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50) public async Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50)
{ {
await EnsureToolAllowedAsync(McpToolNames.SearchSchema); if (await EnsureToolAllowedAsync(McpToolNames.SearchSchema) is { Problem: { } problem })
{
return problem;
}
var normalized = dictionary.Normalize(term); var normalized = dictionary.Normalize(term);
if (normalized.Length == 0) if (normalized.Length == 0)
{ {
return []; return new List<McpSchemaMatchDto>();
} }
var limit = NormalizeLimit(maxResults, 50); var limit = NormalizeLimit(maxResults, 50);
@ -168,7 +180,93 @@ public class McpSchemaAppService(
var forms = await dictionary.GetListFormsAsync(); var forms = await dictionary.GetListFormsAsync();
var readable = await accessFilter.GetReadableListFormCodesAsync(); var readable = await accessFilter.GetReadableListFormCodesAsync();
return [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))]; List<McpDictionaryListForm> result = [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))];
// Gelistirici, sozlukte henuz olmayan (DB Migrate bekleyen ya da sozluk tazelenmemis) ekran ve
// tablolari da gorur; lookup, widget ve view SQL'i bunlarin gercek kolon adlariyla yazilir.
if (!CurrentTenant.IsAvailable && await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
result.AddRange(await GetSeedOnlySourcesAsync(forms));
return result;
}
/// <summary>
/// Seed klasorunde olup sozlukte olmayan kaynaklar: wizard dosyalarinin ekranlari (alanlari
/// <c>Groups</c> alanlarindan) ve yalnizca <c>sql/object</c> scripti olan tablolar (kolonlari scriptten).
/// <see cref="McpListSummaryDto.SourceKind"/> <c>pending</c> / <c>pendingTable</c> olarak isaretlenir:
/// kolon adlari dogrudur ama query_list migrate'ten once calismaz.
/// </summary>
private async Task<List<McpDictionaryListForm>> GetSeedOnlySourcesAsync(IReadOnlyList<McpDictionaryListForm> known)
{
var codes = known.Select(form => form.ListFormCode).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
var tables = known.Select(form => form.TableName).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
var result = new List<McpDictionaryListForm>();
foreach (var file in await wizardAppService.GetFiles())
{
if (file.ComponentKind == WizardComponentKindEnum.Custom || codes.Contains(file.ListFormCode ?? string.Empty))
continue;
var seed = await wizardAppService.GetFile(file.FileName);
if (seed.Wizard is not { } wizard || string.IsNullOrWhiteSpace(wizard.SelectCommand))
continue;
// Alanlar dosyadaki cevaplardan ekranin sozlesmesine cevrilir; sozluk ekranla ayni degerleri gorur.
var listForm = wizard.BuildListForm();
var table = listForm.SelectCommandType == SelectCommandTypeEnum.Query ? null : listForm.SelectCommand?.Split('.')[^1].Trim('[', ']', '"');
if (table is not null)
tables.Add(table);
result.Add(new McpDictionaryListForm
{
ListFormCode = listForm.ListFormCode,
DisplayName = seed.Wizard?.TitleTr ?? seed.Wizard?.TitleEn,
Title = seed.Wizard?.TitleEn,
Description = seed.Wizard?.DescTr,
TableName = table,
SqlSourceName = listForm.SelectCommand,
SourceKind = PendingSourceKind,
KeyFieldName = listForm.KeyFieldName,
SearchText = string.Join(' ', listForm.ListFormCode, seed.Wizard?.TitleTr, seed.Wizard?.TitleEn, table),
Fields = [.. wizard.BuildFields().Select(field => new McpDictionaryField
{
Field = field.FieldName,
DisplayName = field.CaptionName,
SqlDataType = field.SourceDbType.ToString(),
IsVisible = field.Visible,
IsBooleanColumn = field.SourceDbType == DbType.Boolean,
LookupValueColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.ValueExpr,
LookupDisplayColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.DisplayExpr,
IsDefaultSelectColumn = field.Visible
})]
});
}
var scriptFolder = SeedPathResolver.GetPath(configuration, SeedPathResolver.SqlFolder, SeedPathResolver.ObjectFolder);
foreach (var path in Directory.Exists(scriptFolder) ? Directory.GetFiles(scriptFolder, "*.sql") : [])
{
var table = Path.GetFileNameWithoutExtension(path);
if (tables.Contains(table))
continue;
var columns = PostgreSqlTableScript.ReadSeedColumns(configuration, table);
if (columns.Count == 0)
continue;
result.Add(new McpDictionaryListForm
{
ListFormCode = table,
TableName = table,
SqlSourceName = table,
SourceKind = PendingTableSourceKind,
KeyFieldName = columns.ContainsKey("Id") ? "Id" : null,
SearchText = table,
Fields = [.. columns.Keys.Select(column => new McpDictionaryField { Field = column, IsVisible = true, IsDefaultSelectColumn = true })]
});
}
return result;
} }
private static McpListSummaryDto ToSummary(McpDictionaryListForm form) private static McpListSummaryDto ToSummary(McpDictionaryListForm form)

View file

@ -44,6 +44,13 @@ public static class McpConsts
/// </summary> /// </summary>
public const string OAuthClientId = "Platform_Mcp"; public const string OAuthClientId = "Platform_Mcp";
/// <summary>
/// Yerel makinedeki MCP proxy'sinin (mcp-remote) istemci kimligi. Geri donus adresleri
/// DbMigrator'da <c>OpenIddict:Applications:Platform_Mcp_Local</c> ayarindadir. Loopback adresine
/// makinedeki her surec dinleyebildigi icin acik onay ister.
/// </summary>
public const string LocalOAuthClientId = "Platform_Mcp_Local";
/// <summary> /// <summary>
/// Dinamik kayitla (RFC 7591) uretilen istemcilerin kimlik oneki. Bilinen saglayicilarin /// Dinamik kayitla (RFC 7591) uretilen istemcilerin kimlik oneki. Bilinen saglayicilarin
/// (Claude, ChatGPT) disindaki MCP istemcileri bu onekle kendi OpenIddict uygulamasini alir. /// (Claude, ChatGPT) disindaki MCP istemcileri bu onekle kendi OpenIddict uygulamasini alir.
@ -67,6 +74,7 @@ public static class McpConsts
public static bool IsMcpClient(string? clientId) public static bool IsMcpClient(string? clientId)
=> clientId is not null => clientId is not null
&& (string.Equals(clientId, OAuthClientId, System.StringComparison.Ordinal) && (string.Equals(clientId, OAuthClientId, System.StringComparison.Ordinal)
|| string.Equals(clientId, LocalOAuthClientId, System.StringComparison.Ordinal)
|| clientId.StartsWith(DynamicClientIdPrefix, System.StringComparison.Ordinal)); || clientId.StartsWith(DynamicClientIdPrefix, System.StringComparison.Ordinal));
/// <summary>MCP ucunun sunucu adresine gore yolu.</summary> /// <summary>MCP ucunun sunucu adresine gore yolu.</summary>
@ -98,7 +106,7 @@ public static class McpConsts
} }
/// <summary> /// <summary>
/// Gelistirme talebi (<c>App.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup /// Gelistirme talebi (<c>Sas.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup
/// anahtarlariyla ayni olmak zorundadir. /// anahtarlariyla ayni olmak zorundadir.
/// </summary> /// </summary>
public static class DevRequestStatuses public static class DevRequestStatuses

View file

@ -6,7 +6,7 @@ namespace Sozsoft.Mcp;
/// <summary> /// <summary>
/// Modulun paylasilan sozlesmesi. Kullaniciya gorunen metinler platformun kendi /// Modulun paylasilan sozlesmesi. Kullaniciya gorunen metinler platformun kendi
/// <c>PlatformResource</c> kaynagindan (configs/seeds/host/data/App.Languages.LanguageText.json) okunur; modul ikinci bir dil /// <c>PlatformResource</c> kaynagindan (configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json) okunur; modul ikinci bir dil
/// kaynagi acmaz. /// kaynagi acmaz.
/// </summary> /// </summary>
[DependsOn( [DependsOn(

View file

@ -1,12 +1,12 @@
namespace Sozsoft.Mcp; namespace Sozsoft.Mcp;
/// <summary> /// <summary>
/// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/host/data/App.Languages.LanguageText.json</c> /// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json</c>
/// dosyasinda EN + TR olarak durur; koda gomulu metin kullanilmaz. /// dosyasinda EN + TR olarak durur; koda gomulu metin kullanilmaz.
/// </summary> /// </summary>
public static class McpErrorCodes public static class McpErrorCodes
{ {
private const string Prefix = "App.Mcp.Error."; private const string Prefix = "Sas.Mcp.Error.";
public const string NoServerRecord = Prefix + "NoServerRecord"; public const string NoServerRecord = Prefix + "NoServerRecord";
public const string ToolDisabled = Prefix + "ToolDisabled"; public const string ToolDisabled = Prefix + "ToolDisabled";
@ -31,10 +31,14 @@ public static class McpErrorCodes
public const string RedirectUriNotAllowed = Prefix + "RedirectUriNotAllowed"; public const string RedirectUriNotAllowed = Prefix + "RedirectUriNotAllowed";
public const string DevGuideUnavailable = Prefix + "DevGuideUnavailable"; public const string DevGuideUnavailable = Prefix + "DevGuideUnavailable";
public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound"; public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound";
public const string DevGuideSearchMatches = Prefix + "DevGuideSearchMatches";
public const string InvalidWizard = Prefix + "InvalidWizard"; public const string InvalidWizard = Prefix + "InvalidWizard";
public const string InvalidWizardValue = Prefix + "InvalidWizardValue";
public const string WizardCodeMissing = Prefix + "WizardCodeMissing";
public const string SeedRowsInvalid = Prefix + "SeedRowsInvalid"; public const string SeedRowsInvalid = Prefix + "SeedRowsInvalid";
public const string SeedRowsNotAllowed = Prefix + "SeedRowsNotAllowed"; public const string SeedRowsNotAllowed = Prefix + "SeedRowsNotAllowed";
public const string SeedRowsUnknownFields = Prefix + "SeedRowsUnknownFields"; public const string SeedRowsUnknownFields = Prefix + "SeedRowsUnknownFields";
public const string SeedRowsFieldsOnOtherScreens = Prefix + "SeedRowsFieldsOnOtherScreens";
public const string ComponentNameRequired = Prefix + "ComponentNameRequired"; public const string ComponentNameRequired = Prefix + "ComponentNameRequired";
public const string ComponentCodeRequired = Prefix + "ComponentCodeRequired"; public const string ComponentCodeRequired = Prefix + "ComponentCodeRequired";
public const string ComponentNotFound = Prefix + "ComponentNotFound"; public const string ComponentNotFound = Prefix + "ComponentNotFound";
@ -46,6 +50,9 @@ public static class McpErrorCodes
public const string VerifyPermissionMissing = Prefix + "VerifyPermissionMissing"; public const string VerifyPermissionMissing = Prefix + "VerifyPermissionMissing";
public const string VerifySelectPassed = Prefix + "VerifySelectPassed"; public const string VerifySelectPassed = Prefix + "VerifySelectPassed";
public const string VerifySelectFailed = Prefix + "VerifySelectFailed"; public const string VerifySelectFailed = Prefix + "VerifySelectFailed";
public const string SelectFailed = Prefix + "SelectFailed";
public const string SelectFailedColumns = Prefix + "SelectFailedColumns";
public const string SelectTableNotInstalled = Prefix + "SelectTableNotInstalled";
public const string VerifyComponentInactive = Prefix + "VerifyComponentInactive"; public const string VerifyComponentInactive = Prefix + "VerifyComponentInactive";
public const string VerifyComponentRouteMismatch = Prefix + "VerifyComponentRouteMismatch"; public const string VerifyComponentRouteMismatch = Prefix + "VerifyComponentRouteMismatch";
public const string VerifySeedClosureIncomplete = Prefix + "VerifySeedClosureIncomplete"; public const string VerifySeedClosureIncomplete = Prefix + "VerifySeedClosureIncomplete";
@ -58,12 +65,23 @@ public static class McpErrorCodes
public const string DevRequestNoteRequired = Prefix + "DevRequestNoteRequired"; public const string DevRequestNoteRequired = Prefix + "DevRequestNoteRequired";
public const string DevRequestSubTasksRequired = Prefix + "DevRequestSubTasksRequired"; public const string DevRequestSubTasksRequired = Prefix + "DevRequestSubTasksRequired";
public const string DevRequestSubTasksInvalid = Prefix + "DevRequestSubTasksInvalid"; public const string DevRequestSubTasksInvalid = Prefix + "DevRequestSubTasksInvalid";
public const string DevRequestModuleDocsIncomplete = Prefix + "DevRequestModuleDocsIncomplete";
public const string WizardDescriptionTooShort = Prefix + "WizardDescriptionTooShort";
public const string VisualDesignerComponent = Prefix + "VisualDesignerComponent"; public const string VisualDesignerComponent = Prefix + "VisualDesignerComponent";
public const string ReportNameRequired = Prefix + "ReportNameRequired";
public const string ReportHtmlRequired = Prefix + "ReportHtmlRequired";
public const string ReportCategoryRequired = Prefix + "ReportCategoryRequired";
public const string ReportCategoryNotFound = Prefix + "ReportCategoryNotFound";
public const string ReportTemplateNotFound = Prefix + "ReportTemplateNotFound";
public const string ReportListFormNotFound = Prefix + "ReportListFormNotFound";
public const string ReportPlaceholdersUnknown = Prefix + "ReportPlaceholdersUnknown";
public const string TableColumnsRequired = Prefix + "TableColumnsRequired"; public const string TableColumnsRequired = Prefix + "TableColumnsRequired";
public const string UnsupportedColumnType = Prefix + "UnsupportedColumnType"; public const string UnsupportedColumnType = Prefix + "UnsupportedColumnType";
public const string InvalidColumnLength = Prefix + "InvalidColumnLength"; public const string InvalidColumnLength = Prefix + "InvalidColumnLength";
public const string InvalidDefaultValue = Prefix + "InvalidDefaultValue"; public const string InvalidDefaultValue = Prefix + "InvalidDefaultValue";
public const string DuplicateColumn = Prefix + "DuplicateColumn"; public const string DuplicateColumn = Prefix + "DuplicateColumn";
public const string TableColumnNotFound = Prefix + "TableColumnNotFound";
public const string ForeignKeyTableNotInstalledFirst = Prefix + "ForeignKeyTableNotInstalledFirst";
public const string SqlObjectInvalid = Prefix + "SqlObjectInvalid";
public const string TableAlreadyExists = Prefix + "TableAlreadyExists"; public const string TableAlreadyExists = Prefix + "TableAlreadyExists";
public const string TableProviderNotSupported = Prefix + "TableProviderNotSupported";
} }

View file

@ -0,0 +1,54 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Localization;
namespace Sozsoft.Mcp;
/// <summary>
/// Aracin modelle konusarak cozulecek reddi: eksik ya da hatali girdi, durum kurali, eksik yetki.
/// Istisna olarak atilmaz — bu durum aracin beklenen cevabidir; atilan istisna debugger'i durdurur
/// ve SDK'da hata logu uretir. Istemciye hata sonucu (<c>IsError</c>) olarak gider, model mesaji
/// okuyup girdiyi duzeltir ve tekrar dener.
/// </summary>
public sealed record McpProblem(string Message)
{
public static McpProblem From(IStringLocalizer localizer, string name, params object[] arguments)
=> new(localizer[name, arguments]);
}
/// <summary>Aracin degeri ya da <see cref="McpProblem"/>'i; ikisi birden olmaz.</summary>
public readonly struct McpOutcome<T>
{
private McpOutcome(T? value, McpProblem? problem)
{
Value = value;
Problem = problem;
}
public T? Value { get; }
public McpProblem? Problem { get; }
public bool TryGetValue([MaybeNullWhen(false)] out T value, [NotNullWhen(false)] out McpProblem? problem)
{
value = Value!;
problem = Problem;
return problem is null;
}
public static implicit operator McpOutcome<T>(T value) => new(value, null);
public static implicit operator McpOutcome<T>(McpProblem problem) => new(default, problem);
}
/// <summary>Yetki reddi mesajinda yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir.</summary>
public static class McpPermissionText
{
public static string Describe(IStringLocalizer localizer, string permissionName)
{
var displayName = localizer[permissionName];
return displayName.ResourceNotFound || displayName.Value == permissionName
? permissionName
: $"{displayName.Value} ({permissionName})";
}
}

View file

@ -0,0 +1,28 @@
namespace Sozsoft.Mcp;
/// <summary>
/// MCP islemlerinin talep Timesheets satirina yazilan aciklamalari. Karsiliklari platformun
/// <c>Sas.Languages.LanguageText/{CultureName}.json</c> dosyalarinda (her dilde bir satir) durur; satir Turkce yazilir.
/// </summary>
public static class McpTimesheetTexts
{
private const string Prefix = "Sas.Mcp.Timesheet.";
public const string Analysis = Prefix + "Analysis";
public const string AppendNote = Prefix + "AppendNote";
public const string AddSubTasks = Prefix + "AddSubTasks";
public const string CreateTable = Prefix + "CreateTable";
public const string SaveSqlObject = Prefix + "SaveSqlObject";
public const string GenerateCrudEndpoints = Prefix + "GenerateCrudEndpoints";
public const string DeployWizard = Prefix + "DeployWizard";
public const string DeleteWizard = Prefix + "DeleteWizard";
public const string SaveComponent = Prefix + "SaveComponent";
public const string DeleteComponent = Prefix + "DeleteComponent";
public const string SaveReportCategory = Prefix + "SaveReportCategory";
public const string SaveReportTemplate = Prefix + "SaveReportTemplate";
public const string DeleteReportTemplate = Prefix + "DeleteReportTemplate";
public const string InsertRow = Prefix + "InsertRow";
public const string UpdateRow = Prefix + "UpdateRow";
public const string DeleteRow = Prefix + "DeleteRow";
public const string SaveModuleDoc = Prefix + "SaveModuleDoc";
}

View file

@ -24,6 +24,8 @@ public static class McpToolNames
public const string DeleteRow = "delete_row"; public const string DeleteRow = "delete_row";
public const string GetDevGuide = "get_dev_guide"; public const string GetDevGuide = "get_dev_guide";
public const string CreateTable = "create_table"; public const string CreateTable = "create_table";
public const string SaveSqlObject = "save_sql_object";
public const string GenerateCrudEndpoints = "generate_crud_endpoints";
public const string ListWizards = "list_wizards"; public const string ListWizards = "list_wizards";
public const string GetWizard = "get_wizard"; public const string GetWizard = "get_wizard";
public const string DeployWizard = "deploy_wizard"; public const string DeployWizard = "deploy_wizard";
@ -38,9 +40,16 @@ public static class McpToolNames
public const string GetComponent = "get_component"; public const string GetComponent = "get_component";
public const string SaveComponent = "save_component"; public const string SaveComponent = "save_component";
public const string DeleteComponent = "delete_component"; public const string DeleteComponent = "delete_component";
public const string ListReportTemplates = "list_report_templates";
public const string GetReportTemplate = "get_report_template";
public const string SaveReportCategory = "save_report_category";
public const string SaveReportTemplate = "save_report_template";
public const string DeleteReportTemplate = "delete_report_template";
public const string GetModuleDoc = "get_module_doc";
public const string SaveModuleDoc = "save_module_doc";
/// <summary> /// <summary>
/// Ekran ve bilesen gelistirme araclari; kiraci baglaminda hicbiri acilmaz. Okuyanlar /// Ekran, bilesen ve rapor sablonu gelistirme araclari; kiraci baglaminda hicbiri acilmaz. Okuyanlar
/// <c>McpServers.Invoke</c> ile, degistirenler <c>McpServers.Write</c> ile acilir /// <c>McpServers.Invoke</c> ile, degistirenler <c>McpServers.Write</c> ile acilir
/// (<see cref="RequiredPermissions"/>). /// (<see cref="RequiredPermissions"/>).
/// </summary> /// </summary>
@ -48,6 +57,8 @@ public static class McpToolNames
[ [
GetDevGuide, GetDevGuide,
CreateTable, CreateTable,
SaveSqlObject,
GenerateCrudEndpoints,
ListWizards, ListWizards,
GetWizard, GetWizard,
DeployWizard, DeployWizard,
@ -61,7 +72,14 @@ public static class McpToolNames
ListComponents, ListComponents,
GetComponent, GetComponent,
SaveComponent, SaveComponent,
DeleteComponent DeleteComponent,
ListReportTemplates,
GetReportTemplate,
SaveReportCategory,
SaveReportTemplate,
DeleteReportTemplate,
GetModuleDoc,
SaveModuleDoc
]; ];
/// <summary> /// <summary>
@ -105,6 +123,8 @@ public static class McpToolNames
[UpdateRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [UpdateRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[DeleteRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [DeleteRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[CreateTable] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [CreateTable] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[SaveSqlObject] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[GenerateCrudEndpoints] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[DeployWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [DeployWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[DeleteWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [DeleteWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[StartDevRequest] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [StartDevRequest] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
@ -113,13 +133,17 @@ public static class McpToolNames
[AddDevRequestSubTasks] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [AddDevRequestSubTasks] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[SaveComponent] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [SaveComponent] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[DeleteComponent] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write, [DeleteComponent] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[SaveReportCategory] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[SaveReportTemplate] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[DeleteReportTemplate] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
[SaveModuleDoc] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
}; };
/// <summary> /// <summary>
/// Arkadaki servisin <c>[Authorize]</c> attribute'u ile istedigi yetkiler. Attribute ile /// Arkadaki servisin <c>[Authorize]</c> attribute'u ile istedigi yetkiler. Attribute ile
/// reddedilen cagri eksik yetkinin adini tasimaz; kullaniciya hangi yetkinin eksik oldugunu /// reddedilen cagri eksik yetkinin adini tasimaz; kullaniciya hangi yetkinin eksik oldugunu
/// soyleyebilmek icin burada tutulur. Kodda adiyla istenen yetkiler (ekran C/R/U/D, /// soyleyebilmek icin burada tutulur. Kodda adiyla istenen yetkiler (ekran C/R/U/D,
/// <c>Wizards.Create/Update</c>, Custom Components, <c>App.DevRequests.Update</c>) reddi zaten /// <c>Wizards.Create/Update</c>, Custom Components, <c>Sas.DevRequests.Update</c>) reddi zaten
/// adiyla bildirdigi icin buraya yazilmaz. /// adiyla bildirdigi icin buraya yazilmaz.
/// </summary> /// </summary>
public static readonly IReadOnlyDictionary<string, string[]> ServicePermissions = new Dictionary<string, string[]> public static readonly IReadOnlyDictionary<string, string[]> ServicePermissions = new Dictionary<string, string[]>
@ -129,6 +153,8 @@ public static class McpToolNames
[VerifyWizard] = [PlatformConsts.AppCodes.Listforms.Wizard], [VerifyWizard] = [PlatformConsts.AppCodes.Listforms.Wizard],
[DeployWizard] = [PlatformConsts.AppCodes.Listforms.Wizard], [DeployWizard] = [PlatformConsts.AppCodes.Listforms.Wizard],
[DeleteWizard] = [PlatformConsts.AppCodes.Listforms.Wizard, PlatformConsts.AppCodes.Listforms.Wizards.Delete, PlatformConsts.AppCodes.SqlQueryManagers.Default], [DeleteWizard] = [PlatformConsts.AppCodes.Listforms.Wizard, PlatformConsts.AppCodes.Listforms.Wizards.Delete, PlatformConsts.AppCodes.SqlQueryManagers.Default],
[GetModuleDoc] = [PlatformConsts.AppCodes.Listforms.Wizard],
[SaveModuleDoc] = [PlatformConsts.AppCodes.Listforms.Wizard, PlatformConsts.AppCodes.Listforms.Wizards.Update],
}; };
/// <summary> /// <summary>

View file

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.Authorization.Permissions;
namespace Sozsoft.Mcp;
public static class PermissionCheckerExtensions
{
/// <summary>
/// Verilen yetki adlarından kullanıcıya açık olanlar; tek toplu kontrol yapılır ve sonuç
/// adla sorgulanabilen küme olarak döner. Araç listesi, ekran süzgeci ve ret mesajı aynı kümeyi okur.
/// </summary>
public static async Task<HashSet<string>> GetGrantedNamesAsync(
this IPermissionChecker permissionChecker,
IEnumerable<string> names)
{
var distinct = names.Distinct(StringComparer.Ordinal).ToArray();
if (distinct.Length == 0)
{
return new HashSet<string>(StringComparer.Ordinal);
}
var grants = await permissionChecker.IsGrantedAsync(distinct);
return grants.Result
.Where(result => result.Value == PermissionGrantResult.Granted)
.Select(result => result.Key)
.ToHashSet(StringComparer.Ordinal);
}
}

View file

@ -7,16 +7,15 @@ using Volo.Abp.DependencyInjection;
namespace Sozsoft.Mcp.Data; namespace Sozsoft.Mcp.Data;
/// <summary> /// <summary>
/// Bilinen MCP istemcisini (<see cref="McpConsts.OAuthClientId"/>) seed eder. Geri donus /// Bilinen MCP istemcilerini (<see cref="McpConsts.OAuthClientId"/>,
/// adresleri <c>OpenIddict:Applications:Platform_Mcp:RedirectUris</c> ayarindadir; yeni bir /// <see cref="McpConsts.LocalOAuthClientId"/>) seed eder. Geri donus adresleri
/// saglayici eklemek bu listeye callback eklemektir. /// <c>OpenIddict:Applications:{ClientId}:RedirectUris</c> ayarindadir; yeni bir saglayici eklemek
/// bu listeye callback eklemektir. Ayari olmayan istemci olusturulmaz.
/// </summary> /// </summary>
public class McpOAuthClientDataSeedContributor( public class McpOAuthClientDataSeedContributor(
IConfiguration configuration, IConfiguration configuration,
IMcpOAuthClientManager clientManager) : IDataSeedContributor, ITransientDependency IMcpOAuthClientManager clientManager) : IDataSeedContributor, ITransientDependency
{ {
private const string RedirectUrisKey = "OpenIddict:Applications:Platform_Mcp:RedirectUris";
public async Task SeedAsync(DataSeedContext context) public async Task SeedAsync(DataSeedContext context)
{ {
// OpenIddict uygulamalari host kapsamindadir; kiraci seed turlarinda tekrarlanmaz. // OpenIddict uygulamalari host kapsamindadir; kiraci seed turlarinda tekrarlanmaz.
@ -25,12 +24,15 @@ public class McpOAuthClientDataSeedContributor(
return; return;
} }
var redirectUris = configuration.GetSection(RedirectUrisKey).Get<string[]>(); foreach (var clientId in new[] { McpConsts.OAuthClientId, McpConsts.LocalOAuthClientId })
if (redirectUris is null || redirectUris.Length == 0)
{ {
return; var redirectUris = configuration.GetSection($"OpenIddict:Applications:{clientId}:RedirectUris").Get<string[]>();
} if (redirectUris is null || redirectUris.Length == 0)
{
continue;
}
await clientManager.EnsureKnownClientAsync(redirectUris); await clientManager.EnsureKnownClientAsync(clientId, redirectUris);
}
} }
} }

View file

@ -25,6 +25,12 @@ public interface IMcpDevGuide
/// metninin bir parcasidir; bulunamazsa null. /// metninin bir parcasidir; bulunamazsa null.
/// </summary> /// </summary>
Task<McpDevGuideSection?> GetSectionAsync(string document, string section); Task<McpDevGuideSection?> GetSectionAsync(string document, string section);
/// <summary>
/// Metninde <paramref name="term"/> gecen bolumler, gecme sayisina gore. Baslik olmayan bir
/// terim (editor adi, alan adi) istendiginde modele nereye bakacagini soyler.
/// </summary>
Task<IReadOnlyList<McpDevGuideHeading>> SearchAsync(string term, int maxResults);
} }
/// <param name="Document">Belgenin kisa adi (<see cref="McpDevGuide.Documents"/>).</param> /// <param name="Document">Belgenin kisa adi (<see cref="McpDevGuide.Documents"/>).</param>
@ -102,6 +108,40 @@ public partial class McpDevGuide(IConfiguration configuration) : DomainService,
return new McpDevGuideSection(match.Heading, content, [.. children.Select(child => child.Heading)]); return new McpDevGuideSection(match.Heading, content, [.. children.Select(child => child.Heading)]);
} }
public async Task<IReadOnlyList<McpDevGuideHeading>> SearchAsync(string term, int maxResults)
{
var wanted = term.Trim();
if (wanted.Length == 0)
{
return [];
}
var hits = new List<(McpDevGuideHeading Heading, int Count, int Order)>();
foreach (var document in Documents.Keys)
{
var lines = await ReadLinesAsync(document);
var headings = ParseHeadings(document, lines);
// Her satir kendisinden onceki en yakin basliga sayilir; boylece en dar bolum doner.
for (var i = 0; i < headings.Count; i++)
{
var end = i + 1 < headings.Count ? headings[i + 1].Line : lines.Length;
var count = lines[headings[i].Line..end]
.Count(line => line.Contains(wanted, StringComparison.OrdinalIgnoreCase));
if (count > 0)
{
hits.Add((headings[i].Heading, count, hits.Count));
}
}
}
return [.. hits
.OrderByDescending(hit => hit.Count)
.ThenBy(hit => hit.Order)
.Take(maxResults)
.Select(hit => hit.Heading)];
}
private async Task<string[]> ReadLinesAsync(string document) private async Task<string[]> ReadLinesAsync(string document)
{ {
if (!Documents.TryGetValue(document, out var fileName)) if (!Documents.TryGetValue(document, out var fileName))

View file

@ -55,17 +55,7 @@ public class McpListFormAccessFilter(
} }
} }
var distinctPermissions = readPermissions.Values.Distinct(StringComparer.Ordinal).ToArray(); var granted = await permissionChecker.GetGrantedNamesAsync(readPermissions.Values);
if (distinctPermissions.Length == 0)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var grants = await permissionChecker.IsGrantedAsync(distinctPermissions);
var granted = grants.Result
.Where(result => result.Value == PermissionGrantResult.Granted)
.Select(result => result.Key)
.ToHashSet(StringComparer.Ordinal);
return readPermissions return readPermissions
.Where(entry => granted.Contains(entry.Value)) .Where(entry => granted.Contains(entry.Value))

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@ -6,18 +6,22 @@ using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using Sozsoft.Platform; using Sozsoft.Platform;
using Volo.Abp;
using Volo.Abp.DistributedLocking;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Domain.Services; using Volo.Abp.Domain.Services;
using Volo.Abp.MultiTenancy; using Volo.Abp.MultiTenancy;
using Volo.Abp.OpenIddict.Applications; using Volo.Abp.OpenIddict.Applications;
using Volo.Abp.Uow;
namespace Sozsoft.Mcp.Services; namespace Sozsoft.Mcp.Services;
/// <summary> /// <summary>
/// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir: /// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir:
/// <list type="bullet"> /// <list type="bullet">
/// <item><b>Bilinen istemci</b> (<see cref="McpConsts.OAuthClientId"/>): geri donus adresleri /// <item><b>Bilinen istemci</b>: geri donus adresleri DbMigrator ayarinda sabit olan istemciler —
/// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez.</item> /// <see cref="McpConsts.OAuthClientId"/> (Claude, ChatGPT; onay ekrani gostermez) ve
/// <see cref="McpConsts.LocalOAuthClientId"/> (yerel mcp-remote proxy'si; acik onayli).</item>
/// <item><b>Dinamik istemci</b> (<see cref="McpConsts.DynamicClientIdPrefix"/>): baska bir MCP /// <item><b>Dinamik istemci</b> (<see cref="McpConsts.DynamicClientIdPrefix"/>): baska bir MCP
/// istemcisi (Cursor, VS Code, Claude Code, ...) kayit ucundan kendi uygulamasini alir. Kayit /// istemcisi (Cursor, VS Code, Claude Code, ...) kayit ucundan kendi uygulamasini alir. Kayit
/// herkese acik oldugu icin bu istemciler <b>acik onay</b> ister: kullanici giris yaptiktan sonra /// herkese acik oldugu icin bu istemciler <b>acik onay</b> ister: kullanici giris yaptiktan sonra
@ -28,10 +32,10 @@ namespace Sozsoft.Mcp.Services;
public interface IMcpOAuthClientManager public interface IMcpOAuthClientManager
{ {
/// <summary>Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller.</summary> /// <summary>Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller.</summary>
Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris); Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris);
/// <summary> /// <summary>
/// Istemci kaydi. Adresler bilinen istemcide kayitliysa onu, degilse ayni adres kumesine /// Istemci kaydi. Adresler bir bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
/// sahip dinamik istemciyi dondurur; yoksa yenisini uretir. /// sahip dinamik istemciyi dondurur; yoksa yenisini uretir.
/// </summary> /// </summary>
Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris); Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris);
@ -40,25 +44,33 @@ public interface IMcpOAuthClientManager
public class McpOAuthClientManager( public class McpOAuthClientManager(
IAbpApplicationManager applicationManager, IAbpApplicationManager applicationManager,
IRepository<OpenIddictApplication, Guid> applicationRepository, IRepository<OpenIddictApplication, Guid> applicationRepository,
ICurrentTenant currentTenant) ICurrentTenant currentTenant,
IAbpDistributedLock distributedLock,
IUnitOfWorkManager unitOfWorkManager)
: DomainService, IMcpOAuthClientManager : DomainService, IMcpOAuthClientManager
{ {
private const int MaxClientNameLength = 100; private const int MaxClientNameLength = 100;
private const string RegistrationLockName = "Mcp:OAuthClientRegistration";
private static readonly TimeSpan RegistrationLockTimeout = TimeSpan.FromSeconds(15);
private static readonly Dictionary<string, (string DisplayName, string ConsentType)> KnownClients = new(StringComparer.Ordinal)
{
[McpConsts.OAuthClientId] = ("MCP Connectors", OpenIddictConstants.ConsentTypes.Implicit),
[McpConsts.LocalOAuthClientId] = ("MCP Local Proxy", OpenIddictConstants.ConsentTypes.Explicit)
};
/// <summary>Tarayici disi calistirilabilir icerik tasiyan, geri donus adresi olamayacak semalar.</summary> /// <summary>Tarayici disi calistirilabilir icerik tasiyan, geri donus adresi olamayacak semalar.</summary>
private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"]; private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"];
public async Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris) public async Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris)
{ {
var (displayName, consentType) = KnownClients[clientId];
// OpenIddict uygulamalari host kapsamindadir. // OpenIddict uygulamalari host kapsamindadir.
using (currentTenant.Change(null)) using (currentTenant.Change(null))
{ {
var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId); var existing = await applicationManager.FindByClientIdAsync(clientId);
var descriptor = BuildDescriptor( var descriptor = BuildDescriptor(clientId, displayName, redirectUris, consentType);
McpConsts.OAuthClientId,
"MCP Connectors",
redirectUris,
OpenIddictConstants.ConsentTypes.Implicit);
if (existing is null) if (existing is null)
{ {
@ -88,42 +100,63 @@ public class McpOAuthClientManager(
using (currentTenant.Change(null)) using (currentTenant.Change(null))
{ {
var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId); foreach (var knownClientId in KnownClients.Keys)
if (known is not null)
{ {
var known = await applicationManager.FindByClientIdAsync(knownClientId);
if (known is null)
{
continue;
}
var knownUris = await applicationManager.GetRedirectUrisAsync(known); var knownUris = await applicationManager.GetRedirectUrisAsync(known);
if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri)))) if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri))))
{ {
return McpClientRegistration.Accepted(McpConsts.OAuthClientId); return McpClientRegistration.Accepted(knownClientId);
} }
} }
var dynamicClients = await GetDynamicClientsAsync(); // Masaustu istemcisi ayni anda birden cok proxy sureci baslatir ve hepsi ayni adreslerle
// kayit olur. Kontrol ile ekleme kilitsiz ve istegin UoW'sinde kalirsa hicbiri digerinin
// kaydini goremez, ayni adres kumesi icin birden cok uygulama acilir. Kilit altinda
// calisip kendi UoW'sinde commit eden kayit, sonraki istegin onu bulmasini saglar.
await using var handle = await distributedLock.TryAcquireAsync(RegistrationLockName, RegistrationLockTimeout)
?? throw new AbpException($"Could not acquire '{RegistrationLockName}' lock.");
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
// baglanir, tablo buyumez. var registration = await RegisterDynamicClientAsync(clientName, redirectUris);
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris)); await uow.CompleteAsync();
if (match.ClientId is not null)
{
return McpClientRegistration.Accepted(match.ClientId);
}
if (dynamicClients.Count >= McpConsts.MaxDynamicClients) return registration;
{
return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture));
}
var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N");
await applicationManager.CreateAsync(BuildDescriptor(
clientId,
DisplayNameOf(clientName, redirectUris.First()),
redirectUris,
OpenIddictConstants.ConsentTypes.Explicit));
return McpClientRegistration.Accepted(clientId);
} }
} }
private async Task<McpClientRegistration> RegisterDynamicClientAsync(string? clientName, IReadOnlyCollection<string> redirectUris)
{
var dynamicClients = await GetDynamicClientsAsync();
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
// baglanir, tablo buyumez.
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
if (match.ClientId is not null)
{
return McpClientRegistration.Accepted(match.ClientId);
}
if (dynamicClients.Count >= McpConsts.MaxDynamicClients)
{
return McpClientRegistration.Rejected(McpClientRegistrationError.LimitReached, McpConsts.MaxDynamicClients.ToString(CultureInfo.InvariantCulture));
}
var clientId = McpConsts.DynamicClientIdPrefix + GuidGenerator.Create().ToString("N");
await applicationManager.CreateAsync(BuildDescriptor(
clientId,
DisplayNameOf(clientName, redirectUris.First()),
redirectUris,
OpenIddictConstants.ConsentTypes.Explicit));
return McpClientRegistration.Accepted(clientId);
}
private async Task<List<(string? ClientId, IReadOnlyList<string> RedirectUris)>> GetDynamicClientsAsync() private async Task<List<(string? ClientId, IReadOnlyList<string> RedirectUris)>> GetDynamicClientsAsync()
{ {
var query = await applicationRepository.GetQueryableAsync(); var query = await applicationRepository.GetQueryableAsync();

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Platform;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Domain.Services; using Volo.Abp.Domain.Services;
@ -43,11 +44,6 @@ public class McpSchemaDictionary(
ICurrentTenant currentTenant) ICurrentTenant currentTenant)
: DomainService, IMcpSchemaDictionary : DomainService, IMcpSchemaDictionary
{ {
private static readonly JsonSerializerOptions DictionaryJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly CultureInfo TurkishCulture = CultureInfo.GetCultureInfo("tr-TR"); private static readonly CultureInfo TurkishCulture = CultureInfo.GetCultureInfo("tr-TR");
public string ResolveTenantName() public string ResolveTenantName()
@ -81,7 +77,7 @@ public class McpSchemaDictionary(
return []; return [];
} }
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, DictionaryJsonOptions); var document = JsonSerializer.Deserialize<DictionaryDocument>(json, PlatformJsonOptions.CaseInsensitive);
return document?.ListForms ?? []; return document?.ListForms ?? [];
} }

View file

@ -2,9 +2,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Sozsoft.Platform.Data;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Localization; using Sozsoft.Platform.Localization;
using Volo.Abp;
using Volo.Abp.Domain.Services; using Volo.Abp.Domain.Services;
namespace Sozsoft.Mcp.Services; namespace Sozsoft.Mcp.Services;
@ -16,14 +16,11 @@ namespace Sozsoft.Mcp.Services;
/// </summary> /// </summary>
public interface IMcpSqlGuard public interface IMcpSqlGuard
{ {
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar.</summary> /// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur.</summary>
void EnsureReadOnlyStatement(string sql); McpProblem? CheckReadOnlyStatement(string sql);
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary> /// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType); McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
/// <summary>Saglayicinin varsayilan semasi.</summary>
string DefaultSchemaName(DataSourceTypeEnum dataSourceType);
/// <summary> /// <summary>
/// Satir tavanini sorgunun kendisine yazar: SQL Server'da <c>TOP (n)</c>, PostgreSQL'de /// Satir tavanini sorgunun kendisine yazar: SQL Server'da <c>TOP (n)</c>, PostgreSQL'de
@ -35,16 +32,11 @@ public interface IMcpSqlGuard
/// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in /// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in
/// bekledigi bicime cevirir. /// bekledigi bicime cevirir.
/// </summary> /// </summary>
Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters); McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
} }
public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard
{ {
/// <summary>Tek parcali SQL tanimlayicisi; sema ve nesne adlari bu kalibi gecmek zorundadir.</summary>
private static readonly Regex SafeIdentifierRegex = new(
@"^[A-Za-z_][A-Za-z0-9_]{0,127}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeParameterNameRegex = new( private static readonly Regex SafeParameterNameRegex = new(
@"^@?[A-Za-z_][A-Za-z0-9_]{0,127}$", @"^@?[A-Za-z_][A-Za-z0-9_]{0,127}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant); RegexOptions.Compiled | RegexOptions.CultureInvariant);
@ -78,18 +70,18 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
@"\bLIMIT\s+\d+", @"\bLIMIT\s+\d+",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public void EnsureReadOnlyStatement(string sql) public McpProblem? CheckReadOnlyStatement(string sql)
{ {
if (string.IsNullOrWhiteSpace(sql)) if (string.IsNullOrWhiteSpace(sql))
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.EmptyQuery]); return McpProblem.From(localizer, McpErrorCodes.EmptyQuery);
} }
var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''"); var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''");
if (stripped.Contains(';', StringComparison.Ordinal)) if (stripped.Contains(';', StringComparison.Ordinal))
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]); return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly);
} }
var trimmed = stripped.TrimStart(); var trimmed = stripped.TrimStart();
@ -98,30 +90,29 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
if (!startsWithRead) if (!startsWithRead)
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]); return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired);
} }
var forbidden = ForbiddenStatementRegex.Match(stripped); var forbidden = ForbiddenStatementRegex.Match(stripped);
if (forbidden.Success)
{ return forbidden.Success
throw new UserFriendlyException( ? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]); : null;
}
} }
public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType) public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
{ {
EnsureSafeIdentifier(schemaName); foreach (var name in (string?[])[schemaName, objectName])
EnsureSafeIdentifier(objectName); {
if (!SqlIdentifier.IsSafe(name))
{
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
}
}
return dataSourceType == DataSourceTypeEnum.Postgresql return SqlIdentifier.QuoteQualified(schemaName, objectName, dataSourceType);
? $"\"{schemaName}\".\"{objectName}\""
: $"[{schemaName}].[{objectName}]";
} }
public string DefaultSchemaName(DataSourceTypeEnum dataSourceType)
=> dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo";
public string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType) public string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType)
{ {
if (dataSourceType == DataSourceTypeEnum.Postgresql) if (dataSourceType == DataSourceTypeEnum.Postgresql)
@ -144,7 +135,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
: sql; : sql;
} }
public Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters) public McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
{ {
var normalized = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); var normalized = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (parameters is null) if (parameters is null)
@ -156,7 +147,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
{ {
if (key is null || !SafeParameterNameRegex.IsMatch(key)) if (key is null || !SafeParameterNameRegex.IsMatch(key))
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]); return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty);
} }
normalized[key.TrimStart('@')] = value!; normalized[key.TrimStart('@')] = value!;
@ -164,12 +155,4 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
return normalized; return normalized;
} }
private void EnsureSafeIdentifier(string identifier)
{
if (!SafeIdentifierRegex.IsMatch(identifier ?? string.Empty))
{
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier ?? string.Empty]);
}
}
} }

View file

@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Localization;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Localization;
using Volo.Abp.Domain.Services;
namespace Sozsoft.Mcp.Services;
/// <summary>
/// Ekranin veri kaynagi olan view, procedure ve fonksiyon scriptlerini SQL Query Manager'in yazdigi
/// yere (<c>sql/object/{Ad}.sql</c>) ve PostgreSQL esine (<c>postgres/object/{Ad}.sql</c>) yazar.
/// Govde diyalektler arasinda guvenle cevrilemedigi icin (<see cref="PostgreSqlTableScript"/>) iki
/// script de istemciden gelir; biri olmadan yazilmaz.
/// <para>
/// Veritabaninda calistirilmaz: nesneyi, tablo gibi DB Migrate kurar (<c>SqlDataSeeder</c>). Nesnenin
/// adi SQL Server scriptinin basligindan okunur; basliklar tekrar calismaya dayanikli bicime cevrilir
/// (<c>CREATE OR ALTER</c> / <c>CREATE OR REPLACE</c>), ayni adla tekrar yazmak scripti gunceller.
/// </para>
/// </summary>
public interface IMcpSqlObjectWriter
{
McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript);
}
public partial class McpSqlObjectWriter(
IConfiguration configuration,
IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlObjectWriter
{
private const string SqlServerProvider = "SQL Server";
private const string PostgreSqlProvider = "PostgreSQL";
public McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript)
{
var sqlServer = Canonicalize(sqlServerScript, SqlServerHeaderRegex(), "CREATE OR ALTER");
if (sqlServer is null)
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, SqlServerProvider);
var postgreSql = Canonicalize(postgreSqlScript, PostgreSqlHeaderRegex(), "CREATE OR REPLACE");
if (postgreSql is null || !string.Equals(sqlServer.Name, postgreSql.Name, StringComparison.OrdinalIgnoreCase))
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, PostgreSqlProvider);
// Tablo scriptinin yerine view yazilirsa migrate tabloyu bir daha kuramazdi.
if (PostgreSqlTableScript.ReadSeedColumns(configuration, sqlServer.Name).Count > 0)
return McpProblem.From(localizer, McpErrorCodes.TableAlreadyExists, sqlServer.Name);
var fileName = $"{sqlServer.Name}.sql";
var sqlServerPath = Write(SeedPathResolver.SqlFolder, fileName, sqlServer.Script);
var postgreSqlPath = Write(SeedPathResolver.PostgresFolder, fileName, postgreSql.Script);
return new McpSavedSqlObject(sqlServer.Name, sqlServer.Kind, sqlServerPath, postgreSqlPath);
}
/// <summary>Scripti saglayicinin <c>object</c> klasorune yazar; seed kokune goreli yolu dondurur.</summary>
private string Write(string providerFolder, string fileName, string script)
{
var folder = SeedPathResolver.GetPath(configuration, providerFolder, SeedPathResolver.ObjectFolder);
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, fileName), script);
return $"{providerFolder}/{SeedPathResolver.ObjectFolder}/{fileName}";
}
/// <summary>
/// Script tek bir nesne tanimlamalidir; basligi tekrar calismaya dayanikli bicime cevrilir.
/// Yorumlar yalnizca aramada yok sayilir, dosyada kalir. Tek nesne tanimlamayan script icin null.
/// </summary>
private static CanonicalScript? Canonicalize(string script, Regex headerRegex, string createClause)
{
var headers = string.IsNullOrWhiteSpace(script) ? null : headerRegex.Matches(CommentRegex().Replace(script, " "));
if (headers is not { Count: 1 })
return null;
var name = headers[0].Groups["name"].Value;
var kind = headers[0].Groups["kind"].Value.ToUpperInvariant() switch
{
"PROC" => "PROCEDURE",
var value => value
};
// Yorumdaki ornek baslik degismesin diye yalnizca nesnenin adini tasiyan ilk baslik cevrilir.
var replaced = false;
var canonical = headerRegex.Replace(script.Trim(), m =>
{
if (replaced || !string.Equals(m.Groups["name"].Value, name, StringComparison.OrdinalIgnoreCase))
return m.Value;
replaced = true;
return $"{createClause} {kind} {m.Groups["qualified"].Value}";
});
return new CanonicalScript(canonical + Environment.NewLine, name, kind);
}
private sealed record CanonicalScript(string Script, string Name, string Kind);
[GeneratedRegex(@"/\*.*?\*/|--[^\r\n]*", RegexOptions.Singleline)]
private static partial Regex CommentRegex();
[GeneratedRegex(@"\b(?:CREATE\s+OR\s+ALTER|CREATE|ALTER)\s+(?<kind>VIEW|PROCEDURE|PROC|FUNCTION)\s+(?<qualified>(?:\[?dbo\]?\s*\.\s*)?\[?(?<name>[A-Za-z_][A-Za-z0-9_]*)\]?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SqlServerHeaderRegex();
[GeneratedRegex(@"\bCREATE\s+(?:OR\s+REPLACE\s+)?(?<kind>VIEW|PROCEDURE|FUNCTION)\s+(?<qualified>(?:""?public""?\s*\.\s*)?""?(?<name>[A-Za-z_][A-Za-z0-9_]*)""?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PostgreSqlHeaderRegex();
}
/// <param name="Kind"><c>VIEW</c>, <c>PROCEDURE</c> ya da <c>FUNCTION</c>.</param>
/// <param name="SqlServerPath">Seed kokune gore goreli yol.</param>
/// <param name="PostgreSqlPath">Seed kokune gore goreli yol.</param>
public sealed record McpSavedSqlObject(string Name, string Kind, string SqlServerPath, string PostgreSqlPath);

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -6,6 +6,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Sozsoft.Platform.Data;
using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Localization; using Sozsoft.Platform.Localization;
@ -17,9 +18,13 @@ namespace Sozsoft.Mcp.Services;
/// <summary> /// <summary>
/// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script /// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script
/// tasarimcinin <c>generateCreateTableSql</c> ciktisiyla ayni bicimdedir ve tasarimcinin deploy /// tasarimcinin <c>generateCreateTableSql</c> ciktisiyla ayni bicimdedir ve tasarimcinin deploy
/// sonrasi yazdigi yere (<c>{kapsam}/sql/object/{Tablo}.sql</c>) yazilir; boylece tablo /// sonrasi yazdigi yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo
/// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir. /// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir.
/// <para> /// <para>
/// Veritabaninda calistirilmaz: tabloyu, wizard deploy'unun ekranini kurdugu gibi kullanicinin
/// calistirdigi DB Migrate kurar (<c>SqlDataSeeder</c>).
/// </para>
/// <para>
/// Yalnizca olusturma yapar. Var olan tabloya dokunulmaz — kolon ekleme/degistirme tasarimcinin /// Yalnizca olusturma yapar. Var olan tabloya dokunulmaz — kolon ekleme/degistirme tasarimcinin
/// isidir ve sema degisikligi kurallarina (kayipsiz ekleme, degistirme/dusurme yok) tabidir. /// isidir ve sema degisikligi kurallarina (kayipsiz ekleme, degistirme/dusurme yok) tabidir.
/// Kolon tipi ve varsayilan deger serbest metin degil, beyaz listeden gecer; istemci SQL yazamaz. /// Kolon tipi ve varsayilan deger serbest metin degil, beyaz listeden gecer; istemci SQL yazamaz.
@ -27,7 +32,7 @@ namespace Sozsoft.Mcp.Services;
/// </summary> /// </summary>
public interface IMcpTableCreator public interface IMcpTableCreator
{ {
Task<McpCreatedTable> CreateAsync(McpTableDefinition definition); Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition);
} }
public class McpTableCreator( public class McpTableCreator(
@ -39,10 +44,17 @@ public class McpTableCreator(
private const int DefaultTextLength = 100; private const int DefaultTextLength = 100;
private const int MaxTextLength = 4000; private const int MaxTextLength = 4000;
private static readonly Regex SafeIdentifierRegex = new( /// <summary>
@"^[A-Za-z_][A-Za-z0-9_]{0,127}$", /// Istemcinin SQL bicimiyle yazdigi tip: <c>nvarchar(50)</c>, <c>nvarchar (MAX)</c>,
/// <c>decimal(18, 4)</c>. Boyut tipten ayrilir; tip adi yine beyaz listeden gecer.
/// </summary>
private static readonly Regex SizedTypeRegex = new(
@"^(?<name>[A-Za-z0-9]+)\s*\(\s*(?<size>[^)]*?)\s*\)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant); RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary>
private const string DecimalSize = "18,4";
/// <summary> /// <summary>
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi /// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez. /// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
@ -59,6 +71,16 @@ public class McpTableCreator(
]; ];
// Sablon kolonlari tasarimcinin FULL_AUDIT_COLUMNS / TENANT_COLUMN tanimlariyla aynidir. // Sablon kolonlari tasarimcinin FULL_AUDIT_COLUMNS / TENANT_COLUMN tanimlariyla aynidir.
/// <summary>Tasarimcinin FK silme davranislari; anahtar istemcinin yazdigi, deger SQL karsiligidir.</summary>
private static readonly Dictionary<string, string> ReferentialActions = new(StringComparer.OrdinalIgnoreCase)
{
["NoAction"] = "NO ACTION",
["Cascade"] = "CASCADE",
["SetNull"] = "SET NULL",
};
private const string DefaultReferentialAction = "NoAction";
private static readonly McpTableColumn IdColumn = new("Id", "uniqueidentifier", null, false, "NEWID()"); private static readonly McpTableColumn IdColumn = new("Id", "uniqueidentifier", null, false, "NEWID()");
private static readonly McpTableColumn TenantColumn = new("TenantId", "uniqueidentifier", null, true, null); private static readonly McpTableColumn TenantColumn = new("TenantId", "uniqueidentifier", null, true, null);
@ -74,59 +96,87 @@ public class McpTableCreator(
new("DeleterId", "uniqueidentifier", null, true, null) new("DeleterId", "uniqueidentifier", null, true, null)
]; ];
public async Task<McpCreatedTable> CreateAsync(McpTableDefinition definition) public async Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition)
{ {
Check.NotNull(definition, nameof(definition)); Check.NotNull(definition, nameof(definition));
var tableName = (definition.TableName ?? string.Empty).Trim(); var tableName = (definition.TableName ?? string.Empty).Trim();
EnsureSafeIdentifier(tableName); if (CheckIdentifier(tableName) is { } invalidName)
{
return invalidName;
}
var columns = BuildColumns(definition); if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) ||
!BuildIndexes(tableName, definition.Indexes, columns).TryGetValue(out var indexes, out problem) ||
!BuildForeignKeys(definition.ForeignKeys, columns).TryGetValue(out var foreignKeys, out problem))
{
return problem;
}
var context = await queryExecutor.ResolveDataSourceAsync(); var context = await queryExecutor.ResolveDataSourceAsync();
if (context.DataSourceType != DataSourceTypeEnum.Mssql) var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
async Task<bool> TableExistsAsync(string name)
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.TableProviderNotSupported, context.DataSourceType.ToString()]); var existing = await context.Repository.ExecuteScalarAsync<object?>(
isPostgreSql ? "SELECT to_regclass(@name)::text" : "SELECT OBJECT_ID(@name, 'U')",
context.ConnectionString,
new Dictionary<string, object> { ["name"] = isPostgreSql ? $"public.\"{name}\"" : $"{SchemaName}.{name}" });
return existing is not null and not DBNull;
} }
var existing = await context.Repository.ExecuteScalarAsync<int?>( // Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun
"SELECT OBJECT_ID(@name, 'U')", // scriptinin ustune yazmaz.
context.ConnectionString, if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0)
new Dictionary<string, object> { ["name"] = $"{SchemaName}.{tableName}" });
if (existing is not null)
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]); return Problem(McpErrorCodes.TableAlreadyExists, tableName);
} }
var script = BuildScript(tableName, columns); foreach (var foreignKey in foreignKeys)
{
if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled)
{
return notInstalled;
}
}
// GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir. // Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir.
await context.Repository.ExecuteAsync( var script = BuildScript(tableName, columns, indexes, foreignKeys);
string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")), if (!PostgreSqlTableScript.TryConvert(script, out _))
context.ConnectionString); {
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
}
var seedFilePath = SaveSeedScript(tableName, script); // Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin
// calistirdigi DB Migrate'tedir (SqlDataSeeder).
var (seedFilePath, _) = PostgreSqlTableScript.WriteSeedFiles(configuration, tableName, script);
return new McpCreatedTable(tableName, script, seedFilePath); return new McpCreatedTable(tableName, script, seedFilePath);
} }
private List<McpTableColumn> BuildColumns(McpTableDefinition definition) private McpOutcome<List<McpTableColumn>> BuildColumns(McpTableDefinition definition)
{ {
if (definition.Columns.Count == 0) if (definition.Columns.Count == 0)
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnsRequired]); return Problem(McpErrorCodes.TableColumnsRequired);
} }
var userColumns = definition.Columns.Select(NormalizeColumn).ToList();
var columns = new List<McpTableColumn> { IdColumn }; var columns = new List<McpTableColumn> { IdColumn };
if (definition.MultiTenant) if (definition.MultiTenant)
{ {
columns.Add(TenantColumn); columns.Add(TenantColumn);
} }
columns.AddRange(userColumns); foreach (var column in definition.Columns)
{
if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem))
{
return problem;
}
columns.Add(normalized);
}
if (definition.FullAudited) if (definition.FullAudited)
{ {
@ -137,44 +187,168 @@ public class McpTableCreator(
.GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase) .GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1); .FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null) return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key);
{
throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]);
}
return columns;
} }
private McpTableColumn NormalizeColumn(McpTableColumn column) /// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
private McpOutcome<List<McpTableIndex>> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
{
var result = new List<McpTableIndex>();
foreach (var index in indexes)
{
var indexColumns = new List<string>();
foreach (var column in index.Columns)
{
if (!ResolveColumn(column, columns).TryGetValue(out var resolved, out var problem))
{
return problem;
}
if (!indexColumns.Contains(resolved))
indexColumns.Add(resolved);
}
var name = $"{(index.Unique ? "UQ" : "IX")}_{tableName}_{string.Join("_", indexColumns)}";
// Kolonsuz ya da ayni kolonlarla tekrar eden index uretilecek bir sey tasimaz.
if (indexColumns.Count > 0 && !result.Exists(existing => string.Equals(existing.Name, name, StringComparison.OrdinalIgnoreCase)))
result.Add(new McpTableIndex(indexColumns, index.Unique, name));
}
return result;
}
private McpOutcome<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
{
var result = new List<McpTableForeignKey>();
foreach (var foreignKey in foreignKeys)
{
if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem))
{
return problem;
}
var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim();
var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim();
if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid)
{
return invalid;
}
// Tanimadigi davranis (Restrict gibi) SQL Server'in varsayilanina, NO ACTION'a duser.
var action = ReferentialActions.Keys.FirstOrDefault(key => string.Equals(key, foreignKey.OnDelete?.Trim(), StringComparison.OrdinalIgnoreCase))
?? DefaultReferentialAction;
// Bir kolonun tek FK'si olur; ayni kolon icin tekrar eden tanimin ilki gecerlidir.
if (!result.Exists(existing => string.Equals(existing.Column, column, StringComparison.OrdinalIgnoreCase)))
result.Add(new McpTableForeignKey(column, referencedTable, referencedColumn, action));
}
return result;
}
/// <summary>
/// Seed scriptleri DB Migrate'te dosya adi sirasiyla calisir (<c>SqlDataSeeder</c>). FK'nin
/// gosterdigi tablo bir seed scriptiyle kuruluyorsa o dosya bu tablonunkinden once gelmelidir;
/// scripti yoksa (EF ile kurulan platform tablosu) veritabaninda bulunmalidir. Aksi halde
/// sifirdan kurulumda migrate FK satirinda duserdi.
/// </summary>
private async Task<McpProblem?> CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func<string, Task<bool>> tableExistsAsync)
{
var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0;
var installed = hasScript
? Comparer<string>.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0
: await tableExistsAsync(foreignKey.ReferencedTable);
return installed
? null
: Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName);
}
private McpOutcome<string> ResolveColumn(string? name, List<McpTableColumn> columns)
{
var match = columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase));
return match is null ? Problem(McpErrorCodes.TableColumnNotFound, name ?? string.Empty) : match.Name;
}
private McpOutcome<McpTableColumn> NormalizeColumn(McpTableColumn column)
{ {
var name = (column.Name ?? string.Empty).Trim(); var name = (column.Name ?? string.Empty).Trim();
EnsureSafeIdentifier(name); if (CheckIdentifier(name) is { } invalidName)
{
return invalidName;
}
var type = SupportedTypes.FirstOrDefault(candidate => if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem))
string.Equals(candidate, column.Type?.Trim(), StringComparison.OrdinalIgnoreCase)) {
?? throw new UserFriendlyException( return problem;
localizer[McpErrorCodes.UnsupportedColumnType, column.Type ?? string.Empty, string.Join(", ", SupportedTypes)]); }
int? maxLength = null; int? maxLength = null;
if (type == "nvarchar") if (resolved.Type == "nvarchar")
{ {
maxLength = column.MaxLength ?? DefaultTextLength; maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength;
if (maxLength is < 1 or > MaxTextLength) if (maxLength is < 1 or > MaxTextLength)
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidColumnLength, name, MaxTextLength]); return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength);
} }
} }
var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim(); var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim();
if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue)) if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue))
{ {
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]); return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue);
} }
return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue); return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue);
} }
private static string BuildScript(string tableName, List<McpTableColumn> columns) /// <summary>
/// Tipi beyaz listedeki adina indirger. <c>nvarchar(N)</c> uzunlugu dondurur,
/// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir —
/// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi.
/// </summary>
private McpOutcome<ResolvedType> ResolveType(string? rawType)
{
var trimmed = rawType?.Trim() ?? string.Empty;
var name = trimmed;
string? size = null;
var sized = SizedTypeRegex.Match(trimmed);
if (sized.Success)
{
name = sized.Groups["name"].Value;
size = Regex.Replace(sized.Groups["size"].Value, @"\s+", string.Empty);
}
int? maxLength = null;
if (size is not null)
{
if (name.Equals("nvarchar", StringComparison.OrdinalIgnoreCase) && size.Equals("max", StringComparison.OrdinalIgnoreCase))
{
name = "nvarchar(MAX)";
}
else if (name.Equals("nvarchar", StringComparison.OrdinalIgnoreCase) && int.TryParse(size, out var length))
{
maxLength = length;
}
else if (!(name.Equals("decimal", StringComparison.OrdinalIgnoreCase) && size == DecimalSize))
{
name = trimmed;
}
}
var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase));
return type is null
? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes))
: new ResolvedType(type, maxLength);
}
private sealed record ResolvedType(string Type, int? MaxLength);
private static string BuildScript(string tableName, List<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
{ {
var fullTableName = $"[{SchemaName}].[{tableName}]"; var fullTableName = $"[{SchemaName}].[{tableName}]";
@ -195,6 +369,42 @@ public class McpTableCreator(
lines.Add("END"); lines.Add("END");
lines.Add("GO"); lines.Add("GO");
// Tasarimcinin unique/index/FK deyimleri; her biri varlik kontroluyle yazilir ki migrate
// scripti yeniden calistirdiginda dusmesin.
foreach (var index in indexes)
{
var columnList = string.Join(", ", index.Columns.Select(column => $"[{column}] ASC"));
lines.Add(string.Empty);
if (index.Unique)
{
lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{index.Name}]', 'UQ') IS NULL");
lines.Add("BEGIN");
lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{index.Name}] UNIQUE NONCLUSTERED ({columnList});");
}
else
{
lines.Add($"IF INDEXPROPERTY(OBJECT_ID(N'{fullTableName}'), '{index.Name}', 'IndexID') IS NULL");
lines.Add("BEGIN");
lines.Add($" CREATE INDEX [{index.Name}] ON {fullTableName} ({columnList});");
}
lines.Add("END");
lines.Add("GO");
}
foreach (var foreignKey in foreignKeys)
{
var name = $"FK_{tableName}_{foreignKey.Column}";
lines.Add(string.Empty);
lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{name}]', 'F') IS NULL");
lines.Add("BEGIN");
lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{name}] FOREIGN KEY ([{foreignKey.Column}]) " +
$"REFERENCES [{SchemaName}].[{foreignKey.ReferencedTable}] ([{foreignKey.ReferencedColumn}]) " +
$"ON DELETE {ReferentialActions[foreignKey.OnDelete!]} ON UPDATE NO ACTION;");
lines.Add("END");
lines.Add("GO");
}
return string.Join('\n', lines); return string.Join('\n', lines);
} }
@ -213,42 +423,30 @@ public class McpTableCreator(
return $" [{column.Name}] {type} {nullPart}{defaultPart}"; return $" [{column.Name}] {type} {nullPart}{defaultPart}";
} }
/// <summary> private McpProblem? CheckIdentifier(string identifier)
/// Tasarimcinin <c>SaveTableScriptAsync</c> ile yazdigi yol: kapsam istegin kiracisini izler. => SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier);
/// Dosyasiz tablo veritabani sifirlandiginda geri gelmezdi; yazilamazsa islem hata verir.
/// </summary>
private string SaveSeedScript(string tableName, string script)
{
var outputPath = SeedPathResolver.GetScopePath(
configuration,
CurrentTenant.Id,
SeedPathResolver.GetProviderFolderName(isPostgreSql: false),
SeedPathResolver.ObjectFolder);
Directory.CreateDirectory(outputPath); private McpProblem Problem(string name, params object[] arguments)
=> McpProblem.From(localizer, name, arguments);
var filePath = Path.Combine(outputPath, $"{tableName}.sql");
File.WriteAllText(filePath, script);
return $"{SeedPathResolver.GetScopeFolderName(CurrentTenant.Id)}/{SeedPathResolver.SqlFolder}/{SeedPathResolver.ObjectFolder}/{tableName}.sql";
}
private void EnsureSafeIdentifier(string identifier)
{
if (!SafeIdentifierRegex.IsMatch(identifier))
{
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier]);
}
}
} }
/// <param name="FullAudited">Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi.</param> /// <param name="FullAudited">Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi.</param>
/// <param name="MultiTenant"><c>TenantId</c> kolonu eklensin mi.</param> /// <param name="MultiTenant"><c>TenantId</c> kolonu eklensin mi.</param>
/// <param name="Indexes">Index ve unique kisitlari; ad sunucuda uretilir (<c>IX_/UQ_{Tablo}_{Kolonlar}</c>).</param>
/// <param name="ForeignKeys">FK'ler; ad <c>FK_{Tablo}_{Kolon}</c>.</param>
public sealed record McpTableDefinition( public sealed record McpTableDefinition(
string TableName, string TableName,
IReadOnlyList<McpTableColumn> Columns, IReadOnlyList<McpTableColumn> Columns,
bool FullAudited, bool FullAudited,
bool MultiTenant); bool MultiTenant,
IReadOnlyList<McpTableIndex> Indexes,
IReadOnlyList<McpTableForeignKey> ForeignKeys);
public sealed record McpTableIndex(IReadOnlyList<string> Columns, bool Unique, string? Name = null);
/// <param name="ReferencedColumn">Bos ise <c>Id</c>.</param>
/// <param name="OnDelete"><c>NoAction</c> (varsayilan), <c>Cascade</c> ya da <c>SetNull</c>.</param>
public sealed record McpTableForeignKey(string Column, string ReferencedTable, string? ReferencedColumn, string? OnDelete);
/// <param name="MaxLength">Yalnizca <c>nvarchar</c> icin; bos ise tasarimcinin varsayilani (100).</param> /// <param name="MaxLength">Yalnizca <c>nvarchar</c> icin; bos ise tasarimcinin varsayilani (100).</param>
public sealed record McpTableColumn( public sealed record McpTableColumn(

View file

@ -1,7 +1,10 @@
using System; using System;
using System.Linq; using System.Linq;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol;
using ModelContextProtocol.AspNetCore; using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Sozsoft.Mcp.Tools; using Sozsoft.Mcp.Tools;
using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Modularity; using Volo.Abp.Modularity;
@ -52,6 +55,7 @@ public class McpHttpApiModule : AbpModule
.WithTools<McpQueryTools>() .WithTools<McpQueryTools>()
.WithTools<McpDataTools>() .WithTools<McpDataTools>()
.WithTools<McpDevelopTools>() .WithTools<McpDevelopTools>()
.WithTools<McpReportTools>()
.WithPrompts(typeof(McpDevelopPrompts)) .WithPrompts(typeof(McpDevelopPrompts))
.WithRequestFilters(filters => .WithRequestFilters(filters =>
{ {
@ -71,23 +75,27 @@ public class McpHttpApiModule : AbpModule
} }
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex)) catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
{ {
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null); throw new McpException(await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null), ex);
} }
return result; return result;
}); });
// SDK arac hatasinin mesajini yalnizca McpException ise iletir; yetki reddi ve is // MCP'nin kendi reddi istisna degildir (McpProblem). Arkadaki platform servisinin yetki
// kurali hatalari burada cevrilir ki istemci nedenini (eksik yetkiyi) gorsun. // reddi ve is kurali hatasi ise istisnadir; firlatilirsa SDK onu yigin izli ERR olarak
// loglar. Hata sonucu (IsError) olarak donulur; model nedeni okuyup devam eder.
filters.AddCallToolFilter(next => async (requestContext, cancellationToken) => filters.AddCallToolFilter(next => async (requestContext, cancellationToken) =>
{ {
McpToolArgumentNormalizer.Normalize(requestContext.Params, requestContext.MatchedPrimitive as McpServerTool);
try try
{ {
return await next(requestContext, cancellationToken); return await next(requestContext, cancellationToken);
} }
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex)) catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
{ {
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name); var message = await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
return McpToolResult.Error(message);
} }
}); });
}); });

View file

@ -24,10 +24,10 @@ internal static class McpServerInfoDefaults
"then produce them with deploy_wizard and save_component; the guide's file-writing steps map to these tools. " + "then produce them with deploy_wizard and save_component; the guide's file-writing steps map to these tools. " +
"Agree on screen type, data source, module and permissions with the user before deploying. " + "Agree on screen type, data source, module and permissions with the user before deploying. " +
"Analyzing development requests (Backlog -> Analysis) follows get_dev_guide section 0.12: read with get_dev_request, " + "Analyzing development requests (Backlog -> Analysis) follows get_dev_guide section 0.12: read with get_dev_request, " +
"write the analysis with save_dev_request_analysis, produce nothing and never touch Description; the user moves an approved analysis to Ready to Start. " + "write the analysis with save_dev_request_analysis, produce nothing and never touch Description; pass productionReady true only when the analysis is ready for production without conditions (the request moves to Ready to Start), otherwise it stays in Analysis. " +
"Development requests (App.DevRequests) follow get_dev_guide section 0.11: claim each one with " + "Development requests (Sas.DevRequests) follow get_dev_guide section 0.11: claim each one with " +
"start_dev_request, which only accepts requests in Ready to Start; images pasted into the request arrive as image content and are part of the request (layout and demo rows). " + "start_dev_request, which only accepts requests in Ready to Start; images pasted into the request arrive as image content and are part of the request (layout and demo rows). " +
"A request (Description) and its approved Analysis are its own brief: do not ask questions and do not wait for DB Migrate; deploy (demo rows go in deploy_wizard seedRows), move the request to Testing and finish. " + "A request (Description) and its approved Analysis are its own brief: do not ask questions and do not wait for DB Migrate; deploy (demo rows go in deploy_wizard seedRows), complete the module document with get_module_doc / save_module_doc (section 0.13), then move the request to Testing and finish. " +
"When a tool reports a missing permission, tell the user which permission is missing and stop; " + "When a tool reports a missing permission, tell the user which permission is missing and stop; " +
"do not try to reach the same result through another tool."; "do not try to reach the same result through another tool.";
} }

View file

@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Sozsoft.Mcp;
/// <summary>
/// Model metin parametresine tirnaksiz deger (<c>section: 4.2</c>) ya da JSON metni yerine dogrudan
/// dizi/nesne (<c>filter: ["Status","=","Open"]</c>) gonderebilir. SDK her parametreyi ayri ayri
/// <c>string</c> olarak bagladigi icin bu deger <see cref="JsonException"/> ("Cannot get the value of a
/// token type 'Number' / 'StartArray' as a string") ile duser. Deger cagri araca ulasmadan, aracin kendi
/// semasina bakilarak ham JSON metnine cevrilir — JSON bekleyen parametre ayni metni alir; string icin
/// ozel converter kullanilmaz, cunku arac semasindaki <c>"type": "string"</c> bilgisini siler.
/// </summary>
internal static class McpToolArgumentNormalizer
{
public static void Normalize(CallToolRequestParams? parameters, McpServerTool? tool)
{
if (parameters?.Arguments is not { Count: > 0 } arguments
|| tool is null
|| !tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties)
|| properties.ValueKind != JsonValueKind.Object)
{
return;
}
Dictionary<string, JsonElement>? normalized = null;
foreach (var (name, value) in arguments)
{
if (value.ValueKind is JsonValueKind.String or JsonValueKind.Null or JsonValueKind.Undefined
|| !properties.TryGetProperty(name, out var schema)
|| !IsStringOnly(schema))
{
continue;
}
normalized ??= new Dictionary<string, JsonElement>(arguments);
normalized[name] = JsonSerializer.SerializeToElement(value.GetRawText());
}
if (normalized is not null)
{
parameters.Arguments = normalized;
}
}
// Nullable metin parametresi semada ["string", "null"] olarak gelir; baska tip de kabul eden bir
// birlesim tipi donusturulmez, degeri SDK oldugu gibi baglar.
private static bool IsStringOnly(JsonElement schema)
{
if (!schema.TryGetProperty("type", out var type))
{
return false;
}
return type.ValueKind switch
{
JsonValueKind.String => type.GetString() == "string",
JsonValueKind.Array => type.EnumerateArray()
.Select(item => item.GetString())
.Where(item => item != "null")
.SequenceEqual(["string"]),
_ => false
};
}
}

View file

@ -1,9 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using ModelContextProtocol;
using Sozsoft.Platform.Localization; using Sozsoft.Platform.Localization;
using Volo.Abp; using Volo.Abp;
using Volo.Abp.Authorization; using Volo.Abp.Authorization;
@ -13,11 +12,12 @@ using Volo.Abp.DependencyInjection;
namespace Sozsoft.Mcp; namespace Sozsoft.Mcp;
/// <summary> /// <summary>
/// Arac hatalarini istemcinin gorebilecegi bicime cevirir. SDK yalnizca <see cref="McpException"/> /// Aracin arkasindaki platform servislerinden (wizard, custom component, ListForm verisi) ve sinif
/// mesajini iletir; diger her istisna "An error occurred invoking" olarak gider ve model neden /// duzeyindeki <c>[Authorize]</c>'dan gelen istisnalari istemcinin gorebilecegi mesaja cevirir. MCP'nin
/// reddedildigini bilemez. Yetki reddinde eksik yetkiler adiyla, is kurali hatasinda /// kendi reddi istisna degildir, <see cref="McpProblem"/> olarak doner. SDK cevrilmemis istisnayi
/// (<see cref="UserFriendlyException"/>) zaten yerellestirilmis mesaj iletilir. Diger istisnalar /// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde
/// cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz. /// eksik yetkiler adiyla, is kurali hatasinda (<see cref="UserFriendlyException"/>) zaten
/// yerellestirilmis mesaj iletilir. Diger istisnalar cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
/// </summary> /// </summary>
public class McpToolErrorTranslator( public class McpToolErrorTranslator(
IPermissionChecker permissionChecker, IPermissionChecker permissionChecker,
@ -26,10 +26,10 @@ public class McpToolErrorTranslator(
public static bool CanTranslate(Exception exception) public static bool CanTranslate(Exception exception)
=> exception is AbpAuthorizationException or UserFriendlyException; => exception is AbpAuthorizationException or UserFriendlyException;
public async Task<McpException> TranslateAsync(Exception exception, string? toolName) public async Task<string> TranslateAsync(Exception exception, string? toolName)
=> exception is AbpAuthorizationException authorization => exception is AbpAuthorizationException authorization
? new McpException(await DescribeMissingPermissionsAsync(authorization, toolName), exception) ? await DescribeMissingPermissionsAsync(authorization, toolName)
: new McpException(exception.Message, exception); : exception.Message;
/// <summary> /// <summary>
/// <c>CheckAsync("...")</c> reddi yetki adini tasir; <c>[Authorize]</c> attribute'u tasimaz. Ikinci /// <c>CheckAsync("...")</c> reddi yetki adini tasir; <c>[Authorize]</c> attribute'u tasimaz. Ikinci
@ -43,10 +43,11 @@ public class McpToolErrorTranslator(
candidates.Add(policyName); candidates.Add(policyName);
} }
var grants = await permissionChecker.IsGrantedAsync([.. candidates.Distinct(StringComparer.Ordinal)]); var granted = await permissionChecker.GetGrantedNamesAsync(candidates);
var missing = grants.Result var missing = candidates
.Where(result => result.Value != PermissionGrantResult.Granted) .Distinct(StringComparer.Ordinal)
.Select(result => Describe(result.Key)) .Where(permission => !granted.Contains(permission))
.Select(permission => McpPermissionText.Describe(localizer, permission))
.ToList(); .ToList();
var target = toolName ?? McpConsts.EndpointPath; var target = toolName ?? McpConsts.EndpointPath;
@ -55,14 +56,4 @@ public class McpToolErrorTranslator(
? localizer[McpErrorCodes.PermissionDeniedUnspecified, target] ? localizer[McpErrorCodes.PermissionDeniedUnspecified, target]
: localizer[McpErrorCodes.PermissionDenied, target, string.Join(", ", missing)]; : localizer[McpErrorCodes.PermissionDenied, target, string.Join(", ", missing)];
} }
/// <summary>Yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir.</summary>
private string Describe(string permissionName)
{
var displayName = localizer[permissionName];
return displayName.ResourceNotFound || displayName.Value == permissionName
? permissionName
: $"{displayName.Value} ({permissionName})";
}
} }

View file

@ -0,0 +1,18 @@
namespace Sozsoft.Mcp.Tools;
/// <summary>
/// Arac sonucundaki gorselleri istemcinin boyut sinirina sigdirir. MCP istemcileri tek bir arac
/// sonucunu sinirlar (Claude Desktop 1 MB); talep aciklamasina yapistirilan ekran goruntuleri
/// tek basina bu siniri asabilir. Gorsel isleme kutuphanesi barindiran uygulamadadir; modul
/// grafik paketine baglanmaz.
/// </summary>
public interface IMcpImageShrinker
{
/// <summary>
/// Base64 uzunlugu <paramref name="maxLength"/>'i asmayan gorsel dondurur: sigiyorsa oldugu gibi,
/// sigmiyorsa kucultulmus JPEG. Okunabilir boyutta sigdirilamazsa ya da cozulemezse null.
/// </summary>
McpShrunkImage? Shrink(string mimeType, string base64, int maxLength);
}
public sealed record McpShrunkImage(string MimeType, string Data);

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
@ -16,43 +17,43 @@ public sealed class McpDataTools(IMcpDataAppService dataAppService)
{ {
[McpServerTool(Name = McpToolNames.InsertRow, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.InsertRow, Destructive = false, Idempotent = false)]
[Description("Bir listeye yeni kayit ekler. Alan adlari describe_list cikisindaki field degerleridir.")] [Description("Bir listeye yeni kayit ekler. Alan adlari describe_list cikisindaki field degerleridir.")]
public async Task<McpWriteResultDto> InsertRowAsync( public async Task<CallToolResult> InsertRowAsync(
[Description("Kaydin eklenecegi listenin kodu.")] [Description("Kaydin eklenecegi listenin kodu.")]
string listFormCode, string listFormCode,
[Description("Alan adi -> deger eslemesi.")] [Description("Alan adi -> deger eslemesi.")]
Dictionary<string, object?> values) Dictionary<string, object?> values)
=> await dataAppService.InsertRowAsync(new McpRowRequestDto => McpToolResult.From(await dataAppService.InsertRowAsync(new McpRowRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Values = values ?? [] Values = values ?? []
}); }));
[McpServerTool(Name = McpToolNames.UpdateRow, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.UpdateRow, Destructive = true, Idempotent = true)]
[Description("Bir listedeki kaydi anahtar degerine gore gunceller. Yalnizca verilen alanlar yazilir.")] [Description("Bir listedeki kaydi anahtar degerine gore gunceller. Yalnizca verilen alanlar yazilir.")]
public async Task<McpWriteResultDto> UpdateRowAsync( public async Task<CallToolResult> UpdateRowAsync(
[Description("Kaydin bulundugu listenin kodu.")] [Description("Kaydin bulundugu listenin kodu.")]
string listFormCode, string listFormCode,
[Description("Guncellenecek kaydin anahtar alan degeri; describe_list cikisindaki keyFieldName alanina karsilik gelir.")] [Description("Guncellenecek kaydin anahtar alan degeri; describe_list cikisindaki keyFieldName alanina karsilik gelir.")]
List<object?> keys, List<object?> keys,
[Description("Alan adi -> yeni deger eslemesi.")] [Description("Alan adi -> yeni deger eslemesi.")]
Dictionary<string, object?> values) Dictionary<string, object?> values)
=> await dataAppService.UpdateRowAsync(new McpRowRequestDto => McpToolResult.From(await dataAppService.UpdateRowAsync(new McpRowRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Keys = keys ?? [], Keys = keys ?? [],
Values = values ?? [] Values = values ?? []
}); }));
[McpServerTool(Name = McpToolNames.DeleteRow, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.DeleteRow, Destructive = true, Idempotent = true)]
[Description("Bir listedeki kaydi anahtar degerine gore siler. Ekranin kendi silme komutu kullanilir; soft delete tasiyan tablolarda kayit pasife cekilir.")] [Description("Bir listedeki kaydi anahtar degerine gore siler. Ekranin kendi silme komutu kullanilir; soft delete tasiyan tablolarda kayit pasife cekilir.")]
public async Task<McpWriteResultDto> DeleteRowAsync( public async Task<CallToolResult> DeleteRowAsync(
[Description("Kaydin bulundugu listenin kodu.")] [Description("Kaydin bulundugu listenin kodu.")]
string listFormCode, string listFormCode,
[Description("Silinecek kaydin anahtar alan degeri.")] [Description("Silinecek kaydin anahtar alan degeri.")]
List<object?> keys) List<object?> keys)
=> await dataAppService.DeleteRowAsync(new McpRowRequestDto => McpToolResult.From(await dataAppService.DeleteRowAsync(new McpRowRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Keys = keys ?? [] Keys = keys ?? []
}); }));
} }

View file

@ -13,44 +13,46 @@ namespace Sozsoft.Mcp.Tools;
public static class McpDevelopPrompts public static class McpDevelopPrompts
{ {
[McpServerPrompt(Name = McpPromptNames.AnalyzeDevRequest)] [McpServerPrompt(Name = McpPromptNames.AnalyzeDevRequest)]
[Description("Tek bir gelistirme talebini analiz eder ve Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")] [Description("Tek bir gelistirme talebini analiz eder; uretime hazirsa Ready to Start'a, degilse Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")]
public static string AnalyzeDevRequest( public static string AnalyzeDevRequest(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request) string request)
=> $"App.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " + => $"Sas.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " +
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " + "Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
"Talebi ve gorsellerini get_dev_request ile oku; analizi save_dev_request_analysis ile yaz. " + "Talebi ve gorsellerini get_dev_request ile oku; analizi save_dev_request_analysis ile yaz. " +
"Hicbir sey uretme (tablo, wizard, bilesen yok) ve Description'a dokunma. " + "Hicbir sey uretme (tablo, wizard, bilesen yok) ve Description'a dokunma. " +
"Arac reddederse (talep Backlog ya da Analysis degil, bulunamadi ya da birden fazla eslesme var) nedenini bildir ve dur. " + "Arac reddederse (talep Backlog ya da Analysis degil, bulunamadi ya da birden fazla eslesme var) nedenini bildir ve dur. " +
"Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar ve Uretime hazir satiri; talebi Ready to Start'a kullanicinin alacagini belirt."; "Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar, Uretime hazir satiri ve talebin yeni durumu (Ready to Start ya da Analysis).";
[McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)] [McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)]
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder ve Analysis durumuna alir.")] [Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder; uretime hazir olanlari Ready to Start'a, digerlerini Analysis durumuna alir.")]
public static string AnalyzeBacklogRequests() public static string AnalyzeBacklogRequests()
=> "App.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " + => "Sas.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " +
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " + "Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
"Her talebi get_dev_request ile oku, analizi save_dev_request_analysis ile yaz; reddedilen talebi atla ve rapora yaz. " + "Her talebi get_dev_request ile oku, analizi save_dev_request_analysis ile yaz; reddedilen talebi atla ve rapora yaz. " +
"Hicbir sey uretme ve Description'a dokunma. " + "Hicbir sey uretme ve Description'a dokunma. " +
"Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi ve Uretime hazir durumu; talepleri Ready to Start'a kullanicinin alacagini tek satirda belirt."; "Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi, Uretime hazir durumu ve talebin yeni durumu (Ready to Start ya da Analysis).";
[McpServerPrompt(Name = McpPromptNames.ProcessDevRequest)] [McpServerPrompt(Name = McpPromptNames.ProcessDevRequest)]
[Description("Tek bir gelistirme talebini isler. Talep Ready to Start durumunda olmalidir.")] [Description("Tek bir gelistirme talebini isler. Talep Ready to Start durumunda olmalidir.")]
public static string ProcessDevRequest( public static string ProcessDevRequest(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request) string request)
=> $"App.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " + => $"Sas.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " +
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " + "Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
"Talebi start_dev_request ile ustlen. Arac reddederse (talep Ready to Start degil, bulunamadi ya da " + "Talebi start_dev_request ile ustlen. Arac reddederse (talep Ready to Start degil, bulunamadi ya da " +
"birden fazla eslesme var) hicbir sey uretme, baska bir talebe gecme: nedenini bildir ve dur. " + "birden fazla eslesme var) hicbir sey uretme, baska bir talebe gecme: nedenini bildir ve dur. " +
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme; talebi Testing'e alip bitir. " + "Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
"Testing'e almadan once uretilen ekranin modul dokumani bolumunu get_module_doc ile ekran koduyla oku, todo alt bolumlerini save_module_doc ile ayni kodla doldur (lowcode 0.13); sonra talebi Testing'e alip bitir. " +
"Sonunda rapor ver: uretilen artefaktlar, talebin son durumu ve DB Migrate gerektigi."; "Sonunda rapor ver: uretilen artefaktlar, talebin son durumu ve DB Migrate gerektigi.";
[McpServerPrompt(Name = McpPromptNames.ProcessReadyRequests)] [McpServerPrompt(Name = McpPromptNames.ProcessReadyRequests)]
[Description("Ready to Start durumundaki tum gelistirme taleplerini SortOrder sirasiyla isler.")] [Description("Ready to Start durumundaki tum gelistirme taleplerini SortOrder sirasiyla isler.")]
public static string ProcessReadyRequests() public static string ProcessReadyRequests()
=> "App.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " + => "Sas.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " +
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " + "Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
"Her talebi start_dev_request ile ustlen; reddedilen talebi atla ve rapora yaz. " + "Her talebi start_dev_request ile ustlen; reddedilen talebi atla ve rapora yaz. " +
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme; her talebi Testing'e alip digerine gec. " + "Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
"Her talepte Testing'e almadan once ekranin modul dokumani bolumunu save_module_doc ile doldur (lowcode 0.13), sonra talebi Testing'e alip digerine gec. " +
"Sonunda parti raporu ver: talep basina uretilen artefaktlar ve son durum; DB Migrate gerektigini tek satirda belirt."; "Sonunda parti raporu ver: talep basina uretilen artefaktlar ve son durum; DB Migrate gerektigini tek satirda belirt.";
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -17,138 +18,208 @@ namespace Sozsoft.Mcp.Tools;
/// yolla ayni sekilde olusur. /// yolla ayni sekilde olusur.
/// </summary> /// </summary>
[McpServerToolType] [McpServerToolType]
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService) public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMcpImageShrinker imageShrinker)
{ {
private static readonly JsonSerializerOptions TextOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions TextOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Talep sonucunun (metin + base64 gorseller) ust siniri, karakter. Claude Desktop arac sonucunu
/// 1 MB ile sinirlar; JSON-RPC zarfi ve kacislar icin pay birakilir.
/// </summary>
private const int MaxResultLength = 900_000;
[McpServerTool(Name = McpToolNames.GetDevGuide, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetDevGuide, ReadOnly = true)]
[Description("Ekran ve bilesen uretim sozlesmesini okur: wizard alanlari, enum degerleri, editor/lookup/script sozlesmesi, custom component kurallari. section bos ise baslik listesi doner; once listeyi al, sonra ihtiyac duydugun bolumu numarasiyla iste (ornegin 4.2). Rehberde dosya yazmak anlatilan yerlerde dosya yerine bu sunucunun araclarini kullan.")] [Description("Ekran ve bilesen uretim sozlesmesini okur: wizard alanlari, enum degerleri, editor/lookup/script sozlesmesi, custom component kurallari. section bos ise baslik listesi doner; once listeyi al, sonra ihtiyac duydugun bolumu numarasiyla iste (ornegin 4.2). Rehberde dosya yazmak anlatilan yerlerde dosya yerine bu sunucunun araclarini kullan.")]
public async Task<McpDevGuideDto> GetDevGuideAsync( public async Task<CallToolResult> GetDevGuideAsync(
[Description("'lowcode' (uretim protokolu ve artefakt semalari, varsayilan) ya da 'reference' (JSON kolon sozlesmeleri, tasarimci envanteri).")] [Description("'lowcode' (uretim protokolu ve artefakt semalari, varsayilan) ya da 'reference' (JSON kolon sozlesmeleri, tasarimci envanteri).")]
string? document = null, string? document = null,
[Description("Bolum numarasi (4.2) ya da baslik metninin bir parcasi. Bos birakilirsa baslik listesi doner.")] [Description("Bolum numarasi (4.2), baslik metninin bir parcasi ya da aranacak terim (dxImageUpload). Baslikla eslesmeyen terimde terimin gectigi bolumler listelenir; bos birakilirsa baslik listesi doner.")]
string? section = null) string? section = null)
=> await developAppService.GetDevGuideAsync(document, section); => McpToolResult.From(await developAppService.GetDevGuideAsync(document, section));
[McpServerTool(Name = McpToolNames.CreateTable, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.CreateTable, Destructive = false, Idempotent = false)]
[Description("Ekranin baglanacagi yeni tabloyu SQL Query Manager tablo tasarimcisiyla ayni bicimde olusturur ve script'i seed dosyasina (sql/object) yazar. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. Yalnizca yeni tablo olusturur: tablo varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. SQL Server veri kaynaginda calisir. Ardindan deploy_wizard ile ekrani kur.")] [Description("Ekranin baglanacagi yeni tablonun script'ini SQL Query Manager tablo tasarimcisiyla ayni bicimde uretir ve seed dosyasina (sql/object + postgres/object) yazar; veritabaninda calistirmaz, tabloyu kullanicinin calistirdigi DB Migrate kurar. DB Migrate'i bekleme ve calistirmaya calisma; deploy_wizard kolonlari script'ten okur. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. indexes ile index/unique, foreignKeys ile FK kisitlari ayni scripte yazilir. Yalnizca yeni tablo icindir: tablo ya da script'i varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. View/procedure/function icin save_sql_object kullan. Ardindan deploy_wizard ile ekrani kur.")]
public async Task<McpCreateTableResultDto> CreateTableAsync( public async Task<CallToolResult> CreateTableAsync(
[Description("Tablo adi; harf/rakam/alt cizgi. Adlandirma icin get_dev_guide'daki tablo onek kuralina uy (ornegin Adm_T_...).")] [Description("Tablo adi; harf/rakam/alt cizgi. Adlandirma icin get_dev_guide'daki tablo onek kuralina uy (ornegin Adm_T_...).")]
string tableName, string tableName,
[Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (18,4), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME().")] [Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (her zaman 18,4 yazilir; boyut verme), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME(). Sabit defaultValue ekranda yeni kaydin form degeri olur (deploy tablodan okur); bu yuzden IsActive bit isNullable false defaultValue \"1\", olcu birimi (UomId) defaultValue \"'Adet'\", para birimi (CurrencyId) defaultValue \"'TRY'\" ve talepteki her sabit varsayilan (Sure = 60 → \"60\") kolona yazilir (get_dev_guide 4.3.5.1).")]
List<McpTableColumnDto> columns, List<McpTableColumnDto> columns,
[Description("Tam audit kolonlari eklensin mi. Kullanici acikca istemedikce true birak.")] [Description("Tam audit kolonlari eklensin mi. Kullanici acikca istemedikce true birak.")]
bool fullAudited = true, bool fullAudited = true,
[Description("TenantId kolonu eklensin mi. Kullanici acikca istemedikce true birak.")] [Description("TenantId kolonu eklensin mi. Kullanici acikca istemedikce true birak.")]
bool multiTenant = true) bool multiTenant = true,
=> await developAppService.CreateTableAsync(new McpCreateTableRequestDto [Description("Index ve unique kisitlari: [{\"columns\": [\"Code\"], \"unique\": true}]. Ad sunucuda IX_/UQ_{Tablo}_{Kolonlar} olarak uretilir. Kiraci tablosunda unique kisita TenantId'yi de ekle.")]
List<McpTableIndexDto>? indexes = null,
[Description("FK'ler: [{\"column\": \"UomId\", \"referencedTable\": \"Adm_T_Uom\", \"referencedColumn\": \"Id\", \"onDelete\": \"NoAction\"}]. onDelete: NoAction (varsayilan), Cascade, SetNull. Migrate scriptleri dosya adi sirasiyla calistirdigi icin hedef tablo ya scripti bu tablonunkinden once siralanan bir seed tablosu ya da veritabaninda kurulu bir platform tablosu olmalidir; degilse reddedilir.")]
List<McpTableForeignKeyDto>? foreignKeys = null)
=> McpToolResult.From(await developAppService.CreateTableAsync(new McpCreateTableRequestDto
{ {
TableName = tableName, TableName = tableName,
Columns = columns, Columns = columns,
FullAudited = fullAudited, FullAudited = fullAudited,
MultiTenant = multiTenant MultiTenant = multiTenant,
}); Indexes = indexes ?? [],
ForeignKeys = foreignKeys ?? []
}));
[McpServerTool(Name = McpToolNames.SaveSqlObject, Destructive = true, Idempotent = true)]
[Description("Ekranin veri kaynagi ya da lookup'i olacak view, stored procedure ya da function scriptini SQL Query Manager'in yazdigi yere (sql/object/{Ad}.sql) ve PostgreSQL esine (postgres/object/{Ad}.sql) yazar; ad scriptteki nesne adidir; veritabaninda calistirmaz, nesneyi kullanicinin calistirdigi DB Migrate kurar. Uygulama iki saglayicida calistigi icin iki script de zorunludur ve ayni nesneyi ayni parametre sirasiyla tanimlar (SQL Server: [dbo], PostgreSQL: public, ceviri kurallari get_dev_guide lowcode 8.3.1). Baslik tekrar calismaya dayanikli bicime cevrilir (CREATE OR ALTER / CREATE OR REPLACE); ayni adla tekrar cagrilirsa script guncellenir. Migrate object scriptlerinde once tablolari, sonra fonksiyonlari, en son view/yordamlari kurar (tur icinde dosya adi sirasi): view'in okudugu baska bir view'in adi ondan once siralanmalidir. Birden fazla tablodan beslenen ekranda Query (SelectCommandType 4) yerine bu aracla view yazip ekrani view'e (SelectCommandType 2) bagla. Tablo icin create_table kullan.")]
public async Task<CallToolResult> SaveSqlObjectAsync(
[Description("SQL Server scripti: tek bir CREATE [OR ALTER] VIEW/PROCEDURE/FUNCTION [dbo].[Ad] tanimi; GO ile biten batch'ler kullanilabilir. Dosya adi nesnenin adidir.")]
string sqlServerScript,
[Description("PostgreSQL scripti: ayni nesnenin CREATE OR REPLACE VIEW/FUNCTION/PROCEDURE public.\"Ad\" tanimi.")]
string postgreSqlScript)
=> McpToolResult.From(await developAppService.SaveSqlObjectAsync(sqlServerScript, postgreSqlScript));
[McpServerTool(Name = McpToolNames.GenerateCrudEndpoints, Destructive = true, Idempotent = true)]
[Description("Bir tablo ya da view icin SQL Query Manager'daki CRUD endpoint diyaloguyla ayni REST endpoint'lerini (/api/app/crudendpoint/{entity}: GetList, GetById, Create, Update, Delete) yalnizca crud/{entity}.json seed dosyasina yazar; kayitlari DB Migrate ekler (migrate'i bekleme). Ayni entity icin tekrar cagrilirsa dosya yeniden yazilir. Custom component'in veri okuyup yazacagi tablo icin kullanilir; ListForm ekranlari bu endpoint'lere ihtiyac duymaz. View/procedure/function icin yalnizca GetList ve GetById uretilir. Sas.SqlQueryManager.CrudEndpoints yetkisi ister.")]
public async Task<CallToolResult> GenerateCrudEndpointsAsync(
[Description("Tablo ya da view adi, ornegin 'Stk_T_Product' (sema oneki atilir).")]
string entityName,
[Description("Aktif baslayacak operasyonlar, virgulle: GetList,GetById,Create,Update,Delete. Bos ise hepsi aktif; digerleri pasif kaydedilir.")]
string? operationTypes = null,
[Description("Nesne turu: table (varsayilan), view, procedure, function.")]
string? objectType = null)
=> McpToolResult.From(await developAppService.GenerateCrudEndpointsAsync(entityName, operationTypes, objectType));
[McpServerTool(Name = McpToolNames.ListWizards, ReadOnly = true)] [McpServerTool(Name = McpToolNames.ListWizards, ReadOnly = true)]
[Description("Wizard File Manager'daki wizard dosyalarini listeler: dosya adi, ekran kodu, yol (List/Custom), menu yolu ve bagimli wizard'lar.")] [Description("Wizard File Manager'daki wizard dosyalarini listeler: dosya adi, ekran kodu, yol (List/Custom), menu yolu ve bagimli wizard'lar.")]
public async Task<List<WizardFileInfoDto>> ListWizardsAsync( public async Task<CallToolResult> ListWizardsAsync(
[Description("Dosya adi, wizard adi, ekran kodu ya da bilesen adi icinde aranacak metin. Bos birakilabilir.")] [Description("Dosya adi, wizard adi, ekran kodu ya da bilesen adi icinde aranacak metin. Bos birakilabilir.")]
string? search = null) string? search = null)
=> await developAppService.GetWizardsAsync(search); => McpToolResult.From(await developAppService.GetWizardsAsync(search));
[McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)]
[Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur (Wizard / ListForm / Fields bolumleri). Var olan bir ekrani degistirmeden once oku.")] [Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur: tek Wizard bolumu; Menu/Language/Data/Details/Layout basliklari, ekranin her sekmesi bir blok (Editing, Insert/Update/Delete, FormDefaults, FilterRow, HeaderFilter, Selection, ColumnOption, Pager, StateStoring, ChartOptions …) ve Groups altinda alanlarin butun detay sekmeleri (Filter, Header, Grouping, Summary, Join, ValidationRules, Styling, Permission, PivotSettings). Var olan bir ekrani degistirmeden once oku.")]
public async Task<McpWizardFileDto> GetWizardAsync( public async Task<CallToolResult> GetWizardAsync(
[Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")] [Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")]
string fileName) string fileName)
=> await developAppService.GetWizardAsync(fileName); => McpToolResult.From(await developAppService.GetWizardAsync(fileName));
[McpServerTool(Name = McpToolNames.DeployWizard, Destructive = true, Idempotent = false)] [McpServerTool(Name = McpToolNames.DeployWizard, Destructive = true, Idempotent = false)]
[Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir; ekran wizard cevaplarindan yeniden turetildigi icin tasarimcida yapilmis ve wizard'da karsiligi olmayan ayarlar kaybolur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")] [Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Ust menu zinciri istenen derinlikte kurulur: MenuParentCode ekranin dogrudan ust menusu, Menu.MenuParentPath onun ustundeki seviyeler (kokten asagi, her biri {Code, Icon, En, Tr}); eksik her seviye bir ustunun altina kurulur, var olana baglanilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. SeedData ekrandan girilen kayitlari dosyaya yansitmaz (SeedSyncInsert/Update/Delete kapali); bunu yalnizca istek acikca isterse SeedSync true yaz. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir. Ekranin her ayari Wizard bolumundedir ve adlari get_wizard ciktisindaki gibidir: liste ayarlari bloklarda (Details.PageSize, Editing, Insert/Update/Delete.FieldsDefaultValue, FormDefaults, ExtraFilters, CommandColumns, FilterRow … ChartOptions), alan ayarlari Groups[].Items[] ogesinde (Format, Alignment, Width, Visible, BandName, ListOrderNo, ValidationRules, TotalSummary/GroupSummary, Grouping, Join, Styling, PivotSettings …). Yazmadigin blok deploy'da varsayilaniyla dolar; ListForm/Fields bolumleri kabul edilmez. Yeni kaydin form degeri (Aktif = true, Birim = Adet, Kur = TRY, Sure = 60) ayri yazilmaz: deploy kolonun tablodaki sabit DEFAULT degerini okur; tablodan farkli deger gerekiyorsa alan ogesinde DefaultValue yaz (Groups[].Items[].DefaultValue). StaticData lookup seceneklerinde (RadioGroup, SelectBox, TagBox) name dil anahtaridir (Sas.StaticLookup.{Key}); EN + TR metni bilinen secenegi Language.Texts'te ver, duz metin yazilirsa deploy onu anahtara cevirip TR'yi metinden yazar (get_dev_guide 4.3.4.1). EditorScript'i elle yazma: Script Builder kurallarini ({v:1, rules:[...]}, get_dev_guide 5.3) ver, deploy kodu Script Builder dialogu ile birebir ayni uretir; govde yazsan da atilir. Onay kutusu alanina setValue text 'true'/'false' (0/1 cevrilir). set ile yazilan alanin kurallari da sirayla calisir (derinlik siniri yok); A->B->A dongusu kuran kural yazma, calisma aninda hata olarak gosterilir. Wizard girdisinde taninmayan anahtar adiyla reddedilir, baska adla tekrar deneme. Guncellemede get_wizard ciktisini butunuyle alip degistirerek gonder: dosyadaki her blok cevaptir, yazilmayan blok varsayilana doner. Kimlik ve yetki alanlari (WizardCode, Title/Desc anahtarlari, Permission adlari, popup basligi) ekran kodundan yeniden turetilir. Liste ekraninda DescTr ve DescEn ayrintili olmali (en az 3 satir, 150 karakter): ekran basligindaki bilgi simgesinde gosterilir; ne ise yaradigi, menude nerede ya da hangi ekranin sekmesi oldugu, ana alanlar, otomatik hesaplar ve iliskili ekranlar yazilir (get_dev_guide 4.4.2); kisa aciklama reddedilir. Deploy modulun dokumanini (docs/modules) acar ya da envanterini yeniler; elle yazilan bolumleri save_module_doc ile doldur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")]
public async Task<WizardFileInfoDto?> DeployWizardAsync( public async Task<CallToolResult> DeployWizardAsync(
[Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi) ya da duz wizard cevaplari. Enum alanlari sayi olarak yazilir.")] [Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi; Menu/Language/Data/Details/Layout basliklari toplanmis) ya da duz wizard cevaplari. Bloklar istege baglidir; yazilmayan blok varsayilaniyla dolar. Enum alanlari sayi olarak yazilir.")]
string wizard, string wizard,
[Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak.")] [Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak. Guncelleme dosyayi tamamen degistirir: wizard girdisi get_wizard ile okunan dosyanin degistirilmis tam halidir; Wizard bolumu (WizardCode, Menu, Language, Groups) eksiksiz gonderilir.")]
string? replaceFileName = null, string? replaceFileName = null,
[Description("Demo satirlar: JSON nesne dizisi, anahtarlar ekranin alan adlari ([{\"Name\": \"...\"}]). Guid anahtar, TenantId ve audit alanlarini yazma; sunucu ve migrate doldurur. Yalnizca SeedData true olan liste ekraninda.")] [Description("Demo satirlar: JSON nesne dizisi, anahtarlar ekranin alan adlari ([{\"Name\": \"...\"}]). Guid anahtar, TenantId ve audit alanlarini yazma; sunucu ve migrate doldurur. Yalnizca SeedData true olan liste ekraninda.")]
string? seedRows = null) string? seedRows = null)
=> await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows }); => McpToolResult.From(await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows }));
[McpServerTool(Name = McpToolNames.DeleteWizard, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.DeleteWizard, Destructive = true, Idempotent = true)]
[Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve App.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")] [Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve Sas.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")]
public async Task<WizardDeleteResultDto> DeleteWizardAsync( public async Task<CallToolResult> DeleteWizardAsync(
[Description("Silinecek wizard dosyasinin adi (list_wizards cikisindaki fileName).")] [Description("Silinecek wizard dosyasinin adi (list_wizards cikisindaki fileName).")]
string fileName, string fileName,
[Description("Ekranin tablosu da dusurulsun mu. Yalnizca kullanici acikca onayladiysa true.")] [Description("Ekranin tablosu da dusurulsun mu. Yalnizca kullanici acikca onayladiysa true.")]
bool dropTables = false) bool dropTables = false)
=> await developAppService.DeleteWizardAsync(fileName, dropTables); => McpToolResult.From(await developAppService.DeleteWizardAsync(fileName, dropTables));
[McpServerTool(Name = McpToolNames.VerifyWizard, ReadOnly = true)] [McpServerTool(Name = McpToolNames.VerifyWizard, ReadOnly = true)]
[Description("Deploy edilip DB Migrate ile kurulmus bir wizard'i veri yazmadan dogrular (migrate bekleyen wizard'da install kontrolu basarisiz doner ve diger kontroller yapilmaz): menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. Istege baglidir: kullanici migrate sonrasi dogrulama isterse cagir; gelistirme talebi dongusunun adimi degildir.")] [Description("Deploy edilip DB Migrate ile kurulmus bir wizard'i veri yazmadan dogrular (migrate bekleyen wizard'da install kontrolu basarisiz doner ve diger kontroller yapilmaz): menu kaydi (var mi, etkin mi, URL'si wizard'la ayni mi), menunun istedigi yetki tanimi, liste ekraninda ekranin sorgusu (cagiranin yetkisiyle tek satir), Custom yolunda bilesen (var mi, etkin mi, rotasi menuyle ayni mi) ve seed kapanisi (migrate ya da baska ortamda eksik kalacak bagimlilik). passed yalnizca blocking kontrollerden biri kalirsa false olur; seed_closure engellemez ama raporlanmalidir. Istege baglidir: kullanici migrate sonrasi dogrulama isterse cagir; gelistirme talebi dongusunun adimi degildir.")]
public async Task<McpVerifyResultDto> VerifyWizardAsync( public async Task<CallToolResult> VerifyWizardAsync(
[Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")] [Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")]
string fileName) string fileName)
=> await developAppService.VerifyWizardAsync(fileName); => McpToolResult.From(await developAppService.VerifyWizardAsync(fileName));
[McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)]
[Description("App.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")] [Description("Sas.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")]
public async Task<IList<ContentBlock>> StartDevRequestAsync( public async Task<CallToolResult> StartDevRequestAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi. Ayni basligi tasiyan birden fazla talep varsa Id ver.")] [Description("Talebin Id'si (GUID) ya da tam basligi. Ayni basligi tasiyan birden fazla talep varsa Id ver.")]
string request) string request)
=> ToContent(await developAppService.StartDevRequestAsync(request)); => McpToolResult.From(await developAppService.StartDevRequestAsync(request), ToContent);
[McpServerTool(Name = McpToolNames.GetDevRequest, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetDevRequest, ReadOnly = true)]
[Description("Bir gelistirme talebini durumuna dokunmadan okur; start_dev_request ile ayni cevabi (aciklama + ayri resim icerikleri) dondurur. Analiz oncesi talebi okumak, yarim kalmis (In Progress/Testing) bir talebe donmek ya da gorselleri yeniden gormek icin kullan; talebi ustlenmek icin degil.")] [Description("Bir gelistirme talebini durumuna dokunmadan okur; start_dev_request ile ayni cevabi (aciklama + ayri resim icerikleri) dondurur. Analiz oncesi talebi okumak, yarim kalmis (In Progress/Testing) bir talebe donmek ya da gorselleri yeniden gormek icin kullan; talebi ustlenmek icin degil.")]
public async Task<IList<ContentBlock>> GetDevRequestAsync( public async Task<CallToolResult> GetDevRequestAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request) string request)
=> ToContent(await developAppService.GetDevRequestAsync(request)); => McpToolResult.From(await developAppService.GetDevRequestAsync(request), ToContent);
[McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)]
[Description("Backlog ya da Analysis durumundaki bir talebin analizini (HTML) Analysis alanina yazar ve talebi Analysis durumuna alir; var olan analiz degistirilir, Description'a dokunulmaz. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir. Analizi begenen kullanici talebi Ready to Start'a kendisi alir; MCP almaz.")] [Description("Backlog, Analysis ya da Ready to Start durumundaki bir talebin analizini (HTML) Analysis alanina yazar; var olan analiz degistirilir, Description'a dokunulmaz. productionReady true ise talep Ready to Start'a, false ise Analysis durumuna alinir. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir.")]
public async Task<IList<ContentBlock>> SaveDevRequestAnalysisAsync( public async Task<CallToolResult> SaveDevRequestAnalysisAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request, string request,
[Description("Analizin tamami, HTML.")] [Description("Analizin tamami, HTML.")]
string html) string html,
=> ToContent(await developAppService.SaveDevRequestAnalysisAsync(request, html)); [Description("Analizin 'Uretime hazir' satiri kosulsuz Evet ise true; sartli (onay bekleyen, 'Evet, ancak …') ya da Hayir ise false. HTML'deki satirla ayni olmak zorundadir.")]
bool productionReady)
=> McpToolResult.From(await developAppService.SaveDevRequestAnalysisAsync(request, html, productionReady), ToContent);
[McpServerTool(Name = McpToolNames.AppendDevRequestNote, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.AppendDevRequestNote, Destructive = false, Idempotent = false)]
[Description("Talebin Analysis alaninin sonuna HTML ekler; var olan analize ve Description'a dokunmaz. Description talebin yazildigi haliyle kalir; uretim sonuc notunu (<h4>MCP</h4> + uretilenler, varsayimlar ve DB Migrate bekleyen wizard'lar) bu aracla ekle. Description'i ya da Analysis'i update_row ile yazma.")] [Description("Talebin Analysis alaninin sonuna HTML ekler; var olan analize ve Description'a dokunmaz. Description talebin yazildigi haliyle kalir; uretim sonuc notunu (<h4>MCP</h4> + uretilenler, varsayimlar ve DB Migrate bekleyen wizard'lar) bu aracla ekle. Description'i ya da Analysis'i update_row ile yazma.")]
public async Task<string> AppendDevRequestNoteAsync( public async Task<CallToolResult> AppendDevRequestNoteAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request, string request,
[Description("Eklenecek HTML parcasi.")] [Description("Eklenecek HTML parcasi.")]
string html) string html)
{ => McpToolResult.From(await developAppService.AppendDevRequestNoteAsync(request, html));
await developAppService.AppendDevRequestNoteAsync(request, html);
return "ok";
}
[McpServerTool(Name = McpToolNames.AddDevRequestSubTasks, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.AddDevRequestSubTasks, Destructive = false, Idempotent = false)]
[Description("Talebin Sub Tasks listesine uretilen her artefakt icin tamamlanmis bir satir ekler; atanan kisi baglanan kullanici, bitis tarihi bugun olarak sunucuda yazilir, var olan satirlar korunur. SubTasks alanini update_row ile yazma.")] [Description("Talebin Sub Tasks listesine uretilen her artefakt icin tamamlanmis bir satir ekler; atanan kisi baglanan kullanici, bitis tarihi bugun olarak sunucuda yazilir, var olan satirlar korunur. SubTasks alanini update_row ile yazma.")]
public async Task<string> AddDevRequestSubTasksAsync( public async Task<CallToolResult> AddDevRequestSubTasksAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request, string request,
[Description("Eklenecek satir basliklari, ornegin [\"create_table: Hr_T_Certificate\", \"deploy_wizard: Certificates.json\"].")] [Description("Eklenecek satir basliklari, ornegin [\"create_table: Hr_T_Certificate\", \"deploy_wizard: Certificates.json\"].")]
List<string> titles) List<string> titles)
{ => McpToolResult.From(await developAppService.AddDevRequestSubTasksAsync(request, titles));
await developAppService.AddDevRequestSubTasksAsync(request, titles);
return "ok";
} [McpServerTool(Name = McpToolNames.GetModuleDoc, ReadOnly = true)]
[Description("Modul dokumanini (docs/modules/{Menu}.md, seed klasoru; kok menu basina tek dosya, adi kok menu kodu: Hr, Prc, Adm) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. Menunun altindaki her dal dosyada bir bolumdur; ekranin ya da dalin kodu (Hr.PerformanceReview, Hr.Performance) ya da wizard dosya adi verilirse yalnizca o bolum ve envanteri doner, dosya adi verilirse dosyanin tamami. isComplete false ise missingSections doldurulmamis alt bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu ekran koduyla oku; deploy_wizard dosyayi ve dalin bolumunu yoksa kendisi acar. Menude henuz olmayan yeni modulun kodu (Stk, Stk.Stock) hata vermez: exists false ve doldurulacak iskelet doner; save_module_doc ayni kodla yazinca dosya acilir.")]
public async Task<CallToolResult> GetModuleDocAsync(
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
string name)
=> McpToolResult.From(await developAppService.GetModuleDocAsync(name));
[McpServerTool(Name = McpToolNames.SaveModuleDoc, Destructive = true, Idempotent = true)]
[Description("Modul dokumaninin elle yazilan bolumunu kaydeder (Markdown). Uretimin parcasidir: deploy_wizard'dan sonra get_module_doc ile ekranin bolumunu oku, module-doc:todo isaretli her alt bolumu (Amac ve kapsam, Analiz ve kararlar — onaylanmis analiz ve kararlar, Menu ve ekranlar, Veri modeli, Is kurallari ve hesaplar, Yetki ve gorunurluk, Entegrasyonlar, Akis ornegi, Sik sorulanlar, Dosyalar ve geri alma) doldurup isaretleri kaldirarak ayni ekran ya da dal koduyla gonder: yalnizca o bolum yazilir, diger dallara dokunulmaz. Bos kalacak alt bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Bolumun '<!-- module-doc:section ... claims=... -->' beyan satirini gondermezsen mevcut beyan korunur. Kok menu koduyla (Hr) cagrilirsa dosyanin tamami yazilir. Talep, bolumu tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")]
public async Task<CallToolResult> SaveModuleDocAsync(
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
string name,
[Description("Bolumun (ya da dosya adi verildiyse dosyanin) tamami, Markdown.")]
string content)
=> McpToolResult.From(await developAppService.SaveModuleDocAsync(name, content));
/// <summary> /// <summary>
/// Talep metni JSON olarak, gorseller ayri resim icerigi olarak doner; model resmi ancak bu /// Talep metni JSON olarak, gorseller ayri resim icerigi olarak doner; model resmi ancak bu
/// bicimde gorur, base64 metni olarak goremez. /// bicimde gorur, base64 metni olarak goremez. Sonucun tamami istemcinin sinirina sigar: kalan
/// pay gorsellere esit bolunur, payini asan gorsel kucultulur, kucultulse de sigmayan gonderilmez
/// ve hangisinin eksik oldugu metinde bildirilir.
/// </summary> /// </summary>
private static List<ContentBlock> ToContent(McpDevRequestDto devRequest) private List<ContentBlock> ToContent(McpDevRequestDto devRequest)
{ {
List<ContentBlock> content = [new TextContentBlock { Text = JsonSerializer.Serialize(devRequest, TextOptions) }]; var text = JsonSerializer.Serialize(devRequest, TextOptions);
List<ContentBlock> content = [new TextContentBlock { Text = text }];
foreach (var image in devRequest.Images) var remaining = MaxResultLength - text.Length;
var omitted = new List<string>();
for (var i = 0; i < devRequest.Images.Count; i++)
{ {
var image = devRequest.Images[i];
var share = Math.Max(0, remaining) / (devRequest.Images.Count - i);
var shrunk = imageShrinker.Shrink(image.MimeType, image.Data, share);
if (shrunk is null)
{
omitted.Add(image.Placeholder);
continue;
}
remaining -= shrunk.Data.Length;
// Veri zaten base64; cozup yeniden kodlamak buyuk gorsellerde bosuna bellek harcar. // Veri zaten base64; cozup yeniden kodlamak buyuk gorsellerde bosuna bellek harcar.
content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(image.Data), MimeType = image.MimeType }); content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(shrunk.Data), MimeType = shrunk.MimeType });
}
if (omitted.Count > 0)
{
content.Add(new TextContentBlock
{
Text = $"Boyut siniri nedeniyle gonderilmeyen gorseller: {string.Join(", ", omitted)}. Gerekirse kullanicidan gorselin ne gosterdigini sor."
});
} }
return content; return content;
@ -156,21 +227,21 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
[McpServerTool(Name = McpToolNames.ListComponents, ReadOnly = true)] [McpServerTool(Name = McpToolNames.ListComponents, ReadOnly = true)]
[Description("Custom component'leri ad, rota ve aciklamasiyla listeler; kod dondurmez. isVisualDesigner true olanlarin kodu tasarimci belgesinden uretilir ve MCP ile degistirilemez.")] [Description("Custom component'leri ad, rota ve aciklamasiyla listeler; kod dondurmez. isVisualDesigner true olanlarin kodu tasarimci belgesinden uretilir ve MCP ile degistirilemez.")]
public async Task<List<McpComponentSummaryDto>> ListComponentsAsync( public async Task<CallToolResult> ListComponentsAsync(
[Description("Ad, rota ya da aciklama icinde aranacak metin. Bos birakilabilir.")] [Description("Ad, rota ya da aciklama icinde aranacak metin. Bos birakilabilir.")]
string? search = null) string? search = null)
=> await developAppService.GetComponentsAsync(search); => McpToolResult.From(await developAppService.GetComponentsAsync(search));
[McpServerTool(Name = McpToolNames.GetComponent, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetComponent, ReadOnly = true)]
[Description("Bir custom component'in kodunu, props'unu ve bagimliliklarini dondurur. Degistirmeden once oku.")] [Description("Bir custom component'in kodunu, props'unu ve bagimliliklarini dondurur. Degistirmeden once oku.")]
public async Task<CustomComponentDto?> GetComponentAsync( public async Task<CallToolResult> GetComponentAsync(
[Description("Bilesenin adi.")] [Description("Bilesenin adi.")]
string name) string name)
=> await developAppService.GetComponentAsync(name); => McpToolResult.From(await developAppService.GetComponentAsync(name));
[McpServerTool(Name = McpToolNames.SaveComponent, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.SaveComponent, Destructive = true, Idempotent = true)]
[Description("Custom component'i ada gore kaydeder: yoksa ekler, varsa gunceller. Guncellemede verilmeyen alanlar korunur. Kod kurallari ve kullanilabilir kutuphaneler icin get_dev_guide 7. bolumu. Bilesen menude gorunecekse ardindan ComponentKind 1 ile deploy_wizard cagir.")] [Description("Custom component'i ada gore kaydeder: yoksa ekler, varsa gunceller. Guncellemede verilmeyen alanlar korunur. Kod kurallari ve kullanilabilir kutuphaneler icin get_dev_guide 7. bolumu. Bilesen menude gorunecekse ardindan ComponentKind 1 ile deploy_wizard cagir.")]
public async Task<CustomComponentDto> SaveComponentAsync( public async Task<CallToolResult> SaveComponentAsync(
[Description("Bilesenin adi; kimligidir.")] [Description("Bilesenin adi; kimligidir.")]
string name, string name,
[Description("Bilesenin React kodu. Yeni bilesende zorunlu, guncellemede bos birakilirsa mevcut kod korunur.")] [Description("Bilesenin React kodu. Yeni bilesende zorunlu, guncellemede bos birakilirsa mevcut kod korunur.")]
@ -185,7 +256,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
string? dependencies = null, string? dependencies = null,
[Description("Bilesen etkin mi. Yeni bilesende varsayilan true.")] [Description("Bilesen etkin mi. Yeni bilesende varsayilan true.")]
bool? isActive = null) bool? isActive = null)
=> await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto => McpToolResult.From(await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
{ {
Name = name, Name = name,
Code = code, Code = code,
@ -194,12 +265,12 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
Props = props, Props = props,
Dependencies = dependencies, Dependencies = dependencies,
IsActive = isActive IsActive = isActive
}); }));
[McpServerTool(Name = McpToolNames.DeleteComponent, Destructive = true, Idempotent = false)] [McpServerTool(Name = McpToolNames.DeleteComponent, Destructive = true, Idempotent = false)]
[Description("Custom component'i siler. Bilesene bagli bir Custom wizard varsa once delete_wizard ile o silinir.")] [Description("Custom component'i siler. Bilesene bagli bir Custom wizard varsa once delete_wizard ile o silinir.")]
public async Task DeleteComponentAsync( public async Task<CallToolResult> DeleteComponentAsync(
[Description("Bilesenin adi.")] [Description("Bilesenin adi.")]
string name) string name)
=> await developAppService.DeleteComponentAsync(name); => McpToolResult.From(await developAppService.DeleteComponentAsync(name));
} }

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
@ -12,7 +13,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
{ {
[McpServerTool(Name = McpToolNames.QueryList, ReadOnly = true)] [McpServerTool(Name = McpToolNames.QueryList, ReadOnly = true)]
[Description("Bir listenin verisini uygulamadaki ekranla ayni sorguyla okur; kullanicinin liste ve alan yetkileri, kiraci ve sube filtreleri gecerlidir. Veri okumak icin once bunu kullan. Alan adlari describe_list cikisindaki field degerleridir. Filtre/siralama/gruplama DevExtreme bicimindedir.")] [Description("Bir listenin verisini uygulamadaki ekranla ayni sorguyla okur; kullanicinin liste ve alan yetkileri, kiraci ve sube filtreleri gecerlidir. Veri okumak icin once bunu kullan. Alan adlari describe_list cikisindaki field degerleridir. Filtre/siralama/gruplama DevExtreme bicimindedir.")]
public async Task<McpListQueryResultDto> QueryListAsync( public async Task<CallToolResult> QueryListAsync(
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")] [Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
string listFormCode, string listFormCode,
[Description("Filtre, JSON metni. Ornek: [\"Status\",\"=\",\"Open\"] ya da [[\"Amount\",\">\",100],\"and\",[\"Name\",\"contains\",\"abc\"]]. Operatorler: =, <>, >, >=, <, <=, contains, notcontains, startswith, endswith.")] [Description("Filtre, JSON metni. Ornek: [\"Status\",\"=\",\"Open\"] ya da [[\"Amount\",\">\",100],\"and\",[\"Name\",\"contains\",\"abc\"]]. Operatorler: =, <>, >, >=, <, <=, contains, notcontains, startswith, endswith.")]
@ -29,7 +30,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
int skip = 0, int skip = 0,
[Description("Istenen kayit sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] [Description("Istenen kayit sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
int take = 0) int take = 0)
=> await queryAppService.QueryListAsync(new McpListQueryRequestDto => McpToolResult.From(await queryAppService.QueryListAsync(new McpListQueryRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Filter = filter, Filter = filter,
@ -39,34 +40,34 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
TotalSummary = totalSummary, TotalSummary = totalSummary,
Skip = skip, Skip = skip,
Take = take Take = take
}); }));
[McpServerTool(Name = McpToolNames.RunSelect, ReadOnly = true)] [McpServerTool(Name = McpToolNames.RunSelect, ReadOnly = true)]
[Description("Yalnizca uygulamada SQL Query Manager yetkisi olan kullanicilar icin: tek bir parametreli SELECT ifadesi kosturur. Liste verisi icin query_list tercih edilir. Coklu ifade, DDL ve DML reddedilir; satir sayisi sunucu kaydindaki tavana kirpilir. Sorguya deger gomme, @ad parametresi kullan.")] [Description("Yalnizca uygulamada SQL Query Manager yetkisi olan kullanicilar icin: tek bir parametreli SELECT ifadesi kosturur. Liste verisi icin query_list tercih edilir. Coklu ifade, DDL ve DML reddedilir; satir sayisi sunucu kaydindaki tavana kirpilir. Sorguya deger gomme, @ad parametresi kullan. Kolonun var oldugunu varsayma: IsDeleted, TenantId, IsActive her tabloda yoktur; emin degilsen once INFORMATION_SCHEMA.COLUMNS (PostgreSQL: information_schema.columns) uzerinden tablonun kolonlarini oku, filtreyi yalnizca var olan kolona yaz.")]
public async Task<McpQueryResultDto> RunSelectAsync( public async Task<CallToolResult> RunSelectAsync(
[Description("Tek bir SELECT (ya da WITH ... SELECT) ifadesi. Degerler @ad bicimindeki parametrelerle verilir.")] [Description("Tek bir SELECT (ya da WITH ... SELECT) ifadesi. Degerler @ad bicimindeki parametrelerle verilir.")]
string sql, string sql,
[Description("Sorgudaki adli parametreler: anahtar parametre adi, deger ise ham JSON degeri.")] [Description("Sorgudaki adli parametreler: anahtar parametre adi, deger ise ham JSON degeri.")]
Dictionary<string, object?>? parameters = null, Dictionary<string, object?>? parameters = null,
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] [Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
int maxRows = 0) int maxRows = 0)
=> await queryAppService.RunSelectAsync(new McpSelectRequestDto => McpToolResult.From(await queryAppService.RunSelectAsync(new McpSelectRequestDto
{ {
Sql = sql, Sql = sql,
Parameters = parameters ?? [], Parameters = parameters ?? [],
MaxRows = maxRows MaxRows = maxRows
}); }));
[McpServerTool(Name = McpToolNames.ListQueries, ReadOnly = true)] [McpServerTool(Name = McpToolNames.ListQueries, ReadOnly = true)]
[Description("Veri kaynagindaki kayitli nesneleri (view, stored procedure, function) ve cagrilabilir Custom Endpoint'leri dondurur. run_query bunlardan birini cagirir.")] [Description("Veri kaynagindaki kayitli nesneleri (view, stored procedure, function) ve cagrilabilir Custom Endpoint'leri dondurur. run_query bunlardan birini cagirir.")]
public async Task<List<McpSavedQueryDto>> ListQueriesAsync( public async Task<CallToolResult> ListQueriesAsync(
[Description("Nesne adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")] [Description("Nesne adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
string? search = null) string? search = null)
=> await queryAppService.GetSavedQueriesAsync(search); => McpToolResult.From(await queryAppService.GetSavedQueriesAsync(search));
[McpServerTool(Name = McpToolNames.RunQuery, ReadOnly = true)] [McpServerTool(Name = McpToolNames.RunQuery, ReadOnly = true)]
[Description("Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir. Nesne turu ve adi list_queries cikisindan alinir.")] [Description("Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir. Nesne turu ve adi list_queries cikisindan alinir.")]
public async Task<McpQueryResultDto> RunQueryAsync( public async Task<CallToolResult> RunQueryAsync(
[Description("Nesne turu: view, procedure, tableFunction, scalarFunction ya da customEndpoint.")] [Description("Nesne turu: view, procedure, tableFunction, scalarFunction ya da customEndpoint.")]
string kind, string kind,
[Description("Nesnenin ya da Custom Endpoint'in adi.")] [Description("Nesnenin ya da Custom Endpoint'in adi.")]
@ -77,12 +78,12 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
Dictionary<string, object?>? parameters = null, Dictionary<string, object?>? parameters = null,
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")] [Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
int maxRows = 0) int maxRows = 0)
=> await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto => McpToolResult.From(await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto
{ {
Kind = kind, Kind = kind,
Name = name, Name = name,
SchemaName = schemaName, SchemaName = schemaName,
Parameters = parameters ?? [], Parameters = parameters ?? [],
MaxRows = maxRows MaxRows = maxRows
}); }));
} }

View file

@ -0,0 +1,81 @@
using System.ComponentModel;
using System.Threading.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Sozsoft.Mcp.Dtos;
namespace Sozsoft.Mcp.Tools;
/// <summary>
/// Rapor kategorisi ve DynamicReport sablonu araclari. Sablon HTML'dir ve
/// <c>/admin/reports/DynamicReport/view/{Id}</c> ile basilir; bir ekran kaydiyla acildiginda
/// <c>{{AlanAdi}}</c> yer tutuculari o kaydin alanlariyla doldurulur.
/// </summary>
[McpServerToolType]
public sealed class McpReportTools(IMcpReportAppService reportAppService)
{
[McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)]
[Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")]
public async Task<CallToolResult> ListReportTemplatesAsync(
[Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
string? search = null)
=> McpToolResult.From(await reportAppService.GetReportTemplatesAsync(search));
[McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)]
[Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")]
public async Task<CallToolResult> GetReportTemplateAsync(
[Description("Sablonun Id'si ya da adi.")]
string template)
=> McpToolResult.From(await reportAppService.GetReportTemplateAsync(template));
[McpServerTool(Name = McpToolNames.SaveReportCategory, Destructive = true, Idempotent = true)]
[Description("Rapor kategorisini ada gore kaydeder: yoksa ekler, varsa gunceller. Kayit bir ekran kaydindan basilan HTML sablonu ise var olan 'Raporlar' kategorisini kullan; yeni kategori yalnizca istenince acilir.")]
public async Task<CallToolResult> SaveReportCategoryAsync(
[Description("Kategorinin adi; kimligidir.")]
string name,
[Description("Aciklama.")]
string? description = null,
[Description("Ikon adi.")]
string? icon = null)
=> McpToolResult.From(await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
{
Name = name,
Description = description,
Icon = icon
}));
[McpServerTool(Name = McpToolNames.SaveReportTemplate, Destructive = true, Idempotent = true)]
[Description("DynamicReport sablonunu ada gore kaydeder: yoksa ekler, varsa gunceller; guncellemede verilmeyen alanlar korunur. Sablon A4'e basilan HTML'dir (Standard Taahhutname gibi). listFormCode verilirse HTML icindeki {{AlanAdi}} yer tutuculari o ekranin alanlarina gore dogrulanir ve cevapta rowButton doner: bu butonu ekranin CommandColumnJson listesine ekle (deploy_wizard), VisibleExpression ile kosullandir. Sozlesme icin get_dev_guide 9.12.")]
public async Task<CallToolResult> SaveReportTemplateAsync(
[Description("Sablonun adi; kimligidir.")]
string name,
[Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")]
string? category = null,
[Description("Basilacak HTML. Kayit alanlari {{AlanAdi}} ile, rapor tarihi @@date ile yazilir. Yeni sablonda zorunlu, guncellemede bos birakilirsa mevcut icerik korunur.")]
string? htmlContent = null,
[Description("Aciklama.")]
string? description = null,
[Description("'active' ya da 'passive'. Yeni sablonda varsayilan 'active'.")]
string? status = null,
[Description("Sablonun basilacagi ekranin kodu (ornegin katilimci listesi). Verilirse yer tutucular dogrulanir ve satir butonu doner.")]
string? listFormCode = null,
[Description("Satir butonunun dil anahtari. Verilmezse Sas.Platform.View.")]
string? buttonText = null)
=> McpToolResult.From(await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
{
Name = name,
Category = category,
HtmlContent = htmlContent,
Description = description,
Status = status,
ListFormCode = listFormCode,
ButtonText = buttonText
}));
[McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)]
[Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")]
public async Task<CallToolResult> DeleteReportTemplateAsync(
[Description("Sablonun Id'si ya da adi.")]
string template)
=> McpToolResult.From(await reportAppService.DeleteReportTemplateAsync(template));
}

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using Sozsoft.Mcp.Dtos; using Sozsoft.Mcp.Dtos;
using Volo.Abp.MultiTenancy; using Volo.Abp.MultiTenancy;
@ -31,29 +32,29 @@ public sealed class McpSchemaTools(IMcpSchemaAppService schemaAppService, ICurre
} }
[McpServerTool(Name = McpToolNames.ListLists, ReadOnly = true)] [McpServerTool(Name = McpToolNames.ListLists, ReadOnly = true)]
[Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner.")] [Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner. Gelistiriciye (host, Wizard yetkisi) DB Migrate bekleyen wizard ekranlari (sourceKind pending) ve yalnizca seed scripti olan tablolar (pendingTable) da doner; lookup, widget, view ve Query SQL'ini yazarken kolon adlarini describe_list ile buradan al, tahmin etme.")]
public async Task<List<McpListSummaryDto>> ListListsAsync( public async Task<CallToolResult> ListListsAsync(
[Description("Liste adi, basligi, aciklamasi ya da tablo adi icinde aranacak metin. Bos birakilabilir.")] [Description("Liste adi, basligi, aciklamasi ya da tablo adi icinde aranacak metin. Bos birakilabilir.")]
string? search = null, string? search = null,
[Description("Donecek en fazla liste sayisi. Varsayilan 200.")] [Description("Donecek en fazla liste sayisi. Varsayilan 200.")]
int maxResults = 200) int maxResults = 200)
=> await schemaAppService.GetListsAsync(search, maxResults); => McpToolResult.From(await schemaAppService.GetListsAsync(search, maxResults));
[McpServerTool(Name = McpToolNames.DescribeList, ReadOnly = true)] [McpServerTool(Name = McpToolNames.DescribeList, ReadOnly = true)]
[Description("Tek bir listenin sutunlarini dondurur: alan adi, kullaniciya gorunen basligi, SQL tipi, lookup iliskisi ve sutunun hassas olup olmadigi.")] [Description("Tek bir listenin sutunlarini dondurur: alan adi, kullaniciya gorunen basligi, SQL tipi, lookup iliskisi ve sutunun hassas olup olmadigi.")]
public async Task<McpListDetailDto?> DescribeListAsync( public async Task<CallToolResult> DescribeListAsync(
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")] [Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
string listFormCode) string listFormCode)
=> await schemaAppService.GetListAsync(listFormCode); => McpToolResult.From(await schemaAppService.GetListAsync(listFormCode));
[McpServerTool(Name = McpToolNames.SearchSchema, ReadOnly = true)] [McpServerTool(Name = McpToolNames.SearchSchema, ReadOnly = true)]
[Description("Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama yapar; hangi listenin hangi sutununun arandigini bulmak icin kullanilir.")] [Description("Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama yapar; hangi listenin hangi sutununun arandigini bulmak icin kullanilir.")]
public async Task<List<McpSchemaMatchDto>> SearchSchemaAsync( public async Task<CallToolResult> SearchSchemaAsync(
[Description("Aranacak metin. Turkce karakterler ve aksanlar onemsizdir.")] [Description("Aranacak metin. Turkce karakterler ve aksanlar onemsizdir.")]
string term, string term,
[Description("Donecek en fazla eslesme sayisi. Varsayilan 50.")] [Description("Donecek en fazla eslesme sayisi. Varsayilan 50.")]
int maxResults = 50) int maxResults = 50)
=> await schemaAppService.SearchAsync(term, maxResults); => McpToolResult.From(await schemaAppService.SearchAsync(term, maxResults));
} }
/// <param name="Scope">Istegin baglami: <c>host</c> ya da <c>tenant</c>. Gelistirme araclari yalnizca host'ta aciktir.</param> /// <param name="Scope">Istegin baglami: <c>host</c> ya da <c>tenant</c>. Gelistirme araclari yalnizca host'ta aciktir.</param>

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
namespace Sozsoft.Mcp.Tools;
/// <summary>
/// Servis sonucunu arac sonucuna cevirir. Deger SDK'nin donus degerini yazdigi bicimde
/// (<see cref="McpJsonUtilities.DefaultOptions"/>) JSON metnidir. <see cref="McpProblem"/> hata
/// sonucudur (<c>IsError</c>): istisna atilmadan modele ulasir, model nedeni okuyup girdiyi duzeltir.
/// </summary>
internal static class McpToolResult
{
/// <summary>Degeri olmayan araclarin (not ekleme, silme) basari cevabi.</summary>
private const string Done = "ok";
public static CallToolResult From<T>(McpOutcome<T> outcome)
=> outcome.TryGetValue(out var value, out var problem)
? Text(JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions))
: Error(problem.Message);
public static CallToolResult From(McpProblem? problem)
=> problem is null ? Text(Done) : Error(problem.Message);
/// <summary>Degeri metin disinda icerik (resim) tasiyan araclar icin.</summary>
public static CallToolResult From<T>(McpOutcome<T> outcome, Func<T, IList<ContentBlock>> toContent)
=> outcome.TryGetValue(out var value, out var problem)
? new CallToolResult { Content = toContent(value) }
: Error(problem.Message);
public static CallToolResult Error(string message)
=> new() { IsError = true, Content = [new TextContentBlock { Text = message }] };
private static CallToolResult Text(string text)
=> new() { Content = [new TextContentBlock { Text = text }] };
}

View file

@ -7,6 +7,9 @@ public class NotificationDto : EntityDto<Guid>
{ {
public required string NotificationChannel { get; set; } public required string NotificationChannel { get; set; }
public required string NotificationType { get; set; } public required string NotificationType { get; set; }
/// <summary>Bildirimi üreten yerin yazdığı, çevrilmiş başlık; tip adı (ör. PerformansDurumDegisti) kod olduğu için başlık olarak gösterilmez.</summary>
public string? Subject { get; set; }
public required string Message { get; set; } public required string Message { get; set; }
public bool IsRead { get; set; } public bool IsRead { get; set; }
public bool IsSent { get; set; } public bool IsSent { get; set; }

View file

@ -12,14 +12,6 @@ public partial class NotificationTypeToNotificationTypeDtoMapper : MapperBase<No
public override partial NotificationTypeDto Map(NotificationType source); public override partial NotificationTypeDto Map(NotificationType source);
public override partial void Map(NotificationType source, NotificationTypeDto destination); public override partial void Map(NotificationType source, NotificationTypeDto destination);
public override void BeforeMap(NotificationType source)
{
}
public override void AfterMap(NotificationType source, NotificationTypeDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -28,14 +20,6 @@ public partial class NotificationRuleToNotificationRuleDtoMapper : MapperBase<No
public override partial NotificationRuleDto Map(NotificationRule source); public override partial NotificationRuleDto Map(NotificationRule source);
public override partial void Map(NotificationRule source, NotificationRuleDto destination); public override partial void Map(NotificationRule source, NotificationRuleDto destination);
public override void BeforeMap(NotificationRule source)
{
}
public override void AfterMap(NotificationRule source, NotificationRuleDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -53,14 +37,6 @@ public partial class NotificationToNotificationDtoMapper : MapperBase<Notificati
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] [MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))]
[MapperIgnoreTarget(nameof(NotificationDto.TenantId))] [MapperIgnoreTarget(nameof(NotificationDto.TenantId))]
public override partial void Map(Notification source, NotificationDto destination); public override partial void Map(Notification source, NotificationDto destination);
public override void BeforeMap(Notification source)
{
}
public override void AfterMap(Notification source, NotificationDto destination)
{
}
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@ -82,14 +58,17 @@ public partial class CreateUpdateNotificationRuleDtoToNotificationRuleMapper : M
return destination; return destination;
} }
[MapperIgnoreTarget(nameof(NotificationRule.DeleterId))]
[MapperIgnoreTarget(nameof(NotificationRule.DeletionTime))]
[MapperIgnoreTarget(nameof(NotificationRule.IsCustomized))]
[MapperIgnoreTarget(nameof(NotificationRule.IsDeleted))]
[MapperIgnoreTarget(nameof(NotificationRule.IsFixed))]
[MapperIgnoreTarget(nameof(NotificationRule.LastModificationTime))]
[MapperIgnoreTarget(nameof(NotificationRule.LastModifierId))]
[MapperIgnoreTarget(nameof(NotificationRule.NotificationType))]
[MapperIgnoreTarget(nameof(NotificationRule.Notifications))]
[MapperIgnoreTarget(nameof(NotificationRule.RecipientId))]
[MapperIgnoreTarget(nameof(NotificationRule.TenantId))]
[MapperIgnoreTarget(nameof(NotificationRule.RecipientType))] [MapperIgnoreTarget(nameof(NotificationRule.RecipientType))]
public override partial void Map(CreateUpdateNotificationRuleDto source, NotificationRule destination); public override partial void Map(CreateUpdateNotificationRuleDto source, NotificationRule destination);
public override void BeforeMap(CreateUpdateNotificationRuleDto source)
{
}
public override void AfterMap(CreateUpdateNotificationRuleDto source, NotificationRule destination)
{
}
} }

View file

@ -6,7 +6,7 @@ public static class NotificationConsts
/// <summary> /// <summary>
/// Elle yapilan gonderimlerin bildirim tipi. Kural motorundan gecmeyen her bildirim bu tiple /// Elle yapilan gonderimlerin bildirim tipi. Kural motorundan gecmeyen her bildirim bu tiple
/// yazilir; tip kaydi seed ile gelir (<c>App.Notifications.NotificationTypes.json</c>). /// yazilir; tip kaydi seed ile gelir (<c>Adm.Notifications.NotificationTypes.json</c>).
/// </summary> /// </summary>
public const string ManualTypeName = "Manuel"; public const string ManualTypeName = "Manuel";
} }

View file

@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Localization;
using Volo.Abp.Settings;
namespace Sozsoft.Notifications.Domain;
/// <summary>
/// Bildirim metninin (tarih, sayi, dil anahtari) uretildigi kultur. Istek disinda (worker) is
/// parcaciginin kulturu surec varsayilanidir; alicinin kendi dil tercihi platformda saklanmaz
/// (dil istekte <c>Accept-Language</c> ile gelir), bu yuzden ulasilabilen en yakin deger
/// kiracinin <c>Abp.Localization.DefaultLanguage</c> ayaridir (kiraci → global → varsayilan).
/// </summary>
public interface INotificationCulture
{
Task<IDisposable> UseAsync();
}
public class NotificationCulture(ISettingProvider settingProvider) : INotificationCulture, ITransientDependency
{
public async Task<IDisposable> UseAsync()
{
var culture = await settingProvider.GetOrNullAsync(LocalizationSettingNames.DefaultLanguage);
return culture.IsNullOrWhiteSpace() || !CultureHelper.IsValidCultureCode(culture!)
? NullDisposable.Instance
: CultureHelper.Use(culture!);
}
}

View file

@ -70,6 +70,7 @@ public class NotificationManager : INotificationManager
private readonly INotificationRealtimeNotifier realtimeNotifier; private readonly INotificationRealtimeNotifier realtimeNotifier;
private readonly ICurrentTenant currentTenant; private readonly ICurrentTenant currentTenant;
private readonly IClock clock; private readonly IClock clock;
private readonly INotificationCulture notificationCulture;
public NotificationManager( public NotificationManager(
IReadOnlyRepository<NotificationRule> repository, IReadOnlyRepository<NotificationRule> repository,
@ -80,7 +81,8 @@ public class NotificationManager : INotificationManager
IOrganizationUnitRepository repositoryOrganizationUnit, IOrganizationUnitRepository repositoryOrganizationUnit,
INotificationRealtimeNotifier realtimeNotifier, INotificationRealtimeNotifier realtimeNotifier,
ICurrentTenant currentTenant, ICurrentTenant currentTenant,
IClock clock IClock clock,
INotificationCulture notificationCulture
) )
{ {
this.repository = repository; this.repository = repository;
@ -92,6 +94,7 @@ public class NotificationManager : INotificationManager
this.realtimeNotifier = realtimeNotifier; this.realtimeNotifier = realtimeNotifier;
this.currentTenant = currentTenant; this.currentTenant = currentTenant;
this.clock = clock; this.clock = clock;
this.notificationCulture = notificationCulture;
} }
public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage) public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage)
@ -208,30 +211,36 @@ public class NotificationManager : INotificationManager
.ToListAsync(); .ToListAsync();
var notifications = new List<Notification>(); var notifications = new List<Notification>();
foreach (var channel in channels)
{
foreach (var user in users)
{
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
if (identifier.IsNullOrWhiteSpace())
{
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
continue;
}
notifications.Add(new Notification // {Date}/{Time} yer tutuculari gonderenin istek kulturunde degil kiracinin dilinde yazilir;
// alicinin kendi dil tercihi platformda saklanmadigi icin alici basina ayrim yapilamaz.
using (await notificationCulture.UseAsync())
{
foreach (var channel in channels)
{
foreach (var user in users)
{ {
NotificationTypeId = request.NotificationTypeId, var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
NotificationTemplateId = request.NotificationTemplateId, if (identifier.IsNullOrWhiteSpace())
NotificationRuleId = anchorRule.Id, {
NotificationChannel = channel, result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
Identifier = identifier!, continue;
UserId = user.Id, }
Message = ResolveTokens(request.Message, user),
Subject = request.Subject, notifications.Add(new Notification
IsSent = IsDeliveredOnCreate(channel), {
IsRead = false NotificationTypeId = request.NotificationTypeId,
}); NotificationTemplateId = request.NotificationTemplateId,
NotificationRuleId = anchorRule.Id,
NotificationChannel = channel,
Identifier = identifier!,
UserId = user.Id,
Message = ResolveTokens(request.Message, user),
Subject = request.Subject,
IsSent = IsDeliveredOnCreate(channel),
IsRead = false
});
}
} }
} }

View file

@ -3,7 +3,6 @@
namespace Sozsoft.Sender.Localization; namespace Sozsoft.Sender.Localization;
[LocalizationResourceName("SozsoftSender")] [LocalizationResourceName("SozsoftSender")]
public class ErpSenderResource public class SozsoftMailingResource
{ {
} }

View file

@ -63,7 +63,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
} }
await BackgroundJobManager.EnqueueAsync( await BackgroundJobManager.EnqueueAsync(
new ErpBackgroundEmailSendingJobArgs new SozsoftBackgroundEmailSendingJobArgs
{ {
TenantId = CurrentTenant.Id, TenantId = CurrentTenant.Id,
To = to, To = to,
@ -76,7 +76,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
); );
} }
public async Task<EmailSendResult> SendEmailAsync( public async Task<SendResult> SendEmailAsync(
string[] to, string[] to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,
@ -134,7 +134,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
using var client = await amazonSesClientFactory.GetAmazonSesClient(); using var client = await amazonSesClientFactory.GetAmazonSesClient();
var response = await client.SendEmailAsync(request); var response = await client.SendEmailAsync(request);
var result = new EmailSendResult(); var result = new SendResult();
if (response == null) if (response == null)
{ {
result.Success = false; result.Success = false;
@ -157,7 +157,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
return result; return result;
} }
public async Task<EmailSendResult> SendEmailAsync( public async Task<SendResult> SendEmailAsync(
string to, string to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,

View file

@ -1,9 +0,0 @@
namespace Sozsoft.Sender.Mail;
public class EmailSendResult
{
public bool Success { get; set; }
public string MessageId { get; set; } //Eger basarili ise gateway messageId
public string ErrorMessage { get; set; } //Basarisiz ise hata mesaji
}

View file

@ -7,8 +7,7 @@ namespace Sozsoft.Sender.Mail;
/// </summary> /// </summary>
public interface ISozsoftEmailSender : IEmailSender public interface ISozsoftEmailSender : IEmailSender
{ {
//ErpEmailTemplate template Task<SendResult> SendEmailAsync(
Task<EmailSendResult> SendEmailAsync(
string to, string to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,
@ -16,7 +15,7 @@ public interface ISozsoftEmailSender : IEmailSender
string? subject = null, string? subject = null,
Dictionary<string, string>? attachments = null, Dictionary<string, string>? attachments = null,
bool IsBodyHtml = false); bool IsBodyHtml = false);
Task<EmailSendResult> SendEmailAsync( Task<SendResult> SendEmailAsync(
string[] to, string[] to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,

View file

@ -4,13 +4,13 @@ using Volo.Abp.MultiTenancy;
namespace Sozsoft.Sender.Mail; namespace Sozsoft.Sender.Mail;
public class ErpBackgroundEmailSendingJob : public class SozsoftBackgroundEmailSendingJob :
AsyncBackgroundJob<ErpBackgroundEmailSendingJobArgs>, ITransientDependency AsyncBackgroundJob<SozsoftBackgroundEmailSendingJobArgs>, ITransientDependency
{ {
protected ISozsoftEmailSender EmailSender { get; } protected ISozsoftEmailSender EmailSender { get; }
protected ICurrentTenant CurrentTenant { get; } protected ICurrentTenant CurrentTenant { get; }
public ErpBackgroundEmailSendingJob( public SozsoftBackgroundEmailSendingJob(
ISozsoftEmailSender emailSender, ISozsoftEmailSender emailSender,
ICurrentTenant currentTenant) ICurrentTenant currentTenant)
{ {
@ -18,11 +18,10 @@ public class ErpBackgroundEmailSendingJob :
CurrentTenant = currentTenant; CurrentTenant = currentTenant;
} }
public override async Task ExecuteAsync(ErpBackgroundEmailSendingJobArgs args) public override async Task ExecuteAsync(SozsoftBackgroundEmailSendingJobArgs args)
{ {
using (CurrentTenant.Change(args.TenantId)) using (CurrentTenant.Change(args.TenantId))
{ {
//await EmailSender.SendEmailAsync(args.Template, args.To, args.Params, args.Subject);
await EmailSender.SendEmailAsync( await EmailSender.SendEmailAsync(
args.To, args.To,
args.Sender, args.Sender,

View file

@ -3,7 +3,7 @@
namespace Sozsoft.Sender.Mail; namespace Sozsoft.Sender.Mail;
[Serializable] [Serializable]
public class ErpBackgroundEmailSendingJobArgs : IMultiTenant public class SozsoftBackgroundEmailSendingJobArgs : IMultiTenant
{ {
public Guid? TenantId { get; set; } public Guid? TenantId { get; set; }

View file

@ -4,6 +4,6 @@ namespace Sozsoft.Sender.Rocket;
public interface ISozsoftRocketSender : ITransientDependency public interface ISozsoftRocketSender : ITransientDependency
{ {
Task<RocketSendResult> SendAsync(string Username, string Message); Task<SendResult> SendAsync(string Username, string Message);
} }

View file

@ -1,46 +1,38 @@
using System.Net.Http.Json; using Volo.Abp.Settings;
using Volo.Abp.Settings;
namespace Sozsoft.Sender.Rocket; namespace Sozsoft.Sender.Rocket;
public interface IRocketHttpClient public interface IRocketHttpClient
{ {
Task<RocketSendResultDto> SendMessageAsync(string Username, string Message); Task<SendResult?> SendMessageAsync(string Username, string Message);
} }
public sealed class RocketHttpClient : IRocketHttpClient, IDisposable public sealed class RocketHttpClient : SettingHttpClientBase, IRocketHttpClient
{ {
private readonly HttpClient httpClient; public RocketHttpClient(HttpClient httpClient, ISettingProvider settingProvider)
private readonly ISettingProvider settingProvider; : base(httpClient, settingProvider)
public RocketHttpClient(
HttpClient httpClient,
ISettingProvider settingProvider)
{ {
this.httpClient = httpClient;
this.settingProvider = settingProvider;
} }
public async Task<RocketSendResultDto> SendMessageAsync(string Username, string Message) public async Task<SendResult?> SendMessageAsync(string Username, string Message)
{ {
var url = await settingProvider.GetOrNullAsync(RocketSettingNames.Url); var url = await GetSettingAsync(RocketSettingNames.Url);
var userId = await settingProvider.GetOrNullAsync(RocketSettingNames.UserId); var userId = await GetSettingAsync(RocketSettingNames.UserId);
var token = await settingProvider.GetOrNullAsync(RocketSettingNames.Token); var token = await GetSettingAsync(RocketSettingNames.Token);
var request = new HttpRequestMessage(HttpMethod.Post, $"{url}/chat.postMessage"); // Rocket.Chat cevabı yalnızca { success } taşır; SendResult aynı sözleşmedir, ayrı DTO gerekmez.
request.Headers.Add("X-Auth-Token", token); return await PostJsonAsync<SendResult>(
request.Headers.Add("X-User-Id", userId); $"{url}/chat.postMessage",
request.Content = JsonContent.Create(new RocketChatPostMessageInput new RocketChatPostMessageInput
{ {
Channel = $"@{Username}", Channel = $"@{Username}",
Text = Message Text = Message
}); },
request =>
var response = await httpClient.SendAsync(request); {
response.EnsureSuccessStatusCode(); // Hata durumunda istisna atar. request.Headers.Add("X-Auth-Token", token);
return await response.Content.ReadFromJsonAsync<RocketSendResultDto>(); request.Headers.Add("X-User-Id", userId);
},
ensureSuccess: true);
} }
public void Dispose() => httpClient?.Dispose();
} }

View file

@ -1,7 +0,0 @@
namespace Sozsoft.Sender.Rocket;
public class RocketSendResult
{
public bool Success { get; set; }
}

View file

@ -1,7 +0,0 @@
namespace Sozsoft.Sender.Rocket;
public class RocketSendResultDto
{
public bool Success { get; set; }
}

View file

@ -15,11 +15,12 @@ public class RocketSender : ISozsoftRocketSender
this.logger = logger; this.logger = logger;
} }
public async Task<RocketSendResult> SendAsync(string Username, string Message) public async Task<SendResult> SendAsync(string Username, string Message)
{ {
logger.LogInformation("Sending Rocket message. {Username}, {Message}", Username, Message); logger.LogInformation("Sending Rocket message. {Username}, {Message}", Username, Message);
var result = await httpClient.SendMessageAsync(Username, Message); var result = await httpClient.SendMessageAsync(Username, Message);
if (result.Success) var success = result?.Success == true;
if (success)
{ {
logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message); logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message);
} }
@ -28,10 +29,6 @@ public class RocketSender : ISozsoftRocketSender
logger.LogWarning("Failed to send Rocket message. {Username}, {Message}", Username, Message); logger.LogWarning("Failed to send Rocket message. {Username}, {Message}", Username, Message);
} }
return new RocketSendResult return new SendResult { Success = success };
{
Success = result.Success,
};
} }
} }

View file

@ -2,7 +2,7 @@
public static class RocketSettingNames public static class RocketSettingNames
{ {
public const string Default = "App.Sender.Rocket."; public const string Default = "Adm.Sender.Rocket.";
public const string Url = Default + "Url"; public const string Url = Default + "Url";
public const string UserId = Default + "UserId"; public const string UserId = Default + "UserId";
public const string Token = Default + "Token"; public const string Token = Default + "Token";

View file

@ -0,0 +1,16 @@
namespace Sozsoft.Sender;
/// <summary>
/// Her kanalın (e-posta, SMS, Rocket, WhatsApp, Telegram) tek gönderim sonucu. Kanal yalnızca
/// başarı bilgisi veriyorsa <see cref="MessageId"/> ve <see cref="ErrorMessage"/> boş kalır.
/// </summary>
public class SendResult
{
public bool Success { get; set; }
/// <summary>Başarılı gönderimde sağlayıcının verdiği ileti kimliği.</summary>
public string? MessageId { get; set; }
/// <summary>Başarısız gönderimde sağlayıcının açıklaması.</summary>
public string? ErrorMessage { get; set; }
}

View file

@ -0,0 +1,52 @@
using System.Net.Http.Json;
using Volo.Abp.Settings;
namespace Sozsoft.Sender;
/// <summary>
/// Sağlayıcı istemcilerinin ortak iskeleti: <see cref="IHttpClientFactory"/> ile verilen
/// <see cref="HttpClient"/>, ayarlardan okunan uç nokta ve JSON gövde ile POST. Sağlayıcıya özgü
/// olan yalnızca istek gövdesi, başlıklar ve cevap tipidir.
/// </summary>
public abstract class SettingHttpClientBase : IDisposable
{
protected HttpClient HttpClient { get; }
protected ISettingProvider SettingProvider { get; }
protected SettingHttpClientBase(HttpClient httpClient, ISettingProvider settingProvider)
{
HttpClient = httpClient;
SettingProvider = settingProvider;
}
protected Task<string?> GetSettingAsync(string name) => SettingProvider.GetOrNullAsync(name);
/// <summary>
/// Gövdeyi JSON olarak gönderir ve cevabı <typeparamref name="TResponse"/> olarak okur.
/// <paramref name="ensureSuccess"/> kapalıyken 4xx gövdesi de okunur: bazı API'ler hatayı
/// durum koduyla değil gövdedeki açıklamayla verir.
/// </summary>
protected async Task<TResponse?> PostJsonAsync<TResponse>(
string url,
object body,
Action<HttpRequestMessage>? configureRequest = null,
bool ensureSuccess = false)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
configureRequest?.Invoke(request);
using var response = await HttpClient.SendAsync(request);
if (ensureSuccess)
{
response.EnsureSuccessStatusCode();
}
return await response.Content.ReadFromJsonAsync<TResponse>();
}
public void Dispose()
{
HttpClient.Dispose();
GC.SuppressFinalize(this);
}
}

Some files were not shown because too many files have changed in this diff Show more