Compare commits

..

No commits in common. "main" and "1.3.0" have entirely different histories.
main ... 1.3.0

1879 changed files with 328345 additions and 1262784 deletions

View file

@ -19,11 +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, 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. | | `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 (`App.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 | | `../../configs/seeds/host/docs/modules/{Module}.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 |
--- ---
@ -53,28 +53,26 @@ 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. **No new Core feature without the command.** Core = all source code under `api/` and `ui/`; 6. **Stay inside the framework; ask before stepping out.** The platform may extend itself through
the frame = artifacts generated under `configs/seeds/` (wizard, SQL objects, `crud/`, custom its own mechanisms (seed artifacts; adding a missing event, prop, or small backward-compatible
components, Custom Endpoints, Dynamic Services, workers, notifications, language/permission capability to an existing UI component — when Developer Kit tooling builds ~70% of the
rows, SEO, module docs). A new Core feature — however small or backward compatible: a DTO requested component, the rest is completed with such small additions, never with a new
field, a prop/event on an existing UI component, a script recipe, an editor option key, a component; backward-compatible additions to script recipes / editor options / tokens).
default-value token, a new React component/page, engine/seeder/MCP changes, new entity/ Anything beyond that — a new React component or page, a new toolbox family, core engine changes (ListForm
migration, platform table schema, NuGet/npm packages, `Sas.Menus.Routes.json` rows (except the runtime, seeders, code generator, auth, DataSourceManager, dispatcher/compiler), new ABP
`Seo*` columns of an existing row — lowcode §0.9), external integrations — is written **only entity/module/migration, platform table schema changes, new NuGet/npm dependencies, the
when the user gives the command "core sisteme ekle"** (or an equivalent that names the Core `App.Menus.Routes.json` seed data (except the `Seo*` columns of an existing row, which are part
change). Before concluding Core is needed, scan the full ListForm capability inventory of ordinary production — lowcode §0.9), new external integrations — is **out of frame**: finish everything that
(`lowcode-reference.instructions.md` §0–§9, lowcode §4–§9). Without the command: finish what fits inside the frame first, then ask the user with a single question (why configuration is
fits in the frame and end with a single "Core gerektiren kısım" note (missing capability, why insufficient, the smallest change that works, files touched, rollback) and proceed **only with
the inventory falls short, smallest change, files, rollback) — do not offer Core as an explicit approval**. Approval is per request and does not carry over. Full lists:
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 happens only on the user's explicit command ("core sisteme ekle" or a request naming the code change). - Code-level React or backend development can be considered only if user explicitly requests code implementation and configuration path is insufficient.
- When Core would be needed, explain in a sentence or two why the configuration inventory is not enough. - If exception is required, AI must explain why configuration-based options are not enough.
- Never say "cannot be done": the answer is an in-frame build plus, if needed, a "Core gerektiren kısım" note. - Never say "cannot be done": the answer is either an in-frame build or an approval question.
--- ---
@ -295,8 +293,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: `Sas.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`. - List screen permissions: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`.
- Every save/delete is mirrored into `configs/seeds/{scope}/data/Sas.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are - Every save/delete is mirrored into `configs/seeds/{scope}/data/App.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
@ -310,13 +308,9 @@ 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/object/{TableName}.sql`, full CREATE script, via `save-table-script`; its PostgreSQL counterpart (`{sql|postgres}/object/{TableName}.sql`, full CREATE script, via `save-table-script`). View
`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.
@ -367,7 +361,7 @@ 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 (`Sas.DeveloperKit.McpServers`, §12.2 of on the **MCP Servers** screen (`App.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 read and write data over MCP but cannot develop on the calling tenant's own database. Tenant users read and write data over MCP but cannot develop
(`McpToolNames.TenantTools`: schema tools, `query_list`, `run_select`, `insert_row`/`update_row`/`delete_row` (`McpToolNames.TenantTools`: schema tools, `query_list`, `run_select`, `insert_row`/`update_row`/`delete_row`
@ -463,8 +457,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. Describe the minimal code change with explicit justification — this step is Core: 4. Propose minimal code change with explicit justification — this step is out of frame:
**do not touch code until the user says "core sisteme ekle"** (rule 3.6). **ask and wait for approval before touching code** (rule 3.6).
--- ---
@ -631,7 +625,7 @@ 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, 7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Module}.md` is completed (analysis,
decisions, behaviour) and indexed in `README.md` §10.5. decisions, behaviour) and indexed in `README.md` §10.5.
--- ---
@ -670,8 +664,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_Sas.cs / ListFormSeeder_Adm.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs - ListFormSeeder_Saas.cs / ListFormSeeder_Administration.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
- configs/seeds/data/Sas.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data) - configs/seeds/host/data/App.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
@ -684,14 +678,11 @@ 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. There is a single folder and no seeders above restore them when the database is recreated. Scope folders mirror the CDN layout:
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/{wizard,data,crud,docs,sql/{object,execute},postgres/{object,execute}} configs/seeds/host/{wizard,data,crud,sql/{object,execute},postgres/{object,execute}}
configs/seeds/{sql,postgres}/host/{object,execute} host database only (e.g. scripts over HangFire) configs/seeds/tenants/{tenantId}/… same layout, tenant scope
configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned) configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned)
``` ```
@ -731,13 +722,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/data/Sas.Menus.*.json` patterns, AI must define both: From the `configs/seeds/host/data/App.Menus.*.json` patterns, AI must define both:
- `Sas.Menus.Routes.json` row: - `App.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)
- `Sas.Menus.Menu.json` row: - `App.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:
@ -748,7 +739,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/data/Abp.Identity.Permission*.json` patterns, AI must define: From the `configs/seeds/host/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
@ -768,7 +759,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/data/Sas.SettingDefinitions.json` / `Sas.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services: 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:
- 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
@ -869,10 +860,6 @@ 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
@ -920,8 +907,8 @@ 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 seed folder and which carries Exporting a wizard produces a zip whose paths are relative to the scope folder
every dependency: (`host` / `tenants/{tenantId}`) and which carries 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.
@ -933,7 +920,7 @@ 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/Sas.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the - `data/App.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)
@ -965,7 +952,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` section or, on the List path, no `Data` heading (`SelectCommand`/`KeyFieldName`); a screen's or a `crud/{Entity}.json`'s table missing in file with no `Wizard`/`ListForm` section; 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
@ -1011,8 +998,7 @@ 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 `Wizard` section as-is Migrate installs it (`WizardDataSeeder`) from the file's `ListForm`/`Fields` contract as-is, even
(`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
@ -1022,7 +1008,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: `Sas.Listforms.Wizard.Export` / `.Import`. Permissions: `App.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

@ -122,20 +122,6 @@ 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 Mapper'lar servis klasöründeki `{Alan}Mappers.cs` dosyasındadır (modülde
`{Modül}ApplicationMappers.cs`), ABP'nin `MapperBase<TSource, TDest>` / `{Modül}ApplicationMappers.cs`), ABP'nin `MapperBase<TSource, TDest>` /
@ -146,14 +132,7 @@ Ek kurallar:
`[MapperIgnoreTarget(nameof(...))]` ile açıkça yazılır; iç içe koleksiyon için ayrı `[MapperIgnoreTarget(nameof(...))]` ile açıkça yazılır; iç içe koleksiyon için ayrı
`private partial` eşleme metodu tanımlanır. `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. Bu her tür exception mesajı, uyarı listesi, DTO açıklaması ve anahtarı üzerinden verilir.
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: `{Modul}`, `{Modul}.{Liste}` - Menu/permission keys: `App.{Modul}`, `App.{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/data/Sas.Languages.LanguageText/{CultureName}.json`. - **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/host/data/App.Languages.LanguageText.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,30 +27,27 @@ This document summarizes the rules, standards, and step-by-step instructions for
## Special File Handling Rules ## Special File Handling Rules
- **configs/seeds/data/Sas.Menus.*.json:** - **configs/seeds/host/data/App.Menus.*.json:**
- 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. - 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.
- **configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json:** - **configs/seeds/host/data/App.Languages.LanguageText.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.
- 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. - 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.
- 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/data/Sas.Menus.Menu.json ### 1. configs/seeds/host/data/App.Menus.Menu.json
- **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `Sas.Menus.Module.json`. - **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `App.Menus.Module.json`.
- **{Liste}** added as a child menu under {Modul}. - **{Liste}** added as a child menu under {Modul}.
### 2. configs/seeds/data/Abp.Identity.Permissions.json ### 2. configs/seeds/host/data/Abp.Identity.Permissions.json
- **{Modul} permissions** grouped under `{Modul}`. - **{Modul} permissions** grouped under `App.{Modul}`.
- **{Liste} permissions** nested under {Modul} group. - **{Liste} permissions** nested under {Modul} group.
### 3. ListFormSeeder_Adm.cs ### 3. ListFormSeeder_Administration.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
@ -64,7 +61,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. Sas.Languages.LanguageText/{CultureName}.json ### 7. App.Languages.LanguageText.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`)
@ -77,11 +74,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/data/Sas.Menus.Menu.json'a kök menü, Sas.Menus.Module.json'a modül kaydı olarak ekle - 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/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur - configs/seeds/host/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/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına menü, liste ve alan çevirilerini ekle - configs/seeds/host/data/App.Languages.LanguageText.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
@ -90,8 +87,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/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle - configs/seeds/host/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle
- configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına liste ve alan çevirilerini ekle - configs/seeds/host/data/App.Languages.LanguageText.json dosyasına liste ve alan çevirilerini ekle
``` ```
### 3. Enforce Tenant Isolation and Full Audit ### 3. Enforce Tenant Isolation and Full Audit
@ -104,10 +101,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/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: Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.LanguageText.json dosyasına (EN + TR satırı) şu şekilde ekle:
- {Modul} - App.{Modul}
- {Modul}.{Liste} - App.{Modul}.{Liste}
- Sas.Listform.ListformField.{Alan} - App.Listform.ListformField.{Alan}
``` ```
### 5. SeedConsts and DeleteCommand Rules ### 5. SeedConsts and DeleteCommand Rules
@ -131,7 +128,7 @@ Yeni menü, liste veya alan eklediğinde configs/seeds/data/Sas.Languages.Langua
- [ ] 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})
- [ ] Sas.Languages.LanguageText/{CultureName}.json'da tüm yeni menü, liste ve alanlar var mı? - [ ] App.Languages.LanguageText.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

727
README.md

File diff suppressed because it is too large Load diff

View file

@ -6,15 +6,4 @@
<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: <access token> - token: EAASM83pDJf0BO0ZCYBr3Fx4SES0ox8XiZCgP3FIteprYpwawZCkGYjpMlKk4OZCWgRNZC2Ttofgf8amMf929ZBuDcgYIOsXkPMGBNmVxm2czipzq63LmtbYfyCWsKVy0q1jtay0nRoeDuO2FKhqCfLLtNn9cSWjINVLcz26ptK8a2Oko83cZBzrNsp3cpIECnzqRfEjHdEz1U73y3ZB1MZBOhHilax7yZALf4ZA1Tmk
- 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/<phone-number-id>/register' \ curl --location 'https://graph.facebook.com/v21.0/521106361082067/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/<phone-number-id>/messages - Message göndermek için url : https://graph.facebook.com/v21.0/521106361082067/messages
``` ```
{ {

View file

@ -55,6 +55,7 @@ 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,6 +17,8 @@ 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}"
@ -184,6 +186,18 @@ 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
@ -579,6 +593,7 @@ 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

@ -0,0 +1,22 @@
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,25 +6,6 @@ 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,10 +2,8 @@
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;
@ -14,7 +12,6 @@ using Volo.Abp.Validation;
namespace Sozsoft.Languages; namespace Sozsoft.Languages;
[Authorize]
public class LanguageAppService : CrudAppService< public class LanguageAppService : CrudAppService<
Language, Language,
LanguageDto, LanguageDto,
@ -26,11 +23,6 @@ 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)
@ -48,7 +40,6 @@ 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,10 +2,8 @@
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;
@ -13,7 +11,6 @@ using Volo.Abp.Validation;
namespace Sozsoft.Languages; namespace Sozsoft.Languages;
[Authorize]
public class LanguageKeyAppService : CrudAppService< public class LanguageKeyAppService : CrudAppService<
LanguageKey, LanguageKey,
LanguageKeyDto, LanguageKeyDto,
@ -29,11 +26,6 @@ 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

@ -0,0 +1,64 @@
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,12 +4,11 @@ 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 Sozsoft.Languages.Caching; using Microsoft.Extensions.Configuration;
using Sozsoft.Languages.Entities; using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Localization; using Sozsoft.Languages.Localization;
using Sozsoft.Languages.Permissions; using StackExchange.Redis;
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;
@ -20,11 +19,6 @@ 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,
@ -35,25 +29,20 @@ 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 DistributedCacheCleaner _cacheCleaner; private readonly IConfiguration _configuration;
public LanguageTextAppService( public LanguageTextAppService(
IRepository<LanguageText, Guid> repositoryText, IRepository<LanguageText, Guid> repositoryText,
IRepository<LanguageKey, Guid> repositoryKey, IRepository<LanguageKey, Guid> repositoryKey,
DistributedCacheCleaner cacheCleaner IConfiguration configuration
) )
: 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;
_cacheCleaner = cacheCleaner; _configuration = configuration;
} }
public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input) public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input)
@ -74,7 +63,6 @@ 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(
@ -89,7 +77,6 @@ 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();
@ -122,7 +109,6 @@ 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 =>
@ -133,7 +119,6 @@ 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);
@ -145,11 +130,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()
{ {
await _cacheCleaner.ClearAsync(); var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!);
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,6 +11,14 @@ 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)]
@ -19,6 +27,14 @@ 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)]
@ -27,6 +43,14 @@ 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)]
@ -35,6 +59,14 @@ 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)]
@ -43,6 +75,14 @@ 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)]
@ -53,6 +93,14 @@ 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)]
@ -85,4 +133,12 @@ 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

@ -1,97 +0,0 @@
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,6 +1,5 @@
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;
@ -13,12 +12,10 @@ public class LanguageTextCacheItemInvalidator :
ITransientDependency ITransientDependency
{ {
private readonly IDistributedCache<LanguageTextCacheItem> cache; private readonly IDistributedCache<LanguageTextCacheItem> cache;
private readonly IDatabaseResourceLocalizer localizer;
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache, IDatabaseResourceLocalizer localizer) public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache)
{ {
this.cache = cache; this.cache = cache;
this.localizer = localizer;
} }
public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData) public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData)
@ -26,7 +23,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);
localizer.ClearLocalCache(); return;
} }
} }

View file

@ -1,5 +1,4 @@
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;
@ -14,14 +13,11 @@ 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)
@ -43,7 +39,6 @@ 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,5 +1,4 @@
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;
@ -15,14 +14,8 @@ 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,
@ -34,52 +27,27 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name) public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name)
{ {
var value = GetCacheItem(resource, cultureName)?.Dictionary?.GetOrDefault(name); var item = GetCacheItem(resource, cultureName);
return value == null ? null : new LocalizedString(name, value); var value = item.Dictionary?.GetOrDefault(name);
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);
if (TryGetLocal(key, out var local)) return cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName));
{
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);
} }
@ -99,12 +67,9 @@ 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);
if (!TryGetLocal(key, out var item)) var item = await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName));
{
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,6 +10,5 @@ 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(SozsoftMailQueueResource); LocalizationResource = typeof(ErpMailQueueResource);
ObjectMapperContext = typeof(SozsoftMailQueueModule); ObjectMapperContext = typeof(ErpMailQueueModule);
} }
} }

View file

@ -0,0 +1 @@
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 SozsoftMailQueueResource public class ErpMailQueueResource
{ {
} }

View file

@ -0,0 +1,64 @@
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,36 +2,85 @@
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>
@ -59,7 +108,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
if (fileGenerator == null) if (fileGenerator == null)
{ {
LogManager.LogError($"File generator could not be created. Index: {table.Index}, FileType: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"File Generator oluşturulamadı. Index: {table.Index}, Dosya Tipi: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
continue; continue;
} }
@ -75,7 +124,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
catch (Exception ex) catch (Exception ex)
{ {
LogManager.LogError($"File could not be generated. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"Dosya oluşturulamadı. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
continue; continue;
} }
} }
@ -83,6 +132,70 @@ 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"))
@ -111,3 +224,4 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
} }
} }
} }

View file

@ -1,6 +1,5 @@
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;
@ -24,20 +23,17 @@ 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>
@ -70,7 +66,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
} }
catch (Exception) catch (Exception)
{ {
LogManager.LogError($"Template render failed: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId); LogManager.LogError($"Template Render Başarısız: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
throw; throw;
} }
} }
@ -88,7 +84,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
return; return;
} }
path = Path.Combine(path, id.ToString()); path = Path.Combine(path, id.ToString());
var date = Clock.Now.ToString("yyyy-MM-dd-HH-mm-ss"); var date = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss");
var dosyaAdi = $"mail-{id}-{date}.html "; var dosyaAdi = $"mail-{id}-{date}.html ";
try try
{ {

View file

@ -0,0 +1,12 @@
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,6 +1,4 @@
using Sozsoft.Platform.Localization; namespace Sozsoft.MailQueue.MailGeneration.Models;
namespace Sozsoft.MailQueue.MailGeneration.Models;
public class MailTemplateTableColumnModel public class MailTemplateTableColumnModel
{ {
@ -17,10 +15,5 @@ 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,9 +1,11 @@
using Sozsoft.MailQueue.Domain.Entities; using Microsoft.Extensions.Configuration;
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;
@ -16,105 +18,195 @@ public class MailQueueWorker : BackgroundWorkerBase
public MailQueueWorkerOptions Options { get; set; } public MailQueueWorkerOptions Options { get; set; }
protected ISozsoftEmailSender ErpEmailSender { get; } protected ISozsoftEmailSender ErpEmailSender { get; }
protected IRepository<BackgroundWorker_MailQueue> Repository { get; } protected IRepository<Entities.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<BackgroundWorker_MailQueue> repository, IRepository<Entities.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>
/// Şablonun (TemplateId) gönderilmemiş kuyruk kayıtlarını işler: her kayıt için tablo, gövde ve /// ID'si verilen Template satırının kuyruk kayıtlarını proses ederek bildirimleri yapar
/// 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
{ {
await ProcessQueueAsync(queue, jobId, cancellationToken); // Tablo oluştur
await uow2.CompleteAsync(cancellationToken); var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id);
// 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);
}
private async Task ProcessQueueAsync(BackgroundWorker_MailQueue queue, string jobId, CancellationToken cancellationToken) await uow.SaveChangesAsync(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,7 +1,6 @@
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;
@ -21,50 +20,34 @@ 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 connection = ctx.Database.GetDbConnection(); var cmd = ctx.Database.GetDbConnection().CreateCommand();
await using var cmd = connection.CreateCommand(); var query = $"SELECT * FROM {tabloAdi} WHERE ";
var wheres = new List<string>(); var wheres = new List<string>();
var index = 0;
foreach (var item in where) foreach (var item in where)
{ {
if (!SqlIdentifier.IsSafe(item.Key)) var parameterName = $"@p{item.Key}";
throw new ArgumentException($"Invalid column name: {item.Key}", nameof(where)); wheres.Add($"{item.Key}={parameterName}");
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 = $"SELECT * FROM {SqlIdentifier.QuoteAnsi(tabloAdi)}" cmd.CommandText = query;
+ (wheres.Count > 0 ? " WHERE " + string.Join(" AND ", wheres) : string.Empty);
await connection.OpenAsync();
try try
{ {
await using var reader = await cmd.ExecuteReaderAsync(); cmd.Connection.Open();
table.Load(reader); table.Load(await cmd.ExecuteReaderAsync());
} }
finally finally
{ {
await connection.CloseAsync(); cmd.Connection.Close();
} }
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 => col.FormatValue(row.GetValueOrDefault(col.SutunAdi)))))); sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => 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>@sutun.FormatValue(satir.GetValueOrDefault(sutun.SutunAdi))</td> <td>@satir.GetValueOrDefault(sutun.SutunAdi)</td>
} }
</tr> </tr>
} }

View file

@ -1,7 +1,4 @@
using System.Globalization; using ClosedXML.Excel;
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;
@ -9,8 +6,6 @@ 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)
@ -92,8 +87,7 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
switch (dataType) switch (dataType)
{ {
case XLDataType.Number: case XLDataType.Number:
// Int32'ye cevirmek ondaligi atardi; hucre sayisal kalir, gosterimi Excel bicim kodu verir. cell.SetValue(Convert.ToInt32(value));
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));
@ -172,11 +166,7 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin); cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin);
} }
} }
catch (Exception ex) catch { }
{
// 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,7 +42,6 @@
<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(SozsoftSenderModule) typeof(ErpSenderModule)
)] )]
public class SozsoftMailQueueModule : AbpModule public class ErpMailQueueModule : AbpModule
{ {
public override void ConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context)
{ {
context.Services.AddMapperlyObjectMapper<SozsoftMailQueueModule>(); context.Services.AddMapperlyObjectMapper<ErpMailQueueModule>();
Configure<AbpLocalizationOptions>(options => Configure<AbpLocalizationOptions>(options =>
{ {
options.Resources.Add<SozsoftMailQueueResource>("en"); options.Resources.Add<ErpMailQueueResource>("en");
}); });
Configure<AbpExceptionLocalizationOptions>(options => Configure<AbpExceptionLocalizationOptions>(options =>
{ {
options.MapCodeNamespace("MailQueue", typeof(SozsoftMailQueueResource)); options.MapCodeNamespace("MailQueue", typeof(ErpMailQueueResource));
}); });
context.Services.AddAbpDbContext<MailQueueDbContext>(options => context.Services.AddAbpDbContext<MailQueueDbContext>(options =>
@ -51,7 +51,7 @@ public class SozsoftMailQueueModule : AbpModule
Configure<AbpRazorTemplateCSharpCompilerOptions>(options => Configure<AbpRazorTemplateCSharpCompilerOptions>(options =>
{ {
options.References.Add(MetadataReference.CreateFromFile(typeof(SozsoftMailQueueModule).Assembly.Location)); options.References.Add(MetadataReference.CreateFromFile(typeof(ErpMailQueueModule).Assembly.Location));
}); });
Configure<AbpCompiledViewProviderOptions>(options => Configure<AbpCompiledViewProviderOptions>(options =>
@ -69,7 +69,7 @@ public class SozsoftMailQueueModule : AbpModule
// FileGenerationModule // FileGenerationModule
Configure<AbpVirtualFileSystemOptions>(options => Configure<AbpVirtualFileSystemOptions>(options =>
{ {
options.FileSets.AddEmbedded<SozsoftMailQueueModule>(); options.FileSets.AddEmbedded<ErpMailQueueModule>();
}); });
context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa"); context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa");

View file

@ -71,32 +71,6 @@ 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
@ -125,30 +99,6 @@ 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

@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace Sozsoft.Mcp.Dtos; namespace Sozsoft.Mcp.Dtos;
/// <summary>Rapor kategorisi (<c>Adm.Reports.Categories</c>).</summary> /// <summary>Rapor kategorisi (<c>App.Reports.Categories</c>).</summary>
public class McpReportCategoryDto public class McpReportCategoryDto
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
@ -106,6 +106,6 @@ public class McpReportTemplateSaveRequestDto
/// </summary> /// </summary>
public string? ListFormCode { get; set; } public string? ListFormCode { get; set; }
/// <summary>Satir butonunun dil anahtari; verilmezse <c>Sas.Platform.View</c>.</summary> /// <summary>Satir butonunun dil anahtari; verilmezse <c>App.Platform.View</c>.</summary>
public string? ButtonText { get; set; } public string? ButtonText { get; set; }
} }

View file

@ -63,11 +63,7 @@ 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> /// <summary><c>table</c>, <c>selectSql</c> ya da <c>unknown</c>.</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<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input); Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input);
Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input); Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input);
Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input); Task<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,68 +14,61 @@ namespace Sozsoft.Mcp;
/// </summary> /// </summary>
public interface IMcpDevelopAppService : IApplicationService public interface IMcpDevelopAppService : IApplicationService
{ {
Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section); Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section);
Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input); Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input);
/// <summary>View/procedure/function scriptini iki saglayicinin seed klasorune yazar; veritabaninda calistirmaz.</summary> Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search);
Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript);
/// <summary>Tablo ya da view icin CRUD endpoint'lerini <c>crud/{Entity}.json</c> seed dosyasina yazar; kaydi DB Migrate ekler.</summary> Task<McpWizardFileDto> GetWizardAsync(string fileName);
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<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input); Task<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<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables); Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables);
/// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary> /// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary>
Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName); Task<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<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request); Task<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<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request); Task<McpDevRequestDto> GetDevRequestAsync(string request);
/// <summary> /// <summary>
/// Talebin analizini yazar (var olani degistirir); analiz kosulsuz uretime hazirsa talebi /// Talebin analizini yazar (var olani degistirir) ve talebi Analysis durumuna alir; yalnizca
/// Ready to Start'a, degilse Analysis durumuna alir. Yalnizca Backlog ya da Analysis durumundaki /// Backlog ya da Analysis durumundaki talep analiz edilir.
/// talep analiz edilir.
/// </summary> /// </summary>
Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady); Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html);
/// <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<McpProblem?> AppendDevRequestNoteAsync(string request, string html); Task 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<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles); Task AddDevRequestSubTasksAsync(string request, List<string> titles);
/// <summary>Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur.</summary> /// <summary>Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur.</summary>
Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name); Task<ModuleDocDto> GetModuleDocAsync(string name);
/// <summary> /// <summary>
/// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden /// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden
/// uretilir. /// uretilir.
/// </summary> /// </summary>
Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content); Task<ModuleDocDto> SaveModuleDocAsync(string name, string content);
Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search); Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search);
Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name); Task<CustomComponentDto?> GetComponentAsync(string name);
Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input); Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input);
Task<McpProblem?> DeleteComponentAsync(string name); Task 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<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input); Task<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<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input); Task<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<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null); Task<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<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input); Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input);
} }

View file

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

View file

@ -5,6 +5,7 @@ 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;
@ -45,49 +46,40 @@ public abstract class McpAppService : ApplicationService
} }
/// <summary> /// <summary>
/// Kapali bir araca yapilan cagriyi reddeder: sunucu kaydi yoksa, arac sunucu kaydinda kapaliysa /// Kaydi zorunlu kilan islemler icin. Kayit yoksa istemcinin nedenini gorebilecegi bir
/// ya da kullanicinin uygulamada karsilik gelen yetkisi yoksa. Arac listesini suzmek tek basina /// hata atar.
/// </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<McpOutcome<McpServerConfigDto>> EnsureToolAllowedAsync(string toolName) protected async Task<McpServerConfigDto> EnsureToolAllowedAsync(string toolName)
{ {
var config = await ResolveServerConfigAsync(); var config = await RequireServerConfigAsync();
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))
{ {
return Problem(McpErrorCodes.TenantToolDisabled, toolName); throw new UserFriendlyException(L[McpErrorCodes.TenantToolDisabled, toolName]);
} }
if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName)) if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName))
{ {
return Problem(McpErrorCodes.ToolDisabled, toolName, config.Name); throw new UserFriendlyException(L[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)
{ {
return denied; await AuthorizationService.CheckAsync(permission);
} }
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,29 +1,12 @@
using Sozsoft.Platform.Enums; namespace Sozsoft.Mcp;
namespace Sozsoft.Platform.Data;
/// <summary> /// <summary>
/// Veritabanındaki kayıtlı nesneleri (görünüm, yordam, fonksiyon) listeleyen katalog sorguları. /// Kayitli nesneleri listelemek icin kullanilan katalog sorgulari. Parametre almazlar;
/// Parametre almazlar; sabit metin oldukları için string birleştirme riski yoktur. SQL Query Manager /// sabit metin oldugu icin string birlestirme riski yoktur.
/// ve MCP aynı listeyi gösterdiği için sorgu tek yerde durur; tür ayrımı C# tarafında
/// <see cref="ObjectKinds"/> ile yapılır.
/// </summary> /// </summary>
public static class SqlCatalogQueries internal static class McpCatalogQueries
{ {
/// <summary><c>ObjectKind</c> kolonunun aldığı değerler.</summary> public const string SqlServer = """
public static class ObjectKinds
{
public const string View = "VIEW";
public const string Procedure = "PROCEDURE";
public const string TableFunction = "TABLE_FUNCTION";
public const string ScalarFunction = "SCALAR_FUNCTION";
}
/// <summary><c>SchemaName</c>, <c>ObjectName</c>, <c>ObjectKind</c> kolonlarını şema + ad sırasıyla döner.</summary>
public static string NativeObjects(DataSourceTypeEnum provider)
=> provider == DataSourceTypeEnum.Postgresql ? PostgresNativeObjects : SqlServerNativeObjects;
private const string SqlServerNativeObjects = """
SELECT SELECT
SCHEMA_NAME(o.schema_id) AS SchemaName, SCHEMA_NAME(o.schema_id) AS SchemaName,
o.name AS ObjectName, o.name AS ObjectName,
@ -40,7 +23,7 @@ public static class SqlCatalogQueries
ORDER BY SchemaName, ObjectName ORDER BY SchemaName, ObjectName
"""; """;
private const string PostgresNativeObjects = """ public const string Postgres = """
SELECT n.nspname AS "SchemaName", SELECT n.nspname AS "SchemaName",
c.relname AS "ObjectName", c.relname AS "ObjectName",
'VIEW' AS "ObjectKind" 'VIEW' AS "ObjectKind"

View file

@ -31,44 +31,28 @@ public class McpDataAppService(
private const string DevRequestStatusField = "Status"; private const string DevRequestStatusField = "Status";
private const string DevRequestTestingStatus = "Testing"; private const string DevRequestTestingStatus = "Testing";
public async Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input) public async Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input)
{ {
if (!(await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true)).TryGetValue(out var request, out var problem)) var request = await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true);
{
return problem;
}
var key = await listFormDataAppService.PostInsertAsync(request); var key = await listFormDataAppService.PostInsertAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode); await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode);
return new McpWriteResultDto { Key = ((object?)key)?.ToString() }; return new McpWriteResultDto { Key = ((object?)key)?.ToString() };
} }
public async Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input) public async Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input)
{ {
if (!(await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true)).TryGetValue(out var request, out var problem)) var request = await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true);
{ await EnsureModuleDocsBeforeTestingAsync(input);
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)); 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<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input) public async Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input)
{ {
if (!(await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false)).TryGetValue(out var request, out var problem)) var request = await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false);
{
return problem;
}
var affected = await listFormDataAppService.PostDeleteAsync(request); var affected = await listFormDataAppService.PostDeleteAsync(request);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode);
@ -76,22 +60,22 @@ public class McpDataAppService(
} }
/// <summary> /// <summary>
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumani bolumleri /// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumanlari tamam
/// tamam olmalidir (lowcode §0.13): bolum var ve doldurulmamis (<c>module-doc:todo</c>) alt bolum yok. /// olmalidir (lowcode §0.13): dosya var ve doldurulmamis (<c>module-doc:todo</c>) bolum yok.
/// Talebi Testing'e alan tek yol <c>update_row</c> oldugu icin kural burada uygulanir. Wizard /// 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. /// okuma yetkisi olmayan kullanici wizard uretemeyecegi icin kontrol ona uygulanmaz.
/// </summary> /// </summary>
private async Task<McpProblem?> CheckModuleDocsBeforeTestingAsync(McpRowRequestDto input) private async Task EnsureModuleDocsBeforeTestingAsync(McpRowRequestDto input)
{ {
if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase))
return null; return;
var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString(); var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase))
return null; return;
if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard)) if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
return null; return;
foreach (var key in input.Keys ?? []) foreach (var key in input.Keys ?? [])
{ {
@ -104,17 +88,15 @@ public class McpDataAppService(
var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})")); var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})"));
return Problem(McpErrorCodes.DevRequestModuleDocsIncomplete, detail); throw new UserFriendlyException(L[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<McpOutcome<DataRequestDto>> BuildRequestAsync( private async Task<DataRequestDto> BuildRequestAsync(
McpRowRequestDto input, McpRowRequestDto input,
string toolName, string toolName,
AuthorizationTypeEnum operation, AuthorizationTypeEnum operation,
@ -123,32 +105,29 @@ public class McpDataAppService(
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) await EnsureToolAllowedAsync(toolName);
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.ListFormCode)) if (string.IsNullOrWhiteSpace(input.ListFormCode))
{ {
return Problem(McpErrorCodes.ListFormCodeRequired); throw new UserFriendlyException(L[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 && await CheckPermissionAsync(toolName, permission) is { } denied) if (permission is not null)
{ {
return denied; await AuthorizationService.CheckAsync(permission);
} }
if (requireValues && (input.Values is null || input.Values.Count == 0)) if (requireValues && (input.Values is null || input.Values.Count == 0))
{ {
return Problem(McpErrorCodes.ValuesRequired); throw new UserFriendlyException(L[McpErrorCodes.ValuesRequired]);
} }
if (requireKeys && (input.Keys is null || input.Keys.Count == 0)) if (requireKeys && (input.Keys is null || input.Keys.Count == 0))
{ {
return Problem(McpErrorCodes.KeysRequired); throw new UserFriendlyException(L[McpErrorCodes.KeysRequired]);
} }
return new DataRequestDto return new DataRequestDto

View file

@ -2,6 +2,7 @@
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;
@ -35,7 +36,6 @@ 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,
@ -79,19 +79,16 @@ public class McpDevelopAppService(
public const string SeedClosure = "seed_closure"; public const string SeedClosure = "seed_closure";
} }
public async Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section) public async Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.GetDevGuide) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.GetDevGuide);
{
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)
{ {
return Problem(McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey); throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]);
} }
return new McpDevGuideDto return new McpDevGuideDto
@ -157,31 +154,18 @@ public class McpDevelopAppService(
/// 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<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input) public async Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (await EnsureToolAllowedAsync(McpToolNames.CreateTable) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.CreateTable);
{ await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default);
return problem;
}
if (await CheckPermissionAsync(McpToolNames.CreateTable, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied) var created = await tableCreator.CreateAsync(new McpTableDefinition(
{
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); await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName);
@ -193,84 +177,9 @@ public class McpDevelopAppService(
}; };
} }
/// <summary> public async Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search)
/// 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)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.ListWizards);
{
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))
@ -278,17 +187,14 @@ 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)).ToList(); Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))];
} }
public async Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName) public async Task<McpWizardFileDto> GetWizardAsync(string fileName)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.GetWizard) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.GetWizard);
{
return problem;
}
var seed = await wizardAppService.GetFile(fileName); var seed = await wizardAppService.GetFile(fileName);
@ -299,24 +205,14 @@ public class McpDevelopAppService(
return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() }; return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() };
} }
public async Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input) public async Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (await EnsureToolAllowedAsync(McpToolNames.DeployWizard) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.DeployWizard);
{
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) EnsureDetailedDescription(wizard);
{
return shortDescription;
}
// Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy // Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy
// bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir), // bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir),
@ -324,16 +220,6 @@ public class McpDevelopAppService(
var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard; var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard;
wizard.MenuOrder = previous?.MenuOrder ?? 0; 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 // Alt form sekmesi ana kayit her degistiginde yeniden sorgulanmaz; MCP yeni baglanti
// acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi // acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi
// deger korunur. // deger korunur.
@ -353,10 +239,7 @@ public class McpDevelopAppService(
editGroups[0].Caption = string.Empty; 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.
if (!(await ParseSeedRowsAsync(input.SeedRows, wizard)).TryGetValue(out var seedRows, out invalid)) var seedRows = ParseSeedRows(input.SeedRows, wizard);
{
return invalid;
}
// Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin // 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. // butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir.
@ -367,8 +250,8 @@ public class McpDevelopAppService(
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 && await WriteSeedRowsAsync(file.FileName, seedRows) is { } rowsRejected) if (file != null && seedRows.Count > 0)
return rowsRejected; await WriteSeedRowsAsync(file.FileName, seedRows);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count);
@ -381,32 +264,31 @@ public class McpDevelopAppService(
/// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi /// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi
/// icin MCP deploy'u kisa aciklamayi reddeder. /// icin MCP deploy'u kisa aciklamayi reddeder.
/// </summary> /// </summary>
private McpProblem? CheckDetailedDescription(ListFormWizardDto wizard) private void EnsureDetailedDescription(ListFormWizardDto wizard)
{ {
if (wizard.ComponentKind == WizardComponentKindEnum.Custom) if (wizard.ComponentKind == WizardComponentKindEnum.Custom)
return null; return;
static bool IsDetailed(string? text) => static bool IsDetailed(string? text) =>
!string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(text) &&
text.Trim().Length >= MinDescriptionLength && text.Trim().Length >= MinDescriptionLength &&
text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines; text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines;
return IsDetailed(wizard.DescTr) && IsDetailed(wizard.DescEn) if (!IsDetailed(wizard.DescTr) || !IsDetailed(wizard.DescEn))
? null throw new UserFriendlyException(L[McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength]);
: 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 async Task<McpOutcome<List<Dictionary<string, JsonElement>>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard) private List<Dictionary<string, JsonElement>> ParseSeedRows(string? json, ListFormWizardDto wizard)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
return new List<Dictionary<string, JsonElement>>(); return [];
if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData) if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData)
return Problem(McpErrorCodes.SeedRowsNotAllowed); throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
List<Dictionary<string, JsonElement>>? rows; List<Dictionary<string, JsonElement>>? rows;
try try
@ -419,7 +301,7 @@ public class McpDevelopAppService(
} }
if (rows is null || rows.Any(row => row is null)) if (rows is null || rows.Any(row => row is null))
return Problem(McpErrorCodes.SeedRowsInvalid); throw new UserFriendlyException(L[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)
@ -428,74 +310,33 @@ 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)
return rows; throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), string.Join(", ", fields)]);
var screenFields = string.Join(", ", fields); return rows;
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>
/// Bilinmeyen kolonlari ayni tabloyu okuyan diger wizard ekranlarinin alanlarinda arar; sonuc /// Demo satirlarini ekranin veri dosyasina (<c>ListForm.SeedFilePath</c>) yazar; veritabanina
/// ekran kodundan o ekranda bulunan kolonlara eslemedir. Ekran listesi wizard dosyalarindan /// dokunmaz. Migrate ekrani kurduktan sonra <c>ListFormSeedDataApplier</c> satirlari ekler ve
/// okunur, migrate bekleyen ekranlar da dahildir. /// kiraci/audit alanlarini doldurur. Guid anahtar burada uretilir: dosya her uygulandiginda ayni
/// kayda karsilik gelir. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez.
/// </summary> /// </summary>
private async Task<Dictionary<string, List<string>>> FindSameTableScreensAsync(ListFormWizardDto wizard, List<string> columns) private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
{ {
var owners = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); var listForm = (await wizardAppService.GetFile(fileName)).ListForm;
var table = wizard.SelectCommand?.Trim(); var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/');
if (string.IsNullOrWhiteSpace(table)) if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath))
return owners; throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
var wizardName = wizard.WizardName?.Trim(); var path = SeedPathResolver.GetScopePath(configuration, CurrentTenant.Id, relativePath.Split('/'));
foreach (var file in await wizardAppService.GetFiles()) var seedFile = File.Exists(path)
{ ? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions)
if (file.ComponentKind == WizardComponentKindEnum.Custom || : null;
string.Equals(file.WizardName, wizardName, StringComparison.OrdinalIgnoreCase) || seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName };
string.Equals(file.FileName, wizard.EditFileName, StringComparison.OrdinalIgnoreCase))
continue;
var seed = await wizardAppService.GetFile(file.FileName); var keyFieldName = seedFile.KeyFieldName;
if (!string.Equals(seed.Wizard?.SelectCommand?.Trim(), table, StringComparison.OrdinalIgnoreCase)) var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
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)
{ {
@ -503,16 +344,14 @@ public class McpDevelopAppService(
if (!existing.Add(RowContent(values, keyFieldName))) if (!existing.Add(RowContent(values, keyFieldName)))
continue; continue;
if (wizard.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase)) if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
values[keyFieldName] = GuidGenerator.Create(); values[keyFieldName] = GuidGenerator.Create();
added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values)); seedFile.Rows.Add(values);
} }
// Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz. Directory.CreateDirectory(Path.GetDirectoryName(path)!);
return added.Count > 0 && (await target.UpsertRowsAsync(wizard.ResolveListFormCode(), keyFieldName, added)).Count > 0 await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile));
? 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>
@ -526,12 +365,9 @@ 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<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables) public async Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.DeleteWizard) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.DeleteWizard);
{
return problem;
}
var input = new WizardDeleteInput { FileName = fileName }; var input = new WizardDeleteInput { FileName = fileName };
if (dropTables) if (dropTables)
@ -553,12 +389,9 @@ 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<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName) public async Task<McpVerifyResultDto> VerifyWizardAsync(string fileName)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.VerifyWizard) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.VerifyWizard);
{
return problem;
}
var result = new McpVerifyResultDto { FileName = fileName }; var result = new McpVerifyResultDto { FileName = fileName };
@ -625,26 +458,16 @@ 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<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request) public async Task<McpDevRequestDto> StartDevRequestAsync(string request)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.StartDevRequest) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.StartDevRequest);
{ await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
return problem;
}
if (await CheckPermissionAsync(McpToolNames.StartDevRequest, DevRequestUpdatePermission) is { } denied) var devRequest = await FindDevRequestAsync(request);
{
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))
{ {
return Problem(McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty); throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
} }
devRequest.Status = McpConsts.DevRequestStatuses.InProgress; devRequest.Status = McpConsts.DevRequestStatuses.InProgress;
@ -654,64 +477,41 @@ public class McpDevelopAppService(
return ToDto(devRequest); return ToDto(devRequest);
} }
public async Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request) public async Task<McpDevRequestDto> GetDevRequestAsync(string request)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.GetDevRequest) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.GetDevRequest);
{ await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests);
return problem;
}
if (await CheckPermissionAsync(McpToolNames.GetDevRequest, PlatformConsts.AppCodes.DevRequests) is { } denied) var devRequest = await FindDevRequestAsync(request);
{
return denied;
}
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
{
return notFound;
}
await timesheetRecorder.BeginAsync(devRequest.Id); await timesheetRecorder.BeginAsync(devRequest.Id);
return ToDto(devRequest); return ToDto(devRequest);
} }
/// <summary> /// <summary>
/// Durum kurali modelin dikkatine birakilmaz: uretimi baslamamis (Backlog, Analysis, Ready to Start) /// Durum kurali modelin dikkatine birakilmaz: yalnizca Backlog ya da Analysis durumundaki talep
/// talep analiz edilir; Ready to Start talebin analizi uretim oncesi yeniden yazilabilir. "Uretime hazir: Evet" analiz uretimin karar vermeden uygulayabilecegi analizdir, /// analiz edilir. Analysis'teki talebin analizi begenilmediyse yeniden yazilir; Ready to Start'a
/// talep dogrudan Ready to Start'a gecer; sartli ya da Hayir olan analiz kullanicinin kararini /// kullanici alir.
/// bekledigi icin Analysis'te kalir.
/// </summary> /// </summary>
public async Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady) public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis);
{ await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
return problem;
}
if (await CheckPermissionAsync(McpToolNames.SaveDevRequestAnalysis, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
{ {
return Problem(McpErrorCodes.DevRequestAnalysisRequired); throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]);
} }
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) var devRequest = await FindDevRequestAsync(request);
{
return notFound;
}
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis or McpConsts.DevRequestStatuses.ReadyToStart)) if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis))
{ {
return Problem(McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty); throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
} }
devRequest.Analysis = html.Trim(); devRequest.Analysis = html.Trim();
devRequest.Status = productionReady devRequest.Status = McpConsts.DevRequestStatuses.Analysis;
? McpConsts.DevRequestStatuses.ReadyToStart
: McpConsts.DevRequestStatuses.Analysis;
await devRequestRepository.UpdateAsync(devRequest, autoSave: true); await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis); await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis);
@ -722,33 +522,20 @@ 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<McpProblem?> AppendDevRequestNoteAsync(string request, string html) public async Task AppendDevRequestNoteAsync(string request, string html)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote);
{ await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
return problem;
}
if (await CheckPermissionAsync(McpToolNames.AppendDevRequestNote, DevRequestUpdatePermission) is { } denied)
{
return denied;
}
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
{ {
return Problem(McpErrorCodes.DevRequestNoteRequired); throw new UserFriendlyException(L[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); await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote);
return null;
} }
/// <summary> /// <summary>
@ -756,44 +543,29 @@ 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<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles) public async Task AddDevRequestSubTasksAsync(string request, List<string> titles)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks);
{ 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)
{ {
return Problem(McpErrorCodes.DevRequestSubTasksRequired); throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]);
} }
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound)) var devRequest = await FindDevRequestAsync(request);
{
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; : JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException();
} }
catch (JsonException) catch (JsonException)
{ {
subTasks = null; throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]);
}
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;
@ -814,26 +586,17 @@ 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); await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count);
return null;
} }
public async Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name) public async Task<ModuleDocDto> GetModuleDocAsync(string name)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc);
{ return await moduleDocAppService.GetAsync(name);
return problem;
}
return await moduleDocAppService.GetByNameAsync(name);
} }
public async Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content) public async Task<ModuleDocDto> SaveModuleDocAsync(string name, string content)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc);
{
return problem;
}
var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content }); var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content });
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name); await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name);
@ -863,23 +626,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<McpOutcome<DevRequest>> FindDevRequestAsync(string? request) private async Task<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))
{ {
var byId = await devRequestRepository.FindAsync(id); return await devRequestRepository.FindAsync(id)
return byId is null ? Problem(McpErrorCodes.DevRequestNotFound, value) : byId; ?? throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]);
} }
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 => Problem(McpErrorCodes.DevRequestNotFound, value), 0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]),
1 => matches[0], 1 => matches[0],
_ => Problem(McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))) _ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))])
}; };
} }
@ -986,16 +749,13 @@ 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<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search) public async Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.ListComponents) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.ListComponents);
{
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)
@ -1008,31 +768,24 @@ 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<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name) public async Task<CustomComponentDto?> GetComponentAsync(string name)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.GetComponent) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.GetComponent);
{
return problem;
}
return await FindComponentAsync(name); return await FindComponentAsync(name);
} }
public async Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input) public async Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (await EnsureToolAllowedAsync(McpToolNames.SaveComponent) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.SaveComponent);
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name)) if (string.IsNullOrWhiteSpace(input.Name))
{ {
return Problem(McpErrorCodes.ComponentNameRequired); throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]);
} }
var existing = await FindComponentAsync(input.Name); var existing = await FindComponentAsync(input.Name);
@ -1042,14 +795,14 @@ 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))
{ {
return Problem(McpErrorCodes.VisualDesignerComponent, input.Name); throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]);
} }
if (existing is null) if (existing is null)
{ {
if (string.IsNullOrWhiteSpace(input.Code)) if (string.IsNullOrWhiteSpace(input.Code))
{ {
return Problem(McpErrorCodes.ComponentCodeRequired); throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]);
} }
var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto
@ -1082,44 +835,52 @@ public class McpDevelopAppService(
return updated; return updated;
} }
public async Task<McpProblem?> DeleteComponentAsync(string name) public async Task DeleteComponentAsync(string name)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.DeleteComponent) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.DeleteComponent);
{
return problem;
}
var component = await FindComponentAsync(name); var component = await FindComponentAsync(name)
if (component is null) ?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]);
{
return Problem(McpErrorCodes.ComponentNotFound, name);
}
await componentAppService.DeleteAsync(component.Id); await componentAppService.DeleteAsync(component.Id);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name);
return null;
} }
/// <summary> /// <summary>
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>, basliklar toplanmis) ya da duz /// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>) ya da duz cevaplardan okur.
/// cevaplardan okur; Menu/Language/Data/Details/Layout bloklari ve WizardCode/ModuleId adlari /// Iki bicim de dosyanin okuma kuralindan gecer; boylece Menu/Language bloklari ve
/// dosyadakiyle ayni anlama gelir (<see cref="WizardSeedFileDto.FromDeployJson"/>). /// WizardCode/ModuleId adlari dosyadakiyle ayni anlama gelir.
/// </summary> /// </summary>
private McpOutcome<ListFormWizardDto> ParseWizard(string? json) private ListFormWizardDto ParseWizard(string? json)
{ {
ListFormWizardDto? wizard; JsonObject? root;
try try
{ {
wizard = string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json); root = string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json) as JsonObject;
} }
catch (JsonException ex) catch (JsonException)
{ {
// Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir. root = null;
return Problem(McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message);
} }
return wizard is null ? Problem(McpErrorCodes.InvalidWizard) : wizard; if (root is null)
{
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

@ -3,15 +3,11 @@ using System.Collections.Generic;
using System.Data.Common; 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;
@ -34,52 +30,39 @@ 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 partial class McpQueryAppService( public 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<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input) public async Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (!(await EnsureToolAllowedAsync(McpToolNames.QueryList)).TryGetValue(out var config, out var problem)) var config = await EnsureToolAllowedAsync(McpToolNames.QueryList);
{
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 && await CheckPermissionAsync(McpToolNames.QueryList, permission) is { } denied) if (permission is not null)
{ {
return denied; await AuthorizationService.CheckAsync(permission);
}
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 = sort, Sort = ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort),
Group = group, Group = ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup),
GroupSummary = groupSummary, GroupSummary = ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary),
TotalSummary = totalSummary, TotalSummary = ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary),
Skip = Math.Max(input.Skip, 0), Skip = Math.Max(input.Skip, 0),
Take = take, Take = take,
RequireTotalCount = true, RequireTotalCount = true,
@ -97,7 +80,7 @@ public partial 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 McpOutcome<string?> ToLoadOption(string parameter, string? value, Func<string?, string?> convert) private string? ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
{ {
try try
{ {
@ -105,31 +88,21 @@ public partial class McpQueryAppService(
} }
catch (Exception ex) when (ex is JsonException or InvalidOperationException) catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{ {
return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)); throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]);
} }
} }
public async Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input) public async Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem)) var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect);
{
return problem;
}
var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim(); var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim();
if (sqlGuard.CheckReadOnlyStatement(sql) is { } invalid) sqlGuard.EnsureReadOnlyStatement(sql);
{
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);
@ -142,102 +115,13 @@ public partial class McpQueryAppService(
} }
catch (DbException ex) catch (DbException ex)
{ {
var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null; throw new UserFriendlyException(L[McpErrorCodes.SelectFailed, ex.Message]);
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);
} }
} }
private const int MaxDescribedTables = 5; public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
/// <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)
{ {
var described = TableReferenceRegex().Matches(sql) await EnsureToolAllowedAsync(McpToolNames.ListQueries);
.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))
@ -257,16 +141,17 @@ public partial 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 = SqlCatalogQueries.NativeObjects(context.DataSourceType); var catalogSql = context.DataSourceType == DataSourceTypeEnum.Postgresql
? 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);
@ -292,58 +177,44 @@ public partial class McpQueryAppService(
return results; return results;
} }
public async Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input) public async Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem)) var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery);
if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint))
{ {
return problem; await AuthorizationService.CheckAsync(CustomEndpointPermission);
return await RunCustomEndpointAsync(input, config);
} }
var isCustomEndpoint = IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint); await AuthorizationService.CheckAsync(SqlPermission);
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);
if (isCustomEndpoint) var parameters = sqlGuard.NormalizeParameters(input.Parameters);
{
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)
? SqlIdentifier.DefaultSchema(context.DataSourceType) ? sqlGuard.DefaultSchemaName(context.DataSourceType)
: input.SchemaName; : input.SchemaName;
if (!sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType).TryGetValue(out var qualifiedName, out problem) || var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType);
!BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType).TryGetValue(out var sql, out problem)) var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType);
{
return problem;
}
return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows)); return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows));
} }
private async Task<McpOutcome<McpQueryResultDto>> RunCustomEndpointAsync( private async Task<McpQueryResultDto> RunCustomEndpointAsync(
McpSavedQueryRequestDto input, McpSavedQueryRequestDto input,
Dictionary<string, object> parameters, McpServerConfigDto config)
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))
if (endpoint is null) ?? throw new UserFriendlyException(L[McpErrorCodes.CustomEndpointNotFound, input.Name]);
{
return Problem(McpErrorCodes.CustomEndpointNotFound, input.Name); var maxRows = ResolveMaxRows(config, input.MaxRows);
} 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
@ -353,7 +224,7 @@ public partial class McpQueryAppService(
if (missing.Count > 0) if (missing.Count > 0)
{ {
return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing)); throw new UserFriendlyException(L[McpErrorCodes.MissingParameters, string.Join(", ", missing)]);
} }
var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode); var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode);
@ -389,7 +260,7 @@ public partial class McpQueryAppService(
|| (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))]; || (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))];
} }
private McpOutcome<string> BuildSavedQuerySql( private string BuildSavedQuerySql(
string? kind, string? kind,
string qualifiedName, string qualifiedName,
Dictionary<string, object> parameters, Dictionary<string, object> parameters,
@ -421,7 +292,7 @@ public partial class McpQueryAppService(
: $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd(); : $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd();
} }
return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty); throw new UserFriendlyException(L[McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty]);
} }
private static string? NullIfEmpty(string? value) private static string? NullIfEmpty(string? value)

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
@ -36,17 +36,15 @@ public partial class McpReportAppService(
private const string CategoriesListFormCode = PlatformConsts.AppCodes.Reports.Categories; private const string CategoriesListFormCode = PlatformConsts.AppCodes.Reports.Categories;
private const string TemplatesListFormCode = PlatformConsts.AppCodes.Reports.ReportTemplates; private const string TemplatesListFormCode = PlatformConsts.AppCodes.Reports.ReportTemplates;
private const string ActiveStatus = "active"; private const string ActiveStatus = "active";
private const string DefaultButtonText = "Sas.Platform.View"; private const string DefaultButtonText = "App.Platform.View";
[GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")] [GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")]
private static partial Regex PlaceholderRegex(); private static partial Regex PlaceholderRegex();
public async Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search) public async Task<McpReportCatalogDto> GetReportTemplatesAsync(string? search)
{ {
if (await EnsureCanReadAsync(McpToolNames.ListReportTemplates) is { } problem) await EnsureToolAllowedAsync(McpToolNames.ListReportTemplates);
{ await EnsureCanReadAsync();
return problem;
}
var categories = await categoryRepository.GetListAsync(); var categories = await categoryRepository.GetListAsync();
var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name); var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name);
@ -72,35 +70,25 @@ public partial class McpReportAppService(
}; };
} }
public async Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template) public async Task<McpReportTemplateDto> GetReportTemplateAsync(string template)
{ {
if (await EnsureCanReadAsync(McpToolNames.GetReportTemplate) is { } problem) await EnsureToolAllowedAsync(McpToolNames.GetReportTemplate);
{ await EnsureCanReadAsync();
return problem;
}
var entity = await FindTemplateAsync(template); var entity = await FindTemplateAsync(template)
if (entity is null) ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
{
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
}
return await ToDtoAsync(entity, null, null); return await ToDtoAsync(entity, null, null);
} }
public async Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input) public async Task<McpReportCategoryDto> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
const string toolName = McpToolNames.SaveReportCategory; await EnsureToolAllowedAsync(McpToolNames.SaveReportCategory);
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name)) if (string.IsNullOrWhiteSpace(input.Name))
{ {
return Problem(McpErrorCodes.ReportNameRequired); throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
} }
var name = input.Name.Trim(); var name = input.Name.Trim();
@ -112,33 +100,24 @@ public partial class McpReportAppService(
["Icon"] = input.Icon ?? existing?.Icon ["Icon"] = input.Icon ?? existing?.Icon
}; };
var saved = existing is null var id = existing is null
? await InsertAsync(toolName, CategoriesListFormCode, values) ? await InsertAsync(CategoriesListFormCode, values)
: await UpdateAsync(toolName, CategoriesListFormCode, existing.Id, values); : await UpdateAsync(CategoriesListFormCode, existing.Id, values);
if (!saved.TryGetValue(out var id, out var rejected))
{
return rejected;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name); await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name);
var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id); var saved = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
return category is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(category); return saved is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(saved);
} }
public async Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input) public async Task<McpReportTemplateDto> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
{ {
Check.NotNull(input, nameof(input)); Check.NotNull(input, nameof(input));
const string toolName = McpToolNames.SaveReportTemplate; await EnsureToolAllowedAsync(McpToolNames.SaveReportTemplate);
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
if (string.IsNullOrWhiteSpace(input.Name)) if (string.IsNullOrWhiteSpace(input.Name))
{ {
return Problem(McpErrorCodes.ReportNameRequired); throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
} }
var name = input.Name.Trim(); var name = input.Name.Trim();
@ -146,32 +125,22 @@ public partial class McpReportAppService(
var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent; var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent;
if (string.IsNullOrWhiteSpace(htmlContent)) if (string.IsNullOrWhiteSpace(htmlContent))
{ {
return Problem(McpErrorCodes.ReportHtmlRequired); throw new UserFriendlyException(L[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);
}
} }
var categoryId = string.IsNullOrWhiteSpace(input.Category)
? existing?.CategoryId
: (await FindCategoryAsync(input.Category))?.Id
?? throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryNotFound, input.Category]);
if (categoryId is null) if (categoryId is null)
{ {
return Problem(McpErrorCodes.ReportCategoryRequired); throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryRequired]);
} }
if (!(await FindListFormAsync(input.ListFormCode)).TryGetValue(out var listForm, out var notFound)) var listForm = await FindListFormAsync(input.ListFormCode);
if (listForm is not null)
{ {
return notFound; await EnsurePlaceholdersExistAsync(listForm.ListFormCode, htmlContent);
}
if (listForm is not null && await CheckPlaceholdersExistAsync(listForm.ListFormCode, htmlContent) is { } unknown)
{
return unknown;
} }
var values = new Dictionary<string, object?> var values = new Dictionary<string, object?>
@ -183,74 +152,44 @@ public partial class McpReportAppService(
["Status"] = input.Status ?? existing?.Status ?? ActiveStatus ["Status"] = input.Status ?? existing?.Status ?? ActiveStatus
}; };
var saved = existing is null var id = existing is null
? await InsertAsync(toolName, TemplatesListFormCode, values) ? await InsertAsync(TemplatesListFormCode, values)
: await UpdateAsync(toolName, TemplatesListFormCode, existing.Id, values); : await UpdateAsync(TemplatesListFormCode, existing.Id, values);
if (!saved.TryGetValue(out var id, out var rejected))
{
return rejected;
}
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name); await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name);
var template = await templateRepository.FirstOrDefaultAsync(x => x.Id == id); var saved = await templateRepository.FirstOrDefaultAsync(x => x.Id == id)
if (template is null) ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, name]);
{
return Problem(McpErrorCodes.ReportTemplateNotFound, name);
}
return await ToDtoAsync(template, listForm, input.ButtonText); return await ToDtoAsync(saved, listForm, input.ButtonText);
} }
public async Task<McpProblem?> DeleteReportTemplateAsync(string template) public async Task DeleteReportTemplateAsync(string template)
{ {
const string toolName = McpToolNames.DeleteReportTemplate; await EnsureToolAllowedAsync(McpToolNames.DeleteReportTemplate);
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem }) var entity = await FindTemplateAsync(template)
{ ?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
return problem;
}
var entity = await FindTemplateAsync(template); await listFormDataAppService.PostDeleteAsync(await BuildRequestAsync(
if (entity is null) TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id));
{
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); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteReportTemplate, entity.Name);
return null;
} }
/// <summary> /// <summary>
/// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin /// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin
/// uygulamada goremedigi sablonu gostermez. /// uygulamada goremedigi sablonu gostermez.
/// </summary> /// </summary>
private async Task<McpProblem?> EnsureCanReadAsync(string toolName) private async Task EnsureCanReadAsync()
{ {
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
{
return problem;
}
var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read); var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read);
return await CheckPermissionAsync(toolName, permission ?? TemplatesListFormCode); await AuthorizationService.CheckAsync(permission ?? TemplatesListFormCode);
} }
private async Task<McpOutcome<Guid>> InsertAsync(string toolName, string listFormCode, Dictionary<string, object?> values) private async Task<Guid> InsertAsync(string listFormCode, Dictionary<string, object?> values)
{ {
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Create, values, null)).TryGetValue(out var request, out var problem)) var key = await listFormDataAppService.PostInsertAsync(await BuildRequestAsync(
{ listFormCode, AuthorizationTypeEnum.Create, values, null));
return problem;
}
var key = await listFormDataAppService.PostInsertAsync(request);
// Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur. // Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur.
if (Guid.TryParse(((object?)key)?.ToString(), out var id)) if (Guid.TryParse(((object?)key)?.ToString(), out var id))
@ -264,14 +203,10 @@ public partial class McpReportAppService(
: (await templateRepository.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) private async Task<Guid> UpdateAsync(string listFormCode, Guid id, Dictionary<string, object?> values)
{ {
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Update, values, id)).TryGetValue(out var request, out var problem)) await listFormDataAppService.PostUpdateAsync(await BuildRequestAsync(
{ listFormCode, AuthorizationTypeEnum.Update, values, id));
return problem;
}
await listFormDataAppService.PostUpdateAsync(request);
return id; return id;
} }
@ -279,17 +214,16 @@ public partial class McpReportAppService(
/// 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.
/// </summary> /// </summary>
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync( private async Task<DataRequestDto> BuildRequestAsync(
string toolName,
string listFormCode, string listFormCode,
AuthorizationTypeEnum operation, AuthorizationTypeEnum operation,
Dictionary<string, object?>? values, Dictionary<string, object?>? values,
Guid? key) Guid? key)
{ {
var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation); var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation);
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied) if (permission is not null)
{ {
return denied; await AuthorizationService.CheckAsync(permission);
} }
return new DataRequestDto return new DataRequestDto
@ -321,34 +255,32 @@ public partial class McpReportAppService(
: await categoryRepository.FirstOrDefaultAsync(x => x.Name == value); : await categoryRepository.FirstOrDefaultAsync(x => x.Name == value);
} }
/// <summary>Kod verilmediyse null; verilen kodun ekrani yoksa sorun.</summary> private async Task<ListForm?> FindListFormAsync(string? listFormCode)
private async Task<McpOutcome<ListForm?>> FindListFormAsync(string? listFormCode)
{ {
if (string.IsNullOrWhiteSpace(listFormCode)) if (string.IsNullOrWhiteSpace(listFormCode))
{ {
return default(ListForm); return null;
} }
var code = listFormCode.Trim(); var code = listFormCode.Trim();
var listForm = await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code); return await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code)
?? throw new UserFriendlyException(L[McpErrorCodes.ReportListFormNotFound, code]);
return listForm is null ? Problem(McpErrorCodes.ReportListFormNotFound, code) : listForm;
} }
/// <summary> /// <summary>
/// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin /// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin
/// diye kayitta reddedilir. /// diye kayitta reddedilir.
/// </summary> /// </summary>
private async Task<McpProblem?> CheckPlaceholdersExistAsync(string listFormCode, string htmlContent) private async Task EnsurePlaceholdersExistAsync(string listFormCode, string htmlContent)
{ {
var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode)) var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode))
.Select(x => x.FieldName) .Select(x => x.FieldName)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList(); var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList();
if (unknown.Count > 0)
return unknown.Count == 0 {
? null throw new UserFriendlyException(L[McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)]);
: Problem(McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)); }
} }
private static List<string> GetPlaceholders(string htmlContent) private static List<string> GetPlaceholders(string htmlContent)

View file

@ -1,17 +1,11 @@
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;
@ -26,17 +20,9 @@ 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();
@ -48,9 +34,20 @@ public class McpSchemaAppService(
CurrentTenant.IsAvailable, CurrentTenant.IsAvailable,
config?.AllowedTools ?? []); config?.AllowedTools ?? []);
var granted = await permissionChecker.GetGrantedNamesAsync(candidates var required = 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))];
@ -82,12 +79,9 @@ public class McpSchemaAppService(
}; };
} }
public async Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200) public async Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.ListLists) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.ListLists);
{
return problem;
}
var forms = await GetReadableListFormsAsync(); var forms = await GetReadableListFormsAsync();
var normalizedSearch = dictionary.Normalize(search); var normalizedSearch = dictionary.Normalize(search);
@ -95,19 +89,16 @@ 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).ToList(); return [.. matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary)];
} }
public async Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode) public async Task<McpListDetailDto?> GetListAsync(string listFormCode)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.DescribeList) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.DescribeList);
{
return problem;
}
if (string.IsNullOrWhiteSpace(listFormCode)) if (string.IsNullOrWhiteSpace(listFormCode))
{ {
return default(McpListDetailDto); return null;
} }
var forms = await GetReadableListFormsAsync(); var forms = await GetReadableListFormsAsync();
@ -116,7 +107,7 @@ public class McpSchemaAppService(
if (form is null) if (form is null)
{ {
return default(McpListDetailDto); return null;
} }
var detail = new McpListDetailDto(); var detail = new McpListDetailDto();
@ -126,17 +117,14 @@ public class McpSchemaAppService(
return detail; return detail;
} }
public async Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50) public async Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50)
{ {
if (await EnsureToolAllowedAsync(McpToolNames.SearchSchema) is { Problem: { } problem }) await EnsureToolAllowedAsync(McpToolNames.SearchSchema);
{
return problem;
}
var normalized = dictionary.Normalize(term); var normalized = dictionary.Normalize(term);
if (normalized.Length == 0) if (normalized.Length == 0)
{ {
return new List<McpSchemaMatchDto>(); return [];
} }
var limit = NormalizeLimit(maxResults, 50); var limit = NormalizeLimit(maxResults, 50);
@ -180,93 +168,7 @@ public class McpSchemaAppService(
var forms = await dictionary.GetListFormsAsync(); var forms = await dictionary.GetListFormsAsync();
var readable = await accessFilter.GetReadableListFormCodesAsync(); var readable = await accessFilter.GetReadableListFormCodesAsync();
List<McpDictionaryListForm> result = [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))]; return [.. 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,13 +44,6 @@ 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.
@ -74,7 +67,6 @@ 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>
@ -106,7 +98,7 @@ public static class McpConsts
} }
/// <summary> /// <summary>
/// Gelistirme talebi (<c>Sas.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup /// Gelistirme talebi (<c>App.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/data/Sas.Languages.LanguageText/{CultureName}.json) okunur; modul ikinci bir dil /// <c>PlatformResource</c> kaynagindan (configs/seeds/host/data/App.Languages.LanguageText.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/data/Sas.Languages.LanguageText/{CultureName}.json</c> /// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/host/data/App.Languages.LanguageText.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 = "Sas.Mcp.Error."; private const string Prefix = "App.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";
@ -33,12 +33,9 @@ public static class McpErrorCodes
public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound"; public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound";
public const string DevGuideSearchMatches = Prefix + "DevGuideSearchMatches"; 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";
@ -51,8 +48,6 @@ public static class McpErrorCodes
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 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";
@ -80,8 +75,6 @@ public static class McpErrorCodes
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

@ -1,54 +0,0 @@
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

@ -2,18 +2,16 @@ namespace Sozsoft.Mcp;
/// <summary> /// <summary>
/// MCP islemlerinin talep Timesheets satirina yazilan aciklamalari. Karsiliklari platformun /// 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. /// <c>App.Languages.LanguageText.json</c> dosyasinda EN + TR olarak durur; satir Turkce yazilir.
/// </summary> /// </summary>
public static class McpTimesheetTexts public static class McpTimesheetTexts
{ {
private const string Prefix = "Sas.Mcp.Timesheet."; private const string Prefix = "App.Mcp.Timesheet.";
public const string Analysis = Prefix + "Analysis"; public const string Analysis = Prefix + "Analysis";
public const string AppendNote = Prefix + "AppendNote"; public const string AppendNote = Prefix + "AppendNote";
public const string AddSubTasks = Prefix + "AddSubTasks"; public const string AddSubTasks = Prefix + "AddSubTasks";
public const string CreateTable = Prefix + "CreateTable"; 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 DeployWizard = Prefix + "DeployWizard";
public const string DeleteWizard = Prefix + "DeleteWizard"; public const string DeleteWizard = Prefix + "DeleteWizard";
public const string SaveComponent = Prefix + "SaveComponent"; public const string SaveComponent = Prefix + "SaveComponent";

View file

@ -24,8 +24,6 @@ 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";
@ -57,8 +55,6 @@ public static class McpToolNames
[ [
GetDevGuide, GetDevGuide,
CreateTable, CreateTable,
SaveSqlObject,
GenerateCrudEndpoints,
ListWizards, ListWizards,
GetWizard, GetWizard,
DeployWizard, DeployWizard,
@ -123,8 +119,6 @@ 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,
@ -143,7 +137,7 @@ public static class McpToolNames
/// 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>Sas.DevRequests.Update</c>) reddi zaten /// <c>Wizards.Create/Update</c>, Custom Components, <c>App.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[]>

View file

@ -1,32 +0,0 @@
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,15 +7,16 @@ using Volo.Abp.DependencyInjection;
namespace Sozsoft.Mcp.Data; namespace Sozsoft.Mcp.Data;
/// <summary> /// <summary>
/// Bilinen MCP istemcilerini (<see cref="McpConsts.OAuthClientId"/>, /// Bilinen MCP istemcisini (<see cref="McpConsts.OAuthClientId"/>) seed eder. Geri donus
/// <see cref="McpConsts.LocalOAuthClientId"/>) seed eder. Geri donus adresleri /// adresleri <c>OpenIddict:Applications:Platform_Mcp:RedirectUris</c> ayarindadir; yeni bir
/// <c>OpenIddict:Applications:{ClientId}:RedirectUris</c> ayarindadir; yeni bir saglayici eklemek /// saglayici eklemek bu listeye callback eklemektir.
/// 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.
@ -24,15 +25,12 @@ public class McpOAuthClientDataSeedContributor(
return; return;
} }
foreach (var clientId in new[] { McpConsts.OAuthClientId, McpConsts.LocalOAuthClientId }) var redirectUris = configuration.GetSection(RedirectUrisKey).Get<string[]>();
if (redirectUris is null || redirectUris.Length == 0)
{ {
var redirectUris = configuration.GetSection($"OpenIddict:Applications:{clientId}:RedirectUris").Get<string[]>(); return;
if (redirectUris is null || redirectUris.Length == 0)
{
continue;
}
await clientManager.EnsureKnownClientAsync(clientId, redirectUris);
} }
await clientManager.EnsureKnownClientAsync(redirectUris);
} }
} }

View file

@ -55,7 +55,17 @@ public class McpListFormAccessFilter(
} }
} }
var granted = await permissionChecker.GetGrantedNamesAsync(readPermissions.Values); var distinctPermissions = readPermissions.Values.Distinct(StringComparer.Ordinal).ToArray();
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,22 +6,18 @@ 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>: geri donus adresleri DbMigrator ayarinda sabit olan istemciler — /// <item><b>Bilinen istemci</b> (<see cref="McpConsts.OAuthClientId"/>): geri donus adresleri
/// <see cref="McpConsts.OAuthClientId"/> (Claude, ChatGPT; onay ekrani gostermez) ve /// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez.</item>
/// <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
@ -32,10 +28,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(string clientId, IReadOnlyCollection<string> redirectUris); Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris);
/// <summary> /// <summary>
/// Istemci kaydi. Adresler bir bilinen istemcide kayitliysa onu, degilse ayni adres kumesine /// Istemci kaydi. Adresler 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);
@ -44,33 +40,25 @@ 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(string clientId, IReadOnlyCollection<string> redirectUris) public async Task EnsureKnownClientAsync(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(clientId); var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
var descriptor = BuildDescriptor(clientId, displayName, redirectUris, consentType); var descriptor = BuildDescriptor(
McpConsts.OAuthClientId,
"MCP Connectors",
redirectUris,
OpenIddictConstants.ConsentTypes.Implicit);
if (existing is null) if (existing is null)
{ {
@ -100,63 +88,42 @@ public class McpOAuthClientManager(
using (currentTenant.Change(null)) using (currentTenant.Change(null))
{ {
foreach (var knownClientId in KnownClients.Keys) var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
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(knownClientId); return McpClientRegistration.Accepted(McpConsts.OAuthClientId);
} }
} }
// Masaustu istemcisi ayni anda birden cok proxy sureci baslatir ve hepsi ayni adreslerle var dynamicClients = await GetDynamicClientsAsync();
// 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.");
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); // Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
var registration = await RegisterDynamicClientAsync(clientName, redirectUris); // baglanir, tablo buyumez.
await uow.CompleteAsync(); var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
if (match.ClientId is not null)
{
return McpClientRegistration.Accepted(match.ClientId);
}
return registration; 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<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,7 +5,6 @@ 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;
@ -44,6 +43,11 @@ 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()
@ -77,7 +81,7 @@ public class McpSchemaDictionary(
return []; return [];
} }
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, PlatformJsonOptions.CaseInsensitive); var document = JsonSerializer.Deserialize<DictionaryDocument>(json, DictionaryJsonOptions);
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,11 +16,14 @@ namespace Sozsoft.Mcp.Services;
/// </summary> /// </summary>
public interface IMcpSqlGuard public interface IMcpSqlGuard
{ {
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur.</summary> /// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar.</summary>
McpProblem? CheckReadOnlyStatement(string sql); void EnsureReadOnlyStatement(string sql);
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary> /// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType); 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
@ -32,11 +35,16 @@ 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>
McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters); 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);
@ -70,18 +78,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 McpProblem? CheckReadOnlyStatement(string sql) public void EnsureReadOnlyStatement(string sql)
{ {
if (string.IsNullOrWhiteSpace(sql)) if (string.IsNullOrWhiteSpace(sql))
{ {
return McpProblem.From(localizer, McpErrorCodes.EmptyQuery); throw new UserFriendlyException(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))
{ {
return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly); throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]);
} }
var trimmed = stripped.TrimStart(); var trimmed = stripped.TrimStart();
@ -90,29 +98,30 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
if (!startsWithRead) if (!startsWithRead)
{ {
return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired); throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]);
} }
var forbidden = ForbiddenStatementRegex.Match(stripped); var forbidden = ForbiddenStatementRegex.Match(stripped);
if (forbidden.Success)
return forbidden.Success
? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
: null;
}
public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
{
foreach (var name in (string?[])[schemaName, objectName])
{ {
if (!SqlIdentifier.IsSafe(name)) throw new UserFriendlyException(
{ localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]);
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
}
} }
return SqlIdentifier.QuoteQualified(schemaName, objectName, dataSourceType);
} }
public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
{
EnsureSafeIdentifier(schemaName);
EnsureSafeIdentifier(objectName);
return dataSourceType == DataSourceTypeEnum.Postgresql
? $"\"{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)
@ -135,7 +144,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
: sql; : sql;
} }
public McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters) public 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)
@ -147,7 +156,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
{ {
if (key is null || !SafeParameterNameRegex.IsMatch(key)) if (key is null || !SafeParameterNameRegex.IsMatch(key))
{ {
return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty); throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]);
} }
normalized[key.TrimStart('@')] = value!; normalized[key.TrimStart('@')] = value!;
@ -155,4 +164,12 @@ 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

@ -1,112 +0,0 @@
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,7 +6,6 @@ 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;
@ -18,13 +17,9 @@ 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 yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo /// sonrasi yazdigi yere (<c>{kapsam}/sql/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.
@ -32,7 +27,7 @@ namespace Sozsoft.Mcp.Services;
/// </summary> /// </summary>
public interface IMcpTableCreator public interface IMcpTableCreator
{ {
Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition); Task<McpCreatedTable> CreateAsync(McpTableDefinition definition);
} }
public class McpTableCreator( public class McpTableCreator(
@ -44,6 +39,14 @@ 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(
@"^[A-Za-z_][A-Za-z0-9_]{0,127}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
/// </summary>
/// <summary> /// <summary>
/// Istemcinin SQL bicimiyle yazdigi tip: <c>nvarchar(50)</c>, <c>nvarchar (MAX)</c>, /// 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. /// <c>decimal(18, 4)</c>. Boyut tipten ayrilir; tip adi yine beyaz listeden gecer.
@ -55,10 +58,6 @@ public class McpTableCreator(
/// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary> /// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary>
private const string DecimalSize = "18,4"; private const string DecimalSize = "18,4";
/// <summary>
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
/// </summary>
private static readonly Regex SafeDefaultRegex = new( private static readonly Regex SafeDefaultRegex = new(
@"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$", @"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
@ -71,16 +70,6 @@ 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);
@ -96,87 +85,59 @@ public class McpTableCreator(
new("DeleterId", "uniqueidentifier", null, true, null) new("DeleterId", "uniqueidentifier", null, true, null)
]; ];
public async Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition) public async Task<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();
if (CheckIdentifier(tableName) is { } invalidName) EnsureSafeIdentifier(tableName);
{
return invalidName;
}
if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) || var columns = BuildColumns(definition);
!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();
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql; if (context.DataSourceType != DataSourceTypeEnum.Mssql)
async Task<bool> TableExistsAsync(string name)
{ {
var existing = await context.Repository.ExecuteScalarAsync<object?>( throw new UserFriendlyException(localizer[McpErrorCodes.TableProviderNotSupported, context.DataSourceType.ToString()]);
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;
} }
// Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun var existing = await context.Repository.ExecuteScalarAsync<int?>(
// scriptinin ustune yazmaz. "SELECT OBJECT_ID(@name, 'U')",
if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0) context.ConnectionString,
new Dictionary<string, object> { ["name"] = $"{SchemaName}.{tableName}" });
if (existing is not null)
{ {
return Problem(McpErrorCodes.TableAlreadyExists, tableName); throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]);
} }
foreach (var foreignKey in foreignKeys) var script = BuildScript(tableName, columns);
{
if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled)
{
return notInstalled;
}
}
// Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir. // GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir.
var script = BuildScript(tableName, columns, indexes, foreignKeys); await context.Repository.ExecuteAsync(
if (!PostgreSqlTableScript.TryConvert(script, out _)) string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")),
{ context.ConnectionString);
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
}
// Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin var seedFilePath = SaveSeedScript(tableName, script);
// 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 McpOutcome<List<McpTableColumn>> BuildColumns(McpTableDefinition definition) private List<McpTableColumn> BuildColumns(McpTableDefinition definition)
{ {
if (definition.Columns.Count == 0) if (definition.Columns.Count == 0)
{ {
return Problem(McpErrorCodes.TableColumnsRequired); throw new UserFriendlyException(localizer[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);
} }
foreach (var column in definition.Columns) columns.AddRange(userColumns);
{
if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem))
{
return problem;
}
columns.Add(normalized);
}
if (definition.FullAudited) if (definition.FullAudited)
{ {
@ -187,121 +148,38 @@ 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);
return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key); if (duplicate is not null)
}
/// <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>(); throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]);
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; return columns;
} }
private McpOutcome<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns) private McpTableColumn NormalizeColumn(McpTableColumn column)
{
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();
if (CheckIdentifier(name) is { } invalidName) EnsureSafeIdentifier(name);
{
return invalidName;
}
if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem)) var (type, sizedLength) = ResolveType(column.Type);
{
return problem;
}
int? maxLength = null; int? maxLength = null;
if (resolved.Type == "nvarchar") if (type == "nvarchar")
{ {
maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength; maxLength = column.MaxLength ?? sizedLength ?? DefaultTextLength;
if (maxLength is < 1 or > MaxTextLength) if (maxLength is < 1 or > MaxTextLength)
{ {
return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength); throw new UserFriendlyException(localizer[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))
{ {
return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue); throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]);
} }
return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue); return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue);
} }
/// <summary> /// <summary>
@ -309,7 +187,7 @@ public class McpTableCreator(
/// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir — /// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir —
/// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi. /// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi.
/// </summary> /// </summary>
private McpOutcome<ResolvedType> ResolveType(string? rawType) private (string Type, int? MaxLength) ResolveType(string? rawType)
{ {
var trimmed = rawType?.Trim() ?? string.Empty; var trimmed = rawType?.Trim() ?? string.Empty;
var name = trimmed; var name = trimmed;
@ -339,16 +217,15 @@ public class McpTableCreator(
} }
} }
var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); var type = SupportedTypes.FirstOrDefault(candidate =>
string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase))
?? throw new UserFriendlyException(
localizer[McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)]);
return type is null return (type, maxLength);
? 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)
private static string BuildScript(string tableName, List<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
{ {
var fullTableName = $"[{SchemaName}].[{tableName}]"; var fullTableName = $"[{SchemaName}].[{tableName}]";
@ -369,42 +246,6 @@ 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);
} }
@ -423,30 +264,42 @@ public class McpTableCreator(
return $" [{column.Name}] {type} {nullPart}{defaultPart}"; return $" [{column.Name}] {type} {nullPart}{defaultPart}";
} }
private McpProblem? CheckIdentifier(string identifier) /// <summary>
=> SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier); /// Tasarimcinin <c>SaveTableScriptAsync</c> ile yazdigi yol: kapsam istegin kiracisini izler.
/// 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);
private McpProblem Problem(string name, params object[] arguments) Directory.CreateDirectory(outputPath);
=> 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,10 +1,7 @@
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;
@ -75,27 +72,23 @@ public class McpHttpApiModule : AbpModule
} }
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex)) catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
{ {
throw new McpException(await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null), ex); throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null);
} }
return result; return result;
}); });
// MCP'nin kendi reddi istisna degildir (McpProblem). Arkadaki platform servisinin yetki // SDK arac hatasinin mesajini yalnizca McpException ise iletir; yetki reddi ve is
// reddi ve is kurali hatasi ise istisnadir; firlatilirsa SDK onu yigin izli ERR olarak // kurali hatalari burada cevrilir ki istemci nedenini (eksik yetkiyi) gorsun.
// 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))
{ {
var message = await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name); throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
return McpToolResult.Error(message);
} }
}); });
}); });

View file

@ -24,8 +24,8 @@ 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; 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. " + "write the analysis with save_dev_request_analysis, produce nothing and never touch Description; the user moves an approved analysis to Ready to Start. " +
"Development requests (Sas.DevRequests) follow get_dev_guide section 0.11: claim each one with " + "Development requests (App.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), complete the module document with get_module_doc / save_module_doc (section 0.13), then 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; " +

View file

@ -1,69 +0,0 @@
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,8 +1,9 @@
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;
@ -12,12 +13,11 @@ using Volo.Abp.DependencyInjection;
namespace Sozsoft.Mcp; namespace Sozsoft.Mcp;
/// <summary> /// <summary>
/// Aracin arkasindaki platform servislerinden (wizard, custom component, ListForm verisi) ve sinif /// Arac hatalarini istemcinin gorebilecegi bicime cevirir. SDK yalnizca <see cref="McpException"/>
/// duzeyindeki <c>[Authorize]</c>'dan gelen istisnalari istemcinin gorebilecegi mesaja cevirir. MCP'nin /// mesajini iletir; diger her istisna "An error occurred invoking" olarak gider ve model neden
/// kendi reddi istisna degildir, <see cref="McpProblem"/> olarak doner. SDK cevrilmemis istisnayi /// reddedildigini bilemez. Yetki reddinde eksik yetkiler adiyla, is kurali hatasinda
/// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde /// (<see cref="UserFriendlyException"/>) zaten yerellestirilmis mesaj iletilir. Diger istisnalar
/// eksik yetkiler adiyla, is kurali hatasinda (<see cref="UserFriendlyException"/>) zaten /// cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
/// 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<string> TranslateAsync(Exception exception, string? toolName) public async Task<McpException> TranslateAsync(Exception exception, string? toolName)
=> exception is AbpAuthorizationException authorization => exception is AbpAuthorizationException authorization
? await DescribeMissingPermissionsAsync(authorization, toolName) ? new McpException(await DescribeMissingPermissionsAsync(authorization, toolName), exception)
: exception.Message; : new McpException(exception.Message, exception);
/// <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,11 +43,10 @@ public class McpToolErrorTranslator(
candidates.Add(policyName); candidates.Add(policyName);
} }
var granted = await permissionChecker.GetGrantedNamesAsync(candidates); var grants = await permissionChecker.IsGrantedAsync([.. candidates.Distinct(StringComparer.Ordinal)]);
var missing = candidates var missing = grants.Result
.Distinct(StringComparer.Ordinal) .Where(result => result.Value != PermissionGrantResult.Granted)
.Where(permission => !granted.Contains(permission)) .Select(result => Describe(result.Key))
.Select(permission => McpPermissionText.Describe(localizer, permission))
.ToList(); .ToList();
var target = toolName ?? McpConsts.EndpointPath; var target = toolName ?? McpConsts.EndpointPath;
@ -56,4 +55,14 @@ 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

@ -1,18 +0,0 @@
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,7 +1,6 @@
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;
@ -17,43 +16,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<CallToolResult> InsertRowAsync( public async Task<McpWriteResultDto> 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)
=> McpToolResult.From(await dataAppService.InsertRowAsync(new McpRowRequestDto => 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<CallToolResult> UpdateRowAsync( public async Task<McpWriteResultDto> 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)
=> McpToolResult.From(await dataAppService.UpdateRowAsync(new McpRowRequestDto => 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<CallToolResult> DeleteRowAsync( public async Task<McpWriteResultDto> 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)
=> McpToolResult.From(await dataAppService.DeleteRowAsync(new McpRowRequestDto => await dataAppService.DeleteRowAsync(new McpRowRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Keys = keys ?? [] Keys = keys ?? []
})); });
} }

View file

@ -13,46 +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; uretime hazirsa Ready to Start'a, degilse Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")] [Description("Tek bir gelistirme talebini analiz eder ve 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)
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " + => $"App.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, Uretime hazir satiri ve talebin yeni durumu (Ready to Start ya da Analysis)."; "Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar ve Uretime hazir satiri; talebi Ready to Start'a kullanicinin alacagini belirt.";
[McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)] [McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)]
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder; uretime hazir olanlari Ready to Start'a, digerlerini Analysis durumuna alir.")] [Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder ve Analysis durumuna alir.")]
public static string AnalyzeBacklogRequests() public static string AnalyzeBacklogRequests()
=> "Sas.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " + => "App.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, Uretime hazir durumu ve talebin yeni durumu (Ready to Start ya da Analysis)."; "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.";
[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)
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " + => $"App.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. " + "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. " + "Testing'e almadan once uretilen modulun dokumanini get_module_doc ile oku, todo bolumlerini save_module_doc ile 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()
=> "Sas.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " + => "App.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. " + "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. " + "Her talepte Testing'e almadan once modul dokumanini 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,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -18,208 +17,155 @@ namespace Sozsoft.Mcp.Tools;
/// yolla ayni sekilde olusur. /// yolla ayni sekilde olusur.
/// </summary> /// </summary>
[McpServerToolType] [McpServerToolType]
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMcpImageShrinker imageShrinker) public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
{ {
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<CallToolResult> GetDevGuideAsync( public async Task<McpDevGuideDto> 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), baslik metninin bir parcasi ya da aranacak terim (dxImageUpload). Baslikla eslesmeyen terimde terimin gectigi bolumler listelenir; 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)
=> McpToolResult.From(await developAppService.GetDevGuideAsync(document, section)); => 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 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.")] [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.")]
public async Task<CallToolResult> CreateTableAsync( public async Task<McpCreateTableResultDto> 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 (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).")] [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().")]
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)
[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.")] => await developAppService.CreateTableAsync(new McpCreateTableRequestDto
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<CallToolResult> ListWizardsAsync( public async Task<List<WizardFileInfoDto>> 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)
=> McpToolResult.From(await developAppService.GetWizardsAsync(search)); => await developAppService.GetWizardsAsync(search);
[McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)]
[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.")] [Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur (Wizard / ListForm / Fields bolumleri). Var olan bir ekrani degistirmeden once oku.")]
public async Task<CallToolResult> GetWizardAsync( public async Task<McpWizardFileDto> GetWizardAsync(
[Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")] [Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")]
string fileName) string fileName)
=> McpToolResult.From(await developAppService.GetWizardAsync(fileName)); => 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. 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.")] [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. 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. 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<CallToolResult> DeployWizardAsync( public async Task<WizardFileInfoDto?> DeployWizardAsync(
[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.")] [Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi) ya da duz wizard cevaplari. Enum alanlari sayi olarak yazilir.")]
string wizard, string wizard,
[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.")] [Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak.")]
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)
=> McpToolResult.From(await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows })); => 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 Sas.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 App.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")]
public async Task<CallToolResult> DeleteWizardAsync( public async Task<WizardDeleteResultDto> 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)
=> McpToolResult.From(await developAppService.DeleteWizardAsync(fileName, dropTables)); => 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<CallToolResult> VerifyWizardAsync( public async Task<McpVerifyResultDto> 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)
=> McpToolResult.From(await developAppService.VerifyWizardAsync(fileName)); => await developAppService.VerifyWizardAsync(fileName);
[McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)] [McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)]
[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.")] [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.")]
public async Task<CallToolResult> StartDevRequestAsync( public async Task<IList<ContentBlock>> 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)
=> McpToolResult.From(await developAppService.StartDevRequestAsync(request), ToContent); => ToContent(await developAppService.StartDevRequestAsync(request));
[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<CallToolResult> GetDevRequestAsync( public async Task<IList<ContentBlock>> GetDevRequestAsync(
[Description("Talebin Id'si (GUID) ya da tam basligi.")] [Description("Talebin Id'si (GUID) ya da tam basligi.")]
string request) string request)
=> McpToolResult.From(await developAppService.GetDevRequestAsync(request), ToContent); => ToContent(await developAppService.GetDevRequestAsync(request));
[McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)] [McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)]
[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.")] [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.")]
public async Task<CallToolResult> SaveDevRequestAnalysisAsync( public async Task<IList<ContentBlock>> 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)
[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.")] => ToContent(await developAppService.SaveDevRequestAnalysisAsync(request, html));
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<CallToolResult> AppendDevRequestNoteAsync( public async Task<string> 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<CallToolResult> AddDevRequestSubTasksAsync( public async Task<string> 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)] [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.")] [Description("Modul dokumanini (docs/modules/{Modul}.md, seed klasoru) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. isComplete false ise missingSections doldurulmamis bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu oku. Ad yerine ekranin kodu (App.Hr.PerformanceReview) ya da wizard dosya adi da verilebilir; deploy_wizard dokumani yoksa iskeletini kendisi acar.")]
public async Task<CallToolResult> GetModuleDocAsync( public async Task<ModuleDocDto> GetModuleDocAsync(
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")] [Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
string name) string name)
=> McpToolResult.From(await developAppService.GetModuleDocAsync(name)); => await developAppService.GetModuleDocAsync(name);
[McpServerTool(Name = McpToolNames.SaveModuleDoc, Destructive = true, Idempotent = true)] [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.")] [Description("Modul dokumaninin elle yazilan bolumlerini kaydeder (Markdown, dosyanin tamami). Uretimin parcasidir: deploy_wizard'dan sonra once get_module_doc ile iskeleti oku, module-doc:todo isaretli her 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 gonder; bos kalacak bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Ilk satirdaki '<!-- module-doc: claims=... -->' beyanini koru. Talep, dokumanlari tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")]
public async Task<CallToolResult> SaveModuleDocAsync( public async Task<ModuleDocDto> SaveModuleDocAsync(
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")] [Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
string name, string name,
[Description("Bolumun (ya da dosya adi verildiyse dosyanin) tamami, Markdown.")] [Description("Dokumanin tamami, Markdown.")]
string content) string content)
=> McpToolResult.From(await developAppService.SaveModuleDocAsync(name, content)); => 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. Sonucun tamami istemcinin sinirina sigar: kalan /// bicimde gorur, base64 metni olarak goremez.
/// pay gorsellere esit bolunur, payini asan gorsel kucultulur, kucultulse de sigmayan gonderilmez
/// ve hangisinin eksik oldugu metinde bildirilir.
/// </summary> /// </summary>
private List<ContentBlock> ToContent(McpDevRequestDto devRequest) private static List<ContentBlock> ToContent(McpDevRequestDto devRequest)
{ {
var text = JsonSerializer.Serialize(devRequest, TextOptions); List<ContentBlock> content = [new TextContentBlock { Text = JsonSerializer.Serialize(devRequest, TextOptions) }];
List<ContentBlock> content = [new TextContentBlock { Text = text }];
var remaining = MaxResultLength - text.Length; foreach (var image in devRequest.Images)
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(shrunk.Data), MimeType = shrunk.MimeType }); content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(image.Data), MimeType = image.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;
@ -227,21 +173,21 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
[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<CallToolResult> ListComponentsAsync( public async Task<List<McpComponentSummaryDto>> 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)
=> McpToolResult.From(await developAppService.GetComponentsAsync(search)); => 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<CallToolResult> GetComponentAsync( public async Task<CustomComponentDto?> GetComponentAsync(
[Description("Bilesenin adi.")] [Description("Bilesenin adi.")]
string name) string name)
=> McpToolResult.From(await developAppService.GetComponentAsync(name)); => 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<CallToolResult> SaveComponentAsync( public async Task<CustomComponentDto> 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.")]
@ -256,7 +202,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
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)
=> McpToolResult.From(await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto => await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
{ {
Name = name, Name = name,
Code = code, Code = code,
@ -265,12 +211,12 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMc
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<CallToolResult> DeleteComponentAsync( public async Task DeleteComponentAsync(
[Description("Bilesenin adi.")] [Description("Bilesenin adi.")]
string name) string name)
=> McpToolResult.From(await developAppService.DeleteComponentAsync(name)); => await developAppService.DeleteComponentAsync(name);
} }

View file

@ -1,7 +1,6 @@
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;
@ -13,7 +12,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<CallToolResult> QueryListAsync( public async Task<McpListQueryResultDto> 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.")]
@ -30,7 +29,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)
=> McpToolResult.From(await queryAppService.QueryListAsync(new McpListQueryRequestDto => await queryAppService.QueryListAsync(new McpListQueryRequestDto
{ {
ListFormCode = listFormCode, ListFormCode = listFormCode,
Filter = filter, Filter = filter,
@ -40,34 +39,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. 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.")] [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<CallToolResult> RunSelectAsync( public async Task<McpQueryResultDto> 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)
=> McpToolResult.From(await queryAppService.RunSelectAsync(new McpSelectRequestDto => 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<CallToolResult> ListQueriesAsync( public async Task<List<McpSavedQueryDto>> 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)
=> McpToolResult.From(await queryAppService.GetSavedQueriesAsync(search)); => 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<CallToolResult> RunQueryAsync( public async Task<McpQueryResultDto> 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.")]
@ -78,12 +77,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)
=> McpToolResult.From(await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto => 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

@ -1,6 +1,5 @@
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,37 +15,37 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
{ {
[McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)] [McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)]
[Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")] [Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")]
public async Task<CallToolResult> ListReportTemplatesAsync( public async Task<McpReportCatalogDto> ListReportTemplatesAsync(
[Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")] [Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
string? search = null) string? search = null)
=> McpToolResult.From(await reportAppService.GetReportTemplatesAsync(search)); => await reportAppService.GetReportTemplatesAsync(search);
[McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)] [McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)]
[Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")] [Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")]
public async Task<CallToolResult> GetReportTemplateAsync( public async Task<McpReportTemplateDto> GetReportTemplateAsync(
[Description("Sablonun Id'si ya da adi.")] [Description("Sablonun Id'si ya da adi.")]
string template) string template)
=> McpToolResult.From(await reportAppService.GetReportTemplateAsync(template)); => await reportAppService.GetReportTemplateAsync(template);
[McpServerTool(Name = McpToolNames.SaveReportCategory, Destructive = true, Idempotent = true)] [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.")] [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( public async Task<McpReportCategoryDto> SaveReportCategoryAsync(
[Description("Kategorinin adi; kimligidir.")] [Description("Kategorinin adi; kimligidir.")]
string name, string name,
[Description("Aciklama.")] [Description("Aciklama.")]
string? description = null, string? description = null,
[Description("Ikon adi.")] [Description("Ikon adi.")]
string? icon = null) string? icon = null)
=> McpToolResult.From(await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto => await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
{ {
Name = name, Name = name,
Description = description, Description = description,
Icon = icon Icon = icon
})); });
[McpServerTool(Name = McpToolNames.SaveReportTemplate, Destructive = true, Idempotent = true)] [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.")] [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( public async Task<McpReportTemplateDto> SaveReportTemplateAsync(
[Description("Sablonun adi; kimligidir.")] [Description("Sablonun adi; kimligidir.")]
string name, string name,
[Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")] [Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")]
@ -59,9 +58,9 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
string? status = null, string? status = null,
[Description("Sablonun basilacagi ekranin kodu (ornegin katilimci listesi). Verilirse yer tutucular dogrulanir ve satir butonu doner.")] [Description("Sablonun basilacagi ekranin kodu (ornegin katilimci listesi). Verilirse yer tutucular dogrulanir ve satir butonu doner.")]
string? listFormCode = null, string? listFormCode = null,
[Description("Satir butonunun dil anahtari. Verilmezse Sas.Platform.View.")] [Description("Satir butonunun dil anahtari. Verilmezse App.Platform.View.")]
string? buttonText = null) string? buttonText = null)
=> McpToolResult.From(await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto => await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
{ {
Name = name, Name = name,
Category = category, Category = category,
@ -70,12 +69,12 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
Status = status, Status = status,
ListFormCode = listFormCode, ListFormCode = listFormCode,
ButtonText = buttonText ButtonText = buttonText
})); });
[McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)] [McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)]
[Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")] [Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")]
public async Task<CallToolResult> DeleteReportTemplateAsync( public async Task DeleteReportTemplateAsync(
[Description("Sablonun Id'si ya da adi.")] [Description("Sablonun Id'si ya da adi.")]
string template) string template)
=> McpToolResult.From(await reportAppService.DeleteReportTemplateAsync(template)); => await reportAppService.DeleteReportTemplateAsync(template);
} }

View file

@ -1,7 +1,6 @@
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;
@ -32,29 +31,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. 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.")] [Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner.")]
public async Task<CallToolResult> ListListsAsync( public async Task<List<McpListSummaryDto>> 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)
=> McpToolResult.From(await schemaAppService.GetListsAsync(search, maxResults)); => 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<CallToolResult> DescribeListAsync( public async Task<McpListDetailDto?> DescribeListAsync(
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")] [Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
string listFormCode) string listFormCode)
=> McpToolResult.From(await schemaAppService.GetListAsync(listFormCode)); => 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<CallToolResult> SearchSchemaAsync( public async Task<List<McpSchemaMatchDto>> 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)
=> McpToolResult.From(await schemaAppService.SearchAsync(term, maxResults)); => 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

@ -1,38 +0,0 @@
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

@ -12,6 +12,14 @@ 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)]
@ -20,6 +28,14 @@ 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)]
@ -37,6 +53,14 @@ 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)]
@ -71,4 +95,12 @@ public partial class CreateUpdateNotificationRuleDtoToNotificationRuleMapper : M
[MapperIgnoreTarget(nameof(NotificationRule.TenantId))] [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>Adm.Notifications.NotificationTypes.json</c>). /// yazilir; tip kaydi seed ile gelir (<c>App.Notifications.NotificationTypes.json</c>).
/// </summary> /// </summary>
public const string ManualTypeName = "Manuel"; public const string ManualTypeName = "Manuel";
} }

View file

@ -1,30 +0,0 @@
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,7 +70,6 @@ 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,
@ -81,8 +80,7 @@ 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;
@ -94,7 +92,6 @@ 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)
@ -211,36 +208,30 @@ public class NotificationManager : INotificationManager
.ToListAsync(); .ToListAsync();
var notifications = new List<Notification>(); var notifications = new List<Notification>();
foreach (var channel in channels)
// {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)
{ {
foreach (var user in users) var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
if (identifier.IsNullOrWhiteSpace())
{ {
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel); result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
if (identifier.IsNullOrWhiteSpace()) continue;
{
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
continue;
}
notifications.Add(new Notification
{
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
});
} }
notifications.Add(new Notification
{
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,6 +3,7 @@
namespace Sozsoft.Sender.Localization; namespace Sozsoft.Sender.Localization;
[LocalizationResourceName("SozsoftSender")] [LocalizationResourceName("SozsoftSender")]
public class SozsoftMailingResource public class ErpSenderResource
{ {
} }

View file

@ -63,7 +63,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
} }
await BackgroundJobManager.EnqueueAsync( await BackgroundJobManager.EnqueueAsync(
new SozsoftBackgroundEmailSendingJobArgs new ErpBackgroundEmailSendingJobArgs
{ {
TenantId = CurrentTenant.Id, TenantId = CurrentTenant.Id,
To = to, To = to,
@ -76,7 +76,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
); );
} }
public async Task<SendResult> SendEmailAsync( public async Task<EmailSendResult> 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 SendResult(); var result = new EmailSendResult();
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<SendResult> SendEmailAsync( public async Task<EmailSendResult> SendEmailAsync(
string to, string to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,

View file

@ -0,0 +1,9 @@
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,7 +7,8 @@ namespace Sozsoft.Sender.Mail;
/// </summary> /// </summary>
public interface ISozsoftEmailSender : IEmailSender public interface ISozsoftEmailSender : IEmailSender
{ {
Task<SendResult> SendEmailAsync( //ErpEmailTemplate template
Task<EmailSendResult> SendEmailAsync(
string to, string to,
KeyValuePair<string, string>? sender, KeyValuePair<string, string>? sender,
dynamic? @params, dynamic? @params,
@ -15,7 +16,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<SendResult> SendEmailAsync( Task<EmailSendResult> 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 SozsoftBackgroundEmailSendingJob : public class ErpBackgroundEmailSendingJob :
AsyncBackgroundJob<SozsoftBackgroundEmailSendingJobArgs>, ITransientDependency AsyncBackgroundJob<ErpBackgroundEmailSendingJobArgs>, ITransientDependency
{ {
protected ISozsoftEmailSender EmailSender { get; } protected ISozsoftEmailSender EmailSender { get; }
protected ICurrentTenant CurrentTenant { get; } protected ICurrentTenant CurrentTenant { get; }
public SozsoftBackgroundEmailSendingJob( public ErpBackgroundEmailSendingJob(
ISozsoftEmailSender emailSender, ISozsoftEmailSender emailSender,
ICurrentTenant currentTenant) ICurrentTenant currentTenant)
{ {
@ -18,10 +18,11 @@ public class SozsoftBackgroundEmailSendingJob :
CurrentTenant = currentTenant; CurrentTenant = currentTenant;
} }
public override async Task ExecuteAsync(SozsoftBackgroundEmailSendingJobArgs args) public override async Task ExecuteAsync(ErpBackgroundEmailSendingJobArgs 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 SozsoftBackgroundEmailSendingJobArgs : IMultiTenant public class ErpBackgroundEmailSendingJobArgs : 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<SendResult> SendAsync(string Username, string Message); Task<RocketSendResult> SendAsync(string Username, string Message);
} }

View file

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

View file

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

View file

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

View file

@ -15,12 +15,11 @@ public class RocketSender : ISozsoftRocketSender
this.logger = logger; this.logger = logger;
} }
public async Task<SendResult> SendAsync(string Username, string Message) public async Task<RocketSendResult> 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);
var success = result?.Success == true; if (result.Success)
if (success)
{ {
logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message); logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message);
} }
@ -29,6 +28,10 @@ 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 SendResult { Success = success }; return new RocketSendResult
{
Success = result.Success,
};
} }
} }

View file

@ -2,7 +2,7 @@
public static class RocketSettingNames public static class RocketSettingNames
{ {
public const string Default = "Adm.Sender.Rocket."; public const string Default = "App.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

@ -1,16 +0,0 @@
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

@ -1,52 +0,0 @@
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);
}
}

View file

@ -5,6 +5,6 @@ namespace Sozsoft.Sender.Sms;
public interface ISozsoftSmsSender : ISmsSender, ITransientDependency public interface ISozsoftSmsSender : ISmsSender, ITransientDependency
{ {
Task<SendResult> SendAsync(string PhoneNumber, string Message); Task<SmsSendResult> SendAsync(string PhoneNumber, string Message);
} }

View file

@ -7,51 +7,61 @@ public interface IPostaGuverciniHttpClient
Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message); Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message);
} }
public sealed class PostaGuverciniHttpClient : SettingHttpClientBase, IPostaGuverciniHttpClient public sealed class PostaGuverciniHttpClient : IPostaGuverciniHttpClient, IDisposable
{ {
public PostaGuverciniHttpClient(HttpClient httpClient, ISettingProvider settingProvider) private readonly HttpClient httpClient;
: base(httpClient, settingProvider) private readonly ISettingProvider settingProvider;
public PostaGuverciniHttpClient(
HttpClient httpClient,
ISettingProvider settingProvider)
{ {
this.httpClient = httpClient;
this.settingProvider = settingProvider;
} }
public async Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message) public async Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message)
{ {
var url = await GetSettingAsync(PostaGuverciniSettingNames.Url); var url = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Url);
var user = await GetSettingAsync(PostaGuverciniSettingNames.Username); var u = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Username);
var password = await GetSettingAsync(PostaGuverciniSettingNames.Password); var p = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Password);
//httpClient.BaseAddress = new Uri(url);
// Sağlayıcı yalnızca sorgu dizisi kabul eder; metindeki '&' ve '=' kaçırılmazsa parametre sınırı bozulur. var response = await httpClient.GetStringAsync($"{url}/sendsms.asp?user={u}&password={p}&gsm={PhoneNumber}&text={Message}");
var response = await HttpClient.GetStringAsync( // Gelen result başarı: errno=0&errtext=&message_id=EZ_2643D2B5-QCFD-40B7-E95F-4204CFCD0409&charge=-1
$"{url}/sendsms.asp?user={Uri.EscapeDataString(user ?? string.Empty)}&password={Uri.EscapeDataString(password ?? string.Empty)}" + // Gelen result hata: errno=10&errtext=HATA: Giriş bilgileri hatalı!
$"&gsm={Uri.EscapeDataString(PhoneNumber)}&text={Uri.EscapeDataString(Message)}");
// Başarı: errno=0&errtext=&message_id=EZ_2643D2B5-...&charge=-1
// Hata: errno=10&errtext=HATA: Giriş bilgileri hatalı!
var result = new PostaGuverciniSmsSendResultDto(); var result = new PostaGuverciniSmsSendResultDto();
var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; try
foreach (var item in response.Split('&', options))
{ {
var token = item.Split('=', 2, options); var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
var value = token.Length == 2 ? token[1] : null; foreach (var item in response.Split('&', options))
switch (token[0])
{ {
case "errno": var token = item.Split('=', options);
result.ErrNo = value; switch (token[0])
break; {
case "errtext": case "errno":
result.ErrText = value; result.ErrNo = token.Length == 2 ? token[1] : null;
break; break;
case "message_id": case "errtext":
result.MessageId = value; result.ErrText = token.Length == 2 ? token[1] : null;
break; break;
case "charge": case "message_id":
result.Charge = value; result.MessageId = token.Length == 2 ? token[1] : null;
break; break;
default: case "charge":
break; result.Charge = token.Length == 2 ? token[1] : null;
break;
default:
break;
}
} }
} }
catch
{
}
return result; return result;
} }
public void Dispose() => httpClient?.Dispose();
} }

View file

@ -2,7 +2,7 @@
public static class PostaGuverciniSettingNames public static class PostaGuverciniSettingNames
{ {
public const string Default = "Adm.Sender.Sms.PostaGuvercini."; public const string Default = "App.Sender.Sms.PostaGuvercini.";
public const string Url = Default + "Url"; public const string Url = Default + "Url";
public const string Username = Default + "Username"; public const string Username = Default + "Username";
public const string Password = Default + "Password"; public const string Password = Default + "Password";

View file

@ -19,7 +19,7 @@ public class PostaGuverciniSmsSender : ISozsoftSmsSender
public Task SendAsync(SmsMessage smsMessage) => SendAsync(smsMessage.PhoneNumber, smsMessage.Text); public Task SendAsync(SmsMessage smsMessage) => SendAsync(smsMessage.PhoneNumber, smsMessage.Text);
public async Task<SendResult> SendAsync(string PhoneNumber, string Message) public async Task<SmsSendResult> SendAsync(string PhoneNumber, string Message)
{ {
PhoneNumber = Check.NotNullOrWhiteSpace(PhoneNumber, nameof(PhoneNumber), 11, 10); PhoneNumber = Check.NotNullOrWhiteSpace(PhoneNumber, nameof(PhoneNumber), 11, 10);
Message = Check.NotNullOrWhiteSpace(Message, nameof(Message)); Message = Check.NotNullOrWhiteSpace(Message, nameof(Message));
@ -36,7 +36,7 @@ public class PostaGuverciniSmsSender : ISozsoftSmsSender
logger.LogWarning("Failed to send SMS message. {PhoneNumber}, {Message}, {Error}", PhoneNumber, Message, $"{result.ErrNo}:{result.ErrText}"); logger.LogWarning("Failed to send SMS message. {PhoneNumber}, {Message}, {Error}", PhoneNumber, Message, $"{result.ErrNo}:{result.ErrText}");
} }
return new SendResult return new SmsSendResult
{ {
Success = result.IsSuccess, Success = result.IsSuccess,
ErrorMessage = $"{result.ErrNo}:{result.ErrText}", ErrorMessage = $"{result.ErrNo}:{result.ErrText}",

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