Compare commits
58 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d99ac59cdc | ||
|
|
23667314ee | ||
|
|
df3294e02a | ||
|
|
679f8fcb89 | ||
|
|
702dffe50e | ||
|
|
f6ee88baf3 | ||
|
|
20cbd50dc8 | ||
|
|
c72881622d | ||
|
|
569fc80f12 | ||
|
|
cd41d9dff3 | ||
|
|
5e8ee03fc8 | ||
|
|
a56bdc3e79 | ||
|
|
48e7d34026 | ||
|
|
29f4bc8b33 | ||
|
|
c0c90174d5 | ||
|
|
60992800a8 | ||
|
|
52b6e1201d | ||
|
|
fed76687e3 | ||
|
|
55bd2626b4 | ||
|
|
6db67e121d | ||
|
|
8a3a00ae62 | ||
|
|
dae0a55e16 | ||
|
|
d3d0fa0985 | ||
|
|
2d48626219 | ||
|
|
6f472b15f0 | ||
|
|
f44308756c | ||
|
|
f341730b4a | ||
|
|
eb57bcd5ca | ||
|
|
94ae8ed7ac | ||
|
|
1e3c66913d | ||
|
|
4323e87572 | ||
|
|
78ecb82b34 | ||
|
|
c2e6ef8a37 | ||
|
|
2a449a158d | ||
|
|
372c83e52d | ||
|
|
ba565a8b31 | ||
|
|
0e97c4e081 | ||
|
|
ddff672eac | ||
|
|
1ede045bac | ||
|
|
dea283e7cf | ||
|
|
3af82ecf94 | ||
|
|
ce5d7764d1 | ||
|
|
3922c46539 | ||
|
|
7e5322d831 | ||
|
|
b9b26eff7c | ||
|
|
135a85e682 | ||
|
|
02b8d3abb4 | ||
|
|
53e26ce787 | ||
|
|
c1454ab818 | ||
|
|
35118ab2d9 | ||
|
|
68dd9b28e6 | ||
|
|
9e83d6dabe | ||
|
|
5ded5d1229 | ||
|
|
a09f71521f | ||
|
|
2d59f7116a | ||
|
|
10abf9c08d | ||
|
|
da98513c42 | ||
|
|
e6db756de3 |
1879 changed files with 1262860 additions and 328421 deletions
96
.github/instructions/ai.instructions.md
vendored
96
.github/instructions/ai.instructions.md
vendored
|
|
@ -19,11 +19,11 @@ Companion documents (this file wins on conflict):
|
|||
| 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-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. |
|
||||
| `lowcode-reference.instructions.md` | **Capability inventory**: field-by-field contract of every ListForm/ListFormField JSON column, chart and pivot options, SQL command hooks, Visual Designer toolbox, the scheduled-mail module and (§12) every ready-made screen under the Saas, Administration and HR (`Hr`) menus. Sweep its §0 checklist before producing a screen or component; a request whose answer already exists as a platform screen (§12.6) is configured there, not rebuilt. |
|
||||
| `dotnet.instructions.md` | Binding code standard for everything under `api/` |
|
||||
| `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 |
|
||||
| `../../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 |
|
||||
| `../../configs/seeds/docs/modules/{Menu}.md` | **How a generated module works**: discovery answers, approved recipe, screens, formulas, status rules, permissions, FAQ. Read it first when asked how a module/component behaves; write or update it with every module/component change (`lowcode.instructions.md` §0.13). Index: `README.md` §10.5 |
|
||||
| `../../CLAUDE.md` | Operating rules and standing defaults for AI agents in this repo |
|
||||
|
||||
---
|
||||
|
|
@ -53,26 +53,28 @@ System nature:
|
|||
3. Every proposal must include tenant and permission design.
|
||||
4. Never bypass platform authorization patterns.
|
||||
5. Never hardcode secrets, tenant ids, or connection strings.
|
||||
6. **Stay inside the framework; ask before stepping out.** The platform may extend itself through
|
||||
its own mechanisms (seed artifacts; adding a missing event, prop, or small backward-compatible
|
||||
capability to an existing UI component — when Developer Kit tooling builds ~70% of the
|
||||
requested component, the rest is completed with such small additions, never with a new
|
||||
component; backward-compatible additions to script recipes / editor options / tokens).
|
||||
Anything beyond that — a new React component or page, a new toolbox family, core engine changes (ListForm
|
||||
runtime, seeders, code generator, auth, DataSourceManager, dispatcher/compiler), new ABP
|
||||
entity/module/migration, platform table schema changes, new NuGet/npm dependencies, the
|
||||
`App.Menus.Routes.json` seed data (except the `Seo*` columns of an existing row, which are part
|
||||
of ordinary production — lowcode §0.9), new external integrations — is **out of frame**: finish everything that
|
||||
fits inside the frame first, then ask the user with a single question (why configuration is
|
||||
insufficient, the smallest change that works, files touched, rollback) and proceed **only with
|
||||
explicit approval**. Approval is per request and does not carry over. Full lists:
|
||||
6. **No new Core feature without the command.** Core = all source code under `api/` and `ui/`;
|
||||
the frame = artifacts generated under `configs/seeds/` (wizard, SQL objects, `crud/`, custom
|
||||
components, Custom Endpoints, Dynamic Services, workers, notifications, language/permission
|
||||
rows, SEO, module docs). A new Core feature — however small or backward compatible: a DTO
|
||||
field, a prop/event on an existing UI component, a script recipe, an editor option key, a
|
||||
default-value token, a new React component/page, engine/seeder/MCP changes, new entity/
|
||||
migration, platform table schema, NuGet/npm packages, `Sas.Menus.Routes.json` rows (except the
|
||||
`Seo*` columns of an existing row — lowcode §0.9), external integrations — is written **only
|
||||
when the user gives the command "core sisteme ekle"** (or an equivalent that names the Core
|
||||
change). Before concluding Core is needed, scan the full ListForm capability inventory
|
||||
(`lowcode-reference.instructions.md` §0–§9, lowcode §4–§9). Without the command: finish what
|
||||
fits in the frame and end with a single "Core gerektiren kısım" note (missing capability, why
|
||||
the inventory falls short, smallest change, files, rollback) — do not offer Core as an
|
||||
option to pick. Bug fixes that restore documented behaviour without new settings are not new
|
||||
features. The command covers only the named change and does not carry over. Full lists:
|
||||
`lowcode.instructions.md` §0.8.
|
||||
|
||||
Exception policy:
|
||||
|
||||
- Code-level React or backend development can be considered only if user explicitly requests code implementation and configuration path is insufficient.
|
||||
- If exception is required, AI must explain why configuration-based options are not enough.
|
||||
- Never say "cannot be done": the answer is either an in-frame build or an approval question.
|
||||
- Code-level React or backend development happens only on the user's explicit command ("core sisteme ekle" or a request naming the code change).
|
||||
- When Core would be needed, explain in a sentence or two why the configuration inventory is not enough.
|
||||
- Never say "cannot be done": the answer is an in-frame build plus, if needed, a "Core gerektiren kısım" note.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -293,8 +295,8 @@ the Wizard's `Custom` path.
|
|||
per-command Form permissions (`selectPermission`, `insertPermission`, `updatePermission`,
|
||||
`deletePermission`). In `Otomatik` mode the base is the read permission the Wizard created for
|
||||
the component and the commands get `''` / `.Create` / `.Update` / `.Delete` suffixes.
|
||||
- List screen permissions: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete,.Export,.Import}`.
|
||||
- Every save/delete is mirrored into `configs/seeds/{scope}/data/App.DeveloperKit.CustomComponents/{Id}.json`; the endpoints it uses are
|
||||
- List screen permissions: `Sas.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
|
||||
referenced through `crud/{Entity}.json`. A component that is not seed-backed is not done.
|
||||
|
||||
### 10.2 SQL View Designer
|
||||
|
|
@ -308,9 +310,13 @@ designer. Aggregations available in the Group By column: `GroupBy`, `Where`, `SU
|
|||
`COUNT_DISTINCT`, `AVG`, `MIN`, `MAX`.
|
||||
|
||||
Persistence: only the **table designer's** deploy writes a seed file automatically
|
||||
(`{sql|postgres}/object/{TableName}.sql`, full CREATE script, via `save-table-script`). View
|
||||
(`sql/object/{TableName}.sql`, full CREATE script, via `save-table-script`; its PostgreSQL counterpart
|
||||
`postgres/object/{TableName}.sql` is generated from it by `PostgreSqlTableScript`). View
|
||||
Designer deploys and any DDL run from the SQL editor (views, procedures, functions) reach the
|
||||
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
|
||||
and §9.5.
|
||||
|
||||
|
|
@ -361,7 +367,7 @@ AI workflow integration:
|
|||
|
||||
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
|
||||
on the **MCP Servers** screen (`App.DeveloperKit.McpServers`, §12.2 of
|
||||
on the **MCP Servers** screen (`Sas.DeveloperKit.McpServers`, §12.2 of
|
||||
`lowcode-reference.instructions.md`) with its allowed tools and tenant scope; queries always run
|
||||
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`
|
||||
|
|
@ -457,8 +463,8 @@ When configuration cannot satisfy requirement:
|
|||
1. Try SQL-based design
|
||||
2. Try Custom Endpoint
|
||||
3. Try Dynamic Service
|
||||
4. Propose minimal code change with explicit justification — this step is out of frame:
|
||||
**ask and wait for approval before touching code** (rule 3.6).
|
||||
4. Describe the minimal code change with explicit justification — this step is Core:
|
||||
**do not touch code until the user says "core sisteme ekle"** (rule 3.6).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -625,7 +631,7 @@ A proposal is complete only if all are present:
|
|||
4. Menu-route-component mapping included.
|
||||
5. Validation checklist included.
|
||||
6. Risk/rollback notes included.
|
||||
7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Module}.md` is completed (analysis,
|
||||
7. Once produced, the module document `configs/seeds/{scope}/docs/modules/{Menu}.md` is completed (analysis,
|
||||
decisions, behaviour) and indexed in `README.md` §10.5.
|
||||
|
||||
---
|
||||
|
|
@ -664,8 +670,8 @@ There are **two seed surfaces**. AI must not mix them.
|
|||
Platform-owned screens, menus, permissions and language texts. Changing them requires a build and
|
||||
a deploy. AI must learn and teach implementation flow primarily from these assets:
|
||||
|
||||
- ListFormSeeder_Saas.cs / ListFormSeeder_Administration.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
|
||||
- configs/seeds/host/data/App.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data)
|
||||
- ListFormSeeder_Sas.cs / ListFormSeeder_Adm.cs / ListFormSeeder_Hr.cs / ListFormSeeder_{Modul}.cs
|
||||
- configs/seeds/data/Sas.Languages.{Language,LanguageText}.json — languages and texts (ListForm seed data)
|
||||
- WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read
|
||||
surface B below
|
||||
- ListFormSeedDataApplier, CrudEndpointSeedApplier (Domain/Data/) — the single home of the apply
|
||||
|
|
@ -678,11 +684,14 @@ a deploy. AI must learn and teach implementation flow primarily from these asset
|
|||
**B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through
|
||||
`SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer, the CRUD
|
||||
Endpoint Manager and every ListForm screen with a `SeedFilePath` write here; the
|
||||
seeders above restore them when the database is recreated. Scope folders mirror the CDN layout:
|
||||
seeders above restore them when the database is recreated. There is a single folder and no
|
||||
tenant scope: the host and every tenant with its own database are seeded from the same files
|
||||
(`SeedFileScope`; a tenant sharing the host database is skipped — the host seed already wrote
|
||||
there). Files are written only in host context; a tenant's record never reaches a seed file.
|
||||
|
||||
```
|
||||
configs/seeds/host/{wizard,data,crud,sql/{object,execute},postgres/{object,execute}}
|
||||
configs/seeds/tenants/{tenantId}/… same layout, tenant scope
|
||||
configs/seeds/{wizard,data,crud,docs,sql/{object,execute},postgres/{object,execute}}
|
||||
configs/seeds/{sql,postgres}/host/{object,execute} host database only (e.g. scripts over HangFire)
|
||||
configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned)
|
||||
```
|
||||
|
||||
|
|
@ -722,13 +731,13 @@ AI must prioritize metadata-based field behavior instead of suggesting custom Re
|
|||
|
||||
### 31.3 Step 3 - Add menu and route mapping
|
||||
|
||||
From the `configs/seeds/host/data/App.Menus.*.json` patterns, AI must define both:
|
||||
From the `configs/seeds/data/Sas.Menus.*.json` patterns, AI must define both:
|
||||
|
||||
- `App.Menus.Routes.json` row:
|
||||
- `Sas.Menus.Routes.json` row:
|
||||
- `Id`, `Key`, `Path`, `ComponentPath`, `RouteType`, `Authority`
|
||||
- for a visitor-facing route, also its `Seo*` columns — there is no separate SEO table
|
||||
(lowcode §0.9)
|
||||
- `App.Menus.Menu.json` row:
|
||||
- `Sas.Menus.Menu.json` row:
|
||||
- `ParentCode`, `Code`, `DisplayName`, `Url`, `Icon`, `RequiredPermissionName`, `Order`
|
||||
|
||||
For dynamic list screens, base route pattern is:
|
||||
|
|
@ -739,7 +748,7 @@ AI must ensure menu URL and route path point to same screen contract.
|
|||
|
||||
### 31.4 Step 4 - Add permissions
|
||||
|
||||
From the `configs/seeds/host/data/Abp.Identity.Permission*.json` patterns, AI must define:
|
||||
From the `configs/seeds/data/Abp.Identity.Permission*.json` patterns, AI must define:
|
||||
|
||||
- Permission group (if missing)
|
||||
- Permission definitions for feature root and actions
|
||||
|
|
@ -759,7 +768,7 @@ AI must never propose menu/route without permission contract.
|
|||
|
||||
### 31.5 Step 5 - Add settings/integration dependencies
|
||||
|
||||
From the `configs/seeds/host/data/App.SettingDefinitions.json` / `App.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services:
|
||||
From the `configs/seeds/data/Sas.SettingDefinitions.json` / `Sas.Definitions.AiBot.json` patterns, AI must define required settings when feature depends on external services:
|
||||
|
||||
- Sender credentials and endpoints (sms/mail/whatsapp/rocket)
|
||||
- AiBot integration endpoint and activation state
|
||||
|
|
@ -860,6 +869,10 @@ Rules:
|
|||
|
||||
- Do not require user to explicitly request these columns each time.
|
||||
- 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.
|
||||
|
||||
### 32.2 Wizard menu parent fallback behavior
|
||||
|
|
@ -907,8 +920,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
|
||||
|
||||
Exporting a wizard produces a zip whose paths are relative to the scope folder
|
||||
(`host` / `tenants/{tenantId}`) and which carries every dependency:
|
||||
Exporting a wizard produces a zip whose paths are relative to the seed folder and which carries
|
||||
every dependency:
|
||||
|
||||
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.
|
||||
|
|
@ -920,7 +933,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
|
||||
through `SubFormsDto[].Code`, recursively
|
||||
- `data/App.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the
|
||||
- `data/Sas.DeveloperKit.CustomComponents/{Id}.json` — `Custom` path; the file is filtered down to the
|
||||
exported component and its dependency chain, never exported whole
|
||||
- `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)
|
||||
|
|
@ -952,7 +965,7 @@ up so `RollbackImport` can undo the whole batch), closed by `CompleteImport`.
|
|||
`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,
|
||||
so the check cannot be bypassed by calling the endpoint directly. Errors: malformed JSON; a wizard
|
||||
file with no `Wizard`/`ListForm` section; a screen's or a `crud/{Entity}.json`'s table missing in
|
||||
file with no `Wizard` section or, on the List path, no `Data` heading (`SelectCommand`/`KeyFieldName`); a screen's or a `crud/{Entity}.json`'s table missing in
|
||||
the target **and** absent from the archive's scripts; a `data/{code}.json` whose ListForm exists
|
||||
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
|
||||
|
|
@ -998,7 +1011,8 @@ later steps would fail on it; (b) columns missing from existing tables are added
|
|||
through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows, except the platform files that hold
|
||||
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
|
||||
Migrate installs it (`WizardDataSeeder`) from the file's `ListForm`/`Fields` contract as-is, even
|
||||
Migrate installs it (`WizardDataSeeder`) from the file's `Wizard` section as-is
|
||||
(`Wizard.BuildListForm()/BuildFields()`; the file has no separate `ListForm`/`Fields` sections), even
|
||||
when the target already has the screen.
|
||||
|
||||
A wizard deploy (wizard screen, MCP `deploy_wizard`, edit) never writes to the database: it only
|
||||
|
|
@ -1008,7 +1022,7 @@ grants the permissions to a role (admin included); roles get them from the role
|
|||
|
||||
Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` /
|
||||
`execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries.
|
||||
Permissions: `App.Listforms.Wizard.Export` / `.Import`.
|
||||
Permissions: `Sas.Listforms.Wizard.Export` / `.Import`.
|
||||
|
||||
AI must not propose moving a screen between environments by copying a single json file.
|
||||
|
||||
|
|
|
|||
23
.github/instructions/dotnet.instructions.md
vendored
23
.github/instructions/dotnet.instructions.md
vendored
|
|
@ -122,6 +122,20 @@ Ek kurallar:
|
|||
- **Repository**: özel sorgu gerekiyorsa Domain'de arayüz + EntityFrameworkCore'da
|
||||
implementasyon. AppService içinde `DbContext` doğrudan kullanılmaz.
|
||||
- **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.
|
||||
Mapper'lar servis klasöründeki `{Alan}Mappers.cs` dosyasındadır (modülde
|
||||
`{Modül}ApplicationMappers.cs`), ABP'nin `MapperBase<TSource, TDest>` /
|
||||
|
|
@ -132,7 +146,14 @@ Ek kurallar:
|
|||
`[MapperIgnoreTarget(nameof(...))]` ile açıkça yazılır; iç içe koleksiyon için ayrı
|
||||
`private partial` eşleme metodu tanımlanır.
|
||||
- **Localization**: kullanıcıya görünen metin koda gömülmez, `PlatformResource`
|
||||
anahtarı üzerinden verilir.
|
||||
anahtarı üzerinden verilir. Bu her tür exception mesajı, uyarı listesi, DTO açıklaması ve
|
||||
kayda yazılan sabit değer (ör. sistem onay açıklaması) için geçerlidir; Türkçe ya da
|
||||
İngilizce statik metin backend'den dönmez. Servislerde `L["Sas.X.Y", arg]`; localizer
|
||||
alamayan statik/yardımcı sınıflarda `new BusinessException(PlatformConsts.AppName + ":Sas.X.Y").WithData("name", v)`
|
||||
(ABP kodu `Platform:` ad alanıyla `PlatformResource`'a eşler, dil anahtarı kodun tamamıdır,
|
||||
yer tutucu `{name}`). Log satırları ve geliştiriciye dönük `[Obsolete]` metinleri İngilizcedir,
|
||||
yerelleştirilmez. MCP araç açıklamaları ve `McpProblem` metinleri modele dönüktür, bu kuralın
|
||||
dışındadır; `ModuleDocRenderer`'ın ürettiği modül dokümanı Türkçe proje belgesidir.
|
||||
- **Çok değerli alan taşıyan girdi DTO'su**: ekranda `dxTagBox` (ya da çoklu seçimli
|
||||
`dxGridBox`) ile doldurulan bir alan tipli bir DTO'ya bağlanıyorsa property `string`
|
||||
kalır ve `[JsonConverter(typeof(MultiValueStringJsonConverter))]` ile işaretlenir —
|
||||
|
|
|
|||
47
.github/instructions/list.instructions.md
vendored
47
.github/instructions/list.instructions.md
vendored
|
|
@ -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.
|
||||
- **Naming Conventions:**
|
||||
- Table names: `{Modul}_T_{Entity}` (e.g., `Mrp_T_Order`)
|
||||
- Menu/permission keys: `App.{Modul}`, `App.{Modul}.{Liste}`
|
||||
- Menu/permission keys: `{Modul}`, `{Modul}.{Liste}`
|
||||
- **Tenant Isolation:** All data, forms, and permissions must be tenant-aware.
|
||||
- **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/host/data/App.Languages.LanguageText.json`.
|
||||
- **Localization:** Every menu, list, and field must have a corresponding entry in `configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json`.
|
||||
- **No Redundant Code:** Avoid duplicating permission grants or seeding logic across modules.
|
||||
- **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`.
|
||||
|
|
@ -27,27 +27,30 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
|
||||
## Special File Handling Rules
|
||||
|
||||
- **configs/seeds/host/data/App.Menus.*.json:**
|
||||
- Never add, remove or re-path a route in `App.Menus.Routes.json`. The only field set you may edit on an existing row is its `Seo*` columns (lowcode §0.9). Only `App.Menus.Module.json` and `App.Menus.Menu.json` can be changed for menu additions or updates. All menu additions must use the platform's configuration mechanisms or be added to those two files as required by platform design.
|
||||
- **configs/seeds/data/Sas.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.
|
||||
|
||||
- **configs/seeds/host/data/App.Languages.LanguageText.json:**
|
||||
- **configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json:**
|
||||
- When adding a new key, always check if the key already exists. Only add the key if it does not exist to prevent duplicates.
|
||||
- Each key is two rows (`CultureName` `en` and `tr`). `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID.
|
||||
- The data lives in the folder `configs/seeds/data/Sas.Languages.LanguageText/`: one file per language (`ar, de-DE, en, es, fi, fr, hi, hr, hu, it, pt-BR, ru, sk, sl, tr, zh-Hans`, i.e. every `CultureName` of `Sas.Languages.Language.json`). The file name is the language's `CultureName` (`{CultureName}.json`: `tr.json`, `de-DE.json`) and its `Rows` hold all rows of that language.
|
||||
- Wizard, MCP and every component/screen/menu addition follow the same rule: the generated EN + TR rows are written by the deploy/seeder, the producer then writes the translated rows for all other languages (`lowcode.instructions.md` §4.4.3, CLAUDE.md standing default 27). Deleting a key removes its row from every file.
|
||||
- Every new key is added to **all** language files (one row per file). `en` and `tr` carry the authored text; the other 14 files get its translation. When a language is added to `Sas.Languages.Language.json`, create its file and translate every key.
|
||||
- Each row's `Id` = UUID v5 (URL namespace) of `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}`; never a random GUID.
|
||||
|
||||
---
|
||||
|
||||
## File-by-File Change Summary
|
||||
|
||||
|
||||
### 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 `App.Menus.Module.json`.
|
||||
### 1. configs/seeds/data/Sas.Menus.Menu.json
|
||||
- **{Modul} menu** added as a top-level menu with `ModuleId: {Modul}`; the module row goes into `Sas.Menus.Module.json`.
|
||||
- **{Liste}** added as a child menu under {Modul}.
|
||||
|
||||
### 2. configs/seeds/host/data/Abp.Identity.Permissions.json
|
||||
- **{Modul} permissions** grouped under `App.{Modul}`.
|
||||
### 2. configs/seeds/data/Abp.Identity.Permissions.json
|
||||
- **{Modul} permissions** grouped under `{Modul}`.
|
||||
- **{Liste} permissions** nested under {Modul} group.
|
||||
|
||||
### 3. ListFormSeeder_Administration.cs
|
||||
### 3. ListFormSeeder_Adm.cs
|
||||
- **Removed** {Liste} seeding logic (moved to {Modul} seeder).
|
||||
|
||||
### 4. ListFormSeeder_{Modul}.cs
|
||||
|
|
@ -61,7 +64,7 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
### 6. PlatformIdentityDataSeeder.cs
|
||||
- **Removed** redundant {Modul} permission grant logic (now handled by the permission seed data).
|
||||
|
||||
### 7. App.Languages.LanguageText.json
|
||||
### 7. Sas.Languages.LanguageText/{CultureName}.json
|
||||
- **Added** localization entries for:
|
||||
- {Modul} menu and {Liste} list
|
||||
- {Liste} list fields (e.g., `FullName`, `Phone`)
|
||||
|
|
@ -74,11 +77,11 @@ This document summarizes the rules, standards, and step-by-step instructions for
|
|||
### 1. Add a New Module (e.g., {Modul})
|
||||
```
|
||||
Yeni bir modül ekle (ör: {Modul}):
|
||||
- configs/seeds/host/data/App.Menus.Menu.json'a kök menü, App.Menus.Module.json'a modül kaydı olarak ekle
|
||||
- configs/seeds/host/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur
|
||||
- configs/seeds/data/Sas.Menus.Menu.json'a kök menü, Sas.Menus.Module.json'a modül kaydı olarak ekle
|
||||
- configs/seeds/data/Abp.Identity.PermissionGroups.json'da ayrı bir grup oluştur
|
||||
- ListFormSeeder_{Modul}.cs dosyası oluştur ve list-form seed'ini buraya taşı
|
||||
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak adlandır
|
||||
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına menü, liste ve alan çevirilerini ekle
|
||||
- configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json dosyasına menü, liste ve alan çevirilerini ekle
|
||||
```
|
||||
|
||||
### 2. Add a New List Under a Module
|
||||
|
|
@ -87,8 +90,8 @@ Yeni bir liste ekle (ör: {Liste}):
|
|||
- {Modul} ana menüsünün altına ekle
|
||||
- ListFormSeeder_{Modul}.cs dosyasına seed kodunu ekle
|
||||
- SqlObjects.sql'de tabloyu {Modul}_T_{Entity} olarak oluştur
|
||||
- configs/seeds/host/data/Abp.Identity.Permissions.json'da ilgili izinleri {Modul} grubuna ekle
|
||||
- configs/seeds/host/data/App.Languages.LanguageText.json dosyasına liste ve alan çevirilerini ekle
|
||||
- configs/seeds/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
|
||||
```
|
||||
|
||||
### 3. Enforce Tenant Isolation and Full Audit
|
||||
|
|
@ -101,10 +104,10 @@ Tablo ve list-form için tenant izolasyonu ve full-audit alanlarını ekle:
|
|||
|
||||
### 4. Add/Update Localization
|
||||
```
|
||||
Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.LanguageText.json dosyasına (EN + TR satırı) şu şekilde ekle:
|
||||
- App.{Modul}
|
||||
- App.{Modul}.{Liste}
|
||||
- App.Listform.ListformField.{Alan}
|
||||
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:
|
||||
- {Modul}
|
||||
- {Modul}.{Liste}
|
||||
- Sas.Listform.ListformField.{Alan}
|
||||
```
|
||||
|
||||
### 5. SeedConsts and DeleteCommand Rules
|
||||
|
|
@ -128,7 +131,7 @@ Yeni menü, liste veya alan eklediğinde configs/seeds/host/data/App.Languages.L
|
|||
- [ ] Menü ve izinler doğru hiyerarşide mi?
|
||||
- [ ] List-form seed'leri doğru modül dosyasında mı?
|
||||
- [ ] Tablo isimleri ve referansları standartlara uygun mu? (örn: {Modul}_T_{Entity})
|
||||
- [ ] App.Languages.LanguageText.json'da tüm yeni menü, liste ve alanlar var mı?
|
||||
- [ ] Sas.Languages.LanguageText/{CultureName}.json'da tüm yeni menü, liste ve alanlar var mı?
|
||||
- [ ] Redundant kod veya izin grant'ı var mı?
|
||||
- [ ] 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?
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1944
.github/instructions/lowcode.instructions.md
vendored
1944
.github/instructions/lowcode.instructions.md
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -48,13 +48,13 @@ options you rejected.
|
|||
3. Every implementation proposal must include tenant and permission design.
|
||||
4. Never bypass platform authorization patterns.
|
||||
5. Never hardcode secrets, tenant IDs, or connection strings.
|
||||
6. Never step outside the framework without explicit approval (standing default 11).
|
||||
6. Never add a new Core feature (`api/`, `ui/` code) without the command "core sisteme ekle" (standing default 11).
|
||||
|
||||
Exception:
|
||||
|
||||
- Custom React/backend code is allowed only when the user explicitly requests implementation
|
||||
and configuration is insufficient.
|
||||
- In such cases, explain why configuration-first options are not enough — in a sentence or two.
|
||||
- Custom React/backend code is written only on the user's command ("core sisteme ekle" or a
|
||||
request that names the code change) — never because configuration seemed insufficient.
|
||||
- When Core would be needed, say why the ListForm inventory is not enough — in a sentence or two.
|
||||
|
||||
## Architecture Guardrails
|
||||
|
||||
|
|
@ -111,9 +111,10 @@ These sections encode expectations the user will **not** repeat in every prompt.
|
|||
| `api/` kod standardı (sürüm, nullable, ölü kod, modern C#, ABP katmanları) | `.github/instructions/dotnet.instructions.md` |
|
||||
| Modül/liste ekleme prosedürü, seeder dosya sorumlulukları | `.github/instructions/list.instructions.md` |
|
||||
| Ne nerede yaşıyor, hangi ekran ne yapıyor | `README.md` |
|
||||
| Bir modülün nasıl çalıştığı, analizi ve kararları | `configs/seeds/host/docs/modules/{Modül}.md` (dizin: `README.md` §10.5) |
|
||||
| Bir modülün nasıl çalıştığı, analizi ve kararları | `configs/seeds/docs/modules/{Menü}.md` (dizin: `README.md` §10.5) |
|
||||
| Yetki kodları | `PlatformConsts.AppCodes` (backend) + `constants/permission.constant.ts` (UI) |
|
||||
| Seed yolları | `SeedPathResolver` |
|
||||
| SQL Server → PostgreSQL çeviri kuralları | `.github/instructions/lowcode.instructions.md` §8.3.1 |
|
||||
|
||||
Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → bu dosya → `README.md`.
|
||||
|
||||
|
|
@ -122,10 +123,21 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
1. **Yeni tablo** → tenant + tam audit kolonları varsayılan gelir (`Id`, `TenantId`,
|
||||
`CreationTime`, `CreatorId`, `LastModificationTime`, `LastModifierId`, `IsDeleted`,
|
||||
`DeletionTime`, `DeleterId`). Kullanıcı açıkça "tenant yok / audit yok" demedikçe çıkarma.
|
||||
Tanım **olmayan** (işlem/belge) yeni tablo `TenantId` gibi `BranchId uniqueidentifier NOT NULL`
|
||||
(PG `uuid NOT NULL`) da taşır; ekranı `IsBranch: true`. **Şube alanı her ekranda aynı sözleşmeyle
|
||||
gelir** (`BranchId` kolonu taşıyan tanım ekranları dahil): seçenekler kullanıcıya atanmış aktif
|
||||
şubelerdir (`Sas_T_BranchUsers`, Kullanıcılar › Yetkiler › Şube Yönetimi; `LookupQueryValues.UserBranchValues`),
|
||||
yeni kayıt bu listenin **ilk seçeneğiyle** açılır (`DbQuery`, `LookupQueryValues.UserDefaultBranchValue`
|
||||
ile aynı metin). Departman şubesi, sabit şube ya da ekrana özel sorgu yazılmaz.
|
||||
Bu tablodan okuyan view/rapor ekranı da `BranchId`'yi taşır ve `IsBranch: true` olur. Ana kayda
|
||||
bağlı tablo da kolonu taşır; formda önce Şube seçilir, ana kayıt lookup'ı şubeye göre süzülür
|
||||
(`CascadeParentFields: BranchId`) + alt form ilişkisi `BranchId → BranchId`. Ayrıntı:
|
||||
`lowcode.instructions.md` §8.1 (İşlem tablosu → `BranchId`).
|
||||
Yeni **tanım** tablosu (tanım olup olmadığına talebin içeriğine göre analizde karar verilir;
|
||||
sabit ad listesi yoktur) buna ek olarak `IsActive bit NOT NULL DEFAULT 1` ile gelir; liste + form alanı, varsayılan
|
||||
`true`, Aktif/Pasif widget'ı. Tanım olmasa da MCP ile (ya da wizard ile) üretilen her ekranda
|
||||
`IsActive` kolonu aynı varsayılanla gelir: tabloda `NOT NULL DEFAULT 1` (`create_table`'da
|
||||
`true`, Aktif/Pasif widget'ı. Tanım **olmayan** tabloya (işlem/belge tablosu, onun alt formları:
|
||||
satır, malzeme, süre, duruş, not) `IsActive` eklenmez; yalnızca talep aktif/pasif bayrağını açıkça
|
||||
isterse aynı varsayılanla gelir: tabloda `NOT NULL DEFAULT 1` (`create_table`'da
|
||||
`isNullable: false`, `defaultValue: "1"`), formda yeni kayıt `true` ile açılır
|
||||
(`FormFieldsDefaultValueJson`). Ayrıntı: `lowcode.instructions.md` §8.1.
|
||||
Görsel tutan her kolon (fotoğraf, logo, avatar kopyası) `nvarchar(MAX)` + editör `dxImageUpload`;
|
||||
|
|
@ -137,7 +149,13 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
3. **Yeni ekran/menü** → yetki sözleşmesi olmadan önerilmez. Menü ve route aynı ekran
|
||||
sözleşmesini göstermeli.
|
||||
4. **Kullanıcıya görünen her metin** → dil anahtarı üzerinden, EN + TR birlikte. Koda gömülü
|
||||
metin önerme.
|
||||
metin önerme. Wizard, MCP ya da herhangi bir bileşen/ekran/menü eklemesinin yazdığı **her** anahtar
|
||||
`Sas.Languages.LanguageText/` klasöründeki bütün dil dosyalarına da yazılır (standing default 27).
|
||||
**Kullanıcıya görünen her tarih ve sayı** → aktif kültürle: UI'da `localeFormat.ts`
|
||||
(`formatLocaleDate/Time/DateTime/Number/Currency/Fixed`), API'de `CultureFormat` + `CultureInfo.CurrentCulture`;
|
||||
DevExtreme kolon/editörü adlı biçim kullanır (`shortDate`, `shortDateShortTime`, `shortTime`, `decimal`,
|
||||
`fixedPoint`, `currency`). Sabit desen (`DD.MM.YYYY`, `dd/MM/yyyy`), `'tr-TR'` sabiti, `toLocaleString()` ve
|
||||
görüntülenen ondalık için `toFixed` yazılmaz; makine biçimi (ISO yük, `<input type=date>`) `ui/README.md`'de.
|
||||
5. **Runtime'da üretilen her artefakt** (wizard, custom component, crud endpoint) → ilgili seed
|
||||
dosyası da yazılır/güncellenir. Veritabanı sıfırlandığında geri gelmeyen bir şey üretme.
|
||||
6. **Yeni ekran/komponent talebi — yazılı ya da görsel** → dosya yazmadan önce **keşif turu**:
|
||||
|
|
@ -151,29 +169,32 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
Ayrıntı: `lowcode.instructions.md` §0.6–§0.7.
|
||||
7. **Ziyaretçiye açık yeni yol** (public `RoutePath` taşıyan custom component, `Pub` menüsü,
|
||||
yeni public sayfa) → SEO alanları da doldurulur. Ayrı bir SEO tablosu yoktur: meta, yolu
|
||||
tanımlayan kaydın `Seo*` kolonlarındadır — `data/App.Menus.Routes.json` ya da
|
||||
`data/App.DeveloperKit.CustomComponents/{Id}.json`. Metin `::Anahtar` ile dil anahtarı taşır.
|
||||
tanımlayan kaydın `Seo*` kolonlarındadır — `data/Sas.Menus.Routes.json` ya da
|
||||
`data/Sas.DeveloperKit.CustomComponents/{Id}.json`. Metin `::Anahtar` ile dil anahtarı taşır.
|
||||
Kullanıcı SEO bilgisi girmez; üreten yazar. Genel varsayılanlar kayıtta değil
|
||||
`App.SiteManagement.Seo.*` ayarlarındadır. Ayrıntı: `lowcode.instructions.md` §0.9.
|
||||
`Adm.SiteManagement.Seo.*` ayarlarındadır. Ayrıntı: `lowcode.instructions.md` §0.9.
|
||||
8. **Ekran içi hesap/koşul ihtiyacı** → önce `EditorScript` (Script Builder tarifleri), sonra
|
||||
`EditorOptions`; kod yazmak son çare.
|
||||
9. **SQL** → her zaman parametreli. String birleştirme ile sorgu kurma.
|
||||
10. **Silme** → `DeleteCommand = DefaultDeleteCommand("{Tablo}")`; ham SQL string'i yazma.
|
||||
11. **Çerçeve sınırı** → uygulama kendini kendi mekanizmalarıyla geliştirir (seed artefaktları;
|
||||
var olan `ui` bileşenine eksik olay/prop/küçük yetenek ekleme — komponentin %70'i hazır
|
||||
araçlarla kuruluyorsa kalanı bu eklemelerle tamamlanır, yeni bileşen yazılmaz; geriye dönük
|
||||
uyumlu tarif/seçenek/token ekleme).
|
||||
Bunun dışına çıkan her şey — yeni React bileşeni/sayfası, çekirdek motor, yeni entity/
|
||||
migration, platform tablosu şeması, yeni paket, `Routes`, dış entegrasyon — **önce sorulur,
|
||||
yalnızca onayla yapılır**; onay talebe özeldir. Çerçeve içinde yapılabilen kısım sorudan önce
|
||||
bitirilir. "Yapılamaz" cevabı yoktur. Listeler: `lowcode.instructions.md` §0.8.
|
||||
11. **Core'a yeni özellik yalnızca komutla** → Core = `api/` + `ui/` kaynak kodu; çerçeve =
|
||||
`configs/seeds/` artefaktları. Kullanıcı **"core sisteme ekle"** (ya da Core değişikliğini adıyla
|
||||
isteyen eşdeğer komut) demedikçe Core'a yeni özellik yazılmaz — küçük/geriye dönük uyumlu olması
|
||||
fark etmez: DTO alanı, var olan bileşene prop/olay, tarif, `EditorOptions` anahtarı, token, yeni
|
||||
React bileşeni/sayfası, çekirdek motor, entity/migration, platform tablosu şeması, paket,
|
||||
`Routes`, dış entegrasyon. "Core gerekiyor" demeden önce ListForm'un bütün yetenekleri taranır
|
||||
(`lowcode-reference.instructions.md` §0–§9, `lowcode` §4–§9). Komut yoksa çerçeve içindeki kısım
|
||||
bitirilir, son mesajda tek bir "Core gerektiren kısım" notu yazılır (soru kutusuyla Core önerilmez).
|
||||
Bildirilen hatanın yeni ayar eklemeden düzeltilmesi yeni özellik değildir. Komut yalnızca adı
|
||||
geçen değişikliği kapsar, sonraki talebe taşınmaz; eklenen yetenek envantere yazılır. "Yapılamaz"
|
||||
cevabı yoktur. Ayrıntı: `lowcode.instructions.md` §0.8.
|
||||
12. **Liste ekranı görünümleri** → prompt/görsel/tarifle üretilen her liste ekranı `Grid` + `Card` +
|
||||
`Pivot` + `Chart` açık, `DefaultLayout: "grid"` ile gelir; "liste/grid" demek diğerlerini
|
||||
kapatmak değildir. Bir görünüm yalnızca istek onu açıkça dışarıda bıraktığında `false` yazılır.
|
||||
Tree/Gantt/Scheduler/Todo yalnızca istenince açılır. Ayrıntı: `lowcode.instructions.md` §9.8.
|
||||
13. **Liste ekranı widget'ları** → Wizard ya da MCP `deploy_wizard` ile üretilen her List ekranı
|
||||
sorulmadan **en az 3 kartlık** `Widgets` ile gelir; kartlar tablonun durum / boolean bayrak /
|
||||
tarih / tutar kolonlarından seçilir, başlıklar dil anahtarıdır (`App.Widget.*` önce). Kiracı
|
||||
tarih / tutar kolonlarından seçilir, başlıklar dil anahtarıdır (`Sas.Widget.*` önce). Kiracı
|
||||
tablosunda her kart `"TenantId" = '@TENANTID'` taşır; `IsDeleted` koşulu yazılmaz. Form/Chart,
|
||||
alt form ve basit tanım listeleri istisnadır. **Alt form oluşturulurken widget oluşturulmaz**:
|
||||
başka bir ekranın `SubForms` sekmesi olarak üretilen her ekran — menüsüz ya da kendi menüsü olan —
|
||||
|
|
@ -184,63 +205,192 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
Alt form ekranının menü/yetki/düzenleme kuralları (menüsüz alt formun yetkisi ana ekranın altında,
|
||||
`Form` sekmesi tek kayıt + `AllowAdding`/`AllowUpdating` açık): `lowcode.instructions.md` §4.6.
|
||||
14. **Modül dokümanı** → MCP ya da wizard ile oluşturulan her modül çalışma şeklini
|
||||
`configs/seeds/{kapsam}/docs/modules/{Modül}.md` dosyasında taşır: keşif cevapları ve onaylanan
|
||||
tarif (analiz), ekranlar, veri modeli, hesaplar/durum geçişleri, yetki dağılımı, entegrasyonlar,
|
||||
sık sorulanlar. Dosyayı ve "Ek — Ekran envanteri"ni deploy kendisi açar/yeniler; anlatım
|
||||
bölümlerini (`module-doc:todo`) üreten doldurur — MCP'de `save_module_doc`, Claude Code'da dosyaya
|
||||
doğrudan. MCP talebi doküman tamamlanmadan `Testing`'e alınamaz. Wizard dışı modül dokümanı elle
|
||||
yazılır. README'ye yalnızca §10.5 dizin satırı ve ilgili bölümde tek cümlelik bağlantı yazılır.
|
||||
`configs/seeds/docs/modules/{Menü}.md` dosyasında taşır: dosya kök menü başına tektir ve adı kök
|
||||
menünün kodudur (`Hr.md`, `Prc.md`, `Adm.md`),
|
||||
menünün altındaki her dal dosyada bir `module-doc:section` bölümüdür. Bölüm keşif cevapları ve
|
||||
onaylanan tarifi (analiz), ekranları, veri modelini, hesapları/durum geçişlerini, yetki dağılımını,
|
||||
entegrasyonları, sık sorulanları taşır. Dosyayı, dalın bölümünü ve "Ek — Ekran envanteri"ni deploy
|
||||
kendisi açar/yeniler; anlatım alt bölümlerini (`module-doc:todo`) üreten doldurur — MCP'de
|
||||
`save_module_doc` (ekran/dal koduyla yalnızca o bölüm), Claude Code'da dosyaya doğrudan. MCP talebi
|
||||
bölümü tamamlanmadan `Testing`'e alınamaz. Wizard dışı modülün bölümü elle yazılır. README'ye
|
||||
yalnızca §10.5 dizin satırı/bölüm adı ve ilgili bölümde tek cümlelik bağlantı yazılır.
|
||||
"X nasıl çalışıyor?" sorusunda önce bu dosya okunur. Ayrıntı: `lowcode.instructions.md` §0.13.
|
||||
15. **Ekran açıklaması** → wizard/MCP ile üretilen her liste ekranının (alt formlar dahil)
|
||||
`DescTr`/`DescEn` metni ayrıntılıdır; başlıktaki ⓘ simgesinde gösterilir: ne işe yaradığı,
|
||||
menüde nerede / hangi ekranın sekmesi olduğu, ana alanlar, otomatik hesap ve geçişler, ilişkili
|
||||
ekranlar (`RichText` biçimi). MCP `deploy_wizard` 3 satır / 150 karakterin altını reddeder.
|
||||
Ayrıntı: `lowcode.instructions.md` §4.4.2.
|
||||
16. **İki veritabanı sağlayıcısı** → uygulama SQL Server ile olduğu gibi PostgreSQL ile de çalışır
|
||||
(`SettingsConsts.DefaultDatabaseProvider`). Veritabanına dokunan her değişiklik ikisi için birlikte
|
||||
düşünülür ve yazılır; kullanıcı bunu her istekte hatırlatmaz:
|
||||
- `configs/seeds/sql/{object,execute,host/…}/X.sql` yazılan/değişen her dosyanın
|
||||
`configs/seeds/postgres/…/X.sql` eşi aynı işte yazılır/güncellenir (aynı ad, aynı parametre sırası,
|
||||
şema PG'de `public`). Biri olmadan diğeri commit edilmez; "PG sonra" yoktur.
|
||||
- EF modeli SQL Server sözdizimiyle yazılır (kolon tipi, index filtresi, check, varsayılan); PG
|
||||
karşılığı `PostgreSqlModelSyntax` üzerinden `PlatformDbContext.ConfigureProviderSpecificModel`'de
|
||||
üretilir. Çeviriye girmeyen yeni bir sözdizimi eklenirse çeviri de eklenir.
|
||||
- C#'ta üretilen SQL (Dapper, `QueryManager`, seeder komutları, ekran `*Command`/`LookupQuery`
|
||||
metinleri) ya iki sağlayıcıda da çalışan tırnaklı/ANSI biçimde yazılır ya da `DataSourceType`'a göre
|
||||
dallanır.
|
||||
- SQL değişikliği doğrulanırken PG eşi de bir PostgreSQL veritabanında çalıştırılır (iki kez —
|
||||
idempotent). Ayrıntı ve dönüşüm tablosu: `lowcode.instructions.md` §8.3.1.
|
||||
17. **Her prompt sonunda optimizasyon + ölü kod temizliği** → kullanıcı "optimize et, ölü kodları
|
||||
kaldır" yazmaz; her iş bitmeden, kullanıcı istemese de o işte dokunulan kod üzerinde yapılır:
|
||||
kullanılmayan `using`/üye/parametre/tip/dil anahtarı silinir (tip silmeden önce repo geneli
|
||||
tarama, "Definition of done"), aynı mantığın ikinci kopyası ortak metoda indirilir, gereksiz
|
||||
tekrar sorgu/dosya okuması kaldırılır, kod/değer **statik yazılmaz** — tablo adı
|
||||
`TableNameResolver`'dan, değer tablo/seed/ayar verisinden dinamik bulunur. Son mesajda ne
|
||||
temizlendiği tek satırla söylenir.
|
||||
Kapsam seed artefaktlarını da içerir: yeni bir yordam/view/ekran eskisinin işini devralınca eskisi
|
||||
aynı işte silinir (iki sağlayıcının dosyası, wizard'ı, menü/yetki/dil satırları). Kullanımı biten
|
||||
SQL nesnesi (tablo, view, yordam, fonksiyon), menüsüz ve referanssız wizard, ekranı olmayan `data/`
|
||||
dosyası ve hiçbir ekran öğesinin göstermediği dil anahtarı bırakılmaz; tarama kuralı
|
||||
`lowcode.instructions.md` §8.6.
|
||||
18. **Yeni kaydın form varsayılanı** → Wizard ve MCP ile üretilen her listede form varsayılanı
|
||||
kolonun veritabanındaki sabit `DEFAULT` değeridir; deploy onu tablodan (katalog ya da seed
|
||||
scripti) okuyup `FormFieldsDefaultValueJson`'a yazar, değer koda gömülmez. Bu yüzden tablo
|
||||
şu `DEFAULT`'larla kurulur: `IsActive` → `1` (true), ölçü birimi (`*UomId`) → `'Adet'`, para birimi
|
||||
(`*CurrencyId`/`*Currency`) → `'TRY'` (lookup'ın anahtarı neyse); talepte başka sabit varsayılan
|
||||
varsa (Süre = 60) o da kolonun `DEFAULT`'udur. Alana özel değer wizard öğesinin `DefaultValue`'su ile
|
||||
verilir. Ayrıntı: `lowcode.instructions.md` §4.3.5.1.
|
||||
19. **Sabit lookup seçenekleri** → RadioGroup/SelectBox/TagBox/Autocomplete'in `StaticData` seçeneklerinde
|
||||
`name` düz metin değil `Sas.StaticLookup.*` dil anahtarıdır; EN + TR satırı dil dosyasına yazılır.
|
||||
Deploy düz metni kendisi anahtara çevirir (aynı TR metinli anahtar varsa onu kullanır) —
|
||||
`ListFormWizardAppService.LocalizeStaticLookupsAsync`, `lowcode.instructions.md` §4.3.4.1.
|
||||
20. **EditorScript** → MCP/wizard script'i elle yazmaz, Script Builder kurallarını verir; deploy kodu
|
||||
kurallardan üretir (`EditorScript.Rebuild`). `str/num/bool` alan **adı** alır (`str('X')`, değeri
|
||||
değil). `setValue`'da `true`/`false` mantıksal yazılır (onay kutusu alanında `0`/`1` deploy'da çevrilir).
|
||||
MCP ve Script Builder aynı kuralları aynı koda çevirir; `set` zinciri derinlik sınırsızdır, döngü ve
|
||||
script hatası kullanıcıya bildirilir. Ayrıntı: `lowcode.instructions.md` §4.4, §5.3, §5.6.
|
||||
21. **Demo veri ≠ seed senkronu** → `SeedData: true` yalnızca `SeedFilePath` açar;
|
||||
`SeedSyncInsert/Update/Delete` istekte açıkça istenmedikçe `false`'tur (wizard cevabı `SeedSync`).
|
||||
22. **Wizard adı modül önekli** → yeni wizard'ın adı (= seed dosyasının adı) `{ModuleId}_{Ad}`'dır
|
||||
(`Prc_RejectReason.json`, `Hr_RejectionReason.json`); aynı ekran adı başka modülde de geçebildiği
|
||||
için öneksiz ad dosya çakışması (`'X' adında bir Wizard zaten var`) üretir. Önek deploy'da
|
||||
`MenuParentModuleId`'den eklenir (`ListFormWizardDto.ResolveWizardName`); analiz ve üretim planında
|
||||
dosya adı önekli yazılır. `configs/seeds/wizard/` altındaki bütün dosyalar bu düzendedir; öneksiz
|
||||
dosya yalnızca eski bir export arşivinden gelebilir ve düzenlenirken adı korunur. Ayrıntı:
|
||||
`lowcode.instructions.md` §4.10 (Adım 2 — Menü).
|
||||
23. **Otomatik numara Sequence'tan** → wizard/MCP üretiminde talep otomatik numara istediğinde ya da
|
||||
analiz bir alanın (`ProjectNo`, `OrderNo`, `RequestNo` …) otomatik numaralanmasına karar verdiğinde
|
||||
alan `Adm.Definitions.Sequence` numaratörüne bağlanır; elle girilen numara/kod alanı bu kurala
|
||||
girmez. Numaratör
|
||||
`data/Adm.Definitions.Sequence.json`'a satır olarak yazılır (uygun olan varsa yeniden kullanılır),
|
||||
alanın varsayılanı `FormFieldsDefaultValueDto`'da `CustomValueType: 5`, `Value` = numaratörün `Name`'i.
|
||||
`@AUTONUMBER`, `MAX + 1` sorgusu ya da SQL ile numara üretilmez (boşluksuz numara açıkça istenmedikçe).
|
||||
Ayrıntı: `lowcode.instructions.md` §4.3.5.2.
|
||||
24. **İş akışlı liste** → tablo, tasarımcının Workflow şablonundaki dört kolonla kurulur ve `WorkflowDto`
|
||||
yalnız bunlara bağlanır: `ApprovalUserName` nvarchar(256), `ApprovalStatus` nvarchar(50),
|
||||
`ApprovalDate` datetime, `ApprovalDescription` nvarchar(200) (PG: varchar/timestamp). Ayrı durum
|
||||
kolonu (`Status`), Guid onaycı kolonu (`ApproverId`) ve red nedeni kolonu açılmaz: durum akışın
|
||||
düğüm başlığıdır, red nedeni onay düğümünün `RejectReason` listesinden seçilip açıklamaya yazılır.
|
||||
Bu dört alan popup düzenleme formunda yer almaz (akış yazar), listede kolon olarak görünür.
|
||||
Ayrıntı: `lowcode.instructions.md` §4.7.
|
||||
25. **Not yalnızca Table kaynağında** → kayıt kaynağı (`SelectCommandType`) Table olmayan listede (View, Query,
|
||||
StoredProcedure, TableValuedFunction) Not yoktur: `PermissionJson.N` boş, `ShowNote` kapalı, `{Kod}.Note` yetki satırı ve
|
||||
satırdaki Not butonu üretilmez; view/sorgu satırının notun bağlanacağı kalıcı kaydı yoktur. Wizard/MCP bunu kendisi uygular
|
||||
(`ListFormWizardDto.HasNote`), C# seeder'da `DefaultPermissionJson(name, hasNote: false)` yazılır. Ayrıntı:
|
||||
`lowcode.instructions.md` §4.2 (Üretilen yetkiler).
|
||||
26. **Özel komutun parametreleri boş bırakılmaz** → MCP/prompt/wizard ile üretilen ya da düzenlenen ekranda yazılan her
|
||||
`InsertCommand`/`UpdateCommand`/`DeleteCommand` ve `*BeforeCommand`/`*AfterCommand` ile aynı işte
|
||||
`Insert/Update/DeleteFieldsDefaultValueJson` da tamamlanır: komuttaki her `@Parametre` gönderilen alana, `IsTenant`'ın
|
||||
eklediği `@TenantId`'ye ya da listedeki bir satıra (`Id=@ID`, `LastModifierId=@USERID`, `DeleterId=@USERID`,
|
||||
`CreatorId=@USERID`, `*Time=@NOW`) bağlanır; kaynağı olmayan parametre "Must declare the scalar variable" verir.
|
||||
Wizard'ın audit varsayılanları view/sorgu kaynaklı ekranda ve özel komutta eksik kalır, bu yüzden kontrol üretendedir.
|
||||
`SelectFieldsDefaultValueJson` çalışma anında okunmaz; okuma koşulu `DefaultFilter`'a yazılır. Ayrıntı:
|
||||
`lowcode.instructions.md` §4.3.5.3.
|
||||
|
||||
27. **Çok dilli metin: her anahtar bütün dil dosyalarında** → Wizard (`deploy_wizard`, wizard ekranı), MCP (`save_component`,
|
||||
`save_report_*`, `create_table` dil satırları, satır araçları), Custom Component/Visual Designer, menü, yetki grubu,
|
||||
widget, sabit lookup seçeneği (`Sas.StaticLookup.*`) ya da elle yeni ekran — dil anahtarı üreten her iş şunu yapar:
|
||||
1. Anahtarın `LanguageKey`'i ve **EN + TR** metni üretilen yolda yazılır (deploy `Language.Texts` / `Language.*En|Tr`,
|
||||
seeder, elle satır); metni EN + TR veren kullanıcıdır ya da analizdir.
|
||||
2. Aynı işte `Sas.Languages.Language.json`'daki **diğer her dil** (şu an `ar, de-DE, es, fi, fr, hi, hr, hu, it, pt-BR,
|
||||
ru, sk, sl, zh-Hans`) için aynı `ResourceName` + `Key` ile `CultureName` = dil kodu olan satır, o dilin dosyasında
|
||||
(`data/Sas.Languages.LanguageText/{CultureName}.json`, dosya adı dilin `CultureName`'i) çeviriyle yazılır; sabit liste yoktur, dil listesi
|
||||
`Sas.Languages.Language.json`'dan okunur. Yer tutucular (`{0}`), HTML ve kod aynen korunur. `Id` =
|
||||
`Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}` v5 GUID.
|
||||
3. MCP'de EN + TR satırını deploy yazar; diğer dillerin satırları aynı çağrı dizisinde `Sas.Languages.LanguageText` ekranına
|
||||
`insert_row` ile (her dil için bir satır; `CultureName`, `ResourceName`, `Key`, `Value`) eklenir. Claude Code'da
|
||||
dil dosyalarına doğrudan yazılır.
|
||||
4. Anahtarı silen iş (ekran/wizard silme, bileşen silme) bütün dillerin satırını da siler.
|
||||
5. Son mesajda hangi anahtarların kaç dile yazıldığı tek satırla söylenir; çevirisi eksik dil bırakılmaz.
|
||||
Ayrıntı: `lowcode.instructions.md` §4.4.3.
|
||||
|
||||
### Cross-cutting: touching one thing means touching these too
|
||||
|
||||
- **Modül dokümanı** → `ModuleDocManager.RefreshAllAsync` wizard'ı yazan her yoldan çağrılır (deploy,
|
||||
silme, `WizardSeedSynchronizer`, import kapanışı); wizard dosyasını yazan yeni bir yol eklenirse
|
||||
çağrı da eklenir, yoksa envanter bayatlar. Wizard → doküman eşlemesi tek yerdedir
|
||||
(`ModuleDocCatalog`: beyan → alt formun ana ekranı → menü dalı); envanter biçimi
|
||||
çağrı da eklenir, yoksa envanter bayatlar. Wizard → doküman/bölüm eşlemesi tek yerdedir
|
||||
(`ModuleDocCatalog`: dosya = kök menü, bölüm = beyan → alt formun ana ekranı → menü dalı; kök ve
|
||||
dal `data/Sas.Menus.Menu.json`'dan okunur, sabit liste yoktur); envanter biçimi
|
||||
`ModuleDocRenderer`'dadır. Export kapanışı dokümanı taşır (`WizardExportCollector`), import
|
||||
`docs/modules/*.md` yolunu kabul eder.
|
||||
|
||||
- **`scriptRecipes.ts` (TS) ↔ `Domain.Shared/Editors/*.cs`** — biri değişirse diğeri de değişir.
|
||||
İkisinin çıktısı **birebir aynı** olmak zorundadır; aksi hâlde seeder ile basılan script
|
||||
dialogda "elle düzenlenmiş" sayılır ve kural editörü kapanır.
|
||||
- **`phoneFormat.ts` ↔ `DynamicReportPhoneFormatter.cs`** — telefon token'larının (`phoneGlobal`, `phoneTR`,
|
||||
`phoneIntl`, `phoneCountry`) görüntü kuralı iki yerde yazılır: liste/kart istemcide, dinamik rapor sunucuda.
|
||||
Biri değişirse diğeri de değişir; yoksa liste ile rapor aynı numarayı farklı yazar.
|
||||
- **`Sequence.GetNextSequence` (C#) ↔ `Adm_P_SequenceNext.sql` (SQL Server + PG)** — numaratörün biçim
|
||||
(`FormatTemplate` token'ları, `PaddingSize`) ve sıfırlama (`ResetPeriod`/`LastPeriodKey`) kuralı iki yerde yazılır:
|
||||
formdan açılan kayıt C#'tan, yordamın açtığı belge (tekliften sipariş, alternatif teklif) SQL'den numara alır.
|
||||
Biri değişirse diğeri de değişir; yoksa aynı numaratör iki farklı biçimde numara üretir. Eşzamanlılık korumaları
|
||||
ayrıdır ve ikisi de kalır: yordam satır kilidi (`UPDLOCK`/`FOR UPDATE`), C# karşılaştır-ve-yaz
|
||||
(`ISequenceRepository.TryAdvanceAsync`, `SequenceManager` yeniden dener) — entity izlenerek okunup `UpdateAsync`
|
||||
ile yazılmaz, iki form aynı numarayı alırdı.
|
||||
- **`LanguageTextInsertCommand` (ListFormSeeder_Sas) ↔ `LanguageTextSeedBulkInserter`** — dil metninin ekleme kuralı
|
||||
(eksik `LanguageKey`'i metnin `Id`'siyle üretmek, aynı kültür + kaynak + anahtar varsa eklememek) iki yerde yazılır:
|
||||
ekrandan gelen kayıt komuttan, migrate'teki seed satırları toplu yazıcıdan geçer (112 bin satır satır başına ~115 sn,
|
||||
toplu ~2 sn). Biri değişirse diğeri de değişir. Satır sayısı büyüyen başka bir ekran aynı yolu
|
||||
`IListFormSeedBulkInserter` ile alır.
|
||||
- **Liste popup'ı (Grid/Tree) ↔ form sayfası (`FormDevExpress`)** — editör yetenekleri ortak yardımcılardadır
|
||||
(`list/shared/editingForm.ts`, `editingFormScripts.ts`, `cascade.ts`); TagBox/GridBox/ImageUpload/ImageViewer/Rating'in
|
||||
tek uygulaması `form/editors/*`, liste hücresi yalnızca `list/editors/cellEditors.tsx` adaptörüdür. Bir tarafa
|
||||
yetenek eklenirken ortak yardımcıya eklenir; tek tarafa yazılan yetenek eşliği bozar. Tablo:
|
||||
`lowcode.instructions.md` §4.3.4.
|
||||
- **`htmlEditorOptions.ts` ↔ `EditorOptions.Html`** — HtmlEditor varsayılanı iki yerde yazılır ve
|
||||
bloklar birebir aynıdır. İstemci iç içe blokları tek seviye birleştirdiği için seed edilen
|
||||
`toolbar` varsayılanın yerine geçer; biri değişip diğeri değişmezse seed'li ekranlar eski araç
|
||||
çubuğunu gösterir.
|
||||
- **Wizard seed dosyasında tekrar yok** → `Wizard` bölümü yalnızca ekrandan üretilemeyen bilgileri
|
||||
taşır. `ListForm`/`Fields` içinde karşılığı olan her alan (veri kaynağı, select, anahtar alan,
|
||||
kırılım/düzen/düzenleme bayrakları, görünüm option blokları, alt form, widget, iş akışı, alan
|
||||
tanımları) dosyaya yazılmadan çıkarılır (`WizardSeedFileDto.ToSeedJson`) ve okurken geri
|
||||
doldurulur (`FromSeedJson`). Wizard'a yeni bir alan eklerken karşılığı ListForm'da varsa listeye ekle (`CommandColumns` →
|
||||
`CommandColumnJson` bu yolla taşınır; wizard ekranında adımı yoktur, UI modeli düzenlemede korur).
|
||||
Dosyada kalan wizard alanları üç başlık altında toplanır — `Menu`, `Language`, `Groups` — ve
|
||||
`ListFormCode` → `WizardCode`, `MenuParentModuleId` → `ModuleId`, `LanguageTexts` → `Language.Texts`
|
||||
adıyla yazılır; varsayılanı olan alanlar (`CreateMenu: true`, boş `DataSourceConnectionString`, boş
|
||||
`MenuParentPath` / `LanguageTexts`) hiç yazılmaz. Bu yalnızca dosya biçimidir; C#
|
||||
sözleşmesi düzdür, dönüşüm `WizardSeedFileDto.ToSeedJson` / `FromSeedJson` içindedir. **Wizard
|
||||
seed dosyası her zaman bu iki metotla okunup yazılır**; düz `JsonSerializer` çağrısı bloklu
|
||||
bölümleri sessizce düşürür.
|
||||
- **Wizard ile üretilen ekran** → seed dosyası (`configs/seeds/{kapsam}/wizard/{WizardName}.json`)
|
||||
wizard cevaplarının yanında ekranın `GridOptionsEditDto` (`ListForm`) ve alanların
|
||||
`ColumnFormatEditDto` (`Fields`) sözleşmesini taşır. ListForm detayından yapılan her kayıt
|
||||
(`ListFormsAppService`, `ListFormFieldsAppService`, `ListFormJsonRowAppService`) bu bölümleri
|
||||
ve `Wizard` bölümündeki karşılıklarını geri yazar (`WizardSeedSynchronizer`). Bu üçlüden birine
|
||||
yeni bir kayıt yolu eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
|
||||
- **Wizard girdisinden ekran üretimi** → tek kaynak `ListFormWizardDto.BuildListForm/BuildFields`,
|
||||
entity'ye yazma `GridOptionsEditDto.ApplyTo` / `ColumnFormatEditDto.ApplyTo`. Deploy, seeder ve
|
||||
senkronizasyon aynı metotları çağırır; ikinci bir üretim kopyası yazma.
|
||||
- **Wizard seed dosyası tek `Wizard` bölümüdür** → dosyada `ListForm`/`Fields` bölümü yoktur; ekranın
|
||||
bütün sözleşmesi `Wizard` bölümündedir. ListForm detayının her sekmesi orada bir bloktur (`Editing`,
|
||||
`Insert`/`Update`/`Delete`, `FormDefaults`, `Permission`, `FilterRow` … `PivotOption`, görünüm option
|
||||
blokları, `ExtraFilters`, `CommandColumns`, `SubForms`, `Widgets`, `WorkflowDto`, `ChartOptions`), alan
|
||||
detayının her sekmesi `Groups[].Items[]` öğesinin bir alanıdır (`WizardColumnItemInputDto`: `Filter`,
|
||||
`Header`, `Grouping`, `TotalSummary`/`GroupSummary`, `Join`, `ValidationRules`, `Styling`, `Permission`,
|
||||
`PivotSettings`, `ListOrderNo` …). Wizard alanları `Menu`, `Language`, `Data`, `Details`, `Layout`
|
||||
başlıklarına toplanır ve `ListFormCode` → `WizardCode`, `MenuParentModuleId` → `ModuleId`,
|
||||
`LanguageTexts` → `Language.Texts` adıyla yazılır; varsayılanı olan alanlar (`CreateMenu: true`, boş
|
||||
`DataSourceConnectionString`, boş `MenuParentPath` / `LanguageTexts`) hiç yazılmaz. Kısayol cevaplar
|
||||
(`AllowAdding/AllowUpdating/AllowDeleting/AllowDetail/OpenDetailAfterInsert/ConfirmDelete`,
|
||||
`SeedData`/`SeedSync`, öğedeki `IsRequired`) **dosyaya yazılmaz**: `Editing`, `Data.SeedFilePath`/`SeedSync*`
|
||||
ve `ValidationRules`'tan türetilir (`SyncDerivedAnswers`); deploy girdisinde yazılırsa bloğu ezer. Bu yalnızca
|
||||
dosya biçimidir; C# sözleşmesi düzdür, dönüşüm `WizardSeedFileDto.ToSeedJson` / `FromSeedJson` içindedir.
|
||||
**Wizard seed dosyası her zaman bu iki metotla okunup yazılır**; düz `JsonSerializer` çağrısı başlıkları
|
||||
ve türetilen kısayolları sessizce bozar. `FromSeedJson` eski biçimin `ListForm`/`Fields` bölümlerini
|
||||
okuyup `RefreshFrom` ile `Wizard`'a aktarır; bir sonraki yazımda düşerler.
|
||||
- **Wizard ile üretilen ekran** → ListForm detayından yapılan her kayıt (`ListFormsAppService`,
|
||||
`ListFormFieldsAppService`, `ListFormJsonRowAppService`) seed dosyasının `Wizard` bölümünü
|
||||
`ListFormWizardDto.RefreshFrom(listForm, fields)` ile geri yazar (`WizardSeedSynchronizer`). Bu üçlüden
|
||||
birine yeni bir kayıt yolu eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
|
||||
**Eşlik kuralı:** `GridOptionsEditDto`/`ColumnFormatEditDto`'ya eklenen her alanın
|
||||
`ListFormWizardDto`/`WizardColumnItemInputDto`'da karşılığı + `RefreshFrom` + `BuildListForm/BuildFields`
|
||||
eşlemesi olmak zorundadır; yoksa tasarımcıda yapılan değişiklik migrate'te kaybolur.
|
||||
- **Wizard girdisinden ekran üretimi** → tek hat `ListFormWizardDto.ApplyDefaults(isDeleted, isCreated,
|
||||
isModified)` (boş blokları `WizardDefaults` + `WizardConsts` ile doldurur; yetki adları, popup başlığı,
|
||||
durum anahtarı ekran kodundan yeniden türetilir) → `BuildListForm()` / `BuildFields()` (salt eşleme,
|
||||
parametresiz) → `RefreshFrom` (üretilen ekran cevaplara geri yazılır, dosya öyle kaydedilir); entity'ye
|
||||
yazma `GridOptionsEditDto.ApplyTo` / `ColumnFormatEditDto.ApplyTo`. Deploy, seeder (`WizardDataSeeder`
|
||||
dosyadan `Wizard.BuildListForm()/BuildFields()`) ve senkronizasyon aynı metotları çağırır; ikinci bir
|
||||
üretim kopyası yazma.
|
||||
- **Katman sınırı (DDD)** → `DbMigrator` Application'a referans vermez. Seeder ile AppService'in
|
||||
paylaştığı dönüşüm `Application.Contracts`'ta DTO metodudur; entity'ye yazma Domain.Shared'deki
|
||||
`IListFormArtifact` / `IListFormFieldArtifact` sözleşmeleri üzerinden yapılır.
|
||||
Ayrıntı: `dotnet.instructions.md` §5.
|
||||
- **Yeni bir yetki** → `PlatformConsts.AppCodes` + `data/Abp.Identity.Permissions.json` + (UI'da kullanılacaksa)
|
||||
`permission.constant.ts` + `data/App.Languages.LanguageText.json`. Yetki ekranındaki sıra `AbpPermissions.Order`
|
||||
`permission.constant.ts` + `data/Sas.Languages.LanguageText/{CultureName}.json`. Yetki ekranındaki sıra `AbpPermissions.Order`
|
||||
(grup için `AbpPermissionGroups.Order`) sütunundan gelir ve seed satırındaki `Order` alanıyla
|
||||
yazılır; sıra kardeşler arasındadır — her üst yetkinin altında 1'den başlar, yeni satırı menüdeki
|
||||
yerine denk gelen numarayla ekle.
|
||||
|
|
@ -267,20 +417,27 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
çağrıyı reddeder — süzme tek başına yetki sınırı değildir, ikisi birlikte yazılır. `Invoke`
|
||||
uçta değil her MCP servisinin sınıf düzeyindeki `[Authorize]`'dadır; yeni bir MCP AppService'i de
|
||||
bu attribute'la açılır.
|
||||
**Hata istemciye yalnızca `McpException` olarak ulaşır**; `McpToolErrorTranslator` (çağrı
|
||||
filtresi) yetki reddini "şu yetkiniz yok" mesajına, `UserFriendlyException`'ı mesajına çevirir.
|
||||
Kullanıcının göreceği her hata bu iki türden biriyle atılır. Yeni aracın arkasındaki servis
|
||||
bir yetkiyi `[Authorize]` attribute'uyla istiyorsa (ret mesajı yetki adını taşımaz) o yetki
|
||||
`McpToolNames.ServicePermissions`'a eklenir; ekran yetkisi gereken araç yetkiyi
|
||||
`IMcpListFormAccessFilter.GetPermissionNameAsync` + `CheckAsync` ile adıyla önceden ister.
|
||||
**MCP'nin kendi reddi istisna değildir, modelle konuşmadır**: eksik/hatalı girdi, durum kuralı,
|
||||
kapalı araç ve eksik yetki `McpProblem` olarak döner — MCP AppService'leri `McpOutcome<T>` /
|
||||
`McpProblem?` döndürür, domain servisleri (`McpSqlGuard`, `McpTableCreator`, `McpSqlObjectWriter`)
|
||||
de öyle; araç katmanı bunu `McpToolResult.From` ile hata sonucuna (`CallToolResult.IsError`) çevirir,
|
||||
model mesajı okuyup tekrar dener. `throw new UserFriendlyException` ve `AuthorizationService.CheckAsync`
|
||||
MCP kodunda kullanılmaz (debugger'ı durdurur, SDK'da yığın izli `ERR` üretir); yetki
|
||||
`McpAppService.CheckPermissionAsync` ile adıyla istenir. İstisna yalnızca gerçek hatadır (kod hatası,
|
||||
beklenmeyen durum). Arkadaki platform servisinin (wizard, custom component, ListForm verisi) attığı
|
||||
`UserFriendlyException`/yetki reddi ile sınıf düzeyindeki `[Authorize]` reddi `McpToolErrorTranslator`
|
||||
(çağrı filtresi) ile mesaja çevrilir. Yeni aracın arkasındaki servis bir yetkiyi `[Authorize]`
|
||||
attribute'uyla istiyorsa (ret mesajı yetki adını taşımaz) o yetki `McpToolNames.ServicePermissions`'a
|
||||
eklenir; ekran yetkisi gereken araç yetkiyi `IMcpListFormAccessFilter.GetPermissionNameAsync` +
|
||||
`CheckPermissionAsync` ile adıyla önceden ister.
|
||||
Araç → yetki tablosu `README.md` §13.9'dadır; araç ya da yetki değişirse tablo da değişir.
|
||||
Hazır prompt'lar (`McpPromptNames` + `McpDevelopPrompts`) yalnızca metindir; adımları tekrar
|
||||
etmez, `get_dev_guide` bölümüne yönlendirir. Bir kural bağlayıcıysa prompt'a değil araca yazılır
|
||||
(örnek: `Ready to Start` şartı `start_dev_request`'tedir).
|
||||
- **Wizard deploy veritabanına yazmaz** → wizard ekranı, MCP `deploy_wizard` ve düzenleme aynı
|
||||
yoldan (`ListFormWizardAppService.Create`) geçer ve **yalnızca seed dosyası yazar**: `wizard/{Ad}.json`
|
||||
+ menü/modül/izin grubu/yetki/dil satırları (`data/App.Menus.{Menu,Module}.json`,
|
||||
`data/Abp.Identity.Permission{Groups,s}.json`, `data/App.Languages.LanguageText.json`;
|
||||
+ menü/modül/izin grubu/yetki/dil satırları (`data/Sas.Menus.{Menu,Module}.json`,
|
||||
`data/Abp.Identity.Permission{Groups,s}.json`, `data/Sas.Languages.LanguageText/{CultureName}.json`;
|
||||
`WizardSeedRowStore`, anahtarlar `WizardSeedRowKeys`). Kurulum yalnızca DB Migrate'tedir:
|
||||
`WizardDataSeeder` ekrana özgü kayıtları (veri kaynağı, ListForm, alan, iş akışı) kurar,
|
||||
`ListFormSeedDataApplier` satırları ekler. Deploy/import dosyaya `Pending` yazar; Wizard Manager
|
||||
|
|
@ -290,31 +447,39 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
adım ekleme. Ayrıntı: `lowcode.instructions.md` §4.0.
|
||||
- **MCP ile demo veri** → geliştirme isteği örnek satır taşıyorsa (prompt'taki tablo/liste, ekran
|
||||
görüntüsü) ekran `deploy_wizard`'a `SeedData: true` ile gider (`SeedFilePath = data/{ListFormCode}.json`
|
||||
+ `SeedSync*` açık, `ListFormWizardDto.BuildListForm`) ve satırlar aynı çağrıda `seedRows` ile
|
||||
`configs/seeds/host/data/{ListFormCode}.json` dosyasına yazılır (`McpDevelopAppService`; alan adları
|
||||
; `SeedSync*` bayrakları **kapalı** kalır — yalnızca istek ekrandan girilen kayıtların dosyaya
|
||||
yansımasını açıkça isterse `SeedSync: true`, wizard ekranında da aynı; `ListFormWizardDto.BuildListForm`) ve satırlar aynı çağrıda `seedRows` ile
|
||||
`configs/seeds/data/{ListFormCode}.json` dosyasına yazılır (`McpDevelopAppService`; alan adları
|
||||
ekrana göre doğrulanır, `Guid` anahtarı sunucu üretir). Migrate ekranı kurar, ardından satırları
|
||||
ekler; MCP migrate'i beklemez. Talep döngüsü `Testing`'de biter, `Staging`'e kullanıcı alır. Dosya elle yazılmaz, görselden okunamayan değer uydurulmaz.
|
||||
`SeedData` wizard ekranında sorulmaz ama düzenlemede korunur (`RefreshFrom` + UI modeli).
|
||||
Ayrıntı: `lowcode.instructions.md` §0.10.1.
|
||||
- **Talepteki görsel** → `App.DevRequests` açıklamasına yapıştırılan görsel base64 `data:` URI olarak
|
||||
- **Talepteki görsel** → `Sas.DevRequests` açıklamasına yapıştırılan görsel base64 `data:` URI olarak
|
||||
kalır, hiçbir yere yüklenmez. `start_dev_request` / `get_dev_request` onu metinden ayıklar
|
||||
(`McpDevRequestImages`) ve MCP resim içeriği (`ImageContentBlock`) olarak döndürür; metinde
|
||||
`[image-N]` kalır. Açıklama bu yüzden `update_row` ile yeniden yazılmaz. `SubTasks` de
|
||||
`[image-N]` kalır. Sonucun tamamı istemcinin araç sonucu sınırına (Claude Desktop 1 MB) sığar:
|
||||
kalan pay görsellere bölünür, payını aşan görsel `IMcpImageShrinker` (Host'ta SkiaSharp) ile küçültülüp
|
||||
JPEG'e çevrilir, yine sığmayan gönderilmez ve metinde bildirilir; kaynak açıklama değişmez.
|
||||
Açıklama bu yüzden `update_row` ile yeniden yazılmaz. `SubTasks` de
|
||||
`add_dev_request_subtasks` ile yazılır: atanan kişi bağlanan kullanıcı, bitiş tarihi bugün —
|
||||
sunucu doldurur, modele bırakılmaz. `Timesheets`'i tamamen sunucu yazar
|
||||
(`IMcpDevRequestTimesheetRecorder`): `get_dev_request`/`start_dev_request` talebi etkin talep yapar,
|
||||
sonraki her üretim/veri yazma işlemi (`create_table`, `deploy_wizard`, `delete_wizard`,
|
||||
sonraki her üretim/veri yazma işlemi (`create_table`, `save_sql_object`, `generate_crud_endpoints`,
|
||||
`deploy_wizard`, `delete_wizard`,
|
||||
`save_component`, `delete_component`, `save_report_category`, `save_report_template`,
|
||||
`delete_report_template`, satır araçları, `save_dev_request_analysis`,
|
||||
`append_dev_request_note`, `add_dev_request_subtasks`) ona `App.Mcp.Timesheet.*` anahtarından Türkçe
|
||||
`append_dev_request_note`, `add_dev_request_subtasks`) ona `Sas.Mcp.Timesheet.*` anahtarından Türkçe
|
||||
açıklama ve ölçülen süreyle bir satır ekler; yalnızca talebi açan `start_dev_request` satır yazmaz. Yeni bir üretim/veri yazma aracı eklenirse kaydedici çağrısı ve dil anahtarı da eklenir.
|
||||
Satır aracın UoW'si commit edildikten sonra ayrı UoW'de yazılır; aynı UoW'de yazmak Dapper
|
||||
transaction'ının `Sas_H_DevRequest` kilidini EF'in ayrı bağlantısından bekletip zaman aşımına düşürür.
|
||||
- **Talep analizi** → `Description` talebin yazıldığı hâliyle kalır; MCP ona yazmaz. Analiz ve
|
||||
üretim notları `Analysis` kolonundadır (Todo panosunda `AnalysisExpr`, Açıklama yanında sekme).
|
||||
Durum sırası `Backlog → Analysis → Ready to Start → …`: "Analiz et" `save_dev_request_analysis`
|
||||
ile yalnızca `Backlog`/`Analysis` talebini analiz edip `Analysis`'e alır (hiçbir şey üretmez),
|
||||
`Ready to Start`'a kullanıcı taşır, üretim notu `append_dev_request_note` ile `Analysis`'e eklenir.
|
||||
ile yalnızca üretimi başlamamış (`Backlog`/`Analysis`/`Ready to Start`) talebi analiz eder (ekran/tablo üretmez,
|
||||
modül dokümanına yazmaz — dosya ve dal bölümü üretimde, deploy ile açılır; onaylanan analiz bölümün
|
||||
"2. Analiz ve kararlar" alt bölümüne üretimde `save_module_doc` ile yazılır). Durumu sunucu
|
||||
`productionReady` ile belirler: "Üretime hazır: Evet" → `Ready to Start`, şartlı ya da Hayır →
|
||||
`Analysis` (kullanıcı kararı bekler). Üretim notu `append_dev_request_note` ile `Analysis`'e eklenir.
|
||||
Analiz standardı tek yerdedir: `lowcode.instructions.md` §0.12; prompt'lar oraya yönlendirir.
|
||||
- **MCP şema araçları** → tablo/sütun bilgisi canlı katalogdan değil `Sas_H_DbTableColumn`
|
||||
sözlüğünden okunur (`IMcpSchemaDictionary`). Sözlüğün alan adları
|
||||
|
|
@ -323,25 +488,51 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
değil ekranların alanlarını döker: bir tablonun ekranda alan olarak tanımlanmamış kolonunu MCP göremez.
|
||||
Modelin okuması gereken kolon (ör. `AbpUsers`'ın kişisel bilgileri) ilgili ekranda gizli alan olarak durur.
|
||||
- **MCP OAuth** → bilinen sağlayıcılar (Claude, ChatGPT) seed edilmiş `Platform_Mcp` istemcisini,
|
||||
yerel `mcp-remote` proxy'si açık onaylı `Platform_Mcp_Local` istemcisini,
|
||||
diğer MCP istemcileri `/connect/register` ile üretilen `mcp_` önekli açık onaylı istemcileri
|
||||
kullanır (`McpOAuthClientManager`; seed `McpOAuthClientDataSeedContributor`). Bilinen sağlayıcı
|
||||
eklemek = DbMigrator'daki `Platform_Mcp:RedirectUris` listesine callback eklemek. MCP
|
||||
istemcisi kararı tek yerdedir (`McpConsts.IsMcpClient`): `resource` parametresi yok sayılır ve
|
||||
token kullanıcı oturumuna bağlanmaz; token ucuna yeni bir oturum yolu eklenirse bu istisna
|
||||
korunur, yoksa connector web oturumunu düşürür.
|
||||
- **MCP yalnızca seed dosyası yazar** → Wizard gibi: geliştirme araçları (`create_table`,
|
||||
`save_sql_object`, `generate_crud_endpoints`, `deploy_wizard`) yalnızca `configs/seeds/` altına dosya
|
||||
yazar, veritabanına DB Migrate yansıtır. Yeni bir geliştirme aracı veritabanına yazan AppService'i
|
||||
çağırmaz, seed dosyasını üreten ortak metodu kullanır (ör. `CrudEndpointSeedFileDto.Generate/Write`;
|
||||
diyaloğun servisi de aynı metottan geçer). Hata kodu yalnızca migrate'i bozacak girdi içindir;
|
||||
düzeltilebilen girdi (bilinmeyen seçenek, boş/tekrar eden index) sessizce normalize edilir.
|
||||
Kolon adları tahmin edilmez: şema araçları geliştiriciye migrate bekleyen wizard ekranlarını ve
|
||||
seed-only tabloları da döner (`McpSchemaAppService.GetSeedOnlySourcesAsync`). `deploy_wizard`
|
||||
girdisi dosya biçimi (`{"Wizard": {...}}`, başlıklı) ya da düz cevaptır (`WizardSeedFileDto.FromDeployJson`);
|
||||
bloklar isteğe bağlıdır (yazılmayan blok `ApplyDefaults` ile dolar), kısayol cevap bloktan üstündür,
|
||||
eski biçimin `ListForm`/`Fields` bölümleri reddedilir (`Sas.ListForm.WizardError.LegacySectionsNotAccepted`);
|
||||
`get_wizard` yeni biçimi döner.
|
||||
- **MCP yetki vermez** → yeni bir MCP aracı kullanıcının uygulamadaki yetkisini aşamaz: liste verisi
|
||||
ekranın sorgusundan (`IListFormSelectAppService`/`IListFormDataAppService`) geçer; liste
|
||||
yetkisini aşan bir yetenek (ham SQL gibi) `McpToolNames.RequiredPermissions`'a uygulamadaki
|
||||
karşılık gelen yetkiyle eklenir. `create_table` bu yüzden `.Write`'a ek olarak `App.SqlQueryManager`
|
||||
karşılık gelen yetkiyle eklenir. `create_table` bu yüzden `.Write`'a ek olarak `Sas.SqlQueryManager`
|
||||
ister; tablo scriptini tasarımcının `generateCreateTableSql` çıktısıyla aynı biçimde üretir
|
||||
(`McpTableCreator`) — tasarımcının şablon kolonları ya da script biçimi değişirse o da değişir.
|
||||
(`McpTableCreator`) ve **veritabanında çalıştırmaz** — MCP veritabanı kurmaz, tabloyu da ekran gibi
|
||||
kullanıcının çalıştırdığı DB Migrate kurar; wizard deploy'u kurulmamış tablonun kolonlarını seed
|
||||
scriptinden okur (`PostgreSqlTableScript.ReadSeedColumns`) — tasarımcının şablon kolonları ya da script biçimi değişirse o da değişir. Tasarımcı
|
||||
(`SaveTableScriptAsync`) ve `create_table` T-SQL dosyanın PostgreSQL eşini `PostgreSqlTableScript` ile
|
||||
üretip `postgres/object/` altına yazar; script biçimine yeni bir deyim eklenirse çevirisi de eklenir,
|
||||
yoksa PG eşi yazılmaz (log uyarısı). Düşürülen nesnenin dosyası iki klasörden de silinir.
|
||||
- **Çalışan (persona) ekranları ve profil** → çalışanın/yöneticinin kendi listeleri İK menüsüne
|
||||
konmaz: menüsüz `Hr.My*`/`Team*` wizard + Intranet panosu widget'ı. Profil sayfasının İzinlerim /
|
||||
Puantajım / Performansım sekmeleri persona ekranlarını salt okunur gösteren çekirdek bileşenlerdir
|
||||
(düzenle/sil yok, tek aksiyon izin talebi oluşturma); yeni persona
|
||||
ekranını profile eklemek kod + rota değişikliğidir (onay). Çalışan Bilgileri sekmesi ve Genel sekmesi
|
||||
kullanıcının kendi alanlarını `my-profile` ek alanlarından okur (`PlatformProfileAppService.ProfileExtraProperties`);
|
||||
sağlık, ücret ve işten çıkış alanları bu listeye girmez. Ayrıntı: `lowcode.instructions.md` §0.12.1.
|
||||
- **Bildirim kapsamı** → bildirim tipi, kuralı ve kuyruğu kiracıya aittir (`Adm_T_NotificationTemplate`,
|
||||
`Adm_T_NotificationType`, `Adm_T_NotificationRule`, `Adm_T_Notification`; dördü de `IMultiTenant`) ve ekranların tamamı
|
||||
Administration › Bildirimler altındadır. Ayrı bir manuel gönderim tablosu yoktur: **Bildirimler**
|
||||
ekranının ekleme formu elle gönderimdir, uç alıcı × kanal açılımını kuyruğa yazar
|
||||
(`list-form-dynamic-api/notification-insert`). Yeni kiracıya varsayılan tür/kural kümesi
|
||||
kopyalanır (`NotificationTenantDefaultsSeedContributor`); kaynak host kapsamındaki seed
|
||||
dosyalarıdır, satırlar yeni kimliklerle yazılır. Kiracı verisine dokunan zamanlanmış işin
|
||||
gelir: kendi veritabanı olan kiracı seed dosyasından alır, host veritabanını paylaşana
|
||||
`NotificationTenantDefaultsSeedContributor` aynı dosyalardan yeni kimliklerle kopyalar; iki yol
|
||||
aynı kiracıda çalışmaz. Kiracı verisine dokunan zamanlanmış işin
|
||||
`Scope` alanı `Tenant` ya da `Host ve Kiracılar` olmalıdır — `Host` kapsamında çalışan bir iş
|
||||
kiracı satırlarını göremez (`WorkerScopeEnum`, `PlatformBackgroundWorker`).
|
||||
- **Bildirim bayrakları** → sıra değişmez: `IsSent` (iletildi) olmadan `IsRead` yazılmaz, `IsRead`
|
||||
|
|
@ -354,7 +545,7 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
gerçeklemesi barındıran uygulamada — modül katmanı ASP.NET Core'a bağlanmaz) — duyuru en iyi çabadır, istemcideki yedek yoklama kaldırılmaz.
|
||||
Ayrıntı: `README.md` §13.3–§13.5.
|
||||
- **SEO** → **ayrı bir SEO tablosu yoktur**; meta, yolu tanımlayan kaydın üzerinde yaşar. Üç
|
||||
parça birlikte çalışır: genel varsayılanlar `App.SiteManagement.Seo.*` ayarları, yol bazlı
|
||||
parça birlikte çalışır: genel varsayılanlar `Adm.SiteManagement.Seo.*` ayarları, yol bazlı
|
||||
alanlar `Route.Seo*` ve `CustomComponent.Seo*` (ikisinin de `SeedSync*` bayrakları açık),
|
||||
istemci tarafı `SeoHead`. Üçü de uygulama konfigürasyonunun `seo` alanından beslenir
|
||||
(`PlatformApplicationConfigurationContributor` → `SeoAppService.GetConfigAsync`); `sitemap.xml`
|
||||
|
|
@ -365,14 +556,24 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
yazıları) `SeoAppService` içindedir; yeni bir public kaynak türü eklenirse toplayıcıya da
|
||||
eklenir, yoksa sayfa sitemap'te görünmez. Yeni bir meta alanı = iki entity + `SeoRouteDto` +
|
||||
`SeoFields` (seeder) + `SeoHead` birlikte.
|
||||
- **Yeni bir dil anahtarı** → `configs/seeds/host/data/App.Languages.LanguageText.json` (EN + TR iki satır;
|
||||
`Id` = `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}` adından URL isim alanlı v5 GUID); eklemeden önce anahtarın zaten var olup
|
||||
olmadığını kontrol et.
|
||||
- **Yeni bir dil anahtarı** → `configs/seeds/data/Sas.Languages.LanguageText/` klasörünün **bütün** dil dosyalarına
|
||||
(`ar, de-DE, en, es, fi, fr, hi, hr, hu, it, pt-BR, ru, sk, sl, tr, zh-Hans` — `Sas.Languages.Language.json`'daki
|
||||
her dil bir dosyadır; **dosya adı dilin `CultureName`'idir** (`{CultureName}.json`: `tr.json`, `de-DE.json`), dosya
|
||||
o dilin bütün satırlarını `Rows` içinde taşır), dil dosyasına anahtar başına bir satır; EN + TR yazılan metindir,
|
||||
diğer 14 dil bu iki metinden çevrilir. Yeni bir dil `Sas.Languages.Language.json`'a eklenirse klasöre
|
||||
`{CultureName}.json` açılır ve bütün anahtarlar çevrilir. Ekranın seed hedefi gruplu klasördür
|
||||
(`SeedFilePath` = `data/Sas.Languages.LanguageText/{CultureName}.json`): ekrandan insert/update/delete yalnızca
|
||||
satırın kültür dosyasına yazar, satır başına dosya açılmaz.
|
||||
`Id` = `Sas_H_LanguageText:{ResourceName}:{Key}:{CultureName}` adından URL isim alanlı v5 GUID; eklemeden önce anahtarın
|
||||
zaten var olup olmadığını kontrol et.
|
||||
- **Custom component sınıfları** → bileşen kodu veritabanında yaşar, Tailwind derlemede yalnızca
|
||||
seed dosyasını görür. Seed dosyası `ui/` dışında olduğu için aynı sınıflar `ui/safelist.txt`
|
||||
sonuna da yazılır (`twSafelistGenerator`, derlemede otomatik); yoksa uygulama sunucuda stilsiz
|
||||
kalır. İkinci bir safelist dosyası açma.
|
||||
- **Custom component kaydı/silinmesi** → `data/App.DeveloperKit.CustomComponents/{Id}.json`
|
||||
dosyasına da yazılır (`ui/twSafelistGenerator.ts` Vite eklentisi, derlemede otomatik); yoksa
|
||||
uygulama sunucuda stilsiz kalır. İkinci bir safelist dosyası açma. Çalışma anında veriden üretilen
|
||||
sınıf (`bg-${renk}-500`) dosyaya değil `src/assets/styles/tailwind/index.css`'teki `@source inline`
|
||||
satırına eklenir. Sınıf adları Tailwind 4'tür (`shadow-xs`, `rounded-sm`, `bg-black/50`,
|
||||
`shrink-0`, `x!`); tema `tailwind.config.ts`'te değil aynı dosyanın `@theme` bloğundadır.
|
||||
- **Custom component kaydı/silinmesi** → `data/Sas.DeveloperKit.CustomComponents/{Id}.json`
|
||||
(Custom Components ekranının seed verisi); ekran klasör modundadır, her bileşen kendi satır
|
||||
dosyasındadır. Hem listeden hem Visual Designer'dan (`CustomComponentAppService`) yapılan kayıt
|
||||
aynı klasöre yansır; kullandığı endpoint'ler `crud/{Entity}.json`.
|
||||
|
|
@ -408,8 +609,8 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
Kapanış (`CompleteImport`) bağımlılık sırasıyla çalışır: `sql/object` → `sql/execute` → `crud`
|
||||
→ `data` → `wizard`. Tablo eksikse önce scripti çalışır; sıra değiştirilmez. Ekran import'ta
|
||||
**kurulmaz**: wizard dosyasına `Pending` yazılır ve ekran deploy'daki gibi DB Migrate'te kurulur;
|
||||
dosyadaki `ListForm`/`Fields` sözleşmesi olduğu gibi kullanılır, wizard cevaplarından yeniden
|
||||
türetilmez — türetmek tasarımcı değişikliklerini silerdi. Wizard'ın menü/yetki/dil satırlarını
|
||||
dosyanın `Wizard` bölümündeki bloklar olduğu gibi uygulanır (`BuildListForm/BuildFields`), varsayılanlar
|
||||
yeniden üretilmez — tasarımcı değişiklikleri bloklarda durduğu için korunur. Wizard'ın menü/yetki/dil satırlarını
|
||||
taşıyan platform dosyaları `data` adımında uygulanmaz (`WizardSeedRowStore.ListFormCodes`).
|
||||
Engelleyici bulgu iki sınıftır: **arşiv kaynaklı** (`RequiresReexport` — bozuk/eksik dosya,
|
||||
karşılığı olmayan referans) hedef ortamda giderilemez, export tekrarlanır; **ortam kaynaklı**
|
||||
|
|
@ -419,21 +620,45 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
olmasıdır: tabloya dair bir bulgu üretirken `TableName` (+ tablo hedefte yoksa `TableMissing`)
|
||||
doldurulur, istemci tablo tasarımcısını buna göre açar.
|
||||
- **`ListForm.SeedFilePath` dolu bir ekran** → `SeedSyncInsert/SeedSyncUpdate/SeedSyncDelete`
|
||||
bayraklarından işaretli olan işlemler kapsam klasöründeki hedefe yansır
|
||||
(`ListFormSeedDataSynchronizer`). Hedef iki biçimdedir ve ayrım uzantıdadır: `.json` ile biten
|
||||
bayraklarından işaretli olan işlemler seed klasöründeki hedefe yansır
|
||||
(`ListFormSeedDataSynchronizer`); yalnızca host bağlamındaki kayıt yansır, kiracının kaydı
|
||||
veritabanında kalır. Hedef üç biçimdedir ve ayrım yoldadır: `.json` ile biten
|
||||
değer **tek dosyadır** (`data/{ListFormCode}.json`, bütün satırlar orada), uzantısız değer
|
||||
**klasördür** (`data/{ListFormCode}/{anahtar}.json`, her satır kendi dosyasında) ve klasör yoksa
|
||||
ilk kayıtta oluşturulur. Klasör modu satırı büyük ya da sık yazılan ekranlar içindir
|
||||
(varsayılan tek dosya; şu an klasör kullanan tek ekran Custom Components). Applier `data/`
|
||||
**klasördür** (`data/{ListFormCode}/{anahtar}.json`, her satır kendi dosyasında), `{Alan}.json` ile
|
||||
biten değer **gruplu klasördür** (`data/{ListFormCode}/{Alan}.json` → aynı alan değerini taşıyan satırlar
|
||||
`{değer}.json`'da; dil metinleri `{CultureName}`) ve klasör yoksa ilk kayıtta oluşturulur. Klasör modu
|
||||
satırı büyük ya da sık yazılan, gruplu mod satır başına dosyanın on binlerce dosya üreteceği ekranlar
|
||||
içindir (varsayılan tek dosya). **Biçimin tek kaynağı ekranın tanımıdır**; diskte ne bulunduğu kararı
|
||||
değiştirmez ve tanım kod içinde ekran adıyla sabitlenmez. Hedefi okuyan/yazan her yol
|
||||
(senkronizasyon, export/import, temizleme, wizard satır deposu, MCP demo satırları, bildirim
|
||||
varsayılanları) `ListFormSeedTarget` üzerinden gider; paylaşılan ekranların tanımı
|
||||
`ListFormWizardAppService.ReadSeedFilePathsAsync` ile okunup bileşenlere verilir. Applier `data/`
|
||||
altını özyinelemeli tarar ve **bir klasörün tamamını tek unit of work'te** uygular — ekran
|
||||
tanımı, alan listesi ve veri kaynağı satır dosyası başına değil ekran başına bir kez çözülür;
|
||||
dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Tek dosyaya bağlı eski bir kayıt,
|
||||
klasörü diskte varsa migrate sırasında klasöre taşınır.
|
||||
dosyadaki `ListFormCode` boşsa ekran kodu klasör adından gelir. Ekran bir hedef tanımlıyorsa
|
||||
yalnızca o hedef uygulanır; tanımın dışında kalan dosya (biçimi değişmiş ekranın eski dosyası)
|
||||
uyarıyla atlanır. Biçimi değiştirmek tanımı (ListForm detayı ya da seeder) değiştirmektir;
|
||||
yeniden wizard deploy'u tanımlı hedefi korur.
|
||||
Migrate/seed sırasında ve wizard import'unun kapanışında
|
||||
(`CompleteImport`) `ListFormSeedDataApplier` dosyayı geri uygular. Migrate/seed'de kayıt yoksa
|
||||
eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki
|
||||
değerlerle güncellenir ve soft delete izleri (`IsDeleted`/`DeletionTime`/`DeleterId`) temizlenir. Satırı veritabanından okuyan senkronizasyon açık UoW varsa commit sonrasında kendi UoW'sinde çalışır (EF ile yazan çağıran, ör. `MenuAppService`, aksi hâlde eski değeri dosyaya yazardı); yalnızca dosyaya dokunan `RemoveRowsByFieldAsync` hemen çalışır. Yeni bir kayıt yolu (yeni endpoint, toplu işlem, tipli AppService) eklenirse
|
||||
senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
|
||||
- **Seed kapsamı** → `configs/seeds/` tek klasördür; `host/` ya da `tenants/{tenantId}/` yoktur.
|
||||
Host ve kendi veritabanı olan her tenant aynı dosyalarla kurulur, host veritabanını paylaşan
|
||||
tenant'ta dosyalar yeniden uygulanmaz (`SeedFileScope`; migrate kararı `DataSeedContext`'e yazar).
|
||||
Dosyayı okuyan yeni bir seeder `SeedFileScope.IsEnabled` ile başlar. Dosyalar yalnızca host
|
||||
bağlamında yazılır; dosyaya yazan yeni bir yol tenant bağlamında yazmaz. Bekleyen wizard kurulumu
|
||||
(`Pending`) dosyaları uygulayan son veritabanında kapanır (`SeedFileScope.CompletesPending`).
|
||||
Tek istisna `{sql|postgres}/host/{object,execute}`: yalnızca host'ta çalışır; tenant veritabanında
|
||||
karşılığı olmayan nesneye (HangFire tabloları) dayanan script buraya konur, tablo önekine bakılmaz.
|
||||
Kendi veritabanı olan tenant'ta EF ile yalnızca `IMultiTenant` varlıklar o veritabanına gider;
|
||||
ListForm, menü ve yetki tanımını ABP her zaman host'ta okur. Kiracı tablosunun satırları kiracı
|
||||
adıyla kayıtlı `DataSource` üzerinden gider. Kiracı yalnızca kimliğiyle değiştirildiğinde ad boş
|
||||
kalır; bu durumda adı `DataSourceManager` kiracı kaydından okur. Yeni tenant yöneticisi ilk
|
||||
seed'de kiracı tarafı bütün yetkileri alır (`PlatformIdentityDataSeeder`). Bunun nedeni,
|
||||
DbMigrator'da dinamik yetki deposunun kapalı olmasıdır: ABP'nin yetki seed'i seed dosyasından
|
||||
gelen yetkileri göremez.
|
||||
- **Seed dosyası yazmak** → veri dosyası `ListFormSeedDataFileDto.Serialize`, wizard dosyası
|
||||
`WizardSeedFileDto.ToSeedJson` ile yazılır; düz `JsonSerializer.Serialize` + `File.WriteAllText`
|
||||
kullanılmaz. System.Text.Json emojiyi her zaman vekil çift kaçışına çevirir; dosya baştan
|
||||
|
|
@ -446,34 +671,38 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
kaydın `LastModificationTime`/`CreationTime` değerinden yeniyse güncellenir/silinir. Migrate
|
||||
kullanıcının runtime revizyonunu ezmez. Yeni bir seeder yazarken bu kuralı taklit et: **migrate
|
||||
varsayılanı eklemektir, ezmek değil.**
|
||||
- **Modül = menü = tablo öneki = yetki kökü** → kök menüler `Sas` (`App.Saas`), `Adm`
|
||||
(`App.Administration`), `Hr` (`App.Hr`, İK) ve `Pub` modüllerindedir. Bir ekranı modüller arası
|
||||
- **Modül = menü = tablo öneki = yetki kökü** → platformun C# seeder'lı kök menüleri `Sas`,
|
||||
`Adm`, `Hr` (İK) ve `Pub` (kök menü `Pub.Header`) modüllerindedir
|
||||
(`TableNameResolver.MenuPrefix` yalnızca bunları bilir); wizard ile üretilen modüller kendi kök menüsü ve
|
||||
tablo önekiyle gelir — `Prj`, `Prc`, `Stk` (`Stk_`),
|
||||
`Mnt` (`Mnt_`), `Prd` (`Prd_`); tam liste `data/Sas.Menus.Menu.json`
|
||||
kök satırlarıdır. Bir ekranı modüller arası
|
||||
taşımak birlikte yapılır: `TableNameResolver` (`MenuPrefix`) + `AppCodes` (Seed/Platform) +
|
||||
`ListFormSeeder_{Saas,Administration,Hr}.cs` + `data/{ListFormCode}.json` adı + menü/yetki/dil
|
||||
satırları + route `Authority`. Yetki `Modules` değeri: Saas/Administration `Sas|Adm`, İK `Sas|Hr`
|
||||
`ListFormSeeder_{Sas,Adm,Hr}.cs` + `data/{ListFormCode}.json` adı + menü/yetki/dil
|
||||
satırları + route `Authority`. Yetki `Modules` değeri: Sas/Adm `Sas|Adm`, İK `Sas|Hr`
|
||||
(`Sas` host, `Adm`/`Hr` kiracı görünürlüğü). İK departman/pozisyon `BranchId` zorunludur; seed
|
||||
satırı host şubesine (`PlatformConsts.Branches.BranchIdDefaultValue`) bağlanır.
|
||||
- **Menü ağacı ve yetkiler** → `configs/seeds/host/data/App.Menus.{Routes,Module,Menu}.json` ve
|
||||
- **Menü ağacı ve yetkiler** → `configs/seeds/data/Sas.Menus.{Routes,Module,Menu}.json` ve
|
||||
`Abp.Identity.Permission{Groups,s}.json`; platformun kendi menü/yetki kayıtları da diğer liste
|
||||
verisiyle aynı mekanizmadan (`ListFormSeedDataApplier`) geçer, ayrı bir seeder yoktur. `Order`
|
||||
alanı bağımlılık sırasını kurar: route → modül → menü, yetki grubu → yetki, ülke grubu → ülke →
|
||||
şehir → ilçe (`App.Definitions.{CountryGroup,Country,City,District}.json`), bildirim tipi → kuralı
|
||||
(`App.Notifications.Notification{Types,Rules}.json`). Para birimi, hitap, AI bot
|
||||
(`App.Definitions.{Currency,ContactTitle,AiBot}.json`), ayar tanımları (`App.SettingDefinitions.json`)
|
||||
ve zamanlanmış işler (`App.BackgroundWorkers.RecurringJobs.json`), dil → metin
|
||||
(`App.Languages.{Language,LanguageText}.json`; `LanguagesData.json` + `LanguageDataSeeder` kaldırıldı) da buradadır; `HostData.json`
|
||||
şehir → ilçe (`Sas.Definitions.{CountryGroup,Country,City,District}.json`), departman → onaycı (`Hr.Department` → `Adm.Definitions.Approver`), bildirim tipi → kuralı
|
||||
(`Adm.Notifications.Notification{Types,Rules}.json`). Para birimi, hitap, AI bot
|
||||
(`Sas.Definitions.{Currency,ContactTitle,AiBot}.json`), ayar tanımları (`Sas.SettingDefinitions.json`)
|
||||
ve zamanlanmış işler (`Sas.BackgroundWorkers.RecurringJobs.json`), dil → metin
|
||||
(`Sas.Languages.{Language,LanguageText}.json`; `LanguagesData.json` + `LanguageDataSeeder` kaldırıldı) da buradadır; `HostData.json`
|
||||
kaldırıldı, `HostDataSeeder` yalnızca bağlantı cümlesi appsettings'ten gelen varsayılan veri kaynağını kurar. Dosyalar ekranın
|
||||
anahtarını (`KeyFieldName`) kullanır; `Guid` anahtarlı satırın `Id`'si iş anahtarından türetilmiş
|
||||
sabit bir GUID'dir, satır eklerken aynı kuralla üret. `App.Menus.Routes.json` dosyasına
|
||||
sabit bir GUID'dir, satır eklerken aynı kuralla üret. `Sas.Menus.Routes.json` dosyasına
|
||||
yalnızca var olan satırın `Seo*` alanları için dokunulur (standing default 7); rota eklemek,
|
||||
silmek ya da yolunu değiştirmek çerçeve dışıdır.
|
||||
- **Public site** → menü `Menu` tablosunda `ModuleId = "Pub"` altında (`PlatformConsts.PublicSite`);
|
||||
`App.Public.Header` hem üst menüyü hem alt bilgideki hızlı bağlantıları besler (ikisi de
|
||||
`PublicNav` düğümü) ve ziyaretçiye uygulama konfigürasyonunun `publicMenus` alanıyla taşınır
|
||||
`Pub.Header` hem üst menüyü hem alt bilgideki hızlı bağlantıları besler (ikisi de
|
||||
`PublicNav` düğümü) ve ziyaretçiye uygulama konfigürasyonunun `menus.public` alanıyla taşınır
|
||||
(menü ucu `[Authorize]`). Sayfa ise Custom Component'tir: `/admin/` ile
|
||||
başlamayan `RoutePath` otomatik public route olur ve aynı yolu paylaşan fiziksel route'u gölgeler;
|
||||
`RoutePath` boş bırakılabilir (rotasız bileşen). Yeni public sayfa =
|
||||
`data/App.DeveloperKit.CustomComponents/{Id}.json` satırı + `Pub` menü kaydı; ikisi birlikte yazılır.
|
||||
`data/Sas.DeveloperKit.CustomComponents/{Id}.json` satırı + `Pub` menü kaydı; ikisi birlikte yazılır.
|
||||
Üst ve alt bilgi rotasız bileşenlerdir (`PublicHeader`, `PublicFooter`), layout ikisini de
|
||||
`renderComponent` ile çizer; rotasız bileşende `RoutePath` **NULL** yazılır (tekil indeks yalnızca
|
||||
dolu değerleri kapsar). Üst bilginin davranışı `components/publicSite` katalog düğümlerinde,
|
||||
|
|
@ -496,8 +725,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
|
|||
silinmeden önce **repo geneli** (api + ui + configs) referans taraması yapılır; DI ile çözülen
|
||||
tipler düz metin aramasında referanssız görünür.
|
||||
- Yorum satırına alınmış kod bloğu bırakılmaz.
|
||||
- Seed tarafında ölü artefakt yok (`lowcode.instructions.md` §8.6): yerini yenisine bırakan SQL nesnesi,
|
||||
wizard ve veri dosyası silinmiş; her `sql/` dosyasının `postgres/` eşi var; kullanılmayan dil anahtarı
|
||||
wizard'ın `Language.Texts`/`InsertedRecords`'undan ve dil dosyasından çıkarılmış.
|
||||
- Modül/ekran/bileşen ekleyen ya da davranışını değiştiren iş modül dokümanını
|
||||
(`configs/seeds/{kapsam}/docs/modules/{Modül}.md`) yazar veya günceller; liste ekranının açıklaması
|
||||
(`configs/seeds/docs/modules/{Menü}.md`) yazar veya günceller; liste ekranının açıklaması
|
||||
ayrıntılıdır (standing default 14–15).
|
||||
- `api/` değişikliğinden sonra: `dotnet build -p:EnforceCodeStyleInBuild=true` — 0 hata, yeni
|
||||
`IDE00xx` yok.
|
||||
|
|
@ -6,4 +6,15 @@
|
|||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 Generate Access Token oluşturulur.
|
||||
|
||||
- token: EAASM83pDJf0BO0ZCYBr3Fx4SES0ox8XiZCgP3FIteprYpwawZCkGYjpMlKk4OZCWgRNZC2Ttofgf8amMf929ZBuDcgYIOsXkPMGBNmVxm2czipzq63LmtbYfyCWsKVy0q1jtay0nRoeDuO2FKhqCfLLtNn9cSWjINVLcz26ptK8a2Oko83cZBzrNsp3cpIECnzqRfEjHdEz1U73y3ZB1MZBOhHilax7yZALf4ZA1Tmk
|
||||
- token: <access token>
|
||||
- WhatsApp telefon numarasını Register etmek için aşağıdaki postman Curl gönderilir.
|
||||
|
||||
```
|
||||
curl --location 'https://graph.facebook.com/v21.0/521106361082067/register' \
|
||||
curl --location 'https://graph.facebook.com/v21.0/<phone-number-id>/register' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Authorization: Bearer <access token>' \
|
||||
--data '{
|
||||
|
|
@ -68,7 +68,7 @@ username=system%40sozsoft.com
|
|||
}'
|
||||
```
|
||||
|
||||
- Message göndermek için url : https://graph.facebook.com/v21.0/521106361082067/messages
|
||||
- Message göndermek için url : https://graph.facebook.com/v21.0/<phone-number-id>/messages
|
||||
|
||||
```
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ COPY "src/Sozsoft.Platform.Domain/Sozsoft.Platform.Domain.csproj" "src/Sozsoft.P
|
|||
COPY "src/Sozsoft.Platform.Domain.Shared/Sozsoft.Platform.Domain.Shared.csproj" "src/Sozsoft.Platform.Domain.Shared/"
|
||||
COPY "src/Sozsoft.Platform.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.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.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/"
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.Applicatio
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sozsoft.Platform.HttpApi", "src\Sozsoft.Platform.HttpApi\Sozsoft.Platform.HttpApi.csproj", "{4164BDF7-F527-4E85-9CE6-E3C2D7426A27}"
|
||||
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}"
|
||||
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}"
|
||||
|
|
@ -186,18 +184,6 @@ Global
|
|||
{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.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.Build.0 = Debug|Any CPU
|
||||
{AA94D832-1CCC-4715-95A9-A483F23A1A5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
|
@ -593,7 +579,6 @@ Global
|
|||
{42F719ED-8413-4895-B5B4-5AB56079BC66} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{520659C8-C734-4298-A3DA-B539DB9DFC0B} = {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}
|
||||
{748584B1-BA69-4F6A-81AA-F4BDE6BCE29D} = {CA9AC87F-097E-4F15-8393-4BC07735A5B0}
|
||||
{4D886EEF-D4CF-4879-A10D-249D762E4ED9} = {03E1C8DA-035E-4882-AF81-F392139FCF38}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
using System.Threading.Tasks;
|
||||
using Volo.Abp;
|
||||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[IntegrationService]
|
||||
public interface ILanguageKeyIntegrationService
|
||||
{
|
||||
Task CreateKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey
|
||||
);
|
||||
|
||||
Task DeleteKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey);
|
||||
}
|
||||
|
||||
|
|
@ -6,6 +6,25 @@ public class LanguagesPermissions
|
|||
{
|
||||
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()
|
||||
{
|
||||
return ReflectionHelper.GetPublicConstantsRecursively(typeof(LanguagesPermissions));
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Entities;
|
||||
|
|
@ -12,6 +14,7 @@ using Volo.Abp.Validation;
|
|||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[Authorize]
|
||||
public class LanguageAppService : CrudAppService<
|
||||
Language,
|
||||
LanguageDto,
|
||||
|
|
@ -23,6 +26,11 @@ public class LanguageAppService : CrudAppService<
|
|||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
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)
|
||||
|
|
@ -40,6 +48,7 @@ public class LanguageAppService : CrudAppService<
|
|||
return await base.CreateAsync(input);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.Language.Default)]
|
||||
public async Task<LanguageDto> GetLanguageByCultureNameAsync(string CultureName)
|
||||
{
|
||||
var item = await Repository.FirstOrDefaultAsync(a => a.CultureName == CultureName);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
|
@ -11,6 +13,7 @@ using Volo.Abp.Validation;
|
|||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[Authorize]
|
||||
public class LanguageKeyAppService : CrudAppService<
|
||||
LanguageKey,
|
||||
LanguageKeyDto,
|
||||
|
|
@ -26,6 +29,11 @@ public class LanguageKeyAppService : CrudAppService<
|
|||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
namespace Sozsoft.Languages;
|
||||
|
||||
[IntegrationService]
|
||||
public class LanguageKeyIntegrationService : ILanguageKeyIntegrationService, ITransientDependency
|
||||
{
|
||||
private readonly IRepository<LanguageKey, Guid> _repository;
|
||||
|
||||
public LanguageKeyIntegrationService(IRepository<LanguageKey, Guid> repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task CreateKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey
|
||||
)
|
||||
{
|
||||
//NameKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = NameKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//DescriptionKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = DescriptionKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//MainGroupKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = MainGroupKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
|
||||
//SubGroupKey
|
||||
if (!await _repository.AnyAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName))
|
||||
{
|
||||
await _repository.InsertAsync(new LanguageKey { Key = SubGroupKey, ResourceName = LanguagesConsts.AppName });
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteKeysForSettingsAsync(
|
||||
string NameKey,
|
||||
string DescriptionKey,
|
||||
string MainGroupKey,
|
||||
string SubGroupKey)
|
||||
{
|
||||
await _repository.DeleteAsync(a => a.Key == NameKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == DescriptionKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == MainGroupKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
await _repository.DeleteAsync(a => a.Key == SubGroupKey && a.ResourceName == LanguagesConsts.AppName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4,11 +4,12 @@ using System.ComponentModel.DataAnnotations;
|
|||
using System.Linq;
|
||||
using System.Linq.Dynamic.Core;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Languages.Caching;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using StackExchange.Redis;
|
||||
using Sozsoft.Languages.Permissions;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
|
@ -19,6 +20,11 @@ using Volo.Abp.Validation;
|
|||
|
||||
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<
|
||||
LanguageText,
|
||||
LanguageTextDto,
|
||||
|
|
@ -29,20 +35,25 @@ public class LanguageTextAppService : CrudAppService<
|
|||
{
|
||||
private readonly IRepository<LanguageText, Guid> _repositoryText;
|
||||
private readonly IRepository<LanguageKey, Guid> _repositoryKey;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly DistributedCacheCleaner _cacheCleaner;
|
||||
|
||||
public LanguageTextAppService(
|
||||
IRepository<LanguageText, Guid> repositoryText,
|
||||
IRepository<LanguageKey, Guid> repositoryKey,
|
||||
IConfiguration configuration
|
||||
DistributedCacheCleaner cacheCleaner
|
||||
)
|
||||
: base(repositoryText)
|
||||
{
|
||||
LocalizationResource = typeof(LanguagesResource);
|
||||
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;
|
||||
_repositoryKey = repositoryKey;
|
||||
_configuration = configuration;
|
||||
_cacheCleaner = cacheCleaner;
|
||||
}
|
||||
|
||||
public override async Task<LanguageTextDto> CreateAsync(LanguageTextCreateUpdateDto input)
|
||||
|
|
@ -63,6 +74,7 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return await base.CreateAsync(input);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Update)]
|
||||
public async Task UpdateLanguageTextAsync(LanguageTextCreateUpdateDto input)
|
||||
{
|
||||
var entity = await _repositoryText.GetAsync(
|
||||
|
|
@ -77,6 +89,7 @@ public class LanguageTextAppService : CrudAppService<
|
|||
}
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<PagedResultDto<LanguageTextTranslatedDto>> GetLanguageTextTranslatedAsync(LanguageTextTranslatedRequestDto input)
|
||||
{
|
||||
var query = await _repositoryKey.GetQueryableAsync();
|
||||
|
|
@ -109,6 +122,7 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return new PagedResultDto<LanguageTextTranslatedDto>(totalCount, dtoLanguageTextTranslate);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<LanguageTextDto> GetLanguageTextAsync(LanguageTextFilteredDto input)
|
||||
{
|
||||
var entity = await _repositoryText.FirstOrDefaultAsync(a =>
|
||||
|
|
@ -119,6 +133,7 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return ObjectMapper.Map<LanguageText, LanguageTextDto>(entity);
|
||||
}
|
||||
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task<List<LanguageTextDto>> GetLanguageTextByCultureNameAsync(string CultureName)
|
||||
{
|
||||
var item = await _repositoryText.GetListAsync(a => a.CultureName == CultureName);
|
||||
|
|
@ -130,11 +145,11 @@ public class LanguageTextAppService : CrudAppService<
|
|||
return ObjectMapper.Map<List<LanguageText>, List<LanguageTextDto>>(item);
|
||||
}
|
||||
|
||||
// Dil Metinleri listesindeki "Redis önbelleğini temizle" butonu da bu yetkiyle görünür.
|
||||
[Authorize(LanguagesPermissions.LanguageText.Default)]
|
||||
public async Task ClearRedisCacheAsync()
|
||||
{
|
||||
var redis = ConnectionMultiplexer.Connect(_configuration["Redis:Configuration"]!);
|
||||
var db = redis.GetDatabase();
|
||||
await db.ExecuteAsync("FLUSHALL");
|
||||
await _cacheCleaner.ClearAsync();
|
||||
}
|
||||
|
||||
protected virtual IQueryable<LanguageKey> ApplySorting(IQueryable<LanguageKey> query, LanguageTextTranslatedRequestDto input)
|
||||
|
|
|
|||
|
|
@ -11,14 +11,6 @@ public partial class LanguageToLanguageInfoMapper : MapperBase<Language, Languag
|
|||
public override partial LanguageInfo Map(Language source);
|
||||
|
||||
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)]
|
||||
|
|
@ -27,14 +19,6 @@ public partial class LanguageToLanguageDtoMapper : MapperBase<Language, Language
|
|||
public override partial LanguageDto Map(Language source);
|
||||
|
||||
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)]
|
||||
|
|
@ -43,14 +27,6 @@ public partial class LanguageKeyToLanguageKeyDtoMapper : MapperBase<LanguageKey,
|
|||
public override partial LanguageKeyDto Map(LanguageKey source);
|
||||
|
||||
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)]
|
||||
|
|
@ -59,14 +35,6 @@ public partial class LanguageTextToLanguageTextDtoMapper : MapperBase<LanguageTe
|
|||
public override partial LanguageTextDto Map(LanguageText source);
|
||||
|
||||
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)]
|
||||
|
|
@ -75,14 +43,6 @@ public partial class LanguageTextToLanguageTextEtoMapper : MapperBase<LanguageTe
|
|||
public override partial LanguageTextEto Map(LanguageText source);
|
||||
|
||||
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)]
|
||||
|
|
@ -93,14 +53,6 @@ public partial class LanguageTextCreateUpdateDtoToLanguageTextMapper : MapperBas
|
|||
|
||||
[MapperIgnoreTarget(nameof(LanguageText.LanguageKey))]
|
||||
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)]
|
||||
|
|
@ -133,12 +85,4 @@ public partial class LanguageKeyToLanguageTextTranslatedDtoMapper : MapperBase<L
|
|||
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreationTime))]
|
||||
[MapperIgnoreTarget(nameof(LanguageTextTranslatedDto.CreatorId))]
|
||||
public override partial void Map(LanguageKey source, LanguageTextTranslatedDto destination);
|
||||
|
||||
public override void BeforeMap(LanguageKey source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(LanguageKey source, LanguageTextTranslatedDto destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
namespace Sozsoft.Languages.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Uygulamanın dağıtık önbelleğini boşaltır. <c>FLUSHALL</c> yerine yalnızca bu uygulamanın anahtarlarını
|
||||
/// siler: Redis başka bir uygulama ya da ortamla paylaşılıyorsa onların verisine dokunulmaz.
|
||||
/// Bağlantı süreç başına tektir; her çağrıda yeni bağlantı açmak bağlantı sızdırıyordu.
|
||||
/// </summary>
|
||||
public class DistributedCacheCleaner : ISingletonDependency, IDisposable
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private readonly Lazy<ConnectionMultiplexer> _connection;
|
||||
private readonly string[] _keyPatterns;
|
||||
private readonly IDatabaseResourceLocalizer _localizer;
|
||||
|
||||
public DistributedCacheCleaner(
|
||||
IConfiguration configuration,
|
||||
IOptions<AbpDistributedCacheOptions> cacheOptions,
|
||||
IDatabaseResourceLocalizer localizer)
|
||||
{
|
||||
_localizer = localizer;
|
||||
_connection = new Lazy<ConnectionMultiplexer>(
|
||||
() => ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]!));
|
||||
|
||||
// ABP iki biçimde yazar: IDistributedCache<T> anahtarı [t:{kiracı},]c:{önbellek},k:{KeyPrefix}{anahtar},
|
||||
// bellek içi tanım önbelleklerinin damgası ise doğrudan {KeyPrefix}_Abp...Stamp. Host "Platform",
|
||||
// DbMigrator "Platform:" öneki kullanır; ':' atılınca desenler iki sürecin anahtarlarını da kapsar.
|
||||
var prefix = (cacheOptions.Value.KeyPrefix ?? string.Empty).TrimEnd(':');
|
||||
_keyPatterns = [$"{prefix}*", $"*,k:{prefix}*"];
|
||||
}
|
||||
|
||||
/// <returns>Silinen anahtar sayısı.</returns>
|
||||
public async Task<long> ClearAsync()
|
||||
{
|
||||
var connection = _connection.Value;
|
||||
var database = connection.GetDatabase();
|
||||
long removed = 0;
|
||||
|
||||
foreach (var endpoint in connection.GetEndPoints())
|
||||
{
|
||||
var server = connection.GetServer(endpoint);
|
||||
if (server.IsReplica || !server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pattern in _keyPatterns)
|
||||
{
|
||||
var batch = new List<RedisKey>(BatchSize);
|
||||
await foreach (var key in server.KeysAsync(database.Database, pattern, BatchSize))
|
||||
{
|
||||
batch.Add(key);
|
||||
if (batch.Count == BatchSize)
|
||||
{
|
||||
removed += await DeleteAsync(database, batch);
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
removed += await DeleteAsync(database, batch);
|
||||
}
|
||||
}
|
||||
|
||||
// Dil sözlüğünün süreç içi kopyası Redis'te değildir; temizlik onu da kapsar.
|
||||
_localizer.ClearLocalCache();
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
// Anahtarlar tek tek silinir: çok anahtarlı DEL, cluster'da farklı slot'lara düşen anahtarları reddeder.
|
||||
private static async Task<long> DeleteAsync(IDatabase database, List<RedisKey> keys)
|
||||
{
|
||||
var results = await Task.WhenAll(keys.Select(key => database.KeyDeleteAsync(key)));
|
||||
return results.Count(deleted => deleted);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connection.IsValueCreated)
|
||||
{
|
||||
_connection.Value.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities.Events;
|
||||
|
|
@ -12,10 +13,12 @@ public class LanguageTextCacheItemInvalidator :
|
|||
ITransientDependency
|
||||
{
|
||||
private readonly IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly IDatabaseResourceLocalizer localizer;
|
||||
|
||||
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache)
|
||||
public LanguageTextCacheItemInvalidator(IDistributedCache<LanguageTextCacheItem> cache, IDatabaseResourceLocalizer localizer)
|
||||
{
|
||||
this.cache = cache;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
public async Task HandleEventAsync(EntityChangedEventData<LanguageText> eventData)
|
||||
|
|
@ -23,7 +26,7 @@ public class LanguageTextCacheItemInvalidator :
|
|||
//distributedCacheOptions.Value.KeyPrefix
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.Entity.ResourceName, eventData.Entity.CultureName);
|
||||
await cache.RemoveAsync(key);
|
||||
return;
|
||||
localizer.ClearLocalCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Localization;
|
||||
using Volo.Abp.Caching;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Entities.Events.Distributed;
|
||||
|
|
@ -13,11 +14,14 @@ public class LanguageTextCacheItemInvalidatorDistributed :
|
|||
ITransientDependency
|
||||
{
|
||||
private readonly IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly IDatabaseResourceLocalizer localizer;
|
||||
|
||||
public LanguageTextCacheItemInvalidatorDistributed(
|
||||
IDistributedCache<LanguageTextCacheItem> cache)
|
||||
IDistributedCache<LanguageTextCacheItem> cache,
|
||||
IDatabaseResourceLocalizer localizer)
|
||||
{
|
||||
this.cache = cache;
|
||||
this.localizer = localizer;
|
||||
}
|
||||
|
||||
public async Task HandleEventAsync(EntityCreatedEto<LanguageTextEto> eventData)
|
||||
|
|
@ -39,6 +43,7 @@ public class LanguageTextCacheItemInvalidatorDistributed :
|
|||
{
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(eventData.ResourceName, eventData.CultureName);
|
||||
await cache.RemoveAsync(key);
|
||||
localizer.ClearLocalCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -14,8 +15,14 @@ namespace Sozsoft.Languages.Localization;
|
|||
|
||||
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 IDistributedCache<LanguageTextCacheItem> cache;
|
||||
private readonly ConcurrentDictionary<string, (LanguageTextCacheItem Item, long ExpiresAt)> localCache = new();
|
||||
|
||||
public DatabaseResourceLocalizer(
|
||||
IRepository<LanguageText, Guid> languageTextRepository,
|
||||
|
|
@ -27,27 +34,52 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
|
|||
|
||||
public LocalizedString GetOrNull(LocalizationResourceBase resource, string cultureName, string name)
|
||||
{
|
||||
var item = GetCacheItem(resource, cultureName);
|
||||
var value = item.Dictionary?.GetOrDefault(name);
|
||||
if (item == null || value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new LocalizedString(name, value);
|
||||
var value = GetCacheItem(resource, cultureName)?.Dictionary?.GetOrDefault(name);
|
||||
return value == null ? null : 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)
|
||||
{
|
||||
// culture, resource, key
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
|
||||
return cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName));
|
||||
if (TryGetLocal(key, out var local))
|
||||
{
|
||||
return local;
|
||||
}
|
||||
|
||||
return SetLocal(key, cache.GetOrAdd(key, () => CreateCacheItem(resource, cultureName)));
|
||||
}
|
||||
|
||||
private bool TryGetLocal(string key, out LanguageTextCacheItem item)
|
||||
{
|
||||
if (localCache.TryGetValue(key, out var entry) && entry.ExpiresAt > Environment.TickCount64)
|
||||
{
|
||||
item = entry.Item;
|
||||
return true;
|
||||
}
|
||||
|
||||
item = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private LanguageTextCacheItem SetLocal(string key, LanguageTextCacheItem item)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
localCache[key] = (item, Environment.TickCount64 + LocalCacheDurationMs);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public void Fill(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
|
@ -67,9 +99,12 @@ public class DatabaseResourceLocalizer : IDatabaseResourceLocalizer, ISingletonD
|
|||
public async Task FillAsync(LocalizationResourceBase resource, string cultureName, Dictionary<string, LocalizedString> dictionary)
|
||||
{
|
||||
var key = LanguageTextCacheItem.CalculateCacheKey(resource.ResourceName, cultureName);
|
||||
var item = await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName));
|
||||
if (!TryGetLocal(key, out var item))
|
||||
{
|
||||
item = SetLocal(key, await cache.GetOrAddAsync(key, () => CreateCacheItemAsync(resource, cultureName)));
|
||||
}
|
||||
|
||||
foreach (var kvp in item.Dictionary)
|
||||
foreach (var kvp in item?.Dictionary ?? [])
|
||||
{
|
||||
dictionary[kvp.Key] = new LocalizedString(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ public interface IDatabaseResourceLocalizer
|
|||
void Fill(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);
|
||||
void ClearLocalCache();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ public abstract class MailQueueAppService : ApplicationService
|
|||
{
|
||||
protected MailQueueAppService()
|
||||
{
|
||||
LocalizationResource = typeof(ErpMailQueueResource);
|
||||
ObjectMapperContext = typeof(ErpMailQueueModule);
|
||||
LocalizationResource = typeof(SozsoftMailQueueResource);
|
||||
ObjectMapperContext = typeof(SozsoftMailQueueModule);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
namespace Sozsoft.MailQueue.Application;
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
namespace Sozsoft.MailQueue.Domain.Shared;
|
||||
|
||||
[LocalizationResourceName(MailQueueConsts.AppName)]
|
||||
public class ErpMailQueueResource
|
||||
public class SozsoftMailQueueResource
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
namespace Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
|
||||
public interface IAttachmentExtractor
|
||||
{
|
||||
IList<AttachmentModel> Extract(string attachment, string attachmentParametre);
|
||||
}
|
||||
|
||||
public class AttachmentExtractor : IAttachmentExtractor, ITransientDependency
|
||||
{
|
||||
protected readonly IParameterExtractor ParameterExtractor;
|
||||
|
||||
public AttachmentExtractor(
|
||||
IParameterExtractor parameterExtractor)
|
||||
{
|
||||
ParameterExtractor = parameterExtractor;
|
||||
}
|
||||
|
||||
public IList<AttachmentModel> Extract(string attachment, string attachmentParametre)
|
||||
{
|
||||
var result = new List<AttachmentModel>();
|
||||
|
||||
if (attachment.IsNullOrWhiteSpace())
|
||||
return result;
|
||||
|
||||
var parameters = ParameterExtractor.Extract(attachmentParametre);
|
||||
/*
|
||||
Dict<
|
||||
0, Dict<
|
||||
Tarih, 1232
|
||||
No 4334
|
||||
>
|
||||
1, Dict<
|
||||
QUERY, ghghg
|
||||
QUERYSQL select ffddf
|
||||
*/
|
||||
//Attachment:
|
||||
//{0:MRP:rpt_DepoCikisCekiListesi:PDF:MailEkteGozukecekAd};{1:CRM:rpt_DepoCikisCekiListesiCarpet:XLS:MailEkteGozukecekAd}
|
||||
//AttachmentParametre:
|
||||
//Filtre değerleri olabilir {0:Tarih=1232&No=4334};{1:Tarih=213}
|
||||
//Veya query olabilir {0:QUERY=…&QUERYSQL=..};{1:QUERY=…&QUERYSQL=..}
|
||||
foreach (var item in attachment.Split("};{"))
|
||||
{
|
||||
var keyValue = item.Trim('{').Trim('}').Split(":");
|
||||
if (keyValue.Length == 5)
|
||||
{
|
||||
result.Add(new AttachmentModel
|
||||
{
|
||||
Index = keyValue[0],
|
||||
DbAdi = keyValue[1],
|
||||
RaporAdi = keyValue[2],
|
||||
ExportDosyaTipi = keyValue[3],
|
||||
Filtreler = parameters.GetOrDefault(keyValue[0]),
|
||||
MailEkAdi = keyValue[4]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,85 +2,36 @@
|
|||
using Sozsoft.MailQueue.FileGeneration;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Guids;
|
||||
using Volo.Abp.TextTemplating;
|
||||
|
||||
namespace Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
/// <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 IParameterExtractor ParameterExtractor { get; }
|
||||
public IAttachmentExtractor AttachmentExtractor { get; }
|
||||
public ITemplateRenderer TemplateRenderer { get; }
|
||||
public IGuidGenerator GuidGenerator { get; }
|
||||
public IServiceProvider ServiceProvider { get; }
|
||||
protected ILogManager LogManager { get; }
|
||||
private readonly IConfiguration Configuration;
|
||||
|
||||
public AttachmentGenerator(
|
||||
IAttachmentExtractor attachmentExtractor,
|
||||
ITemplateRenderer templateRenderer,
|
||||
IGuidGenerator guidGenerator,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogManager logManager,
|
||||
IConfiguration configuration
|
||||
)
|
||||
IConfiguration configuration)
|
||||
{
|
||||
AttachmentExtractor = attachmentExtractor;
|
||||
TemplateRenderer = templateRenderer;
|
||||
GuidGenerator = guidGenerator;
|
||||
ServiceProvider = serviceProvider;
|
||||
LogManager = logManager;
|
||||
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>
|
||||
/// Verilan tablo modeli ile istenilen dosya tipinde (PDF, XLS, TXT) ek üretir
|
||||
/// </summary>
|
||||
|
|
@ -108,7 +59,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
if (fileGenerator == null)
|
||||
{
|
||||
LogManager.LogError($"File Generator oluşturulamadı. Index: {table.Index}, Dosya Tipi: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"File generator could not be created. Index: {table.Index}, FileType: {table.DosyaTipi}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +75,7 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogManager.LogError($"Dosya oluşturulamadı. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"File could not be generated. Index: {table.Index}. {ex}", "ATTACHMENT-TABLO", "KUYRUK", kuyrukId, jobId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -132,70 +83,6 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
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)
|
||||
{
|
||||
if (!Configuration.GetValue<bool>("App:LogMailToFile"))
|
||||
|
|
@ -224,4 +111,3 @@ public class AttachmentGenerator : IAttachmentGenerator, ITransientDependency
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.Timing;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.TextTemplating;
|
||||
|
||||
|
|
@ -23,17 +24,20 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
public IParameterExtractor ParameterExtractor { get; }
|
||||
public ITemplateRenderer TemplateRenderer { get; }
|
||||
protected ILogManager LogManager { get; }
|
||||
protected IClock Clock { get; }
|
||||
|
||||
public MailBodyGenerator(
|
||||
IParameterExtractor parameterExtractor,
|
||||
ITemplateRenderer templateRenderer,
|
||||
IConfiguration configuration,
|
||||
ILogManager logManager)
|
||||
ILogManager logManager,
|
||||
IClock clock)
|
||||
{
|
||||
ParameterExtractor = parameterExtractor;
|
||||
TemplateRenderer = templateRenderer;
|
||||
this.configuration = configuration;
|
||||
LogManager = logManager;
|
||||
Clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -66,7 +70,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LogManager.LogError($"Template Render Başarısız: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
|
||||
LogManager.LogError($"Template render failed: {templateName}", "MAILBODY", "KUYRUK", kuyrukId, jobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +88,7 @@ public class MailBodyGenerator : IMailBodyGenerator, ITransientDependency
|
|||
return;
|
||||
}
|
||||
path = Path.Combine(path, id.ToString());
|
||||
var date = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss");
|
||||
var date = Clock.Now.ToString("yyyy-MM-dd-HH-mm-ss");
|
||||
var dosyaAdi = $"mail-{id}-{date}.html ";
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
|
||||
public class AttachmentModel
|
||||
{
|
||||
public string Index { get; set; }
|
||||
public string DbAdi { get; set; } //MRP.mdb, CRM.mdb
|
||||
public string RaporAdi { get; set; }
|
||||
public string ExportDosyaTipi { get; set; } //PDF, TXT, XLS, ..
|
||||
public Dictionary<string, string> Filtreler { get; set; }
|
||||
public string MailEkAdi { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Sozsoft.Platform.Localization;
|
||||
|
||||
namespace Sozsoft.MailQueue.MailGeneration.Models;
|
||||
|
||||
public class MailTemplateTableColumnModel
|
||||
{
|
||||
|
|
@ -15,5 +17,10 @@ public class MailTemplateTableColumnModel
|
|||
public string AltToplam { 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Sozsoft.MailQueue.Domain.Entities;
|
||||
using Sozsoft.MailQueue.Domain.MailGeneration;
|
||||
using Sozsoft.MailQueue.Domain.Shared;
|
||||
using Sozsoft.Sender.Mail;
|
||||
using Volo.Abp.BackgroundWorkers;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Guids;
|
||||
using Volo.Abp.Timing;
|
||||
using Volo.Abp.Uow;
|
||||
|
||||
|
|
@ -18,195 +16,105 @@ public class MailQueueWorker : BackgroundWorkerBase
|
|||
public MailQueueWorkerOptions Options { get; set; }
|
||||
|
||||
protected ISozsoftEmailSender ErpEmailSender { get; }
|
||||
protected IRepository<Entities.BackgroundWorker_MailQueue> Repository { get; }
|
||||
protected IRepository<BackgroundWorker_MailQueue> Repository { get; }
|
||||
public IClock Clock { get; }
|
||||
public IConfiguration Configuration { get; }
|
||||
public ILogManager LogManager { get; }
|
||||
public ITableGenerator TableGenerator { get; }
|
||||
public IMailBodyGenerator MailBodyGenerator { get; }
|
||||
public IAttachmentGenerator AttachmentGenerator { get; }
|
||||
public IGuidGenerator GuidGenerator { get; }
|
||||
|
||||
public MailQueueWorker(
|
||||
ISozsoftEmailSender erpEmailSender,
|
||||
IRepository<Entities.BackgroundWorker_MailQueue> repository,
|
||||
IRepository<BackgroundWorker_MailQueue> repository,
|
||||
IClock clock,
|
||||
IConfiguration configuration,
|
||||
ILogManager logManager,
|
||||
ITableGenerator tableGenerator,
|
||||
IMailBodyGenerator mailBodyGenerator,
|
||||
IAttachmentGenerator attachmentGenerator,
|
||||
IGuidGenerator guidGenerator
|
||||
)
|
||||
IAttachmentGenerator attachmentGenerator)
|
||||
{
|
||||
ErpEmailSender = erpEmailSender;
|
||||
Repository = repository;
|
||||
Clock = clock;
|
||||
Configuration = configuration;
|
||||
LogManager = logManager;
|
||||
TableGenerator = tableGenerator;
|
||||
MailBodyGenerator = mailBodyGenerator;
|
||||
AttachmentGenerator = attachmentGenerator;
|
||||
GuidGenerator = guidGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ID'si verilen Template satırının kuyruk kayıtlarını proses ederek bildirimleri yapar
|
||||
/// Şablonun (TemplateId) gönderilmemiş kuyruk kayıtlarını işler: her kayıt için tablo, gövde ve
|
||||
/// ekler üretilir, mail gönderilir, sonuç kayda yazılır. Akış: tablo → gövde → ekler → gönderim.
|
||||
/// </summary>
|
||||
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>();
|
||||
using var uow = uowManager.Begin();
|
||||
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);
|
||||
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);
|
||||
try
|
||||
{
|
||||
// Tablo oluştur
|
||||
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);
|
||||
}
|
||||
await ProcessQueueAsync(queue, jobId, cancellationToken);
|
||||
await uow2.CompleteAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogManager.LogError($"Hata. Kuyruk Id: {queue.Id}. {ex}", "HATA", "KUYRUK", queue.Id, jobId);
|
||||
}
|
||||
await uow2.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await uow.CompleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync(cancellationToken);
|
||||
private async Task ProcessQueueAsync(BackgroundWorker_MailQueue queue, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tables = await TableGenerator.GenerateAsync(queue.TableName, queue.TableParameter, queue.Id);
|
||||
|
||||
var mailBody = await MailBodyGenerator.GenerateAsync(
|
||||
TemplateName,
|
||||
queue.MailParameter,
|
||||
queue.Id,
|
||||
tables,
|
||||
jobId);
|
||||
|
||||
// Ekler tablo şablonlarından (IN olmayanlar: PDF, XLS, TXT) üretilir.
|
||||
var attachments = await AttachmentGenerator.GenerateAsync(tables.Values.ToList(), queue.Id, jobId);
|
||||
// IN olmayan bir tablo belirtilmiş ama hiçbiri üretilememişse hata vardır, gönderilmez.
|
||||
if (tables.Values.Any(a => a.DosyaTipi != "IN") && attachments.IsNullOrEmpty())
|
||||
{
|
||||
LogManager.LogWarning("Table attachments could not be generated", "ATTACHMENT", "KUYRUK", queue.Id, jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var senderKeyValue = queue.From.Split(":");
|
||||
var sender = new KeyValuePair<string, string>(
|
||||
senderKeyValue.ElementAtOrDefault(0),
|
||||
senderKeyValue.ElementAtOrDefault(1));
|
||||
var result = await ErpEmailSender.SendEmailAsync(
|
||||
queue.To.Split(";").Select(a => a.Trim()).ToArray(),
|
||||
sender,
|
||||
null,
|
||||
mailBody,
|
||||
Options.MailSubject,
|
||||
attachments,
|
||||
true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
queue.AwsMessageId = result.MessageId;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogManager.LogWarning($"Mail could not be sent. Message: {result.ErrorMessage}", "MAILSEND", "KUYRUK", queue.Id, jobId);
|
||||
queue.AwsMessageId = result.ErrorMessage;
|
||||
}
|
||||
|
||||
queue.SendStatus = result.Success;
|
||||
queue.SendTime = Clock.Now;
|
||||
await Repository.UpdateAsync(queue, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sozsoft.MailQueue.Domain.Entities;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
|
|
@ -20,34 +21,50 @@ public class QueryRepository : IQueryRepository, ITransientDependency
|
|||
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)
|
||||
{
|
||||
if (!SqlIdentifier.IsSafe(tabloAdi))
|
||||
throw new ArgumentException($"Invalid table name: {tabloAdi}", nameof(tabloAdi));
|
||||
|
||||
var table = new DataTable();
|
||||
var ctx = await Repository.GetDbContextAsync();
|
||||
var cmd = ctx.Database.GetDbConnection().CreateCommand();
|
||||
var query = $"SELECT * FROM {tabloAdi} WHERE ";
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
|
||||
var wheres = new List<string>();
|
||||
var index = 0;
|
||||
foreach (var item in where)
|
||||
{
|
||||
var parameterName = $"@p{item.Key}";
|
||||
wheres.Add($"{item.Key}={parameterName}");
|
||||
if (!SqlIdentifier.IsSafe(item.Key))
|
||||
throw new ArgumentException($"Invalid column name: {item.Key}", nameof(where));
|
||||
|
||||
var parameterName = $"@p{index++}";
|
||||
wheres.Add($"{SqlIdentifier.QuoteAnsi(item.Key)} = {parameterName}");
|
||||
var prm = cmd.CreateParameter();
|
||||
prm.ParameterName = parameterName;
|
||||
prm.Value = item.Value;
|
||||
cmd.Parameters.Add(prm);
|
||||
}
|
||||
query += string.Join(" AND ", wheres);
|
||||
cmd.CommandText = query;
|
||||
|
||||
cmd.CommandText = $"SELECT * FROM {SqlIdentifier.QuoteAnsi(tabloAdi)}"
|
||||
+ (wheres.Count > 0 ? " WHERE " + string.Join(" AND ", wheres) : string.Empty);
|
||||
|
||||
await connection.OpenAsync();
|
||||
try
|
||||
{
|
||||
cmd.Connection.Open();
|
||||
table.Load(await cmd.ExecuteReaderAsync());
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
table.Load(reader);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cmd.Connection.Close();
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public class TXTFileGenerator : IFileGenerator, ITransientDependency
|
|||
{
|
||||
var sb = new StringBuilder();
|
||||
table.TabloDegerleri.ForEach(row =>
|
||||
sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => row.GetValueOrDefault(col.SutunAdi)))));
|
||||
sb.AppendLine(string.Join('\t', table.TabloSutunlari.Select(col => col.FormatValue(row.GetValueOrDefault(col.SutunAdi))))));
|
||||
|
||||
return await Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ table.tablo tbody tr:hover {
|
|||
{
|
||||
<tr>
|
||||
@foreach (var sutun in tablo0.TabloSutunlari){
|
||||
<td>@satir.GetValueOrDefault(sutun.SutunAdi)</td>
|
||||
<td>@sutun.FormatValue(satir.GetValueOrDefault(sutun.SutunAdi))</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
using ClosedXML.Excel;
|
||||
using System.Globalization;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Sozsoft.MailQueue.MailGeneration.Models;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
|
||||
|
|
@ -6,6 +9,8 @@ namespace Sozsoft.MailQueue.FileGeneration;
|
|||
|
||||
public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
||||
{
|
||||
public ILogger<XLSFileGenerator> Logger { get; set; } = NullLogger<XLSFileGenerator>.Instance;
|
||||
|
||||
string sheetPassword = "Erp123";
|
||||
|
||||
public async Task<byte[]> Generate(MailTemplateTableModel table)
|
||||
|
|
@ -87,7 +92,8 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
|||
switch (dataType)
|
||||
{
|
||||
case XLDataType.Number:
|
||||
cell.SetValue(Convert.ToInt32(value));
|
||||
// Int32'ye cevirmek ondaligi atardi; hucre sayisal kalir, gosterimi Excel bicim kodu verir.
|
||||
cell.SetValue(Convert.ToDecimal(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case XLDataType.DateTime:
|
||||
cell.SetValue(Convert.ToDateTime(value));
|
||||
|
|
@ -166,7 +172,11 @@ public class XLSFileGenerator : IFileGenerator, ITransientDependency
|
|||
cell.Style.Border.SetOutsideBorder(XLBorderStyleValues.Thin);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Geçersiz CSS değeri hücreyi biçimsiz bırakır; dosya üretimi sürer.
|
||||
Logger.LogWarning(ex, "Cell CSS could not be applied: {Css}", string.Join(";", css.Select(a => a.Key + ":" + a.Value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sozsoft.Sender\Sozsoft.Sender.csproj" />
|
||||
<ProjectReference Include="..\..\src\Sozsoft.Platform.Domain.Shared\Sozsoft.Platform.Domain.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -26,22 +26,22 @@ namespace Sozsoft.MailQueue;
|
|||
typeof(AbpAuthorizationModule),
|
||||
typeof(AbpEntityFrameworkCoreModule),
|
||||
typeof(AbpTextTemplatingRazorModule),
|
||||
typeof(ErpSenderModule)
|
||||
typeof(SozsoftSenderModule)
|
||||
)]
|
||||
public class ErpMailQueueModule : AbpModule
|
||||
public class SozsoftMailQueueModule : AbpModule
|
||||
{
|
||||
public override void ConfigureServices(ServiceConfigurationContext context)
|
||||
{
|
||||
context.Services.AddMapperlyObjectMapper<ErpMailQueueModule>();
|
||||
context.Services.AddMapperlyObjectMapper<SozsoftMailQueueModule>();
|
||||
|
||||
Configure<AbpLocalizationOptions>(options =>
|
||||
{
|
||||
options.Resources.Add<ErpMailQueueResource>("en");
|
||||
options.Resources.Add<SozsoftMailQueueResource>("en");
|
||||
});
|
||||
|
||||
Configure<AbpExceptionLocalizationOptions>(options =>
|
||||
{
|
||||
options.MapCodeNamespace("MailQueue", typeof(ErpMailQueueResource));
|
||||
options.MapCodeNamespace("MailQueue", typeof(SozsoftMailQueueResource));
|
||||
});
|
||||
|
||||
context.Services.AddAbpDbContext<MailQueueDbContext>(options =>
|
||||
|
|
@ -51,7 +51,7 @@ public class ErpMailQueueModule : AbpModule
|
|||
|
||||
Configure<AbpRazorTemplateCSharpCompilerOptions>(options =>
|
||||
{
|
||||
options.References.Add(MetadataReference.CreateFromFile(typeof(ErpMailQueueModule).Assembly.Location));
|
||||
options.References.Add(MetadataReference.CreateFromFile(typeof(SozsoftMailQueueModule).Assembly.Location));
|
||||
});
|
||||
|
||||
Configure<AbpCompiledViewProviderOptions>(options =>
|
||||
|
|
@ -69,7 +69,7 @@ public class ErpMailQueueModule : AbpModule
|
|||
// FileGenerationModule
|
||||
Configure<AbpVirtualFileSystemOptions>(options =>
|
||||
{
|
||||
options.FileSets.AddEmbedded<ErpMailQueueModule>();
|
||||
options.FileSets.AddEmbedded<SozsoftMailQueueModule>();
|
||||
});
|
||||
|
||||
context.Services.AddWkhtmltopdf($"FileGeneration{Path.DirectorySeparatorChar}Rotativa");
|
||||
|
|
|
|||
|
|
@ -71,6 +71,32 @@ public class McpCreateTableRequestDto
|
|||
public bool FullAudited { 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
|
||||
|
|
@ -99,6 +125,30 @@ public class McpCreateTableResultDto
|
|||
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>
|
||||
/// Deploy edilmis bir wizard'in salt-okur dogrulamasi. <see cref="Passed"/> yalnizca
|
||||
/// <see cref="McpVerifyCheckDto.Blocking"/> bir kontrol kaldiginda false olur; engellemeyen
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ using System.Collections.Generic;
|
|||
|
||||
namespace Sozsoft.Mcp.Dtos;
|
||||
|
||||
/// <summary>Rapor kategorisi (<c>App.Reports.Categories</c>).</summary>
|
||||
/// <summary>Rapor kategorisi (<c>Adm.Reports.Categories</c>).</summary>
|
||||
public class McpReportCategoryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
|
@ -106,6 +106,6 @@ public class McpReportTemplateSaveRequestDto
|
|||
/// </summary>
|
||||
public string? ListFormCode { get; set; }
|
||||
|
||||
/// <summary>Satir butonunun dil anahtari; verilmezse <c>App.Platform.View</c>.</summary>
|
||||
/// <summary>Satir butonunun dil anahtari; verilmezse <c>Sas.Platform.View</c>.</summary>
|
||||
public string? ButtonText { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ public class McpListSummaryDto
|
|||
/// <summary>Tablo adi ya da SELECT metni — listenin gercek SQL kaynagi.</summary>
|
||||
public string? SqlSourceName { get; set; }
|
||||
|
||||
/// <summary><c>table</c>, <c>selectSql</c> ya da <c>unknown</c>.</summary>
|
||||
/// <summary>
|
||||
/// <c>table</c>, <c>selectSql</c> ya da <c>unknown</c>; gelistiricide ayrica <c>pending</c> (wizard dosyasi
|
||||
/// var, DB Migrate bekliyor) ve <c>pendingTable</c> (yalnizca seed scripti olan tablo). Kolon adlari
|
||||
/// dogrudur; query_list migrate'ten once calismaz.
|
||||
/// </summary>
|
||||
public string? SourceKind { get; set; }
|
||||
|
||||
public string? KeyFieldName { get; set; }
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ namespace Sozsoft.Mcp;
|
|||
/// </summary>
|
||||
public interface IMcpDataAppService : IApplicationService
|
||||
{
|
||||
Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input);
|
||||
Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input);
|
||||
|
||||
Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input);
|
||||
Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input);
|
||||
|
||||
Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input);
|
||||
Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Platform.DeveloperKit;
|
||||
|
|
@ -14,61 +14,68 @@ namespace Sozsoft.Mcp;
|
|||
/// </summary>
|
||||
public interface IMcpDevelopAppService : IApplicationService
|
||||
{
|
||||
Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section);
|
||||
Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section);
|
||||
|
||||
Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input);
|
||||
Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input);
|
||||
|
||||
Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search);
|
||||
/// <summary>View/procedure/function scriptini iki saglayicinin seed klasorune yazar; veritabaninda calistirmaz.</summary>
|
||||
Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript);
|
||||
|
||||
Task<McpWizardFileDto> GetWizardAsync(string fileName);
|
||||
/// <summary>Tablo ya da view icin CRUD endpoint'lerini <c>crud/{Entity}.json</c> seed dosyasina yazar; kaydi DB Migrate ekler.</summary>
|
||||
Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType);
|
||||
|
||||
Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search);
|
||||
|
||||
Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName);
|
||||
|
||||
/// <summary>Deploy edilen wizard'in ozeti.</summary>
|
||||
Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input);
|
||||
Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input);
|
||||
|
||||
/// <summary>Wizard'i siler; <paramref name="dropTables"/> acikken ekranin secilebilir tablosunu da dusurur.</summary>
|
||||
Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables);
|
||||
Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables);
|
||||
|
||||
/// <summary>Deploy edilmis ekrani veri yazmadan dogrular.</summary>
|
||||
Task<McpVerifyResultDto> VerifyWizardAsync(string fileName);
|
||||
Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName);
|
||||
|
||||
/// <summary>Id ya da basliga gore bulunan talebi, yalnizca Ready to Start durumundaysa In Progress'e alir.</summary>
|
||||
Task<McpDevRequestDto> StartDevRequestAsync(string request);
|
||||
Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request);
|
||||
|
||||
/// <summary>Talebi durumuna dokunmadan okur; yarim kalan bir talebe donmek icindir.</summary>
|
||||
Task<McpDevRequestDto> GetDevRequestAsync(string request);
|
||||
Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request);
|
||||
|
||||
/// <summary>
|
||||
/// Talebin analizini yazar (var olani degistirir) ve talebi Analysis durumuna alir; yalnizca
|
||||
/// Backlog ya da Analysis durumundaki talep analiz edilir.
|
||||
/// Talebin analizini yazar (var olani degistirir); analiz kosulsuz uretime hazirsa talebi
|
||||
/// Ready to Start'a, degilse Analysis durumuna alir. Yalnizca Backlog ya da Analysis durumundaki
|
||||
/// talep analiz edilir.
|
||||
/// </summary>
|
||||
Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html);
|
||||
Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady);
|
||||
|
||||
/// <summary>
|
||||
/// Analizin sonuna HTML ekler. Aciklama talebin yazildigi haliyle kalir; uretim notlari
|
||||
/// analize yazilir.
|
||||
/// </summary>
|
||||
Task AppendDevRequestNoteAsync(string request, string html);
|
||||
Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html);
|
||||
|
||||
/// <summary>
|
||||
/// Talebin SubTasks listesine tamamlanmis satirlar ekler; atanan kisi cagiran kullanici,
|
||||
/// bitis tarihi bugundur.
|
||||
/// </summary>
|
||||
Task AddDevRequestSubTasksAsync(string request, List<string> titles);
|
||||
Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles);
|
||||
|
||||
/// <summary>Modul dokumanini adiyla ya da ait oldugu ekranin kodu/wizard dosyasiyla okur.</summary>
|
||||
Task<ModuleDocDto> GetModuleDocAsync(string name);
|
||||
Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Modul dokumaninin elle yazilan bolumlerini kaydeder; otomatik envanter sunucuda yeniden
|
||||
/// uretilir.
|
||||
/// </summary>
|
||||
Task<ModuleDocDto> SaveModuleDocAsync(string name, string content);
|
||||
Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content);
|
||||
|
||||
Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search);
|
||||
Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search);
|
||||
|
||||
Task<CustomComponentDto?> GetComponentAsync(string name);
|
||||
Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name);
|
||||
|
||||
Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input);
|
||||
Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input);
|
||||
|
||||
Task DeleteComponentAsync(string name);
|
||||
Task<McpProblem?> DeleteComponentAsync(string name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,17 @@ public interface IMcpQueryAppService : IApplicationService
|
|||
/// okuma yetkisi, kiraci/sube filtreleri ve kullanici ozellestirmeleri uygulamadaki gibi
|
||||
/// gecerlidir. Normal kullanicinin veri okuma yolu budur.
|
||||
/// </summary>
|
||||
Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input);
|
||||
Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input);
|
||||
|
||||
/// <summary>
|
||||
/// Tek bir SELECT ifadesi kosturur. Coklu ifade, DDL ve DML reddedilir; satir sayisi
|
||||
/// sunucu kaydindaki tavana kirpilir.
|
||||
/// </summary>
|
||||
Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input);
|
||||
Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input);
|
||||
|
||||
/// <summary>Veri kaynagindaki view/procedure/function nesneleri ve Custom Endpoint'ler.</summary>
|
||||
Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null);
|
||||
Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null);
|
||||
|
||||
/// <summary>Kayitli bir nesneyi ya da Custom Endpoint'i parametreleriyle cagirir.</summary>
|
||||
Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input);
|
||||
Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Rapor kategorisi ve rapor sablonu uretimi. Yazma <c>App.Reports.Categories</c> ve
|
||||
/// <c>App.Reports.ReportTemplates</c> ekranlarinin C/U/D sozlesmesinden gecer; ekran yetkisi,
|
||||
/// Rapor kategorisi ve rapor sablonu uretimi. Yazma <c>Adm.Reports.Categories</c> ve
|
||||
/// <c>Adm.Reports.ReportTemplates</c> ekranlarinin C/U/D sozlesmesinden gecer; ekran yetkisi,
|
||||
/// kiraci filtresi ve seed senkronizasyonu oradaki yolla ayni sekilde uygulanir.
|
||||
/// </summary>
|
||||
public interface IMcpReportAppService : IApplicationService
|
||||
{
|
||||
Task<McpReportCatalogDto> GetReportTemplatesAsync(string? search);
|
||||
Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search);
|
||||
|
||||
/// <summary>Sablonu Id'si ya da adiyla, HTML icerigi ve yer tutuculariyla doner.</summary>
|
||||
Task<McpReportTemplateDto> GetReportTemplateAsync(string template);
|
||||
Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template);
|
||||
|
||||
/// <summary>Kategoriyi ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
|
||||
Task<McpReportCategoryDto> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input);
|
||||
Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input);
|
||||
|
||||
/// <summary>Sablonu ada gore kaydeder: yoksa ekler, varsa gunceller.</summary>
|
||||
Task<McpReportTemplateDto> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input);
|
||||
Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input);
|
||||
|
||||
Task DeleteReportTemplateAsync(string template);
|
||||
Task<McpProblem?> DeleteReportTemplateAsync(string template);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ public interface IMcpSchemaAppService : IApplicationService
|
|||
|
||||
/// <param name="search">Bos birakilirsa tum listeler doner.</param>
|
||||
/// <param name="maxResults">Donecek en fazla liste sayisi.</param>
|
||||
Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200);
|
||||
Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200);
|
||||
|
||||
/// <summary>Tek bir listenin sutunlariyla birlikte tanimi; bulunamazsa null.</summary>
|
||||
Task<McpListDetailDto?> GetListAsync(string listFormCode);
|
||||
Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode);
|
||||
|
||||
/// <summary>
|
||||
/// Liste ve sutun adlari ile basliklari uzerinde aksan ve buyuk/kucuk harf duyarsiz arama.
|
||||
/// </summary>
|
||||
Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50);
|
||||
Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Application.Services;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
|
@ -46,40 +45,49 @@ public abstract class McpAppService : ApplicationService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kaydi zorunlu kilan islemler icin. Kayit yoksa istemcinin nedenini gorebilecegi bir
|
||||
/// 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
|
||||
/// Kapali bir araca yapilan cagriyi reddeder: sunucu kaydi yoksa, 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.
|
||||
/// </summary>
|
||||
protected async Task<McpServerConfigDto> EnsureToolAllowedAsync(string toolName)
|
||||
protected async Task<McpOutcome<McpServerConfigDto>> EnsureToolAllowedAsync(string toolName)
|
||||
{
|
||||
var config = await RequireServerConfigAsync();
|
||||
var config = await ResolveServerConfigAsync();
|
||||
if (config is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.NoServerRecord);
|
||||
}
|
||||
|
||||
if (CurrentTenant.IsAvailable && !McpToolNames.TenantTools.Contains(toolName, StringComparer.Ordinal))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.TenantToolDisabled, toolName]);
|
||||
return Problem(McpErrorCodes.TenantToolDisabled, toolName);
|
||||
}
|
||||
|
||||
if (!McpToolPolicy.IsAllowed(true, CurrentTenant.IsAvailable, config.AllowedTools, toolName))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ToolDisabled, toolName, config.Name]);
|
||||
return Problem(McpErrorCodes.ToolDisabled, toolName, config.Name);
|
||||
}
|
||||
|
||||
if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission))
|
||||
if (McpToolNames.RequiredPermissions.TryGetValue(toolName, out var permission) &&
|
||||
await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
{
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
return denied;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
protected static int ResolveMaxRows(McpServerConfigDto config, int requested)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,28 +31,44 @@ public class McpDataAppService(
|
|||
private const string DevRequestStatusField = "Status";
|
||||
private const string DevRequestTestingStatus = "Testing";
|
||||
|
||||
public async Task<McpWriteResultDto> InsertRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpOutcome<McpWriteResultDto>> InsertRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
var request = await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true);
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.InsertRow, AuthorizationTypeEnum.Create, requireKeys: false, requireValues: true)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var key = await listFormDataAppService.PostInsertAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.InsertRow, input.ListFormCode);
|
||||
|
||||
return new McpWriteResultDto { Key = ((object?)key)?.ToString() };
|
||||
}
|
||||
|
||||
public async Task<McpWriteResultDto> UpdateRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpOutcome<McpWriteResultDto>> UpdateRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
var request = await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true);
|
||||
await EnsureModuleDocsBeforeTestingAsync(input);
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.UpdateRow, AuthorizationTypeEnum.Update, requireKeys: true, requireValues: true)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckModuleDocsBeforeTestingAsync(input) is { } incomplete)
|
||||
{
|
||||
return incomplete;
|
||||
}
|
||||
|
||||
var affected = await listFormDataAppService.PostUpdateAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.UpdateRow, input.ListFormCode, string.Join(", ", input.Values.Keys));
|
||||
|
||||
return new McpWriteResultDto { AffectedRows = affected };
|
||||
}
|
||||
|
||||
public async Task<McpWriteResultDto> DeleteRowAsync(McpRowRequestDto input)
|
||||
public async Task<McpOutcome<McpWriteResultDto>> DeleteRowAsync(McpRowRequestDto input)
|
||||
{
|
||||
var request = await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false);
|
||||
if (!(await BuildRequestAsync(input, McpToolNames.DeleteRow, AuthorizationTypeEnum.Delete, requireKeys: true, requireValues: false)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var affected = await listFormDataAppService.PostDeleteAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteRow, input.ListFormCode);
|
||||
|
||||
|
|
@ -60,22 +76,22 @@ public class McpDataAppService(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumanlari tamam
|
||||
/// olmalidir (lowcode §0.13): dosya var ve doldurulmamis (<c>module-doc:todo</c>) bolum yok.
|
||||
/// Gelistirme talebi Testing'e alinirken talebin urettigi wizard'larin modul dokumani bolumleri
|
||||
/// tamam olmalidir (lowcode §0.13): bolum var ve doldurulmamis (<c>module-doc:todo</c>) alt bolum yok.
|
||||
/// Talebi Testing'e alan tek yol <c>update_row</c> oldugu icin kural burada uygulanir. Wizard
|
||||
/// okuma yetkisi olmayan kullanici wizard uretemeyecegi icin kontrol ona uygulanmaz.
|
||||
/// </summary>
|
||||
private async Task EnsureModuleDocsBeforeTestingAsync(McpRowRequestDto input)
|
||||
private async Task<McpProblem?> CheckModuleDocsBeforeTestingAsync(McpRowRequestDto input)
|
||||
{
|
||||
if (!string.Equals(input.ListFormCode, PlatformConsts.AppCodes.DevRequests, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
return null;
|
||||
|
||||
var status = input.Values?.FirstOrDefault(v => string.Equals(v.Key, DevRequestStatusField, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
|
||||
if (!string.Equals(status, DevRequestTestingStatus, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
return null;
|
||||
|
||||
if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
|
||||
return;
|
||||
return null;
|
||||
|
||||
foreach (var key in input.Keys ?? [])
|
||||
{
|
||||
|
|
@ -88,15 +104,17 @@ public class McpDataAppService(
|
|||
|
||||
var detail = string.Join("; ", incomplete.Select(doc => $"{doc.Path} ({string.Join(", ", doc.MissingSections)})"));
|
||||
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestModuleDocsIncomplete, detail]);
|
||||
return Problem(McpErrorCodes.DevRequestModuleDocsIncomplete, detail);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Istegi ekranin bekledigi bicime cevirir. <c>Data</c> JSON metni olarak verilir; ayni
|
||||
/// bicim ekranin kopyalama yolunda da kullanilir.
|
||||
/// </summary>
|
||||
private async Task<DataRequestDto> BuildRequestAsync(
|
||||
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
|
||||
McpRowRequestDto input,
|
||||
string toolName,
|
||||
AuthorizationTypeEnum operation,
|
||||
|
|
@ -105,29 +123,32 @@ public class McpDataAppService(
|
|||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
await EnsureToolAllowedAsync(toolName);
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.ListFormCode))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ListFormCodeRequired]);
|
||||
return Problem(McpErrorCodes.ListFormCodeRequired);
|
||||
}
|
||||
|
||||
// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
|
||||
// istemci hangi yetkinin eksik oldugunu gorsun.
|
||||
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, operation);
|
||||
if (permission is not null)
|
||||
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
{
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (requireValues && (input.Values is null || input.Values.Count == 0))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ValuesRequired]);
|
||||
return Problem(McpErrorCodes.ValuesRequired);
|
||||
}
|
||||
|
||||
if (requireKeys && (input.Keys is null || input.Keys.Count == 0))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.KeysRequired]);
|
||||
return Problem(McpErrorCodes.KeysRequired);
|
||||
}
|
||||
|
||||
return new DataRequestDto
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
|
@ -36,6 +35,7 @@ public class McpDevelopAppService(
|
|||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpDevGuide devGuide,
|
||||
IMcpTableCreator tableCreator,
|
||||
IMcpSqlObjectWriter sqlObjectWriter,
|
||||
IListFormWizardAppService wizardAppService,
|
||||
ICustomComponentAppService componentAppService,
|
||||
IListFormSelectAppService listFormSelectAppService,
|
||||
|
|
@ -79,16 +79,19 @@ public class McpDevelopAppService(
|
|||
public const string SeedClosure = "seed_closure";
|
||||
}
|
||||
|
||||
public async Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section)
|
||||
public async Task<McpOutcome<McpDevGuideDto>> GetDevGuideAsync(string? document, string? section)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetDevGuide);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetDevGuide) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(section))
|
||||
{
|
||||
var headings = await devGuide.GetHeadingsAsync();
|
||||
if (headings.Count == 0)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]);
|
||||
return Problem(McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey);
|
||||
}
|
||||
|
||||
return new McpDevGuideDto
|
||||
|
|
@ -154,18 +157,31 @@ public class McpDevelopAppService(
|
|||
/// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca
|
||||
/// MCP kapisidir.
|
||||
/// </summary>
|
||||
public async Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input)
|
||||
public async Task<McpOutcome<McpCreateTableResultDto>> CreateTableAsync(McpCreateTableRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
await EnsureToolAllowedAsync(McpToolNames.CreateTable);
|
||||
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.CreateTable) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var created = await tableCreator.CreateAsync(new McpTableDefinition(
|
||||
if (await CheckPermissionAsync(McpToolNames.CreateTable, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
var outcome = await tableCreator.CreateAsync(new McpTableDefinition(
|
||||
input.TableName,
|
||||
[.. (input.Columns ?? []).Select(column => new McpTableColumn(
|
||||
column.Name, column.Type, column.MaxLength, column.IsNullable, column.DefaultValue))],
|
||||
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);
|
||||
|
||||
|
|
@ -177,9 +193,84 @@ public class McpDevelopAppService(
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search)
|
||||
/// <summary>
|
||||
/// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function
|
||||
/// kaydinin karsiligidir. Nesneyi DB Migrate kurar.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpSqlObjectResultDto>> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListWizards);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.SaveSqlObject, PlatformConsts.AppCodes.SqlQueryManagers.Default) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!sqlObjectWriter.Save(sqlServerScript, postgreSqlScript).TryGetValue(out var saved, out var invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveSqlObject, saved.Name);
|
||||
|
||||
return new McpSqlObjectResultDto
|
||||
{
|
||||
Name = saved.Name,
|
||||
Kind = saved.Kind,
|
||||
SeedFilePaths = [saved.SqlServerPath, saved.PostgreSqlPath]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQL Query Manager'daki CRUD endpoint diyaloguyla ayni endpoint kumesini
|
||||
/// (<see cref="CrudEndpointSeedFileDto.Generate"/>) yalnizca <c>crud/{Entity}.json</c> seed dosyasina
|
||||
/// yazar; kayitlari DB Migrate ekler (<c>CrudEndpointSeedApplier</c>). Yetki diyalogunkiyle aynidir.
|
||||
/// </summary>
|
||||
public async Task<McpOutcome<McpCrudEndpointsResultDto>> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.GenerateCrudEndpoints, PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
// Tasarimcidaki gibi sema oneki atilir: endpoint yolu yalnizca nesne adini tasir.
|
||||
var entity = entityName?.Split('.')[^1].Trim('[', ']', '"', ' ') ?? string.Empty;
|
||||
if (CrudEndpointSeedFileDto.SafeFileName(entity) is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty);
|
||||
}
|
||||
|
||||
// Model listeyi JSON dizisi olarak da gonderebilir (["GetList","Create"]); metne cevrilen dizi
|
||||
// virgullu listeye indirilir.
|
||||
var operations = operationTypes is null
|
||||
? null
|
||||
: string.Join(',', operationTypes.Split(',').Select(item => item.Trim('[', ']', '"', ' ')));
|
||||
var seedFile = CrudEndpointSeedFileDto.Generate(entity, operations, objectType, Clock.Now.ToUniversalTime());
|
||||
seedFile.Write(configuration);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.GenerateCrudEndpoints, entity);
|
||||
|
||||
return new McpCrudEndpointsResultDto
|
||||
{
|
||||
EntityName = entity,
|
||||
SeedFilePath = $"{SeedPathResolver.CrudFolder}/{CrudEndpointSeedFileDto.SafeFileName(entity)}.json",
|
||||
Endpoints = [.. seedFile.Endpoints.Select(e => $"{e.Method} {e.Path}{(e.IsActive ? string.Empty : " [inactive]")}")]
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<McpOutcome<List<WizardFileInfoDto>>> GetWizardsAsync(string? search)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListWizards) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var files = await wizardAppService.GetFiles();
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
|
|
@ -187,14 +278,17 @@ public class McpDevelopAppService(
|
|||
return files;
|
||||
}
|
||||
|
||||
return [.. files.Where(file =>
|
||||
return files.Where(file =>
|
||||
Contains(file.FileName, search) || Contains(file.WizardName, search) ||
|
||||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))];
|
||||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search)).ToList();
|
||||
}
|
||||
|
||||
public async Task<McpWizardFileDto> GetWizardAsync(string fileName)
|
||||
public async Task<McpOutcome<McpWizardFileDto>> GetWizardAsync(string fileName)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetWizard);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var seed = await wizardAppService.GetFile(fileName);
|
||||
|
||||
|
|
@ -205,14 +299,24 @@ public class McpDevelopAppService(
|
|||
return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() };
|
||||
}
|
||||
|
||||
public async Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input)
|
||||
public async Task<McpOutcome<WizardFileInfoDto?>> DeployWizardAsync(McpWizardDeployRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeployWizard);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeployWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (!ParseWizard(input.Wizard).TryGetValue(out var wizard, out var invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var wizard = ParseWizard(input.Wizard);
|
||||
wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim();
|
||||
EnsureDetailedDescription(wizard);
|
||||
if (CheckDetailedDescription(wizard) is { } shortDescription)
|
||||
{
|
||||
return shortDescription;
|
||||
}
|
||||
|
||||
// Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy
|
||||
// bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir),
|
||||
|
|
@ -220,6 +324,16 @@ public class McpDevelopAppService(
|
|||
var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard;
|
||||
wizard.MenuOrder = previous?.MenuOrder ?? 0;
|
||||
|
||||
// deploy_wizard dosyayi tamamen degistirir. Duzenlemede WizardCode yazilmamissa kod sessizce
|
||||
// Sas.Wizard.{Ad}'a duser: girdi kismidir ve deploy ekranin menusunu, yetkilerini ve alanlarini
|
||||
// bosaltirdi. Kodu bilerek degistiren girdi WizardCode'u acikca yazar.
|
||||
if (previous is not null && string.IsNullOrWhiteSpace(wizard.ListFormCode) &&
|
||||
!string.IsNullOrWhiteSpace(previous.ListFormCode) &&
|
||||
!string.Equals(previous.ListFormCode.Trim(), wizard.ResolveListFormCode(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Problem(McpErrorCodes.WizardCodeMissing, wizard.EditFileName!, previous.ListFormCode.Trim());
|
||||
}
|
||||
|
||||
// Alt form sekmesi ana kayit her degistiginde yeniden sorgulanmaz; MCP yeni baglanti
|
||||
// acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi
|
||||
// deger korunur.
|
||||
|
|
@ -239,7 +353,10 @@ public class McpDevelopAppService(
|
|||
editGroups[0].Caption = string.Empty;
|
||||
|
||||
// Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz.
|
||||
var seedRows = ParseSeedRows(input.SeedRows, wizard);
|
||||
if (!(await ParseSeedRowsAsync(input.SeedRows, wizard)).TryGetValue(out var seedRows, out invalid))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
// Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin
|
||||
// butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir.
|
||||
|
|
@ -250,8 +367,8 @@ public class McpDevelopAppService(
|
|||
var files = await wizardAppService.GetFiles();
|
||||
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);
|
||||
if (file != null && seedRows.Count > 0 && await WriteSeedRowsAsync(file.FileName, seedRows) is { } rowsRejected)
|
||||
return rowsRejected;
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count);
|
||||
|
||||
|
|
@ -264,31 +381,32 @@ public class McpDevelopAppService(
|
|||
/// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi
|
||||
/// icin MCP deploy'u kisa aciklamayi reddeder.
|
||||
/// </summary>
|
||||
private void EnsureDetailedDescription(ListFormWizardDto wizard)
|
||||
private McpProblem? CheckDetailedDescription(ListFormWizardDto wizard)
|
||||
{
|
||||
if (wizard.ComponentKind == WizardComponentKindEnum.Custom)
|
||||
return;
|
||||
return null;
|
||||
|
||||
static bool IsDetailed(string? text) =>
|
||||
!string.IsNullOrWhiteSpace(text) &&
|
||||
text.Trim().Length >= MinDescriptionLength &&
|
||||
text.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)) >= MinDescriptionLines;
|
||||
|
||||
if (!IsDetailed(wizard.DescTr) || !IsDetailed(wizard.DescEn))
|
||||
throw new UserFriendlyException(L[McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength]);
|
||||
return IsDetailed(wizard.DescTr) && IsDetailed(wizard.DescEn)
|
||||
? null
|
||||
: Problem(McpErrorCodes.WizardDescriptionTooShort, MinDescriptionLines, MinDescriptionLength);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik
|
||||
/// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler.
|
||||
/// </summary>
|
||||
private List<Dictionary<string, JsonElement>> ParseSeedRows(string? json, ListFormWizardDto wizard)
|
||||
private async Task<McpOutcome<List<Dictionary<string, JsonElement>>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
return new List<Dictionary<string, JsonElement>>();
|
||||
|
||||
if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData)
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
|
||||
return Problem(McpErrorCodes.SeedRowsNotAllowed);
|
||||
|
||||
List<Dictionary<string, JsonElement>>? rows;
|
||||
try
|
||||
|
|
@ -301,7 +419,7 @@ public class McpDevelopAppService(
|
|||
}
|
||||
|
||||
if (rows is null || rows.Any(row => row is null))
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]);
|
||||
return Problem(McpErrorCodes.SeedRowsInvalid);
|
||||
|
||||
var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName)
|
||||
.Append(wizard.KeyFieldName)
|
||||
|
|
@ -310,33 +428,74 @@ public class McpDevelopAppService(
|
|||
|
||||
var unknown = rows.SelectMany(row => row.Keys).Where(key => !fields.Contains(key))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), string.Join(", ", fields)]);
|
||||
if (unknown.Count == 0)
|
||||
return rows;
|
||||
|
||||
return rows;
|
||||
var screenFields = string.Join(", ", fields);
|
||||
var owners = await FindSameTableScreensAsync(wizard, unknown);
|
||||
if (owners.Count == 0)
|
||||
return Problem(McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), screenFields);
|
||||
|
||||
// Ayni tablonun baska ekraninda (cogunlukla alt form sekmesi) alan olan kolon migrate'te
|
||||
// bu ekranin veri dosyasindan yazilmaz; model kolonun nerede durdugunu gorurse alani ekrana
|
||||
// ekleyip ya da satirdan cikarip tekrar dener.
|
||||
var placement = string.Join("; ", owners.Select(owner => $"{owner.Key}: {string.Join(", ", owner.Value)}"));
|
||||
return Problem(McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demo satirlarini ekranin veri dosyasina (<c>ListForm.SeedFilePath</c>) 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. Dosyada ayni icerikli satir varsa (tekrar deploy) yeniden eklenmez.
|
||||
/// Bilinmeyen kolonlari ayni tabloyu okuyan diger wizard ekranlarinin alanlarinda arar; sonuc
|
||||
/// ekran kodundan o ekranda bulunan kolonlara eslemedir. Ekran listesi wizard dosyalarindan
|
||||
/// okunur, migrate bekleyen ekranlar da dahildir.
|
||||
/// </summary>
|
||||
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
|
||||
private async Task<Dictionary<string, List<string>>> FindSameTableScreensAsync(ListFormWizardDto wizard, List<string> columns)
|
||||
{
|
||||
var listForm = (await wizardAppService.GetFile(fileName)).ListForm;
|
||||
var relativePath = listForm?.SeedFilePath?.Replace('\\', '/').Trim('/');
|
||||
if (listForm == null || string.IsNullOrWhiteSpace(relativePath) || SeedPathResolver.IsSeedFolderPath(relativePath))
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
|
||||
var owners = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var table = wizard.SelectCommand?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(table))
|
||||
return owners;
|
||||
|
||||
var path = SeedPathResolver.GetScopePath(configuration, CurrentTenant.Id, relativePath.Split('/'));
|
||||
var seedFile = File.Exists(path)
|
||||
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions)
|
||||
: null;
|
||||
seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName };
|
||||
var wizardName = wizard.WizardName?.Trim();
|
||||
foreach (var file in await wizardAppService.GetFiles())
|
||||
{
|
||||
if (file.ComponentKind == WizardComponentKindEnum.Custom ||
|
||||
string.Equals(file.WizardName, wizardName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(file.FileName, wizard.EditFileName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var keyFieldName = seedFile.KeyFieldName;
|
||||
var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
|
||||
var seed = await wizardAppService.GetFile(file.FileName);
|
||||
if (!string.Equals(seed.Wizard?.SelectCommand?.Trim(), table, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var fieldNames = (seed.Wizard?.Groups ?? [])
|
||||
.SelectMany(group => group.Items ?? [])
|
||||
.Select(item => item.FieldName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var found = columns.Where(fieldNames.Contains).ToList();
|
||||
if (found.Count > 0)
|
||||
owners[seed.Wizard?.ResolveListFormCode() ?? file.ListFormCode ?? file.WizardName] = found;
|
||||
}
|
||||
|
||||
return owners;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demo satirlarini ekranin veri seed hedefine (<c>Data.SeedFilePath</c>; tanima gore tek
|
||||
/// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra
|
||||
/// <c>ListFormSeedDataApplier</c> satirlari ekler ve kiraci/audit alanlarini doldurur. Guid anahtar
|
||||
/// burada uretilir: dosya her uygulandiginda ayni kayda karsilik gelir. Hedefte ayni icerikli satir
|
||||
/// varsa (tekrar deploy) yeniden eklenmez.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
|
||||
{
|
||||
var wizard = (await wizardAppService.GetFile(fileName)).Wizard;
|
||||
var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), wizard?.SeedFilePath);
|
||||
if (wizard == null || target == null)
|
||||
return Problem(McpErrorCodes.SeedRowsNotAllowed);
|
||||
|
||||
var keyFieldName = wizard.KeyFieldName;
|
||||
var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
|
||||
var added = new List<(object Key, Dictionary<string, object> Row)>();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
|
|
@ -344,14 +503,16 @@ public class McpDevelopAppService(
|
|||
if (!existing.Add(RowContent(values, keyFieldName)))
|
||||
continue;
|
||||
|
||||
if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
|
||||
if (wizard.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
|
||||
values[keyFieldName] = GuidGenerator.Create();
|
||||
|
||||
seedFile.Rows.Add(values);
|
||||
added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile));
|
||||
// Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz.
|
||||
return added.Count > 0 && (await target.UpsertRowsAsync(wizard.ResolveListFormCode(), keyFieldName, added)).Count > 0
|
||||
? Problem(McpErrorCodes.SeedRowsInvalid)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary>
|
||||
|
|
@ -365,9 +526,12 @@ public class McpDevelopAppService(
|
|||
/// Ekranin tablosu ancak <paramref name="dropTables"/> acikken ve Wizard Manager'daki onayla ayni
|
||||
/// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir.
|
||||
/// </summary>
|
||||
public async Task<WizardDeleteResultDto> DeleteWizardAsync(string fileName, bool dropTables)
|
||||
public async Task<McpOutcome<WizardDeleteResultDto>> DeleteWizardAsync(string fileName, bool dropTables)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteWizard);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeleteWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var input = new WizardDeleteInput { FileName = fileName };
|
||||
if (dropTables)
|
||||
|
|
@ -389,9 +553,12 @@ public class McpDevelopAppService(
|
|||
/// iz birakirdi. Seed kapanisi engellemez: ekran bu ortamda calisir, eksik dosya yalnizca
|
||||
/// migrate ya da baska bir ortamda ortaya cikar.
|
||||
/// </summary>
|
||||
public async Task<McpVerifyResultDto> VerifyWizardAsync(string fileName)
|
||||
public async Task<McpOutcome<McpVerifyResultDto>> VerifyWizardAsync(string fileName)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.VerifyWizard);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.VerifyWizard) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var result = new McpVerifyResultDto { FileName = fileName };
|
||||
|
||||
|
|
@ -458,16 +625,26 @@ public class McpDevelopAppService(
|
|||
/// ayni unit of work'te In Progress'e alinir. MCP yetki vermez; ekranin guncelleme yetkisi
|
||||
/// ayrica istenir.
|
||||
/// </summary>
|
||||
public async Task<McpDevRequestDto> StartDevRequestAsync(string request)
|
||||
public async Task<McpOutcome<McpDevRequestDto>> StartDevRequestAsync(string request)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.StartDevRequest);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.StartDevRequest) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
if (await CheckPermissionAsync(McpToolNames.StartDevRequest, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (!string.Equals(devRequest.Status, McpConsts.DevRequestStatuses.ReadyToStart, StringComparison.Ordinal))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
|
||||
return Problem(McpErrorCodes.DevRequestNotReady, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
|
||||
}
|
||||
|
||||
devRequest.Status = McpConsts.DevRequestStatuses.InProgress;
|
||||
|
|
@ -477,41 +654,64 @@ public class McpDevelopAppService(
|
|||
return ToDto(devRequest);
|
||||
}
|
||||
|
||||
public async Task<McpDevRequestDto> GetDevRequestAsync(string request)
|
||||
public async Task<McpOutcome<McpDevRequestDto>> GetDevRequestAsync(string request)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetDevRequest);
|
||||
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetDevRequest) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
if (await CheckPermissionAsync(McpToolNames.GetDevRequest, PlatformConsts.AppCodes.DevRequests) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
await timesheetRecorder.BeginAsync(devRequest.Id);
|
||||
|
||||
return ToDto(devRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durum kurali modelin dikkatine birakilmaz: yalnizca Backlog ya da Analysis durumundaki talep
|
||||
/// analiz edilir. Analysis'teki talebin analizi begenilmediyse yeniden yazilir; Ready to Start'a
|
||||
/// kullanici alir.
|
||||
/// Durum kurali modelin dikkatine birakilmaz: uretimi baslamamis (Backlog, Analysis, Ready to Start)
|
||||
/// talep analiz edilir; Ready to Start talebin analizi uretim oncesi yeniden yazilabilir. "Uretime hazir: Evet" analiz uretimin karar vermeden uygulayabilecegi analizdir,
|
||||
/// talep dogrudan Ready to Start'a gecer; sartli ya da Hayir olan analiz kullanicinin kararini
|
||||
/// bekledigi icin Analysis'te kalir.
|
||||
/// </summary>
|
||||
public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html)
|
||||
public async Task<McpOutcome<McpDevRequestDto>> SaveDevRequestAnalysisAsync(string request, string html, bool productionReady)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.SaveDevRequestAnalysis, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]);
|
||||
return Problem(McpErrorCodes.DevRequestAnalysisRequired);
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
|
||||
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis))
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis or McpConsts.DevRequestStatuses.ReadyToStart))
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty);
|
||||
}
|
||||
|
||||
devRequest.Analysis = html.Trim();
|
||||
devRequest.Status = McpConsts.DevRequestStatuses.Analysis;
|
||||
devRequest.Status = productionReady
|
||||
? McpConsts.DevRequestStatuses.ReadyToStart
|
||||
: McpConsts.DevRequestStatuses.Analysis;
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis);
|
||||
|
||||
|
|
@ -522,20 +722,33 @@ public class McpDevelopAppService(
|
|||
/// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize
|
||||
/// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir.
|
||||
/// </summary>
|
||||
public async Task AppendDevRequestNoteAsync(string request, string html)
|
||||
public async Task<McpProblem?> AppendDevRequestNoteAsync(string request, string html)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (await CheckPermissionAsync(McpToolNames.AppendDevRequestNote, DevRequestUpdatePermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNoteRequired]);
|
||||
return Problem(McpErrorCodes.DevRequestNoteRequired);
|
||||
}
|
||||
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim());
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -543,29 +756,44 @@ public class McpDevelopAppService(
|
|||
/// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur.
|
||||
/// Var olan satirlara dokunulmaz.
|
||||
/// </summary>
|
||||
public async Task AddDevRequestSubTasksAsync(string request, List<string> titles)
|
||||
public async Task<McpProblem?> AddDevRequestSubTasksAsync(string request, List<string> titles)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks);
|
||||
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks) is { Problem: { } problem })
|
||||
{
|
||||
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();
|
||||
if (items.Count == 0)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]);
|
||||
return Problem(McpErrorCodes.DevRequestSubTasksRequired);
|
||||
}
|
||||
|
||||
var devRequest = await FindDevRequestAsync(request);
|
||||
if (!(await FindDevRequestAsync(request)).TryGetValue(out var devRequest, out var notFound))
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
JsonArray subTasks;
|
||||
JsonArray? subTasks;
|
||||
try
|
||||
{
|
||||
subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks)
|
||||
? []
|
||||
: JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException();
|
||||
? new JsonArray()
|
||||
: JsonNode.Parse(devRequest.SubTasks) as JsonArray;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]);
|
||||
subTasks = null;
|
||||
}
|
||||
|
||||
if (subTasks is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString());
|
||||
}
|
||||
|
||||
var assignee = CurrentUser.UserName ?? string.Empty;
|
||||
|
|
@ -586,17 +814,26 @@ public class McpDevelopAppService(
|
|||
devRequest.SubTasks = subTasks.ToJsonString();
|
||||
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ModuleDocDto> GetModuleDocAsync(string name)
|
||||
public async Task<McpOutcome<ModuleDocDto>> GetModuleDocAsync(string name)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc);
|
||||
return await moduleDocAppService.GetAsync(name);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
return await moduleDocAppService.GetByNameAsync(name);
|
||||
}
|
||||
|
||||
public async Task<ModuleDocDto> SaveModuleDocAsync(string name, string content)
|
||||
public async Task<McpOutcome<ModuleDocDto>> SaveModuleDocAsync(string name, string content)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content });
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name);
|
||||
|
|
@ -626,23 +863,23 @@ public class McpDevelopAppService(
|
|||
}
|
||||
|
||||
/// <summary>Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir.</summary>
|
||||
private async Task<DevRequest> FindDevRequestAsync(string? request)
|
||||
private async Task<McpOutcome<DevRequest>> FindDevRequestAsync(string? request)
|
||||
{
|
||||
var value = request?.Trim() ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(value, out var id))
|
||||
{
|
||||
return await devRequestRepository.FindAsync(id)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]);
|
||||
var byId = await devRequestRepository.FindAsync(id);
|
||||
return byId is null ? Problem(McpErrorCodes.DevRequestNotFound, value) : byId;
|
||||
}
|
||||
|
||||
var matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value);
|
||||
|
||||
return matches.Count switch
|
||||
{
|
||||
0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]),
|
||||
0 => Problem(McpErrorCodes.DevRequestNotFound, value),
|
||||
1 => matches[0],
|
||||
_ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))])
|
||||
_ => Problem(McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id)))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -749,13 +986,16 @@ public class McpDevelopAppService(
|
|||
private static McpVerifyCheckDto Failed(string name, string message)
|
||||
=> new() { Name = name, Passed = false, Message = message };
|
||||
|
||||
public async Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search)
|
||||
public async Task<McpOutcome<List<McpComponentSummaryDto>>> GetComponentsAsync(string? search)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListComponents);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListComponents) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var components = await GetAllComponentsAsync();
|
||||
|
||||
return [.. components
|
||||
return components
|
||||
.Where(component => string.IsNullOrWhiteSpace(search)
|
||||
|| Contains(component.Name, search)
|
||||
|| Contains(component.RoutePath, search)
|
||||
|
|
@ -768,24 +1008,31 @@ public class McpDevelopAppService(
|
|||
Description = component.Description,
|
||||
IsActive = component.IsActive,
|
||||
IsVisualDesigner = IsVisualDesignerDocument(component.Props)
|
||||
})];
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<CustomComponentDto?> GetComponentAsync(string name)
|
||||
public async Task<McpOutcome<CustomComponentDto?>> GetComponentAsync(string name)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetComponent);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.GetComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
return await FindComponentAsync(name);
|
||||
}
|
||||
|
||||
public async Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input)
|
||||
public async Task<McpOutcome<CustomComponentDto>> SaveComponentAsync(McpComponentSaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveComponent);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SaveComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]);
|
||||
return Problem(McpErrorCodes.ComponentNameRequired);
|
||||
}
|
||||
|
||||
var existing = await FindComponentAsync(input.Name);
|
||||
|
|
@ -795,14 +1042,14 @@ public class McpDevelopAppService(
|
|||
// kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir.
|
||||
if (IsVisualDesignerDocument(props))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]);
|
||||
return Problem(McpErrorCodes.VisualDesignerComponent, input.Name);
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Code))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]);
|
||||
return Problem(McpErrorCodes.ComponentCodeRequired);
|
||||
}
|
||||
|
||||
var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto
|
||||
|
|
@ -835,52 +1082,44 @@ public class McpDevelopAppService(
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async Task DeleteComponentAsync(string name)
|
||||
public async Task<McpProblem?> DeleteComponentAsync(string name)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteComponent);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DeleteComponent) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var component = await FindComponentAsync(name)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]);
|
||||
var component = await FindComponentAsync(name);
|
||||
if (component is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ComponentNotFound, name);
|
||||
}
|
||||
|
||||
await componentAppService.DeleteAsync(component.Id);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>) ya da duz cevaplardan okur.
|
||||
/// Iki bicim de dosyanin okuma kuralindan gecer; boylece Menu/Language bloklari ve
|
||||
/// WizardCode/ModuleId adlari dosyadakiyle ayni anlama gelir.
|
||||
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}}</c>, basliklar toplanmis) ya da duz
|
||||
/// cevaplardan okur; Menu/Language/Data/Details/Layout bloklari ve WizardCode/ModuleId adlari
|
||||
/// dosyadakiyle ayni anlama gelir (<see cref="WizardSeedFileDto.FromDeployJson"/>).
|
||||
/// </summary>
|
||||
private ListFormWizardDto ParseWizard(string? json)
|
||||
private McpOutcome<ListFormWizardDto> ParseWizard(string? json)
|
||||
{
|
||||
JsonObject? root;
|
||||
ListFormWizardDto? wizard;
|
||||
try
|
||||
{
|
||||
root = string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json) as JsonObject;
|
||||
wizard = string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
catch (JsonException ex)
|
||||
{
|
||||
root = null;
|
||||
// Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir.
|
||||
return Problem(McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message);
|
||||
}
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
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]);
|
||||
}
|
||||
return wizard is null ? Problem(McpErrorCodes.InvalidWizard) : wizard;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync()
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ using System.Collections.Generic;
|
|||
using System.Data.Common;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.ListForms.Select;
|
||||
|
|
@ -30,39 +34,52 @@ namespace Sozsoft.Mcp;
|
|||
/// <see cref="IMcpQueryExecutor"/> icindedir.
|
||||
/// </summary>
|
||||
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
|
||||
public class McpQueryAppService(
|
||||
public partial class McpQueryAppService(
|
||||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpSqlGuard sqlGuard,
|
||||
IMcpQueryExecutor queryExecutor,
|
||||
IRepository<CustomEndpoint, Guid> customEndpointRepository,
|
||||
IListFormSelectAppService listFormSelectAppService,
|
||||
IMcpListFormAccessFilter accessFilter)
|
||||
IMcpListFormAccessFilter accessFilter,
|
||||
IConfiguration configuration)
|
||||
: McpAppService(serverConfigProvider), IMcpQueryAppService
|
||||
{
|
||||
private const string SqlPermission = PlatformConsts.AppCodes.SqlQueryManagers.Default;
|
||||
private const string CustomEndpointPermission = PlatformConsts.AppCodes.DeveloperKits.Get;
|
||||
|
||||
public async Task<McpListQueryResultDto> QueryListAsync(McpListQueryRequestDto input)
|
||||
public async Task<McpOutcome<McpListQueryResultDto>> QueryListAsync(McpListQueryRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.QueryList);
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.QueryList)).TryGetValue(out var config, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var take = ResolveMaxRows(config, input.Take);
|
||||
|
||||
var permission = await accessFilter.GetPermissionNameAsync(input.ListFormCode, AuthorizationTypeEnum.Read);
|
||||
if (permission is not null)
|
||||
if (permission is not null && await CheckPermissionAsync(McpToolNames.QueryList, permission) is { } denied)
|
||||
{
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort).TryGetValue(out var sort, out problem) ||
|
||||
!ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup).TryGetValue(out var group, out problem) ||
|
||||
!ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary).TryGetValue(out var groupSummary, out problem) ||
|
||||
!ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary).TryGetValue(out var totalSummary, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var result = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
|
||||
{
|
||||
ListFormCode = input.ListFormCode,
|
||||
Filter = NullIfEmpty(input.Filter),
|
||||
Sort = ToLoadOption(nameof(input.Sort), input.Sort, McpLoadOptions.ToSort),
|
||||
Group = ToLoadOption(nameof(input.Group), input.Group, McpLoadOptions.ToGroup),
|
||||
GroupSummary = ToLoadOption(nameof(input.GroupSummary), input.GroupSummary, McpLoadOptions.ToSummary),
|
||||
TotalSummary = ToLoadOption(nameof(input.TotalSummary), input.TotalSummary, McpLoadOptions.ToSummary),
|
||||
Sort = sort,
|
||||
Group = group,
|
||||
GroupSummary = groupSummary,
|
||||
TotalSummary = totalSummary,
|
||||
Skip = Math.Max(input.Skip, 0),
|
||||
Take = take,
|
||||
RequireTotalCount = true,
|
||||
|
|
@ -80,7 +97,7 @@ public class McpQueryAppService(
|
|||
}
|
||||
|
||||
/// <summary>Bozuk girdi SQL hatasina donusmeden, hangi parametrede oldugu soylenerek reddedilir.</summary>
|
||||
private string? ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
|
||||
private McpOutcome<string?> ToLoadOption(string parameter, string? value, Func<string?, string?> convert)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -88,21 +105,31 @@ public class McpQueryAppService(
|
|||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter)]);
|
||||
return Problem(McpErrorCodes.InvalidLoadOption, JsonNamingPolicy.CamelCase.ConvertName(parameter));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<McpQueryResultDto> RunSelectAsync(McpSelectRequestDto input)
|
||||
public async Task<McpOutcome<McpQueryResultDto>> RunSelectAsync(McpSelectRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.RunSelect);
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.RunSelect)).TryGetValue(out var config, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var sql = (input.Sql ?? string.Empty).Trim().TrimEnd(';').Trim();
|
||||
sqlGuard.EnsureReadOnlyStatement(sql);
|
||||
if (sqlGuard.CheckReadOnlyStatement(sql) is { } invalid)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
var limitedSql = sqlGuard.ApplyRowLimit(sql, maxRows, context.DataSourceType);
|
||||
|
||||
|
|
@ -115,13 +142,102 @@ public class McpQueryAppService(
|
|||
}
|
||||
catch (DbException ex)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.SelectFailed, ex.Message]);
|
||||
var pending = IsUnknownObjectError(ex) ? DescribePendingTables(sql) : null;
|
||||
if (pending is not null)
|
||||
return Problem(McpErrorCodes.SelectTableNotInstalled, ex.Message, pending);
|
||||
|
||||
var columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null;
|
||||
return columns is null
|
||||
? Problem(McpErrorCodes.SelectFailed, ex.Message)
|
||||
: Problem(McpErrorCodes.SelectFailedColumns, ex.Message, columns);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
|
||||
private const int MaxDescribedTables = 5;
|
||||
|
||||
/// <summary>SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column).</summary>
|
||||
private static bool IsUnknownColumnError(DbException ex) =>
|
||||
ex.SqlState == "42703" || ex.Message.Contains("Invalid column name", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>SQL Server 208 ("Invalid object name"), PostgreSQL 42P01 (undefined_table).</summary>
|
||||
private static bool IsUnknownObjectError(DbException ex) =>
|
||||
ex.SqlState == "42P01" || ex.Message.Contains("Invalid object name", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Sorgunun andigi ve veritabaninda olmayan tablolardan seed scripti olanlar (<c>Tablo(A, B); …</c>).
|
||||
/// <c>create_table</c> tabloyu yalnizca scripte yazar, kurulum DB Migrate'tedir; model ayni talepte
|
||||
/// tabloyu sorgularsa kolon tahmini yuzunden degil kurulum beklendigi icin bos doner. Kolonlar
|
||||
/// scriptten okunur, boylece model sema araci cagirmadan ilerler. Scripti olan tablo yoksa null.
|
||||
/// </summary>
|
||||
private string? DescribePendingTables(string sql)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListQueries);
|
||||
var described = TableReferenceRegex().Matches(sql)
|
||||
.Select(match => match.Groups["table"].Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxDescribedTables)
|
||||
.Select(table => (Table: table, Columns: PostgreSqlTableScript.ReadSeedColumns(configuration, table)))
|
||||
.Where(item => item.Columns.Count > 0)
|
||||
.Select(item => $"{item.Table}({string.Join(", ", item.Columns.Keys)})")
|
||||
.ToList();
|
||||
|
||||
return described.Count == 0 ? null : string.Join("; ", described);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (<c>Tablo(A, B, C); …</c>). Model
|
||||
/// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir.
|
||||
/// Kolonlar okunamazsa null doner; asil hata yine iletilir.
|
||||
/// </summary>
|
||||
private async Task<string?> DescribeTableColumnsAsync(McpDataSourceContext context, string sql)
|
||||
{
|
||||
var tables = TableReferenceRegex().Matches(sql)
|
||||
.Select(match => match.Groups["table"].Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxDescribedTables)
|
||||
.ToList();
|
||||
if (tables.Count == 0)
|
||||
return null;
|
||||
|
||||
var parameters = tables.Select((table, index) => (Name: $"t{index}", Table: table))
|
||||
.ToDictionary(p => p.Name, p => (object)p.Table);
|
||||
|
||||
try
|
||||
{
|
||||
var rows = await queryExecutor.ExecuteAsync(
|
||||
context,
|
||||
$"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN ({string.Join(", ", parameters.Keys.Select(name => "@" + name))}) ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||
parameters,
|
||||
int.MaxValue);
|
||||
|
||||
var described = rows.Rows
|
||||
.Select(row => (Table: ReadIgnoreCase(row, "TABLE_NAME"), Column: ReadIgnoreCase(row, "COLUMN_NAME")))
|
||||
.Where(item => item.Table is not null && item.Column is not null)
|
||||
.GroupBy(item => item.Table!, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => $"{group.Key}({string.Join(", ", group.Select(item => item.Column))})")
|
||||
.ToList();
|
||||
|
||||
return described.Count == 0 ? null : string.Join("; ", described);
|
||||
}
|
||||
catch (DbException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadIgnoreCase(Dictionary<string, object?> row, string key) =>
|
||||
row.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
|
||||
|
||||
/// <summary>FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir (<c>[dbo].[Tablo]</c> → <c>Tablo</c>).</summary>
|
||||
[GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?<table>\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex TableReferenceRegex();
|
||||
|
||||
public async Task<McpOutcome<List<McpSavedQueryDto>>> GetSavedQueriesAsync(string? search = null)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListQueries) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var results = new List<McpSavedQueryDto>();
|
||||
|
||||
if (await AuthorizationService.IsGrantedAsync(SqlPermission))
|
||||
|
|
@ -141,17 +257,16 @@ public class McpQueryAppService(
|
|||
|| (item.Description?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))];
|
||||
}
|
||||
|
||||
return [.. results
|
||||
return results
|
||||
.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()
|
||||
{
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
var catalogSql = context.DataSourceType == DataSourceTypeEnum.Postgresql
|
||||
? McpCatalogQueries.Postgres
|
||||
: McpCatalogQueries.SqlServer;
|
||||
var catalogSql = SqlCatalogQueries.NativeObjects(context.DataSourceType);
|
||||
|
||||
// Katalog sorgusunun kendi satir siniri yoktur; nesne sayisi tavani asmaz.
|
||||
var catalog = await queryExecutor.ExecuteAsync(context, catalogSql, new Dictionary<string, object>(), int.MaxValue);
|
||||
|
|
@ -177,44 +292,58 @@ public class McpQueryAppService(
|
|||
return results;
|
||||
}
|
||||
|
||||
public async Task<McpQueryResultDto> RunSavedQueryAsync(McpSavedQueryRequestDto input)
|
||||
public async Task<McpOutcome<McpQueryResultDto>> RunSavedQueryAsync(McpSavedQueryRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
var config = await EnsureToolAllowedAsync(McpToolNames.RunQuery);
|
||||
|
||||
if (IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint))
|
||||
if (!(await EnsureToolAllowedAsync(McpToolNames.RunQuery)).TryGetValue(out var config, out var problem))
|
||||
{
|
||||
await AuthorizationService.CheckAsync(CustomEndpointPermission);
|
||||
return await RunCustomEndpointAsync(input, config);
|
||||
return problem;
|
||||
}
|
||||
|
||||
await AuthorizationService.CheckAsync(SqlPermission);
|
||||
var isCustomEndpoint = IsKind(input.Kind, McpConsts.SavedQueryKinds.CustomEndpoint);
|
||||
if (await CheckPermissionAsync(McpToolNames.RunQuery, isCustomEndpoint ? CustomEndpointPermission : SqlPermission) is { } denied)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
if (!sqlGuard.NormalizeParameters(input.Parameters).TryGetValue(out var parameters, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
if (isCustomEndpoint)
|
||||
{
|
||||
return await RunCustomEndpointAsync(input, parameters, maxRows);
|
||||
}
|
||||
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
|
||||
var schemaName = string.IsNullOrWhiteSpace(input.SchemaName)
|
||||
? sqlGuard.DefaultSchemaName(context.DataSourceType)
|
||||
? SqlIdentifier.DefaultSchema(context.DataSourceType)
|
||||
: input.SchemaName;
|
||||
|
||||
var qualifiedName = sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType);
|
||||
var sql = BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType);
|
||||
if (!sqlGuard.QuoteQualifiedName(schemaName, input.Name, context.DataSourceType).TryGetValue(out var qualifiedName, out problem) ||
|
||||
!BuildSavedQuerySql(input.Kind, qualifiedName, parameters, maxRows, context.DataSourceType).TryGetValue(out var sql, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
return ToResult(await queryExecutor.ExecuteAsync(context, sql, parameters, maxRows));
|
||||
}
|
||||
|
||||
private async Task<McpQueryResultDto> RunCustomEndpointAsync(
|
||||
private async Task<McpOutcome<McpQueryResultDto>> RunCustomEndpointAsync(
|
||||
McpSavedQueryRequestDto input,
|
||||
McpServerConfigDto config)
|
||||
Dictionary<string, object> parameters,
|
||||
int maxRows)
|
||||
{
|
||||
var endpoints = await GetCallableEndpointEntitiesAsync();
|
||||
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.CustomEndpointNotFound, input.Name]);
|
||||
|
||||
var maxRows = ResolveMaxRows(config, input.MaxRows);
|
||||
var parameters = sqlGuard.NormalizeParameters(input.Parameters);
|
||||
var endpoint = endpoints.Find(candidate => string.Equals(candidate.Name, input.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (endpoint is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.CustomEndpointNotFound, input.Name);
|
||||
}
|
||||
|
||||
// Eksik parametre Dapper tarafinda belirsiz bir hataya donusmesin diye burada yakalanir.
|
||||
var missing = endpoint.Parameters
|
||||
|
|
@ -224,7 +353,7 @@ public class McpQueryAppService(
|
|||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.MissingParameters, string.Join(", ", missing)]);
|
||||
return Problem(McpErrorCodes.MissingParameters, string.Join(", ", missing));
|
||||
}
|
||||
|
||||
var context = await queryExecutor.ResolveDataSourceAsync(endpoint.DataSourceCode);
|
||||
|
|
@ -260,7 +389,7 @@ public class McpQueryAppService(
|
|||
|| (permission.ResourceType == "Role" && CurrentUser.Roles.Contains(permission.ResourceId))))];
|
||||
}
|
||||
|
||||
private string BuildSavedQuerySql(
|
||||
private McpOutcome<string> BuildSavedQuerySql(
|
||||
string? kind,
|
||||
string qualifiedName,
|
||||
Dictionary<string, object> parameters,
|
||||
|
|
@ -292,7 +421,7 @@ public class McpQueryAppService(
|
|||
: $"EXEC {qualifiedName} {string.Join(", ", parameters.Keys.Select(name => $"@{name} = @{name}"))}".TrimEnd();
|
||||
}
|
||||
|
||||
throw new UserFriendlyException(L[McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty]);
|
||||
return Problem(McpErrorCodes.UnsupportedObjectKind, kind ?? string.Empty);
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
|
@ -36,15 +36,17 @@ public partial class McpReportAppService(
|
|||
private const string CategoriesListFormCode = PlatformConsts.AppCodes.Reports.Categories;
|
||||
private const string TemplatesListFormCode = PlatformConsts.AppCodes.Reports.ReportTemplates;
|
||||
private const string ActiveStatus = "active";
|
||||
private const string DefaultButtonText = "App.Platform.View";
|
||||
private const string DefaultButtonText = "Sas.Platform.View";
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
public async Task<McpReportCatalogDto> GetReportTemplatesAsync(string? search)
|
||||
public async Task<McpOutcome<McpReportCatalogDto>> GetReportTemplatesAsync(string? search)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListReportTemplates);
|
||||
await EnsureCanReadAsync();
|
||||
if (await EnsureCanReadAsync(McpToolNames.ListReportTemplates) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var categories = await categoryRepository.GetListAsync();
|
||||
var categoryNames = categories.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
|
@ -70,25 +72,35 @@ public partial class McpReportAppService(
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<McpReportTemplateDto> GetReportTemplateAsync(string template)
|
||||
public async Task<McpOutcome<McpReportTemplateDto>> GetReportTemplateAsync(string template)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.GetReportTemplate);
|
||||
await EnsureCanReadAsync();
|
||||
if (await EnsureCanReadAsync(McpToolNames.GetReportTemplate) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var entity = await FindTemplateAsync(template)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
|
||||
var entity = await FindTemplateAsync(template);
|
||||
if (entity is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
|
||||
}
|
||||
|
||||
return await ToDtoAsync(entity, null, null);
|
||||
}
|
||||
|
||||
public async Task<McpReportCategoryDto> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
|
||||
public async Task<McpOutcome<McpReportCategoryDto>> SaveReportCategoryAsync(McpReportCategorySaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveReportCategory);
|
||||
const string toolName = McpToolNames.SaveReportCategory;
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
|
||||
return Problem(McpErrorCodes.ReportNameRequired);
|
||||
}
|
||||
|
||||
var name = input.Name.Trim();
|
||||
|
|
@ -100,24 +112,33 @@ public partial class McpReportAppService(
|
|||
["Icon"] = input.Icon ?? existing?.Icon
|
||||
};
|
||||
|
||||
var id = existing is null
|
||||
? await InsertAsync(CategoriesListFormCode, values)
|
||||
: await UpdateAsync(CategoriesListFormCode, existing.Id, values);
|
||||
var saved = existing is null
|
||||
? await InsertAsync(toolName, CategoriesListFormCode, values)
|
||||
: await UpdateAsync(toolName, CategoriesListFormCode, existing.Id, values);
|
||||
if (!saved.TryGetValue(out var id, out var rejected))
|
||||
{
|
||||
return rejected;
|
||||
}
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportCategory, name);
|
||||
|
||||
var saved = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return saved is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(saved);
|
||||
var category = await categoryRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return category is null ? new McpReportCategoryDto { Id = id, Name = name } : ToDto(category);
|
||||
}
|
||||
|
||||
public async Task<McpReportTemplateDto> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
|
||||
public async Task<McpOutcome<McpReportTemplateDto>> SaveReportTemplateAsync(McpReportTemplateSaveRequestDto input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
await EnsureToolAllowedAsync(McpToolNames.SaveReportTemplate);
|
||||
const string toolName = McpToolNames.SaveReportTemplate;
|
||||
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportNameRequired]);
|
||||
return Problem(McpErrorCodes.ReportNameRequired);
|
||||
}
|
||||
|
||||
var name = input.Name.Trim();
|
||||
|
|
@ -125,22 +146,32 @@ public partial class McpReportAppService(
|
|||
var htmlContent = string.IsNullOrWhiteSpace(input.HtmlContent) ? existing?.HtmlContent : input.HtmlContent;
|
||||
if (string.IsNullOrWhiteSpace(htmlContent))
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportHtmlRequired]);
|
||||
return Problem(McpErrorCodes.ReportHtmlRequired);
|
||||
}
|
||||
|
||||
var categoryId = existing?.CategoryId;
|
||||
if (!string.IsNullOrWhiteSpace(input.Category))
|
||||
{
|
||||
categoryId = (await FindCategoryAsync(input.Category))?.Id;
|
||||
if (categoryId is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportCategoryNotFound, input.Category);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportCategoryRequired]);
|
||||
return Problem(McpErrorCodes.ReportCategoryRequired);
|
||||
}
|
||||
|
||||
var listForm = await FindListFormAsync(input.ListFormCode);
|
||||
if (listForm is not null)
|
||||
if (!(await FindListFormAsync(input.ListFormCode)).TryGetValue(out var listForm, out var notFound))
|
||||
{
|
||||
await EnsurePlaceholdersExistAsync(listForm.ListFormCode, htmlContent);
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (listForm is not null && await CheckPlaceholdersExistAsync(listForm.ListFormCode, htmlContent) is { } unknown)
|
||||
{
|
||||
return unknown;
|
||||
}
|
||||
|
||||
var values = new Dictionary<string, object?>
|
||||
|
|
@ -152,44 +183,74 @@ public partial class McpReportAppService(
|
|||
["Status"] = input.Status ?? existing?.Status ?? ActiveStatus
|
||||
};
|
||||
|
||||
var id = existing is null
|
||||
? await InsertAsync(TemplatesListFormCode, values)
|
||||
: await UpdateAsync(TemplatesListFormCode, existing.Id, values);
|
||||
var saved = existing is null
|
||||
? await InsertAsync(toolName, TemplatesListFormCode, values)
|
||||
: await UpdateAsync(toolName, TemplatesListFormCode, existing.Id, values);
|
||||
if (!saved.TryGetValue(out var id, out var rejected))
|
||||
{
|
||||
return rejected;
|
||||
}
|
||||
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveReportTemplate, name);
|
||||
|
||||
var saved = await templateRepository.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, name]);
|
||||
var template = await templateRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (template is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, name);
|
||||
}
|
||||
|
||||
return await ToDtoAsync(saved, listForm, input.ButtonText);
|
||||
return await ToDtoAsync(template, listForm, input.ButtonText);
|
||||
}
|
||||
|
||||
public async Task DeleteReportTemplateAsync(string template)
|
||||
public async Task<McpProblem?> DeleteReportTemplateAsync(string template)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.DeleteReportTemplate);
|
||||
const string toolName = McpToolNames.DeleteReportTemplate;
|
||||
|
||||
var entity = await FindTemplateAsync(template)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportTemplateNotFound, template]);
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await listFormDataAppService.PostDeleteAsync(await BuildRequestAsync(
|
||||
TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id));
|
||||
var entity = await FindTemplateAsync(template);
|
||||
if (entity is null)
|
||||
{
|
||||
return Problem(McpErrorCodes.ReportTemplateNotFound, template);
|
||||
}
|
||||
|
||||
if (!(await BuildRequestAsync(toolName, TemplatesListFormCode, AuthorizationTypeEnum.Delete, null, entity.Id)).TryGetValue(out var request, out var denied))
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
|
||||
await listFormDataAppService.PostDeleteAsync(request);
|
||||
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteReportTemplate, entity.Name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Okuma depodan yapildigi icin ekranin okuma yetkisi burada istenir; MCP kullanicinin
|
||||
/// uygulamada goremedigi sablonu gostermez.
|
||||
/// </summary>
|
||||
private async Task EnsureCanReadAsync()
|
||||
private async Task<McpProblem?> EnsureCanReadAsync(string toolName)
|
||||
{
|
||||
if (await EnsureToolAllowedAsync(toolName) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var permission = await accessFilter.GetPermissionNameAsync(TemplatesListFormCode, AuthorizationTypeEnum.Read);
|
||||
await AuthorizationService.CheckAsync(permission ?? TemplatesListFormCode);
|
||||
return await CheckPermissionAsync(toolName, permission ?? TemplatesListFormCode);
|
||||
}
|
||||
|
||||
private async Task<Guid> InsertAsync(string listFormCode, Dictionary<string, object?> values)
|
||||
private async Task<McpOutcome<Guid>> InsertAsync(string toolName, string listFormCode, Dictionary<string, object?> values)
|
||||
{
|
||||
var key = await listFormDataAppService.PostInsertAsync(await BuildRequestAsync(
|
||||
listFormCode, AuthorizationTypeEnum.Create, values, null));
|
||||
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Create, values, null)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var key = await listFormDataAppService.PostInsertAsync(request);
|
||||
|
||||
// Insert ucu her veri kaynaginda anahtari dondurmez; bu durumda tekil ad uzerinden bulunur.
|
||||
if (Guid.TryParse(((object?)key)?.ToString(), out var id))
|
||||
|
|
@ -203,10 +264,14 @@ public partial class McpReportAppService(
|
|||
: (await templateRepository.FirstAsync(x => x.Name == name)).Id;
|
||||
}
|
||||
|
||||
private async Task<Guid> UpdateAsync(string listFormCode, Guid id, Dictionary<string, object?> values)
|
||||
private async Task<McpOutcome<Guid>> UpdateAsync(string toolName, string listFormCode, Guid id, Dictionary<string, object?> values)
|
||||
{
|
||||
await listFormDataAppService.PostUpdateAsync(await BuildRequestAsync(
|
||||
listFormCode, AuthorizationTypeEnum.Update, values, id));
|
||||
if (!(await BuildRequestAsync(toolName, listFormCode, AuthorizationTypeEnum.Update, values, id)).TryGetValue(out var request, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await listFormDataAppService.PostUpdateAsync(request);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
|
@ -214,16 +279,17 @@ public partial class McpReportAppService(
|
|||
/// Ekranin kendi kontrolu reddi adsiz bir hatayla bildirir; yetki burada adiyla istenir ki
|
||||
/// istemci hangi yetkinin eksik oldugunu gorsun.
|
||||
/// </summary>
|
||||
private async Task<DataRequestDto> BuildRequestAsync(
|
||||
private async Task<McpOutcome<DataRequestDto>> BuildRequestAsync(
|
||||
string toolName,
|
||||
string listFormCode,
|
||||
AuthorizationTypeEnum operation,
|
||||
Dictionary<string, object?>? values,
|
||||
Guid? key)
|
||||
{
|
||||
var permission = await accessFilter.GetPermissionNameAsync(listFormCode, operation);
|
||||
if (permission is not null)
|
||||
if (permission is not null && await CheckPermissionAsync(toolName, permission) is { } denied)
|
||||
{
|
||||
await AuthorizationService.CheckAsync(permission);
|
||||
return denied;
|
||||
}
|
||||
|
||||
return new DataRequestDto
|
||||
|
|
@ -255,32 +321,34 @@ public partial class McpReportAppService(
|
|||
: await categoryRepository.FirstOrDefaultAsync(x => x.Name == value);
|
||||
}
|
||||
|
||||
private async Task<ListForm?> FindListFormAsync(string? listFormCode)
|
||||
/// <summary>Kod verilmediyse null; verilen kodun ekrani yoksa sorun.</summary>
|
||||
private async Task<McpOutcome<ListForm?>> FindListFormAsync(string? listFormCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listFormCode))
|
||||
{
|
||||
return null;
|
||||
return default(ListForm);
|
||||
}
|
||||
|
||||
var code = listFormCode.Trim();
|
||||
return await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code)
|
||||
?? throw new UserFriendlyException(L[McpErrorCodes.ReportListFormNotFound, code]);
|
||||
var listForm = await listFormRepository.FirstOrDefaultAsync(x => x.ListFormCode == code);
|
||||
|
||||
return listForm is null ? Problem(McpErrorCodes.ReportListFormNotFound, code) : listForm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bilinmeyen yer tutucu raporda oldugu gibi basilir; sertifikada "{{AdSoyad}}" gorunmesin
|
||||
/// diye kayitta reddedilir.
|
||||
/// </summary>
|
||||
private async Task EnsurePlaceholdersExistAsync(string listFormCode, string htmlContent)
|
||||
private async Task<McpProblem?> CheckPlaceholdersExistAsync(string listFormCode, string htmlContent)
|
||||
{
|
||||
var fieldNames = (await listFormFieldRepository.GetListAsync(x => x.ListFormCode == listFormCode))
|
||||
.Select(x => x.FieldName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = GetPlaceholders(htmlContent).Where(x => !fieldNames.Contains(x)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
throw new UserFriendlyException(L[McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown)]);
|
||||
}
|
||||
|
||||
return unknown.Count == 0
|
||||
? null
|
||||
: Problem(McpErrorCodes.ReportPlaceholdersUnknown, listFormCode, string.Join(", ", unknown));
|
||||
}
|
||||
|
||||
private static List<string> GetPlaceholders(string htmlContent)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Sozsoft.Mcp.Services;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.ListForms;
|
||||
using Volo.Abp.Authorization.Permissions;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
|
@ -20,9 +26,17 @@ public class McpSchemaAppService(
|
|||
IMcpServerConfigProvider serverConfigProvider,
|
||||
IMcpSchemaDictionary dictionary,
|
||||
IMcpListFormAccessFilter accessFilter,
|
||||
IPermissionChecker permissionChecker)
|
||||
IPermissionChecker permissionChecker,
|
||||
IListFormWizardAppService wizardAppService,
|
||||
IConfiguration configuration)
|
||||
: 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()
|
||||
=> await ResolveServerConfigAsync();
|
||||
|
||||
|
|
@ -34,20 +48,9 @@ public class McpSchemaAppService(
|
|||
CurrentTenant.IsAvailable,
|
||||
config?.AllowedTools ?? []);
|
||||
|
||||
var required = candidates
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(candidates
|
||||
.Where(McpToolNames.RequiredPermissions.ContainsKey)
|
||||
.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));
|
||||
}
|
||||
.Select(tool => McpToolNames.RequiredPermissions[tool]));
|
||||
|
||||
return [.. candidates.Where(tool =>
|
||||
!McpToolNames.RequiredPermissions.TryGetValue(tool, out var permission) || granted.Contains(permission))];
|
||||
|
|
@ -79,9 +82,12 @@ public class McpSchemaAppService(
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<List<McpListSummaryDto>> GetListsAsync(string? search = null, int maxResults = 200)
|
||||
public async Task<McpOutcome<List<McpListSummaryDto>>> GetListsAsync(string? search = null, int maxResults = 200)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.ListLists);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.ListLists) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var forms = await GetReadableListFormsAsync();
|
||||
var normalizedSearch = dictionary.Normalize(search);
|
||||
|
|
@ -89,16 +95,19 @@ public class McpSchemaAppService(
|
|||
var matches = forms.Where(form => normalizedSearch.Length == 0
|
||||
|| dictionary.Normalize(form.SearchText).Contains(normalizedSearch, StringComparison.Ordinal));
|
||||
|
||||
return [.. matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary)];
|
||||
return matches.Take(NormalizeLimit(maxResults, 200)).Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<McpListDetailDto?> GetListAsync(string listFormCode)
|
||||
public async Task<McpOutcome<McpListDetailDto?>> GetListAsync(string listFormCode)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.DescribeList);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.DescribeList) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(listFormCode))
|
||||
{
|
||||
return null;
|
||||
return default(McpListDetailDto);
|
||||
}
|
||||
|
||||
var forms = await GetReadableListFormsAsync();
|
||||
|
|
@ -107,7 +116,7 @@ public class McpSchemaAppService(
|
|||
|
||||
if (form is null)
|
||||
{
|
||||
return null;
|
||||
return default(McpListDetailDto);
|
||||
}
|
||||
|
||||
var detail = new McpListDetailDto();
|
||||
|
|
@ -117,14 +126,17 @@ public class McpSchemaAppService(
|
|||
return detail;
|
||||
}
|
||||
|
||||
public async Task<List<McpSchemaMatchDto>> SearchAsync(string term, int maxResults = 50)
|
||||
public async Task<McpOutcome<List<McpSchemaMatchDto>>> SearchAsync(string term, int maxResults = 50)
|
||||
{
|
||||
await EnsureToolAllowedAsync(McpToolNames.SearchSchema);
|
||||
if (await EnsureToolAllowedAsync(McpToolNames.SearchSchema) is { Problem: { } problem })
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var normalized = dictionary.Normalize(term);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return [];
|
||||
return new List<McpSchemaMatchDto>();
|
||||
}
|
||||
|
||||
var limit = NormalizeLimit(maxResults, 50);
|
||||
|
|
@ -168,7 +180,93 @@ public class McpSchemaAppService(
|
|||
var forms = await dictionary.GetListFormsAsync();
|
||||
var readable = await accessFilter.GetReadableListFormCodesAsync();
|
||||
|
||||
return [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))];
|
||||
List<McpDictionaryListForm> result = [.. forms.Where(form => form.ListFormCode is not null && readable.Contains(form.ListFormCode))];
|
||||
|
||||
// Gelistirici, sozlukte henuz olmayan (DB Migrate bekleyen ya da sozluk tazelenmemis) ekran ve
|
||||
// tablolari da gorur; lookup, widget ve view SQL'i bunlarin gercek kolon adlariyla yazilir.
|
||||
if (!CurrentTenant.IsAvailable && await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizard))
|
||||
result.AddRange(await GetSeedOnlySourcesAsync(forms));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed klasorunde olup sozlukte olmayan kaynaklar: wizard dosyalarinin ekranlari (alanlari
|
||||
/// <c>Groups</c> alanlarindan) ve yalnizca <c>sql/object</c> scripti olan tablolar (kolonlari scriptten).
|
||||
/// <see cref="McpListSummaryDto.SourceKind"/> <c>pending</c> / <c>pendingTable</c> olarak isaretlenir:
|
||||
/// kolon adlari dogrudur ama query_list migrate'ten once calismaz.
|
||||
/// </summary>
|
||||
private async Task<List<McpDictionaryListForm>> GetSeedOnlySourcesAsync(IReadOnlyList<McpDictionaryListForm> known)
|
||||
{
|
||||
var codes = known.Select(form => form.ListFormCode).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var tables = known.Select(form => form.TableName).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<McpDictionaryListForm>();
|
||||
|
||||
foreach (var file in await wizardAppService.GetFiles())
|
||||
{
|
||||
if (file.ComponentKind == WizardComponentKindEnum.Custom || codes.Contains(file.ListFormCode ?? string.Empty))
|
||||
continue;
|
||||
|
||||
var seed = await wizardAppService.GetFile(file.FileName);
|
||||
if (seed.Wizard is not { } wizard || string.IsNullOrWhiteSpace(wizard.SelectCommand))
|
||||
continue;
|
||||
|
||||
// Alanlar dosyadaki cevaplardan ekranin sozlesmesine cevrilir; sozluk ekranla ayni degerleri gorur.
|
||||
var listForm = wizard.BuildListForm();
|
||||
|
||||
var table = listForm.SelectCommandType == SelectCommandTypeEnum.Query ? null : listForm.SelectCommand?.Split('.')[^1].Trim('[', ']', '"');
|
||||
if (table is not null)
|
||||
tables.Add(table);
|
||||
|
||||
result.Add(new McpDictionaryListForm
|
||||
{
|
||||
ListFormCode = listForm.ListFormCode,
|
||||
DisplayName = seed.Wizard?.TitleTr ?? seed.Wizard?.TitleEn,
|
||||
Title = seed.Wizard?.TitleEn,
|
||||
Description = seed.Wizard?.DescTr,
|
||||
TableName = table,
|
||||
SqlSourceName = listForm.SelectCommand,
|
||||
SourceKind = PendingSourceKind,
|
||||
KeyFieldName = listForm.KeyFieldName,
|
||||
SearchText = string.Join(' ', listForm.ListFormCode, seed.Wizard?.TitleTr, seed.Wizard?.TitleEn, table),
|
||||
Fields = [.. wizard.BuildFields().Select(field => new McpDictionaryField
|
||||
{
|
||||
Field = field.FieldName,
|
||||
DisplayName = field.CaptionName,
|
||||
SqlDataType = field.SourceDbType.ToString(),
|
||||
IsVisible = field.Visible,
|
||||
IsBooleanColumn = field.SourceDbType == DbType.Boolean,
|
||||
LookupValueColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.ValueExpr,
|
||||
LookupDisplayColumn = string.IsNullOrWhiteSpace(field.LookupJson) ? null : field.LookupDto.DisplayExpr,
|
||||
IsDefaultSelectColumn = field.Visible
|
||||
})]
|
||||
});
|
||||
}
|
||||
|
||||
var scriptFolder = SeedPathResolver.GetPath(configuration, SeedPathResolver.SqlFolder, SeedPathResolver.ObjectFolder);
|
||||
foreach (var path in Directory.Exists(scriptFolder) ? Directory.GetFiles(scriptFolder, "*.sql") : [])
|
||||
{
|
||||
var table = Path.GetFileNameWithoutExtension(path);
|
||||
if (tables.Contains(table))
|
||||
continue;
|
||||
|
||||
var columns = PostgreSqlTableScript.ReadSeedColumns(configuration, table);
|
||||
if (columns.Count == 0)
|
||||
continue;
|
||||
|
||||
result.Add(new McpDictionaryListForm
|
||||
{
|
||||
ListFormCode = table,
|
||||
TableName = table,
|
||||
SqlSourceName = table,
|
||||
SourceKind = PendingTableSourceKind,
|
||||
KeyFieldName = columns.ContainsKey("Id") ? "Id" : null,
|
||||
SearchText = table,
|
||||
Fields = [.. columns.Keys.Select(column => new McpDictionaryField { Field = column, IsVisible = true, IsDefaultSelectColumn = true })]
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static McpListSummaryDto ToSummary(McpDictionaryListForm form)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ public static class McpConsts
|
|||
/// </summary>
|
||||
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>
|
||||
/// Dinamik kayitla (RFC 7591) uretilen istemcilerin kimlik oneki. Bilinen saglayicilarin
|
||||
/// (Claude, ChatGPT) disindaki MCP istemcileri bu onekle kendi OpenIddict uygulamasini alir.
|
||||
|
|
@ -67,6 +74,7 @@ public static class McpConsts
|
|||
public static bool IsMcpClient(string? clientId)
|
||||
=> clientId is not null
|
||||
&& (string.Equals(clientId, OAuthClientId, System.StringComparison.Ordinal)
|
||||
|| string.Equals(clientId, LocalOAuthClientId, System.StringComparison.Ordinal)
|
||||
|| clientId.StartsWith(DynamicClientIdPrefix, System.StringComparison.Ordinal));
|
||||
|
||||
/// <summary>MCP ucunun sunucu adresine gore yolu.</summary>
|
||||
|
|
@ -98,7 +106,7 @@ public static class McpConsts
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gelistirme talebi (<c>App.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup
|
||||
/// Gelistirme talebi (<c>Sas.DevRequests</c>) durumlari; ekranin <c>Status</c> lookup
|
||||
/// anahtarlariyla ayni olmak zorundadir.
|
||||
/// </summary>
|
||||
public static class DevRequestStatuses
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace Sozsoft.Mcp;
|
|||
|
||||
/// <summary>
|
||||
/// Modulun paylasilan sozlesmesi. Kullaniciya gorunen metinler platformun kendi
|
||||
/// <c>PlatformResource</c> kaynagindan (configs/seeds/host/data/App.Languages.LanguageText.json) okunur; modul ikinci bir dil
|
||||
/// <c>PlatformResource</c> kaynagindan (configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json) okunur; modul ikinci bir dil
|
||||
/// kaynagi acmaz.
|
||||
/// </summary>
|
||||
[DependsOn(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/host/data/App.Languages.LanguageText.json</c>
|
||||
/// MCP hatalarinin dil anahtarlari. Karsiliklari platformun <c>configs/seeds/data/Sas.Languages.LanguageText/{CultureName}.json</c>
|
||||
/// dosyasinda EN + TR olarak durur; koda gomulu metin kullanilmaz.
|
||||
/// </summary>
|
||||
public static class McpErrorCodes
|
||||
{
|
||||
private const string Prefix = "App.Mcp.Error.";
|
||||
private const string Prefix = "Sas.Mcp.Error.";
|
||||
|
||||
public const string NoServerRecord = Prefix + "NoServerRecord";
|
||||
public const string ToolDisabled = Prefix + "ToolDisabled";
|
||||
|
|
@ -33,9 +33,12 @@ public static class McpErrorCodes
|
|||
public const string DevGuideSectionNotFound = Prefix + "DevGuideSectionNotFound";
|
||||
public const string DevGuideSearchMatches = Prefix + "DevGuideSearchMatches";
|
||||
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 SeedRowsNotAllowed = Prefix + "SeedRowsNotAllowed";
|
||||
public const string SeedRowsUnknownFields = Prefix + "SeedRowsUnknownFields";
|
||||
public const string SeedRowsFieldsOnOtherScreens = Prefix + "SeedRowsFieldsOnOtherScreens";
|
||||
public const string ComponentNameRequired = Prefix + "ComponentNameRequired";
|
||||
public const string ComponentCodeRequired = Prefix + "ComponentCodeRequired";
|
||||
public const string ComponentNotFound = Prefix + "ComponentNotFound";
|
||||
|
|
@ -48,6 +51,8 @@ public static class McpErrorCodes
|
|||
public const string VerifySelectPassed = Prefix + "VerifySelectPassed";
|
||||
public const string VerifySelectFailed = Prefix + "VerifySelectFailed";
|
||||
public const string SelectFailed = Prefix + "SelectFailed";
|
||||
public const string SelectFailedColumns = Prefix + "SelectFailedColumns";
|
||||
public const string SelectTableNotInstalled = Prefix + "SelectTableNotInstalled";
|
||||
public const string VerifyComponentInactive = Prefix + "VerifyComponentInactive";
|
||||
public const string VerifyComponentRouteMismatch = Prefix + "VerifyComponentRouteMismatch";
|
||||
public const string VerifySeedClosureIncomplete = Prefix + "VerifySeedClosureIncomplete";
|
||||
|
|
@ -75,6 +80,8 @@ public static class McpErrorCodes
|
|||
public const string InvalidColumnLength = Prefix + "InvalidColumnLength";
|
||||
public const string InvalidDefaultValue = Prefix + "InvalidDefaultValue";
|
||||
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 TableProviderNotSupported = Prefix + "TableProviderNotSupported";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Localization;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Aracin modelle konusarak cozulecek reddi: eksik ya da hatali girdi, durum kurali, eksik yetki.
|
||||
/// Istisna olarak atilmaz — bu durum aracin beklenen cevabidir; atilan istisna debugger'i durdurur
|
||||
/// ve SDK'da hata logu uretir. Istemciye hata sonucu (<c>IsError</c>) olarak gider, model mesaji
|
||||
/// okuyup girdiyi duzeltir ve tekrar dener.
|
||||
/// </summary>
|
||||
public sealed record McpProblem(string Message)
|
||||
{
|
||||
public static McpProblem From(IStringLocalizer localizer, string name, params object[] arguments)
|
||||
=> new(localizer[name, arguments]);
|
||||
}
|
||||
|
||||
/// <summary>Aracin degeri ya da <see cref="McpProblem"/>'i; ikisi birden olmaz.</summary>
|
||||
public readonly struct McpOutcome<T>
|
||||
{
|
||||
private McpOutcome(T? value, McpProblem? problem)
|
||||
{
|
||||
Value = value;
|
||||
Problem = problem;
|
||||
}
|
||||
|
||||
public T? Value { get; }
|
||||
|
||||
public McpProblem? Problem { get; }
|
||||
|
||||
public bool TryGetValue([MaybeNullWhen(false)] out T value, [NotNullWhen(false)] out McpProblem? problem)
|
||||
{
|
||||
value = Value!;
|
||||
problem = Problem;
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
public static implicit operator McpOutcome<T>(T value) => new(value, null);
|
||||
|
||||
public static implicit operator McpOutcome<T>(McpProblem problem) => new(default, problem);
|
||||
}
|
||||
|
||||
/// <summary>Yetki reddi mesajinda yetkinin gorunen adi ve kodu; rol ekraninda aranabilsin diye kod her zaman yazilir.</summary>
|
||||
public static class McpPermissionText
|
||||
{
|
||||
public static string Describe(IStringLocalizer localizer, string permissionName)
|
||||
{
|
||||
var displayName = localizer[permissionName];
|
||||
|
||||
return displayName.ResourceNotFound || displayName.Value == permissionName
|
||||
? permissionName
|
||||
: $"{displayName.Value} ({permissionName})";
|
||||
}
|
||||
}
|
||||
|
|
@ -2,16 +2,18 @@ namespace Sozsoft.Mcp;
|
|||
|
||||
/// <summary>
|
||||
/// MCP islemlerinin talep Timesheets satirina yazilan aciklamalari. Karsiliklari platformun
|
||||
/// <c>App.Languages.LanguageText.json</c> dosyasinda EN + TR olarak durur; satir Turkce yazilir.
|
||||
/// <c>Sas.Languages.LanguageText/{CultureName}.json</c> dosyalarinda (her dilde bir satir) durur; satir Turkce yazilir.
|
||||
/// </summary>
|
||||
public static class McpTimesheetTexts
|
||||
{
|
||||
private const string Prefix = "App.Mcp.Timesheet.";
|
||||
private const string Prefix = "Sas.Mcp.Timesheet.";
|
||||
|
||||
public const string Analysis = Prefix + "Analysis";
|
||||
public const string AppendNote = Prefix + "AppendNote";
|
||||
public const string AddSubTasks = Prefix + "AddSubTasks";
|
||||
public const string CreateTable = Prefix + "CreateTable";
|
||||
public const string SaveSqlObject = Prefix + "SaveSqlObject";
|
||||
public const string GenerateCrudEndpoints = Prefix + "GenerateCrudEndpoints";
|
||||
public const string DeployWizard = Prefix + "DeployWizard";
|
||||
public const string DeleteWizard = Prefix + "DeleteWizard";
|
||||
public const string SaveComponent = Prefix + "SaveComponent";
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ public static class McpToolNames
|
|||
public const string DeleteRow = "delete_row";
|
||||
public const string GetDevGuide = "get_dev_guide";
|
||||
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 GetWizard = "get_wizard";
|
||||
public const string DeployWizard = "deploy_wizard";
|
||||
|
|
@ -55,6 +57,8 @@ public static class McpToolNames
|
|||
[
|
||||
GetDevGuide,
|
||||
CreateTable,
|
||||
SaveSqlObject,
|
||||
GenerateCrudEndpoints,
|
||||
ListWizards,
|
||||
GetWizard,
|
||||
DeployWizard,
|
||||
|
|
@ -119,6 +123,8 @@ public static class McpToolNames
|
|||
[UpdateRow] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[DeleteRow] = 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,
|
||||
[DeleteWizard] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
[StartDevRequest] = PlatformConsts.AppCodes.DeveloperKits.McpServers.Write,
|
||||
|
|
@ -137,7 +143,7 @@ public static class McpToolNames
|
|||
/// Arkadaki servisin <c>[Authorize]</c> attribute'u ile istedigi yetkiler. Attribute ile
|
||||
/// reddedilen cagri eksik yetkinin adini tasimaz; kullaniciya hangi yetkinin eksik oldugunu
|
||||
/// soyleyebilmek icin burada tutulur. Kodda adiyla istenen yetkiler (ekran C/R/U/D,
|
||||
/// <c>Wizards.Create/Update</c>, Custom Components, <c>App.DevRequests.Update</c>) reddi zaten
|
||||
/// <c>Wizards.Create/Update</c>, Custom Components, <c>Sas.DevRequests.Update</c>) reddi zaten
|
||||
/// adiyla bildirdigi icin buraya yazilmaz.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> ServicePermissions = new Dictionary<string, string[]>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Authorization.Permissions;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
public static class PermissionCheckerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Verilen yetki adlarından kullanıcıya açık olanlar; tek toplu kontrol yapılır ve sonuç
|
||||
/// adla sorgulanabilen küme olarak döner. Araç listesi, ekran süzgeci ve ret mesajı aynı kümeyi okur.
|
||||
/// </summary>
|
||||
public static async Task<HashSet<string>> GetGrantedNamesAsync(
|
||||
this IPermissionChecker permissionChecker,
|
||||
IEnumerable<string> names)
|
||||
{
|
||||
var distinct = names.Distinct(StringComparer.Ordinal).ToArray();
|
||||
if (distinct.Length == 0)
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var grants = await permissionChecker.IsGrantedAsync(distinct);
|
||||
|
||||
return grants.Result
|
||||
.Where(result => result.Value == PermissionGrantResult.Granted)
|
||||
.Select(result => result.Key)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,15 @@ using Volo.Abp.DependencyInjection;
|
|||
namespace Sozsoft.Mcp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Bilinen MCP istemcisini (<see cref="McpConsts.OAuthClientId"/>) seed eder. Geri donus
|
||||
/// adresleri <c>OpenIddict:Applications:Platform_Mcp:RedirectUris</c> ayarindadir; yeni bir
|
||||
/// saglayici eklemek bu listeye callback eklemektir.
|
||||
/// Bilinen MCP istemcilerini (<see cref="McpConsts.OAuthClientId"/>,
|
||||
/// <see cref="McpConsts.LocalOAuthClientId"/>) seed eder. Geri donus adresleri
|
||||
/// <c>OpenIddict:Applications:{ClientId}:RedirectUris</c> ayarindadir; yeni bir saglayici eklemek
|
||||
/// bu listeye callback eklemektir. Ayari olmayan istemci olusturulmaz.
|
||||
/// </summary>
|
||||
public class McpOAuthClientDataSeedContributor(
|
||||
IConfiguration configuration,
|
||||
IMcpOAuthClientManager clientManager) : IDataSeedContributor, ITransientDependency
|
||||
{
|
||||
private const string RedirectUrisKey = "OpenIddict:Applications:Platform_Mcp:RedirectUris";
|
||||
|
||||
public async Task SeedAsync(DataSeedContext context)
|
||||
{
|
||||
// OpenIddict uygulamalari host kapsamindadir; kiraci seed turlarinda tekrarlanmaz.
|
||||
|
|
@ -25,12 +24,15 @@ public class McpOAuthClientDataSeedContributor(
|
|||
return;
|
||||
}
|
||||
|
||||
var redirectUris = configuration.GetSection(RedirectUrisKey).Get<string[]>();
|
||||
if (redirectUris is null || redirectUris.Length == 0)
|
||||
foreach (var clientId in new[] { McpConsts.OAuthClientId, McpConsts.LocalOAuthClientId })
|
||||
{
|
||||
return;
|
||||
}
|
||||
var redirectUris = configuration.GetSection($"OpenIddict:Applications:{clientId}:RedirectUris").Get<string[]>();
|
||||
if (redirectUris is null || redirectUris.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await clientManager.EnsureKnownClientAsync(redirectUris);
|
||||
await clientManager.EnsureKnownClientAsync(clientId, redirectUris);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,17 +55,7 @@ public class McpListFormAccessFilter(
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(readPermissions.Values);
|
||||
|
||||
return readPermissions
|
||||
.Where(entry => granted.Contains(entry.Value))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
|
@ -6,18 +6,22 @@ using System.Text.Json;
|
|||
using System.Threading.Tasks;
|
||||
using OpenIddict.Abstractions;
|
||||
using Sozsoft.Platform;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DistributedLocking;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
using Volo.Abp.OpenIddict.Applications;
|
||||
using Volo.Abp.Uow;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Uzak MCP istemcilerinin OpenIddict uygulamalari. Iki tur istemci vardir:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Bilinen istemci</b> (<see cref="McpConsts.OAuthClientId"/>): geri donus adresleri
|
||||
/// DbMigrator ayarinda sabit olan saglayicilar (Claude, ChatGPT). Onay ekrani gostermez.</item>
|
||||
/// <item><b>Bilinen istemci</b>: geri donus adresleri DbMigrator ayarinda sabit olan istemciler —
|
||||
/// <see cref="McpConsts.OAuthClientId"/> (Claude, ChatGPT; onay ekrani gostermez) ve
|
||||
/// <see cref="McpConsts.LocalOAuthClientId"/> (yerel mcp-remote proxy'si; acik onayli).</item>
|
||||
/// <item><b>Dinamik istemci</b> (<see cref="McpConsts.DynamicClientIdPrefix"/>): baska bir MCP
|
||||
/// 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
|
||||
|
|
@ -28,10 +32,10 @@ namespace Sozsoft.Mcp.Services;
|
|||
public interface IMcpOAuthClientManager
|
||||
{
|
||||
/// <summary>Bilinen istemciyi olusturur ya da geri donus adreslerini gunceller.</summary>
|
||||
Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris);
|
||||
Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris);
|
||||
|
||||
/// <summary>
|
||||
/// Istemci kaydi. Adresler bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
|
||||
/// Istemci kaydi. Adresler bir bilinen istemcide kayitliysa onu, degilse ayni adres kumesine
|
||||
/// sahip dinamik istemciyi dondurur; yoksa yenisini uretir.
|
||||
/// </summary>
|
||||
Task<McpClientRegistration> RegisterAsync(string? clientName, IReadOnlyCollection<string> redirectUris);
|
||||
|
|
@ -40,25 +44,33 @@ public interface IMcpOAuthClientManager
|
|||
public class McpOAuthClientManager(
|
||||
IAbpApplicationManager applicationManager,
|
||||
IRepository<OpenIddictApplication, Guid> applicationRepository,
|
||||
ICurrentTenant currentTenant)
|
||||
ICurrentTenant currentTenant,
|
||||
IAbpDistributedLock distributedLock,
|
||||
IUnitOfWorkManager unitOfWorkManager)
|
||||
: DomainService, IMcpOAuthClientManager
|
||||
{
|
||||
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>
|
||||
private static readonly string[] ForbiddenSchemes = ["javascript", "data", "file", "vbscript", "about", "blob"];
|
||||
|
||||
public async Task EnsureKnownClientAsync(IReadOnlyCollection<string> redirectUris)
|
||||
public async Task EnsureKnownClientAsync(string clientId, IReadOnlyCollection<string> redirectUris)
|
||||
{
|
||||
var (displayName, consentType) = KnownClients[clientId];
|
||||
|
||||
// OpenIddict uygulamalari host kapsamindadir.
|
||||
using (currentTenant.Change(null))
|
||||
{
|
||||
var existing = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
|
||||
var descriptor = BuildDescriptor(
|
||||
McpConsts.OAuthClientId,
|
||||
"MCP Connectors",
|
||||
redirectUris,
|
||||
OpenIddictConstants.ConsentTypes.Implicit);
|
||||
var existing = await applicationManager.FindByClientIdAsync(clientId);
|
||||
var descriptor = BuildDescriptor(clientId, displayName, redirectUris, consentType);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
|
|
@ -88,42 +100,63 @@ public class McpOAuthClientManager(
|
|||
|
||||
using (currentTenant.Change(null))
|
||||
{
|
||||
var known = await applicationManager.FindByClientIdAsync(McpConsts.OAuthClientId);
|
||||
if (known is not null)
|
||||
foreach (var knownClientId in KnownClients.Keys)
|
||||
{
|
||||
var known = await applicationManager.FindByClientIdAsync(knownClientId);
|
||||
if (known is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var knownUris = await applicationManager.GetRedirectUrisAsync(known);
|
||||
if (redirectUris.All(uri => knownUris.Any(registered => SameUri(registered, uri))))
|
||||
{
|
||||
return McpClientRegistration.Accepted(McpConsts.OAuthClientId);
|
||||
return McpClientRegistration.Accepted(knownClientId);
|
||||
}
|
||||
}
|
||||
|
||||
var dynamicClients = await GetDynamicClientsAsync();
|
||||
// Masaustu istemcisi ayni anda birden cok proxy sureci baslatir ve hepsi ayni adreslerle
|
||||
// kayit olur. Kontrol ile ekleme kilitsiz ve istegin UoW'sinde kalirsa hicbiri digerinin
|
||||
// kaydini goremez, ayni adres kumesi icin birden cok uygulama acilir. Kilit altinda
|
||||
// calisip kendi UoW'sinde commit eden kayit, sonraki istegin onu bulmasini saglar.
|
||||
await using var handle = await distributedLock.TryAcquireAsync(RegistrationLockName, RegistrationLockTimeout)
|
||||
?? throw new AbpException($"Could not acquire '{RegistrationLockName}' lock.");
|
||||
|
||||
// Ayni istemci her baglantida yeniden kayit olabilir; ayni adres kumesi tek uygulamaya
|
||||
// baglanir, tablo buyumez.
|
||||
var match = dynamicClients.FirstOrDefault(client => SameUriSet(client.RedirectUris, redirectUris));
|
||||
if (match.ClientId is not null)
|
||||
{
|
||||
return McpClientRegistration.Accepted(match.ClientId);
|
||||
}
|
||||
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
|
||||
var registration = await RegisterDynamicClientAsync(clientName, redirectUris);
|
||||
await uow.CompleteAsync();
|
||||
|
||||
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);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
var query = await applicationRepository.GetQueryableAsync();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Platform;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
|
@ -43,11 +44,6 @@ public class McpSchemaDictionary(
|
|||
ICurrentTenant currentTenant)
|
||||
: DomainService, IMcpSchemaDictionary
|
||||
{
|
||||
private static readonly JsonSerializerOptions DictionaryJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly CultureInfo TurkishCulture = CultureInfo.GetCultureInfo("tr-TR");
|
||||
|
||||
public string ResolveTenantName()
|
||||
|
|
@ -81,7 +77,7 @@ public class McpSchemaDictionary(
|
|||
return [];
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, DictionaryJsonOptions);
|
||||
var document = JsonSerializer.Deserialize<DictionaryDocument>(json, PlatformJsonOptions.CaseInsensitive);
|
||||
|
||||
return document?.ListForms ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
|
@ -16,14 +16,11 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// </summary>
|
||||
public interface IMcpSqlGuard
|
||||
{
|
||||
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar.</summary>
|
||||
void EnsureReadOnlyStatement(string sql);
|
||||
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur.</summary>
|
||||
McpProblem? CheckReadOnlyStatement(string sql);
|
||||
|
||||
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
|
||||
string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
|
||||
|
||||
/// <summary>Saglayicinin varsayilan semasi.</summary>
|
||||
string DefaultSchemaName(DataSourceTypeEnum dataSourceType);
|
||||
McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
|
||||
|
||||
/// <summary>
|
||||
/// Satir tavanini sorgunun kendisine yazar: SQL Server'da <c>TOP (n)</c>, PostgreSQL'de
|
||||
|
|
@ -35,16 +32,11 @@ public interface IMcpSqlGuard
|
|||
/// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in
|
||||
/// bekledigi bicime cevirir.
|
||||
/// </summary>
|
||||
Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
|
||||
McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
|
||||
}
|
||||
|
||||
public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard
|
||||
{
|
||||
/// <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(
|
||||
@"^@?[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
|
@ -78,18 +70,18 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
@"\bLIMIT\s+\d+",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public void EnsureReadOnlyStatement(string sql)
|
||||
public McpProblem? CheckReadOnlyStatement(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.EmptyQuery]);
|
||||
return McpProblem.From(localizer, McpErrorCodes.EmptyQuery);
|
||||
}
|
||||
|
||||
var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''");
|
||||
|
||||
if (stripped.Contains(';', StringComparison.Ordinal))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]);
|
||||
return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly);
|
||||
}
|
||||
|
||||
var trimmed = stripped.TrimStart();
|
||||
|
|
@ -98,30 +90,29 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
|
||||
if (!startsWithRead)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]);
|
||||
return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired);
|
||||
}
|
||||
|
||||
var forbidden = ForbiddenStatementRegex.Match(stripped);
|
||||
if (forbidden.Success)
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]);
|
||||
}
|
||||
|
||||
return forbidden.Success
|
||||
? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
|
||||
: null;
|
||||
}
|
||||
|
||||
public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
|
||||
public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
|
||||
{
|
||||
EnsureSafeIdentifier(schemaName);
|
||||
EnsureSafeIdentifier(objectName);
|
||||
foreach (var name in (string?[])[schemaName, objectName])
|
||||
{
|
||||
if (!SqlIdentifier.IsSafe(name))
|
||||
{
|
||||
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return dataSourceType == DataSourceTypeEnum.Postgresql
|
||||
? $"\"{schemaName}\".\"{objectName}\""
|
||||
: $"[{schemaName}].[{objectName}]";
|
||||
return SqlIdentifier.QuoteQualified(schemaName, objectName, dataSourceType);
|
||||
}
|
||||
|
||||
public string DefaultSchemaName(DataSourceTypeEnum dataSourceType)
|
||||
=> dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo";
|
||||
|
||||
public string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType)
|
||||
{
|
||||
if (dataSourceType == DataSourceTypeEnum.Postgresql)
|
||||
|
|
@ -144,7 +135,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
: sql;
|
||||
}
|
||||
|
||||
public Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
|
||||
public McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
|
||||
{
|
||||
var normalized = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
if (parameters is null)
|
||||
|
|
@ -156,7 +147,7 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
{
|
||||
if (key is null || !SafeParameterNameRegex.IsMatch(key))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]);
|
||||
return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty);
|
||||
}
|
||||
|
||||
normalized[key.TrimStart('@')] = value!;
|
||||
|
|
@ -164,12 +155,4 @@ public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainS
|
|||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void EnsureSafeIdentifier(string identifier)
|
||||
{
|
||||
if (!SafeIdentifierRegex.IsMatch(identifier ?? string.Empty))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier ?? string.Empty]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp.Domain.Services;
|
||||
|
||||
namespace Sozsoft.Mcp.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ekranin veri kaynagi olan view, procedure ve fonksiyon scriptlerini SQL Query Manager'in yazdigi
|
||||
/// yere (<c>sql/object/{Ad}.sql</c>) ve PostgreSQL esine (<c>postgres/object/{Ad}.sql</c>) yazar.
|
||||
/// Govde diyalektler arasinda guvenle cevrilemedigi icin (<see cref="PostgreSqlTableScript"/>) iki
|
||||
/// script de istemciden gelir; biri olmadan yazilmaz.
|
||||
/// <para>
|
||||
/// Veritabaninda calistirilmaz: nesneyi, tablo gibi DB Migrate kurar (<c>SqlDataSeeder</c>). Nesnenin
|
||||
/// adi SQL Server scriptinin basligindan okunur; basliklar tekrar calismaya dayanikli bicime cevrilir
|
||||
/// (<c>CREATE OR ALTER</c> / <c>CREATE OR REPLACE</c>), ayni adla tekrar yazmak scripti gunceller.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IMcpSqlObjectWriter
|
||||
{
|
||||
McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript);
|
||||
}
|
||||
|
||||
public partial class McpSqlObjectWriter(
|
||||
IConfiguration configuration,
|
||||
IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlObjectWriter
|
||||
{
|
||||
private const string SqlServerProvider = "SQL Server";
|
||||
private const string PostgreSqlProvider = "PostgreSQL";
|
||||
|
||||
public McpOutcome<McpSavedSqlObject> Save(string sqlServerScript, string postgreSqlScript)
|
||||
{
|
||||
var sqlServer = Canonicalize(sqlServerScript, SqlServerHeaderRegex(), "CREATE OR ALTER");
|
||||
if (sqlServer is null)
|
||||
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, SqlServerProvider);
|
||||
|
||||
var postgreSql = Canonicalize(postgreSqlScript, PostgreSqlHeaderRegex(), "CREATE OR REPLACE");
|
||||
if (postgreSql is null || !string.Equals(sqlServer.Name, postgreSql.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return McpProblem.From(localizer, McpErrorCodes.SqlObjectInvalid, PostgreSqlProvider);
|
||||
|
||||
// Tablo scriptinin yerine view yazilirsa migrate tabloyu bir daha kuramazdi.
|
||||
if (PostgreSqlTableScript.ReadSeedColumns(configuration, sqlServer.Name).Count > 0)
|
||||
return McpProblem.From(localizer, McpErrorCodes.TableAlreadyExists, sqlServer.Name);
|
||||
|
||||
var fileName = $"{sqlServer.Name}.sql";
|
||||
var sqlServerPath = Write(SeedPathResolver.SqlFolder, fileName, sqlServer.Script);
|
||||
var postgreSqlPath = Write(SeedPathResolver.PostgresFolder, fileName, postgreSql.Script);
|
||||
|
||||
return new McpSavedSqlObject(sqlServer.Name, sqlServer.Kind, sqlServerPath, postgreSqlPath);
|
||||
}
|
||||
|
||||
/// <summary>Scripti saglayicinin <c>object</c> klasorune yazar; seed kokune goreli yolu dondurur.</summary>
|
||||
private string Write(string providerFolder, string fileName, string script)
|
||||
{
|
||||
var folder = SeedPathResolver.GetPath(configuration, providerFolder, SeedPathResolver.ObjectFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, fileName), script);
|
||||
|
||||
return $"{providerFolder}/{SeedPathResolver.ObjectFolder}/{fileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Script tek bir nesne tanimlamalidir; basligi tekrar calismaya dayanikli bicime cevrilir.
|
||||
/// Yorumlar yalnizca aramada yok sayilir, dosyada kalir. Tek nesne tanimlamayan script icin null.
|
||||
/// </summary>
|
||||
private static CanonicalScript? Canonicalize(string script, Regex headerRegex, string createClause)
|
||||
{
|
||||
var headers = string.IsNullOrWhiteSpace(script) ? null : headerRegex.Matches(CommentRegex().Replace(script, " "));
|
||||
if (headers is not { Count: 1 })
|
||||
return null;
|
||||
|
||||
var name = headers[0].Groups["name"].Value;
|
||||
var kind = headers[0].Groups["kind"].Value.ToUpperInvariant() switch
|
||||
{
|
||||
"PROC" => "PROCEDURE",
|
||||
var value => value
|
||||
};
|
||||
|
||||
// Yorumdaki ornek baslik degismesin diye yalnizca nesnenin adini tasiyan ilk baslik cevrilir.
|
||||
var replaced = false;
|
||||
var canonical = headerRegex.Replace(script.Trim(), m =>
|
||||
{
|
||||
if (replaced || !string.Equals(m.Groups["name"].Value, name, StringComparison.OrdinalIgnoreCase))
|
||||
return m.Value;
|
||||
|
||||
replaced = true;
|
||||
return $"{createClause} {kind} {m.Groups["qualified"].Value}";
|
||||
});
|
||||
|
||||
return new CanonicalScript(canonical + Environment.NewLine, name, kind);
|
||||
}
|
||||
|
||||
private sealed record CanonicalScript(string Script, string Name, string Kind);
|
||||
|
||||
[GeneratedRegex(@"/\*.*?\*/|--[^\r\n]*", RegexOptions.Singleline)]
|
||||
private static partial Regex CommentRegex();
|
||||
|
||||
[GeneratedRegex(@"\b(?:CREATE\s+OR\s+ALTER|CREATE|ALTER)\s+(?<kind>VIEW|PROCEDURE|PROC|FUNCTION)\s+(?<qualified>(?:\[?dbo\]?\s*\.\s*)?\[?(?<name>[A-Za-z_][A-Za-z0-9_]*)\]?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SqlServerHeaderRegex();
|
||||
|
||||
[GeneratedRegex(@"\bCREATE\s+(?:OR\s+REPLACE\s+)?(?<kind>VIEW|PROCEDURE|FUNCTION)\s+(?<qualified>(?:""?public""?\s*\.\s*)?""?(?<name>[A-Za-z_][A-Za-z0-9_]*)""?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PostgreSqlHeaderRegex();
|
||||
}
|
||||
|
||||
/// <param name="Kind"><c>VIEW</c>, <c>PROCEDURE</c> ya da <c>FUNCTION</c>.</param>
|
||||
/// <param name="SqlServerPath">Seed kokune gore goreli yol.</param>
|
||||
/// <param name="PostgreSqlPath">Seed kokune gore goreli yol.</param>
|
||||
public sealed record McpSavedSqlObject(string Name, string Kind, string SqlServerPath, string PostgreSqlPath);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
|
@ -6,6 +6,7 @@ using System.Text.RegularExpressions;
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Sozsoft.Platform.Data;
|
||||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Sozsoft.Platform.Localization;
|
||||
|
|
@ -17,9 +18,13 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// <summary>
|
||||
/// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script
|
||||
/// tasarimcinin <c>generateCreateTableSql</c> ciktisiyla ayni bicimdedir ve tasarimcinin deploy
|
||||
/// sonrasi yazdigi yere (<c>{kapsam}/sql/object/{Tablo}.sql</c>) yazilir; boylece tablo
|
||||
/// sonrasi yazdigi yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo
|
||||
/// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir.
|
||||
/// <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
|
||||
/// 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.
|
||||
|
|
@ -27,7 +32,7 @@ namespace Sozsoft.Mcp.Services;
|
|||
/// </summary>
|
||||
public interface IMcpTableCreator
|
||||
{
|
||||
Task<McpCreatedTable> CreateAsync(McpTableDefinition definition);
|
||||
Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition);
|
||||
}
|
||||
|
||||
public class McpTableCreator(
|
||||
|
|
@ -39,14 +44,6 @@ public class McpTableCreator(
|
|||
private const int DefaultTextLength = 100;
|
||||
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>
|
||||
/// 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.
|
||||
|
|
@ -58,6 +55,10 @@ public class McpTableCreator(
|
|||
/// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary>
|
||||
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(
|
||||
@"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
|
@ -70,6 +71,16 @@ public class McpTableCreator(
|
|||
];
|
||||
|
||||
// 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 TenantColumn = new("TenantId", "uniqueidentifier", null, true, null);
|
||||
|
|
@ -85,59 +96,87 @@ public class McpTableCreator(
|
|||
new("DeleterId", "uniqueidentifier", null, true, null)
|
||||
];
|
||||
|
||||
public async Task<McpCreatedTable> CreateAsync(McpTableDefinition definition)
|
||||
public async Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition)
|
||||
{
|
||||
Check.NotNull(definition, nameof(definition));
|
||||
|
||||
var tableName = (definition.TableName ?? string.Empty).Trim();
|
||||
EnsureSafeIdentifier(tableName);
|
||||
if (CheckIdentifier(tableName) is { } invalidName)
|
||||
{
|
||||
return invalidName;
|
||||
}
|
||||
|
||||
var columns = BuildColumns(definition);
|
||||
if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) ||
|
||||
!BuildIndexes(tableName, definition.Indexes, columns).TryGetValue(out var indexes, out problem) ||
|
||||
!BuildForeignKeys(definition.ForeignKeys, columns).TryGetValue(out var foreignKeys, out problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var context = await queryExecutor.ResolveDataSourceAsync();
|
||||
if (context.DataSourceType != DataSourceTypeEnum.Mssql)
|
||||
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
|
||||
|
||||
async Task<bool> TableExistsAsync(string name)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableProviderNotSupported, context.DataSourceType.ToString()]);
|
||||
var existing = await context.Repository.ExecuteScalarAsync<object?>(
|
||||
isPostgreSql ? "SELECT to_regclass(@name)::text" : "SELECT OBJECT_ID(@name, 'U')",
|
||||
context.ConnectionString,
|
||||
new Dictionary<string, object> { ["name"] = isPostgreSql ? $"public.\"{name}\"" : $"{SchemaName}.{name}" });
|
||||
|
||||
return existing is not null and not DBNull;
|
||||
}
|
||||
|
||||
var existing = await context.Repository.ExecuteScalarAsync<int?>(
|
||||
"SELECT OBJECT_ID(@name, 'U')",
|
||||
context.ConnectionString,
|
||||
new Dictionary<string, object> { ["name"] = $"{SchemaName}.{tableName}" });
|
||||
|
||||
if (existing is not null)
|
||||
// Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun
|
||||
// scriptinin ustune yazmaz.
|
||||
if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]);
|
||||
return Problem(McpErrorCodes.TableAlreadyExists, tableName);
|
||||
}
|
||||
|
||||
var script = BuildScript(tableName, columns);
|
||||
foreach (var foreignKey in foreignKeys)
|
||||
{
|
||||
if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled)
|
||||
{
|
||||
return notInstalled;
|
||||
}
|
||||
}
|
||||
|
||||
// GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir.
|
||||
await context.Repository.ExecuteAsync(
|
||||
string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")),
|
||||
context.ConnectionString);
|
||||
// Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir.
|
||||
var script = BuildScript(tableName, columns, indexes, foreignKeys);
|
||||
if (!PostgreSqlTableScript.TryConvert(script, out _))
|
||||
{
|
||||
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
|
||||
}
|
||||
|
||||
var seedFilePath = SaveSeedScript(tableName, script);
|
||||
// Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin
|
||||
// calistirdigi DB Migrate'tedir (SqlDataSeeder).
|
||||
var (seedFilePath, _) = PostgreSqlTableScript.WriteSeedFiles(configuration, tableName, script);
|
||||
|
||||
return new McpCreatedTable(tableName, script, seedFilePath);
|
||||
}
|
||||
|
||||
private List<McpTableColumn> BuildColumns(McpTableDefinition definition)
|
||||
private McpOutcome<List<McpTableColumn>> BuildColumns(McpTableDefinition definition)
|
||||
{
|
||||
if (definition.Columns.Count == 0)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnsRequired]);
|
||||
return Problem(McpErrorCodes.TableColumnsRequired);
|
||||
}
|
||||
|
||||
var userColumns = definition.Columns.Select(NormalizeColumn).ToList();
|
||||
|
||||
var columns = new List<McpTableColumn> { IdColumn };
|
||||
if (definition.MultiTenant)
|
||||
{
|
||||
columns.Add(TenantColumn);
|
||||
}
|
||||
|
||||
columns.AddRange(userColumns);
|
||||
foreach (var column in definition.Columns)
|
||||
{
|
||||
if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
columns.Add(normalized);
|
||||
}
|
||||
|
||||
if (definition.FullAudited)
|
||||
{
|
||||
|
|
@ -148,38 +187,121 @@ public class McpTableCreator(
|
|||
.GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
|
||||
if (duplicate is not null)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]);
|
||||
}
|
||||
|
||||
return columns;
|
||||
return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key);
|
||||
}
|
||||
|
||||
private McpTableColumn NormalizeColumn(McpTableColumn column)
|
||||
/// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
|
||||
private McpOutcome<List<McpTableIndex>> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
|
||||
{
|
||||
var result = new List<McpTableIndex>();
|
||||
foreach (var index in indexes)
|
||||
{
|
||||
var indexColumns = new List<string>();
|
||||
foreach (var column in index.Columns)
|
||||
{
|
||||
if (!ResolveColumn(column, columns).TryGetValue(out var resolved, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
if (!indexColumns.Contains(resolved))
|
||||
indexColumns.Add(resolved);
|
||||
}
|
||||
|
||||
var name = $"{(index.Unique ? "UQ" : "IX")}_{tableName}_{string.Join("_", indexColumns)}";
|
||||
|
||||
// Kolonsuz ya da ayni kolonlarla tekrar eden index uretilecek bir sey tasimaz.
|
||||
if (indexColumns.Count > 0 && !result.Exists(existing => string.Equals(existing.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||
result.Add(new McpTableIndex(indexColumns, index.Unique, name));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpOutcome<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
|
||||
{
|
||||
var result = new List<McpTableForeignKey>();
|
||||
foreach (var foreignKey in foreignKeys)
|
||||
{
|
||||
if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim();
|
||||
var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim();
|
||||
if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
// Tanimadigi davranis (Restrict gibi) SQL Server'in varsayilanina, NO ACTION'a duser.
|
||||
var action = ReferentialActions.Keys.FirstOrDefault(key => string.Equals(key, foreignKey.OnDelete?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
?? DefaultReferentialAction;
|
||||
|
||||
// Bir kolonun tek FK'si olur; ayni kolon icin tekrar eden tanimin ilki gecerlidir.
|
||||
if (!result.Exists(existing => string.Equals(existing.Column, column, StringComparison.OrdinalIgnoreCase)))
|
||||
result.Add(new McpTableForeignKey(column, referencedTable, referencedColumn, action));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed scriptleri DB Migrate'te dosya adi sirasiyla calisir (<c>SqlDataSeeder</c>). FK'nin
|
||||
/// gosterdigi tablo bir seed scriptiyle kuruluyorsa o dosya bu tablonunkinden once gelmelidir;
|
||||
/// scripti yoksa (EF ile kurulan platform tablosu) veritabaninda bulunmalidir. Aksi halde
|
||||
/// sifirdan kurulumda migrate FK satirinda duserdi.
|
||||
/// </summary>
|
||||
private async Task<McpProblem?> CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func<string, Task<bool>> tableExistsAsync)
|
||||
{
|
||||
var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0;
|
||||
var installed = hasScript
|
||||
? Comparer<string>.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0
|
||||
: await tableExistsAsync(foreignKey.ReferencedTable);
|
||||
|
||||
return installed
|
||||
? null
|
||||
: Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName);
|
||||
}
|
||||
|
||||
private McpOutcome<string> ResolveColumn(string? name, List<McpTableColumn> columns)
|
||||
{
|
||||
var match = columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return match is null ? Problem(McpErrorCodes.TableColumnNotFound, name ?? string.Empty) : match.Name;
|
||||
}
|
||||
|
||||
private McpOutcome<McpTableColumn> NormalizeColumn(McpTableColumn column)
|
||||
{
|
||||
var name = (column.Name ?? string.Empty).Trim();
|
||||
EnsureSafeIdentifier(name);
|
||||
if (CheckIdentifier(name) is { } invalidName)
|
||||
{
|
||||
return invalidName;
|
||||
}
|
||||
|
||||
var (type, sizedLength) = ResolveType(column.Type);
|
||||
if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem))
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
int? maxLength = null;
|
||||
if (type == "nvarchar")
|
||||
if (resolved.Type == "nvarchar")
|
||||
{
|
||||
maxLength = column.MaxLength ?? sizedLength ?? DefaultTextLength;
|
||||
maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength;
|
||||
if (maxLength is < 1 or > MaxTextLength)
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidColumnLength, name, MaxTextLength]);
|
||||
return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength);
|
||||
}
|
||||
}
|
||||
|
||||
var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim();
|
||||
if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue))
|
||||
{
|
||||
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]);
|
||||
return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue);
|
||||
}
|
||||
|
||||
return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue);
|
||||
return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -187,7 +309,7 @@ public class McpTableCreator(
|
|||
/// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir —
|
||||
/// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi.
|
||||
/// </summary>
|
||||
private (string Type, int? MaxLength) ResolveType(string? rawType)
|
||||
private McpOutcome<ResolvedType> ResolveType(string? rawType)
|
||||
{
|
||||
var trimmed = rawType?.Trim() ?? string.Empty;
|
||||
var name = trimmed;
|
||||
|
|
@ -217,15 +339,16 @@ public class McpTableCreator(
|
|||
}
|
||||
}
|
||||
|
||||
var type = SupportedTypes.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new UserFriendlyException(
|
||||
localizer[McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)]);
|
||||
var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return (type, maxLength);
|
||||
return type is null
|
||||
? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes))
|
||||
: new ResolvedType(type, maxLength);
|
||||
}
|
||||
|
||||
private static string BuildScript(string tableName, List<McpTableColumn> columns)
|
||||
private sealed record ResolvedType(string Type, int? MaxLength);
|
||||
|
||||
private static string BuildScript(string tableName, List<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
|
||||
{
|
||||
var fullTableName = $"[{SchemaName}].[{tableName}]";
|
||||
|
||||
|
|
@ -246,6 +369,42 @@ public class McpTableCreator(
|
|||
lines.Add("END");
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -264,42 +423,30 @@ public class McpTableCreator(
|
|||
return $" [{column.Name}] {type} {nullPart}{defaultPart}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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? CheckIdentifier(string identifier)
|
||||
=> SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier);
|
||||
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
private McpProblem Problem(string name, params object[] arguments)
|
||||
=> McpProblem.From(localizer, name, arguments);
|
||||
}
|
||||
|
||||
/// <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="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(
|
||||
string TableName,
|
||||
IReadOnlyList<McpTableColumn> Columns,
|
||||
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>
|
||||
public sealed record McpTableColumn(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Tools;
|
||||
using Volo.Abp.AspNetCore.Mvc;
|
||||
using Volo.Abp.Modularity;
|
||||
|
|
@ -72,23 +75,27 @@ public class McpHttpApiModule : AbpModule
|
|||
}
|
||||
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
|
||||
{
|
||||
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null);
|
||||
throw new McpException(await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, null), ex);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// SDK arac hatasinin mesajini yalnizca McpException ise iletir; yetki reddi ve is
|
||||
// kurali hatalari burada cevrilir ki istemci nedenini (eksik yetkiyi) gorsun.
|
||||
// MCP'nin kendi reddi istisna degildir (McpProblem). Arkadaki platform servisinin yetki
|
||||
// reddi ve is kurali hatasi ise istisnadir; firlatilirsa SDK onu yigin izli ERR olarak
|
||||
// loglar. Hata sonucu (IsError) olarak donulur; model nedeni okuyup devam eder.
|
||||
filters.AddCallToolFilter(next => async (requestContext, cancellationToken) =>
|
||||
{
|
||||
McpToolArgumentNormalizer.Normalize(requestContext.Params, requestContext.MatchedPrimitive as McpServerTool);
|
||||
|
||||
try
|
||||
{
|
||||
return await next(requestContext, cancellationToken);
|
||||
}
|
||||
catch (System.Exception ex) when (McpToolErrorTranslator.CanTranslate(ex))
|
||||
{
|
||||
throw await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
|
||||
var message = await requestContext.Services!.GetRequiredService<McpToolErrorTranslator>().TranslateAsync(ex, requestContext.Params?.Name);
|
||||
return McpToolResult.Error(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. " +
|
||||
"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, " +
|
||||
"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 (App.DevRequests) follow get_dev_guide section 0.11: claim each one with " +
|
||||
"write the analysis with save_dev_request_analysis, produce nothing and never touch Description; pass productionReady true only when the analysis is ready for production without conditions (the request moves to Ready to Start), otherwise it stays in Analysis. " +
|
||||
"Development requests (Sas.DevRequests) follow get_dev_guide section 0.11: claim each one with " +
|
||||
"start_dev_request, which only accepts requests in Ready to Start; images pasted into the request arrive as image content and are part of the request (layout and demo rows). " +
|
||||
"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; " +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Model metin parametresine tirnaksiz deger (<c>section: 4.2</c>) ya da JSON metni yerine dogrudan
|
||||
/// dizi/nesne (<c>filter: ["Status","=","Open"]</c>) gonderebilir. SDK her parametreyi ayri ayri
|
||||
/// <c>string</c> olarak bagladigi icin bu deger <see cref="JsonException"/> ("Cannot get the value of a
|
||||
/// token type 'Number' / 'StartArray' as a string") ile duser. Deger cagri araca ulasmadan, aracin kendi
|
||||
/// semasina bakilarak ham JSON metnine cevrilir — JSON bekleyen parametre ayni metni alir; string icin
|
||||
/// ozel converter kullanilmaz, cunku arac semasindaki <c>"type": "string"</c> bilgisini siler.
|
||||
/// </summary>
|
||||
internal static class McpToolArgumentNormalizer
|
||||
{
|
||||
public static void Normalize(CallToolRequestParams? parameters, McpServerTool? tool)
|
||||
{
|
||||
if (parameters?.Arguments is not { Count: > 0 } arguments
|
||||
|| tool is null
|
||||
|| !tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties)
|
||||
|| properties.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, JsonElement>? normalized = null;
|
||||
|
||||
foreach (var (name, value) in arguments)
|
||||
{
|
||||
if (value.ValueKind is JsonValueKind.String or JsonValueKind.Null or JsonValueKind.Undefined
|
||||
|| !properties.TryGetProperty(name, out var schema)
|
||||
|| !IsStringOnly(schema))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized ??= new Dictionary<string, JsonElement>(arguments);
|
||||
normalized[name] = JsonSerializer.SerializeToElement(value.GetRawText());
|
||||
}
|
||||
|
||||
if (normalized is not null)
|
||||
{
|
||||
parameters.Arguments = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Nullable metin parametresi semada ["string", "null"] olarak gelir; baska tip de kabul eden bir
|
||||
// birlesim tipi donusturulmez, degeri SDK oldugu gibi baglar.
|
||||
private static bool IsStringOnly(JsonElement schema)
|
||||
{
|
||||
if (!schema.TryGetProperty("type", out var type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return type.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => type.GetString() == "string",
|
||||
JsonValueKind.Array => type.EnumerateArray()
|
||||
.Select(item => item.GetString())
|
||||
.Where(item => item != "null")
|
||||
.SequenceEqual(["string"]),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using ModelContextProtocol;
|
||||
using Sozsoft.Platform.Localization;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Authorization;
|
||||
|
|
@ -13,11 +12,12 @@ using Volo.Abp.DependencyInjection;
|
|||
namespace Sozsoft.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Arac hatalarini istemcinin gorebilecegi bicime cevirir. SDK yalnizca <see cref="McpException"/>
|
||||
/// mesajini iletir; diger her istisna "An error occurred invoking" olarak gider ve model neden
|
||||
/// reddedildigini bilemez. Yetki reddinde eksik yetkiler adiyla, is kurali hatasinda
|
||||
/// (<see cref="UserFriendlyException"/>) zaten yerellestirilmis mesaj iletilir. Diger istisnalar
|
||||
/// cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
|
||||
/// Aracin arkasindaki platform servislerinden (wizard, custom component, ListForm verisi) ve sinif
|
||||
/// duzeyindeki <c>[Authorize]</c>'dan gelen istisnalari istemcinin gorebilecegi mesaja cevirir. MCP'nin
|
||||
/// kendi reddi istisna degildir, <see cref="McpProblem"/> olarak doner. SDK cevrilmemis istisnayi
|
||||
/// "An error occurred invoking" olarak iletir ve model neden reddedildigini bilemez. Yetki reddinde
|
||||
/// eksik yetkiler adiyla, is kurali hatasinda (<see cref="UserFriendlyException"/>) zaten
|
||||
/// yerellestirilmis mesaj iletilir. Diger istisnalar cevrilmez: ic ayrinti (SQL, yigin) istemciye sizmaz.
|
||||
/// </summary>
|
||||
public class McpToolErrorTranslator(
|
||||
IPermissionChecker permissionChecker,
|
||||
|
|
@ -26,10 +26,10 @@ public class McpToolErrorTranslator(
|
|||
public static bool CanTranslate(Exception exception)
|
||||
=> exception is AbpAuthorizationException or UserFriendlyException;
|
||||
|
||||
public async Task<McpException> TranslateAsync(Exception exception, string? toolName)
|
||||
public async Task<string> TranslateAsync(Exception exception, string? toolName)
|
||||
=> exception is AbpAuthorizationException authorization
|
||||
? new McpException(await DescribeMissingPermissionsAsync(authorization, toolName), exception)
|
||||
: new McpException(exception.Message, exception);
|
||||
? await DescribeMissingPermissionsAsync(authorization, toolName)
|
||||
: exception.Message;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CheckAsync("...")</c> reddi yetki adini tasir; <c>[Authorize]</c> attribute'u tasimaz. Ikinci
|
||||
|
|
@ -43,10 +43,11 @@ public class McpToolErrorTranslator(
|
|||
candidates.Add(policyName);
|
||||
}
|
||||
|
||||
var grants = await permissionChecker.IsGrantedAsync([.. candidates.Distinct(StringComparer.Ordinal)]);
|
||||
var missing = grants.Result
|
||||
.Where(result => result.Value != PermissionGrantResult.Granted)
|
||||
.Select(result => Describe(result.Key))
|
||||
var granted = await permissionChecker.GetGrantedNamesAsync(candidates);
|
||||
var missing = candidates
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(permission => !granted.Contains(permission))
|
||||
.Select(permission => McpPermissionText.Describe(localizer, permission))
|
||||
.ToList();
|
||||
|
||||
var target = toolName ?? McpConsts.EndpointPath;
|
||||
|
|
@ -55,14 +56,4 @@ public class McpToolErrorTranslator(
|
|||
? localizer[McpErrorCodes.PermissionDeniedUnspecified, target]
|
||||
: 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})";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
namespace Sozsoft.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Arac sonucundaki gorselleri istemcinin boyut sinirina sigdirir. MCP istemcileri tek bir arac
|
||||
/// sonucunu sinirlar (Claude Desktop 1 MB); talep aciklamasina yapistirilan ekran goruntuleri
|
||||
/// tek basina bu siniri asabilir. Gorsel isleme kutuphanesi barindiran uygulamadadir; modul
|
||||
/// grafik paketine baglanmaz.
|
||||
/// </summary>
|
||||
public interface IMcpImageShrinker
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64 uzunlugu <paramref name="maxLength"/>'i asmayan gorsel dondurur: sigiyorsa oldugu gibi,
|
||||
/// sigmiyorsa kucultulmus JPEG. Okunabilir boyutta sigdirilamazsa ya da cozulemezse null.
|
||||
/// </summary>
|
||||
McpShrunkImage? Shrink(string mimeType, string base64, int maxLength);
|
||||
}
|
||||
|
||||
public sealed record McpShrunkImage(string MimeType, string Data);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -16,43 +17,43 @@ public sealed class McpDataTools(IMcpDataAppService dataAppService)
|
|||
{
|
||||
[McpServerTool(Name = McpToolNames.InsertRow, Destructive = false, Idempotent = false)]
|
||||
[Description("Bir listeye yeni kayit ekler. Alan adlari describe_list cikisindaki field degerleridir.")]
|
||||
public async Task<McpWriteResultDto> InsertRowAsync(
|
||||
public async Task<CallToolResult> InsertRowAsync(
|
||||
[Description("Kaydin eklenecegi listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Alan adi -> deger eslemesi.")]
|
||||
Dictionary<string, object?> values)
|
||||
=> await dataAppService.InsertRowAsync(new McpRowRequestDto
|
||||
=> McpToolResult.From(await dataAppService.InsertRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Values = values ?? []
|
||||
});
|
||||
}));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.UpdateRow, Destructive = true, Idempotent = true)]
|
||||
[Description("Bir listedeki kaydi anahtar degerine gore gunceller. Yalnizca verilen alanlar yazilir.")]
|
||||
public async Task<McpWriteResultDto> UpdateRowAsync(
|
||||
public async Task<CallToolResult> UpdateRowAsync(
|
||||
[Description("Kaydin bulundugu listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Guncellenecek kaydin anahtar alan degeri; describe_list cikisindaki keyFieldName alanina karsilik gelir.")]
|
||||
List<object?> keys,
|
||||
[Description("Alan adi -> yeni deger eslemesi.")]
|
||||
Dictionary<string, object?> values)
|
||||
=> await dataAppService.UpdateRowAsync(new McpRowRequestDto
|
||||
=> McpToolResult.From(await dataAppService.UpdateRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Keys = keys ?? [],
|
||||
Values = values ?? []
|
||||
});
|
||||
}));
|
||||
|
||||
[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.")]
|
||||
public async Task<McpWriteResultDto> DeleteRowAsync(
|
||||
public async Task<CallToolResult> DeleteRowAsync(
|
||||
[Description("Kaydin bulundugu listenin kodu.")]
|
||||
string listFormCode,
|
||||
[Description("Silinecek kaydin anahtar alan degeri.")]
|
||||
List<object?> keys)
|
||||
=> await dataAppService.DeleteRowAsync(new McpRowRequestDto
|
||||
=> McpToolResult.From(await dataAppService.DeleteRowAsync(new McpRowRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Keys = keys ?? []
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,46 +13,46 @@ namespace Sozsoft.Mcp.Tools;
|
|||
public static class McpDevelopPrompts
|
||||
{
|
||||
[McpServerPrompt(Name = McpPromptNames.AnalyzeDevRequest)]
|
||||
[Description("Tek bir gelistirme talebini analiz eder ve Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")]
|
||||
[Description("Tek bir gelistirme talebini analiz eder; uretime hazirsa Ready to Start'a, degilse Analysis durumuna alir. Talep Backlog ya da Analysis durumunda olmalidir.")]
|
||||
public static string AnalyzeDevRequest(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> $"App.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " +
|
||||
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini analiz et; yalnizca bu talebi. " +
|
||||
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
|
||||
"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. " +
|
||||
"Arac reddederse (talep Backlog ya da Analysis degil, bulunamadi ya da birden fazla eslesme var) nedenini bildir ve dur. " +
|
||||
"Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar ve Uretime hazir satiri; talebi Ready to Start'a kullanicinin alacagini belirt.";
|
||||
"Sonunda kisa rapor ver: menu yeri, uretilecek artefaktlar, (onay) etiketli kararlar, Uretime hazir satiri ve talebin yeni durumu (Ready to Start ya da Analysis).";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.AnalyzeBacklogRequests)]
|
||||
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder ve Analysis durumuna alir.")]
|
||||
[Description("Backlog durumundaki tum gelistirme taleplerini SortOrder sirasiyla analiz eder; uretime hazir olanlari Ready to Start'a, digerlerini Analysis durumuna alir.")]
|
||||
public static string AnalyzeBacklogRequests()
|
||||
=> "App.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " +
|
||||
=> "Sas.DevRequests kuyrugundaki Backlog taleplerini query_list ile SortOrder sirasiyla oku ve tek tek analiz et. " +
|
||||
"Once get_dev_guide ile lowcode 0.12 bolumunu oku ve oradaki standarda uy. " +
|
||||
"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. " +
|
||||
"Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi ve Uretime hazir durumu; talepleri Ready to Start'a kullanicinin alacagini tek satirda belirt.";
|
||||
"Sonunda parti raporu ver: talep basina menu yeri, (onay) etiketli karar sayisi, Uretime hazir durumu ve talebin yeni durumu (Ready to Start ya da Analysis).";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.ProcessDevRequest)]
|
||||
[Description("Tek bir gelistirme talebini isler. Talep Ready to Start durumunda olmalidir.")]
|
||||
public static string ProcessDevRequest(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> $"App.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " +
|
||||
=> $"Sas.DevRequests kuyrugundaki '{request}' talebini isle; yalnizca bu talebi. " +
|
||||
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
|
||||
"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. " +
|
||||
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
|
||||
"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. " +
|
||||
"Testing'e almadan once uretilen ekranin modul dokumani bolumunu get_module_doc ile ekran koduyla oku, todo alt bolumlerini save_module_doc ile ayni kodla doldur (lowcode 0.13); sonra talebi Testing'e alip bitir. " +
|
||||
"Sonunda rapor ver: uretilen artefaktlar, talebin son durumu ve DB Migrate gerektigi.";
|
||||
|
||||
[McpServerPrompt(Name = McpPromptNames.ProcessReadyRequests)]
|
||||
[Description("Ready to Start durumundaki tum gelistirme taleplerini SortOrder sirasiyla isler.")]
|
||||
public static string ProcessReadyRequests()
|
||||
=> "App.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " +
|
||||
=> "Sas.DevRequests kuyrugundaki Ready to Start taleplerini query_list ile SortOrder sirasiyla oku ve tek tek isle. " +
|
||||
"Once get_dev_guide ile lowcode 0.11 bolumunu oku ve oradaki adimlari uygula. " +
|
||||
"Her talebi start_dev_request ile ustlen; reddedilen talebi atla ve rapora yaz. " +
|
||||
"Talep (Description) ve onaylanmis analizi (Analysis) birlikte tarifin kendisidir: soru sorma, DB Migrate'i bekleme. " +
|
||||
"Her talepte Testing'e almadan once modul dokumanini save_module_doc ile doldur (lowcode 0.13), sonra talebi Testing'e alip digerine gec. " +
|
||||
"Her talepte Testing'e almadan once ekranin modul dokumani bolumunu save_module_doc ile doldur (lowcode 0.13), sonra talebi Testing'e alip digerine gec. " +
|
||||
"Sonunda parti raporu ver: talep basina uretilen artefaktlar ve son durum; DB Migrate gerektigini tek satirda belirt.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -17,155 +18,208 @@ namespace Sozsoft.Mcp.Tools;
|
|||
/// yolla ayni sekilde olusur.
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
|
||||
public sealed class McpDevelopTools(IMcpDevelopAppService developAppService, IMcpImageShrinker imageShrinker)
|
||||
{
|
||||
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)]
|
||||
[Description("Ekran ve bilesen uretim sozlesmesini okur: wizard alanlari, enum degerleri, editor/lookup/script sozlesmesi, custom component kurallari. section bos ise baslik listesi doner; once listeyi al, sonra ihtiyac duydugun bolumu numarasiyla iste (ornegin 4.2). Rehberde dosya yazmak anlatilan yerlerde dosya yerine bu sunucunun araclarini kullan.")]
|
||||
public async Task<McpDevGuideDto> GetDevGuideAsync(
|
||||
public async Task<CallToolResult> GetDevGuideAsync(
|
||||
[Description("'lowcode' (uretim protokolu ve artefakt semalari, varsayilan) ya da 'reference' (JSON kolon sozlesmeleri, tasarimci envanteri).")]
|
||||
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.")]
|
||||
string? section = null)
|
||||
=> await developAppService.GetDevGuideAsync(document, section);
|
||||
=> McpToolResult.From(await developAppService.GetDevGuideAsync(document, section));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.CreateTable, Destructive = false, Idempotent = false)]
|
||||
[Description("Ekranin baglanacagi yeni tabloyu SQL Query Manager tablo tasarimcisiyla ayni bicimde olusturur ve script'i seed dosyasina (sql/object) yazar. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. Yalnizca yeni tablo olusturur: tablo varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. SQL Server veri kaynaginda calisir. Ardindan deploy_wizard ile ekrani kur.")]
|
||||
public async Task<McpCreateTableResultDto> CreateTableAsync(
|
||||
[Description("Ekranin baglanacagi yeni tablonun script'ini SQL Query Manager tablo tasarimcisiyla ayni bicimde uretir ve seed dosyasina (sql/object + postgres/object) yazar; veritabaninda calistirmaz, tabloyu kullanicinin calistirdigi DB Migrate kurar. DB Migrate'i bekleme ve calistirmaya calisma; deploy_wizard kolonlari script'ten okur. Id (uniqueidentifier, PK), TenantId ve tam audit kolonlari (CreationTime, CreatorId, LastModificationTime, LastModifierId, IsDeleted, DeletionTime, DeleterId) otomatik eklenir; columns icine yalnizca is kolonlarini yaz. indexes ile index/unique, foreignKeys ile FK kisitlari ayni scripte yazilir. Yalnizca yeni tablo icindir: tablo ya da script'i varsa reddeder, kolon ekleme/degistirme tablo tasarimcisindan yapilir. View/procedure/function icin save_sql_object kullan. Ardindan deploy_wizard ile ekrani kur.")]
|
||||
public async Task<CallToolResult> CreateTableAsync(
|
||||
[Description("Tablo adi; harf/rakam/alt cizgi. Adlandirma icin get_dev_guide'daki tablo onek kuralina uy (ornegin Adm_T_...).")]
|
||||
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().")]
|
||||
[Description("Is kolonlari. type: nvarchar, nvarchar(MAX), int, bigint, decimal (her zaman 18,4 yazilir; boyut verme), float, bit, datetime, datetime2, date, uniqueidentifier, money. maxLength yalnizca nvarchar icin (varsayilan 100). defaultValue: sayi, 'metin' ya da NEWID()/GETDATE()/GETUTCDATE()/SYSUTCDATETIME(). Sabit defaultValue ekranda yeni kaydin form degeri olur (deploy tablodan okur); bu yuzden IsActive bit isNullable false defaultValue \"1\", olcu birimi (UomId) defaultValue \"'Adet'\", para birimi (CurrencyId) defaultValue \"'TRY'\" ve talepteki her sabit varsayilan (Sure = 60 → \"60\") kolona yazilir (get_dev_guide 4.3.5.1).")]
|
||||
List<McpTableColumnDto> columns,
|
||||
[Description("Tam audit kolonlari eklensin mi. Kullanici acikca istemedikce true birak.")]
|
||||
bool fullAudited = true,
|
||||
[Description("TenantId kolonu eklensin mi. Kullanici acikca istemedikce true birak.")]
|
||||
bool multiTenant = true)
|
||||
=> await developAppService.CreateTableAsync(new McpCreateTableRequestDto
|
||||
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.")]
|
||||
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,
|
||||
Columns = columns,
|
||||
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)]
|
||||
[Description("Wizard File Manager'daki wizard dosyalarini listeler: dosya adi, ekran kodu, yol (List/Custom), menu yolu ve bagimli wizard'lar.")]
|
||||
public async Task<List<WizardFileInfoDto>> ListWizardsAsync(
|
||||
public async Task<CallToolResult> ListWizardsAsync(
|
||||
[Description("Dosya adi, wizard adi, ekran kodu ya da bilesen adi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> await developAppService.GetWizardsAsync(search);
|
||||
=> McpToolResult.From(await developAppService.GetWizardsAsync(search));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetWizard, ReadOnly = true)]
|
||||
[Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur (Wizard / ListForm / Fields bolumleri). Var olan bir ekrani degistirmeden once oku.")]
|
||||
public async Task<McpWizardFileDto> GetWizardAsync(
|
||||
[Description("Bir wizard seed dosyasini diskteki bicimiyle dondurur: tek Wizard bolumu; Menu/Language/Data/Details/Layout basliklari, ekranin her sekmesi bir blok (Editing, Insert/Update/Delete, FormDefaults, FilterRow, HeaderFilter, Selection, ColumnOption, Pager, StateStoring, ChartOptions …) ve Groups altinda alanlarin butun detay sekmeleri (Filter, Header, Grouping, Summary, Join, ValidationRules, Styling, Permission, PivotSettings). Var olan bir ekrani degistirmeden once oku.")]
|
||||
public async Task<CallToolResult> GetWizardAsync(
|
||||
[Description("list_wizards cikisindaki fileName degeri, ornegin 'Tags.json'.")]
|
||||
string fileName)
|
||||
=> await developAppService.GetWizardAsync(fileName);
|
||||
=> McpToolResult.From(await developAppService.GetWizardAsync(fileName));
|
||||
|
||||
[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. 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<WizardFileInfoDto?> DeployWizardAsync(
|
||||
[Description("Wizard JSON'u: seed dosyasi bicimi ({\"Wizard\": {...}}, get_wizard cikisi gibi) ya da duz wizard cevaplari. Enum alanlari sayi olarak yazilir.")]
|
||||
[Description("Wizard'i deploy eder: ListForm ekrani (ComponentKind 0) ya da bir custom component'i menuye baglayan kayit (ComponentKind 1), yetkileri, dil anahtarlari ve menusu seed dosyalarina yazilir. Deploy veritabanina yazmaz: ekran, menu ve yetkiler kullanici Wizard Manager'da DB Migrate calistirinca olusur; cikistaki isPending true iken ekran henuz yoktur; migrate'i bekleme, isini bitir ve kullaniciya yalnizca DB Migrate calistirmasini soyle. Eksik modul, ust menu, izin grubu, yetki ve dil satirlari da deploy'da dosyalara yazilir ve migrate onlari dogru sirayla ekler; bunlarin veritabaninda olmamasi beklenen durumdur, uyari olarak raporlama ve kullanicidan elle eklemesini isteme. Ust menu zinciri istenen derinlikte kurulur: MenuParentCode ekranin dogrudan ust menusu, Menu.MenuParentPath onun ustundeki seviyeler (kokten asagi, her biri {Code, Icon, En, Tr}); eksik her seviye bir ustunun altina kurulur, var olana baglanilir. Liste ekraninda Grid, Card, Pivot ve Chart gorunumleri varsayilan aciktir (DefaultLayout grid); kullanici bir gorunumu istemedigini soylemedikce bu bayraklari false yazma. Hedef tablo onceden var olmalidir; yoksa once create_table ile olustur. Istek ornek/demo veri tasiyorsa (prompt'taki tablo/liste ya da ekran goruntusunde gorunen satirlar) SeedData true yaz ve satirlarin hepsini ayni cagrida seedRows ile gonder: satirlar ekranin data/{ListFormCode}.json dosyasina yazilir, migrate ekrani kurduktan sonra ekler. SeedData ekrandan girilen kayitlari dosyaya yansitmaz (SeedSyncInsert/Update/Delete kapali); bunu yalnizca istek acikca isterse SeedSync true yaz. Satirlar icin migrate'i bekleyip insert_row kullanma. replaceFileName verilirse o wizard'in dosyalari yeniden yazilir, onceki kayitlarin yerini migrate'te yenileri alir. Ekranin her ayari Wizard bolumundedir ve adlari get_wizard ciktisindaki gibidir: liste ayarlari bloklarda (Details.PageSize, Editing, Insert/Update/Delete.FieldsDefaultValue, FormDefaults, ExtraFilters, CommandColumns, FilterRow … ChartOptions), alan ayarlari Groups[].Items[] ogesinde (Format, Alignment, Width, Visible, BandName, ListOrderNo, ValidationRules, TotalSummary/GroupSummary, Grouping, Join, Styling, PivotSettings …). Yazmadigin blok deploy'da varsayilaniyla dolar; ListForm/Fields bolumleri kabul edilmez. Yeni kaydin form degeri (Aktif = true, Birim = Adet, Kur = TRY, Sure = 60) ayri yazilmaz: deploy kolonun tablodaki sabit DEFAULT degerini okur; tablodan farkli deger gerekiyorsa alan ogesinde DefaultValue yaz (Groups[].Items[].DefaultValue). StaticData lookup seceneklerinde (RadioGroup, SelectBox, TagBox) name dil anahtaridir (Sas.StaticLookup.{Key}); EN + TR metni bilinen secenegi Language.Texts'te ver, duz metin yazilirsa deploy onu anahtara cevirip TR'yi metinden yazar (get_dev_guide 4.3.4.1). EditorScript'i elle yazma: Script Builder kurallarini ({v:1, rules:[...]}, get_dev_guide 5.3) ver, deploy kodu Script Builder dialogu ile birebir ayni uretir; govde yazsan da atilir. Onay kutusu alanina setValue text 'true'/'false' (0/1 cevrilir). set ile yazilan alanin kurallari da sirayla calisir (derinlik siniri yok); A->B->A dongusu kuran kural yazma, calisma aninda hata olarak gosterilir. Wizard girdisinde taninmayan anahtar adiyla reddedilir, baska adla tekrar deneme. Guncellemede get_wizard ciktisini butunuyle alip degistirerek gonder: dosyadaki her blok cevaptir, yazilmayan blok varsayilana doner. Kimlik ve yetki alanlari (WizardCode, Title/Desc anahtarlari, Permission adlari, popup basligi) ekran kodundan yeniden turetilir. Liste ekraninda DescTr ve DescEn ayrintili olmali (en az 3 satir, 150 karakter): ekran basligindaki bilgi simgesinde gosterilir; ne ise yaradigi, menude nerede ya da hangi ekranin sekmesi oldugu, ana alanlar, otomatik hesaplar ve iliskili ekranlar yazilir (get_dev_guide 4.4.2); kisa aciklama reddedilir. Deploy modulun dokumanini (docs/modules) acar ya da envanterini yeniler; elle yazilan bolumleri save_module_doc ile doldur. Ne yazacagini get_dev_guide 4. bolumunden ogren.")]
|
||||
public async Task<CallToolResult> 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.")]
|
||||
string wizard,
|
||||
[Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak.")]
|
||||
[Description("Guncellemede yerine gecilecek dosyanin adi (list_wizards cikisindaki fileName). Yeni wizard icin bos birak. Guncelleme dosyayi tamamen degistirir: wizard girdisi get_wizard ile okunan dosyanin degistirilmis tam halidir; Wizard bolumu (WizardCode, Menu, Language, Groups) eksiksiz gonderilir.")]
|
||||
string? replaceFileName = null,
|
||||
[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)
|
||||
=> await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows });
|
||||
=> McpToolResult.From(await developAppService.DeployWizardAsync(new McpWizardDeployRequestDto { Wizard = wizard, ReplaceFileName = replaceFileName, SeedRows = seedRows }));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteWizard, Destructive = true, Idempotent = true)]
|
||||
[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<WizardDeleteResultDto> DeleteWizardAsync(
|
||||
[Description("Bir wizard'i ve yalnizca onun urettigi kayitlari siler; baska yerde kullanilan kayitlar korunur ve raporda gosterilir. Ekranin veri aynasi (data/{ListFormCode}.json) ekranla birlikte gider. Bagimli wizard'i olan bir wizard silinemez, once bagimlilar silinir. Ekranin tablosu varsayilan olarak kalir; dropTables true ise tablo DROP edilir, CRUD endpoint'leri ve sql/object + crud seed dosyalari da silinir. Tablo yalnizca create_table ya da tablo tasarimcisiyla olusturulmussa (sql/object scripti varsa) ve baska ekran, Custom Endpoint ya da bilesen kullanmiyorsa dusurulur; aksi halde raporda neden korundugu yazar. Tablo silmek geri alinamaz ve Sas.SqlQueryManager yetkisi ister: dropTables true gondermeden once kullaniciya tablonun ve icindeki verinin silinecegini soyle ve acik onay al.")]
|
||||
public async Task<CallToolResult> DeleteWizardAsync(
|
||||
[Description("Silinecek wizard dosyasinin adi (list_wizards cikisindaki fileName).")]
|
||||
string fileName,
|
||||
[Description("Ekranin tablosu da dusurulsun mu. Yalnizca kullanici acikca onayladiysa true.")]
|
||||
bool dropTables = false)
|
||||
=> await developAppService.DeleteWizardAsync(fileName, dropTables);
|
||||
=> McpToolResult.From(await developAppService.DeleteWizardAsync(fileName, dropTables));
|
||||
|
||||
[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.")]
|
||||
public async Task<McpVerifyResultDto> VerifyWizardAsync(
|
||||
public async Task<CallToolResult> VerifyWizardAsync(
|
||||
[Description("Dogrulanacak wizard dosyasinin adi (list_wizards ya da deploy_wizard cikisindaki fileName).")]
|
||||
string fileName)
|
||||
=> await developAppService.VerifyWizardAsync(fileName);
|
||||
=> McpToolResult.From(await developAppService.VerifyWizardAsync(fileName));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.StartDevRequest, Destructive = false, Idempotent = false)]
|
||||
[Description("App.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")]
|
||||
public async Task<IList<ContentBlock>> StartDevRequestAsync(
|
||||
[Description("Sas.DevRequests kuyrugundaki bir gelistirme talebini ustlenir: talep Ready to Start durumundaysa In Progress'e alir ve talebin metnini (baslik, HTML aciklama, onaylanmis HTML analiz, oncelik, etiketler, SubTasks) dondurur; baska bir durumdaysa reddeder. Aciklamaya yapistirilmis gorseller ayri resim icerigi olarak gelir, aciklamada yerlerinde [image-N] isareti durur; gorseldeki ekran duzeni ve satirlar talebin parcasidir (demo veri icin deploy_wizard SeedData). Uretim aciklama ile analize birlikte dayanir; analizdeki menu, tablo ve kapsam kararlari onaylanmistir. Bir talep uzerinde calismaya bu aracla baslanir, Status'u update_row ile In Progress yapma. Sonraki adimlar icin get_dev_guide lowcode 0.11.")]
|
||||
public async Task<CallToolResult> StartDevRequestAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi. Ayni basligi tasiyan birden fazla talep varsa Id ver.")]
|
||||
string request)
|
||||
=> ToContent(await developAppService.StartDevRequestAsync(request));
|
||||
=> McpToolResult.From(await developAppService.StartDevRequestAsync(request), ToContent);
|
||||
|
||||
[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.")]
|
||||
public async Task<IList<ContentBlock>> GetDevRequestAsync(
|
||||
public async Task<CallToolResult> GetDevRequestAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request)
|
||||
=> ToContent(await developAppService.GetDevRequestAsync(request));
|
||||
=> McpToolResult.From(await developAppService.GetDevRequestAsync(request), ToContent);
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveDevRequestAnalysis, Destructive = true, Idempotent = true)]
|
||||
[Description("Backlog ya da Analysis durumundaki bir talebin analizini (HTML) Analysis alanina yazar ve talebi Analysis durumuna alir; var olan analiz degistirilir, Description'a dokunulmaz. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir. Analizi begenen kullanici talebi Ready to Start'a kendisi alir; MCP almaz.")]
|
||||
public async Task<IList<ContentBlock>> SaveDevRequestAnalysisAsync(
|
||||
[Description("Backlog, Analysis ya da Ready to Start durumundaki bir talebin analizini (HTML) Analysis alanina yazar; var olan analiz degistirilir, Description'a dokunulmaz. productionReady true ise talep Ready to Start'a, false ise Analysis durumuna alinir. Diger durumlarda reddeder. Once get_dev_request ile talebi ve gorsellerini oku; analizin bolumleri ve standardi get_dev_guide lowcode 0.12'dedir.")]
|
||||
public async Task<CallToolResult> SaveDevRequestAnalysisAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Analizin tamami, HTML.")]
|
||||
string html)
|
||||
=> ToContent(await developAppService.SaveDevRequestAnalysisAsync(request, 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.")]
|
||||
bool productionReady)
|
||||
=> McpToolResult.From(await developAppService.SaveDevRequestAnalysisAsync(request, html, productionReady), ToContent);
|
||||
|
||||
[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.")]
|
||||
public async Task<string> AppendDevRequestNoteAsync(
|
||||
public async Task<CallToolResult> AppendDevRequestNoteAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Eklenecek HTML parcasi.")]
|
||||
string html)
|
||||
{
|
||||
await developAppService.AppendDevRequestNoteAsync(request, html);
|
||||
return "ok";
|
||||
}
|
||||
=> McpToolResult.From(await developAppService.AppendDevRequestNoteAsync(request, html));
|
||||
|
||||
[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.")]
|
||||
public async Task<string> AddDevRequestSubTasksAsync(
|
||||
public async Task<CallToolResult> AddDevRequestSubTasksAsync(
|
||||
[Description("Talebin Id'si (GUID) ya da tam basligi.")]
|
||||
string request,
|
||||
[Description("Eklenecek satir basliklari, ornegin [\"create_table: Hr_T_Certificate\", \"deploy_wizard: Certificates.json\"].")]
|
||||
List<string> titles)
|
||||
{
|
||||
await developAppService.AddDevRequestSubTasksAsync(request, titles);
|
||||
return "ok";
|
||||
}
|
||||
=> McpToolResult.From(await developAppService.AddDevRequestSubTasksAsync(request, titles));
|
||||
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetModuleDoc, ReadOnly = true)]
|
||||
[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<ModuleDocDto> GetModuleDocAsync(
|
||||
[Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
|
||||
[Description("Modul dokumanini (docs/modules/{Menu}.md, seed klasoru; kok menu basina tek dosya, adi kok menu kodu: Hr, Prc, Adm) okur: nasil calistigi, analiz/kararlar, ekranlar, hesaplar, yetkiler ve otomatik 'Ek — Ekran envanteri'. Menunun altindaki her dal dosyada bir bolumdur; ekranin ya da dalin kodu (Hr.PerformanceReview, Hr.Performance) ya da wizard dosya adi verilirse yalnizca o bolum ve envanteri doner, dosya adi verilirse dosyanin tamami. isComplete false ise missingSections doldurulmamis alt bolumlerdir. 'Bu modul/ekran nasil calisiyor?' sorusunda once bunu ekran koduyla oku; deploy_wizard dosyayi ve dalin bolumunu yoksa kendisi acar. Menude henuz olmayan yeni modulun kodu (Stk, Stk.Stock) hata vermez: exists false ve doldurulacak iskelet doner; save_module_doc ayni kodla yazinca dosya acilir.")]
|
||||
public async Task<CallToolResult> GetModuleDocAsync(
|
||||
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
|
||||
string name)
|
||||
=> await developAppService.GetModuleDocAsync(name);
|
||||
=> McpToolResult.From(await developAppService.GetModuleDocAsync(name));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveModuleDoc, Destructive = true, Idempotent = true)]
|
||||
[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<ModuleDocDto> SaveModuleDocAsync(
|
||||
[Description("Dokuman adi (HrPerformance), ekran kodu ya da wizard dosya adi.")]
|
||||
[Description("Modul dokumaninin elle yazilan bolumunu kaydeder (Markdown). Uretimin parcasidir: deploy_wizard'dan sonra get_module_doc ile ekranin bolumunu oku, module-doc:todo isaretli her alt bolumu (Amac ve kapsam, Analiz ve kararlar — onaylanmis analiz ve kararlar, Menu ve ekranlar, Veri modeli, Is kurallari ve hesaplar, Yetki ve gorunurluk, Entegrasyonlar, Akis ornegi, Sik sorulanlar, Dosyalar ve geri alma) doldurup isaretleri kaldirarak ayni ekran ya da dal koduyla gonder: yalnizca o bolum yazilir, diger dallara dokunulmaz. Bos kalacak alt bolume 'Yok.' yaz. 'Ek — Ekran envanteri' bolumunu gonderme, sunucu uretir. Bolumun '<!-- module-doc:section ... claims=... -->' beyan satirini gondermezsen mevcut beyan korunur. Kok menu koduyla (Hr) cagrilirsa dosyanin tamami yazilir. Talep, bolumu tamamlanmadan update_row ile Testing'e alinamaz. Bolum standardi get_dev_guide lowcode 0.13.")]
|
||||
public async Task<CallToolResult> SaveModuleDocAsync(
|
||||
[Description("Ekran ya da dal kodu, wizard dosya adi (o bolum) veya kok menu kodu (Hr; dosyanin tamami).")]
|
||||
string name,
|
||||
[Description("Dokumanin tamami, Markdown.")]
|
||||
[Description("Bolumun (ya da dosya adi verildiyse dosyanin) tamami, Markdown.")]
|
||||
string content)
|
||||
=> await developAppService.SaveModuleDocAsync(name, content);
|
||||
=> McpToolResult.From(await developAppService.SaveModuleDocAsync(name, content));
|
||||
|
||||
/// <summary>
|
||||
/// Talep metni JSON olarak, gorseller ayri resim icerigi olarak doner; model resmi ancak bu
|
||||
/// bicimde gorur, base64 metni olarak goremez.
|
||||
/// bicimde gorur, base64 metni olarak goremez. Sonucun tamami istemcinin sinirina sigar: kalan
|
||||
/// pay gorsellere esit bolunur, payini asan gorsel kucultulur, kucultulse de sigmayan gonderilmez
|
||||
/// ve hangisinin eksik oldugu metinde bildirilir.
|
||||
/// </summary>
|
||||
private static List<ContentBlock> ToContent(McpDevRequestDto devRequest)
|
||||
private List<ContentBlock> ToContent(McpDevRequestDto devRequest)
|
||||
{
|
||||
List<ContentBlock> content = [new TextContentBlock { Text = JsonSerializer.Serialize(devRequest, TextOptions) }];
|
||||
var text = JsonSerializer.Serialize(devRequest, TextOptions);
|
||||
List<ContentBlock> content = [new TextContentBlock { Text = text }];
|
||||
|
||||
foreach (var image in devRequest.Images)
|
||||
var remaining = MaxResultLength - text.Length;
|
||||
var omitted = new List<string>();
|
||||
|
||||
for (var i = 0; i < devRequest.Images.Count; i++)
|
||||
{
|
||||
var image = devRequest.Images[i];
|
||||
var share = Math.Max(0, remaining) / (devRequest.Images.Count - i);
|
||||
var shrunk = imageShrinker.Shrink(image.MimeType, image.Data, share);
|
||||
if (shrunk is null)
|
||||
{
|
||||
omitted.Add(image.Placeholder);
|
||||
continue;
|
||||
}
|
||||
|
||||
remaining -= shrunk.Data.Length;
|
||||
|
||||
// Veri zaten base64; cozup yeniden kodlamak buyuk gorsellerde bosuna bellek harcar.
|
||||
content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(image.Data), MimeType = image.MimeType });
|
||||
content.Add(new ImageContentBlock { Data = Encoding.UTF8.GetBytes(shrunk.Data), MimeType = shrunk.MimeType });
|
||||
}
|
||||
|
||||
if (omitted.Count > 0)
|
||||
{
|
||||
content.Add(new TextContentBlock
|
||||
{
|
||||
Text = $"Boyut siniri nedeniyle gonderilmeyen gorseller: {string.Join(", ", omitted)}. Gerekirse kullanicidan gorselin ne gosterdigini sor."
|
||||
});
|
||||
}
|
||||
|
||||
return content;
|
||||
|
|
@ -173,21 +227,21 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
|
|||
|
||||
[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.")]
|
||||
public async Task<List<McpComponentSummaryDto>> ListComponentsAsync(
|
||||
public async Task<CallToolResult> ListComponentsAsync(
|
||||
[Description("Ad, rota ya da aciklama icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> await developAppService.GetComponentsAsync(search);
|
||||
=> McpToolResult.From(await developAppService.GetComponentsAsync(search));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetComponent, ReadOnly = true)]
|
||||
[Description("Bir custom component'in kodunu, props'unu ve bagimliliklarini dondurur. Degistirmeden once oku.")]
|
||||
public async Task<CustomComponentDto?> GetComponentAsync(
|
||||
public async Task<CallToolResult> GetComponentAsync(
|
||||
[Description("Bilesenin adi.")]
|
||||
string name)
|
||||
=> await developAppService.GetComponentAsync(name);
|
||||
=> McpToolResult.From(await developAppService.GetComponentAsync(name));
|
||||
|
||||
[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.")]
|
||||
public async Task<CustomComponentDto> SaveComponentAsync(
|
||||
public async Task<CallToolResult> SaveComponentAsync(
|
||||
[Description("Bilesenin adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Bilesenin React kodu. Yeni bilesende zorunlu, guncellemede bos birakilirsa mevcut kod korunur.")]
|
||||
|
|
@ -202,7 +256,7 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
|
|||
string? dependencies = null,
|
||||
[Description("Bilesen etkin mi. Yeni bilesende varsayilan true.")]
|
||||
bool? isActive = null)
|
||||
=> await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
|
||||
=> McpToolResult.From(await developAppService.SaveComponentAsync(new McpComponentSaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Code = code,
|
||||
|
|
@ -211,12 +265,12 @@ public sealed class McpDevelopTools(IMcpDevelopAppService developAppService)
|
|||
Props = props,
|
||||
Dependencies = dependencies,
|
||||
IsActive = isActive
|
||||
});
|
||||
}));
|
||||
|
||||
[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.")]
|
||||
public async Task DeleteComponentAsync(
|
||||
public async Task<CallToolResult> DeleteComponentAsync(
|
||||
[Description("Bilesenin adi.")]
|
||||
string name)
|
||||
=> await developAppService.DeleteComponentAsync(name);
|
||||
=> McpToolResult.From(await developAppService.DeleteComponentAsync(name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
{
|
||||
[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.")]
|
||||
public async Task<McpListQueryResultDto> QueryListAsync(
|
||||
public async Task<CallToolResult> QueryListAsync(
|
||||
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
|
||||
string listFormCode,
|
||||
[Description("Filtre, JSON metni. Ornek: [\"Status\",\"=\",\"Open\"] ya da [[\"Amount\",\">\",100],\"and\",[\"Name\",\"contains\",\"abc\"]]. Operatorler: =, <>, >, >=, <, <=, contains, notcontains, startswith, endswith.")]
|
||||
|
|
@ -29,7 +30,7 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
int skip = 0,
|
||||
[Description("Istenen kayit sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int take = 0)
|
||||
=> await queryAppService.QueryListAsync(new McpListQueryRequestDto
|
||||
=> McpToolResult.From(await queryAppService.QueryListAsync(new McpListQueryRequestDto
|
||||
{
|
||||
ListFormCode = listFormCode,
|
||||
Filter = filter,
|
||||
|
|
@ -39,34 +40,34 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
TotalSummary = totalSummary,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
});
|
||||
}));
|
||||
|
||||
[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.")]
|
||||
public async Task<McpQueryResultDto> RunSelectAsync(
|
||||
public async Task<CallToolResult> RunSelectAsync(
|
||||
[Description("Tek bir SELECT (ya da WITH ... SELECT) ifadesi. Degerler @ad bicimindeki parametrelerle verilir.")]
|
||||
string sql,
|
||||
[Description("Sorgudaki adli parametreler: anahtar parametre adi, deger ise ham JSON degeri.")]
|
||||
Dictionary<string, object?>? parameters = null,
|
||||
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int maxRows = 0)
|
||||
=> await queryAppService.RunSelectAsync(new McpSelectRequestDto
|
||||
=> McpToolResult.From(await queryAppService.RunSelectAsync(new McpSelectRequestDto
|
||||
{
|
||||
Sql = sql,
|
||||
Parameters = parameters ?? [],
|
||||
MaxRows = maxRows
|
||||
});
|
||||
}));
|
||||
|
||||
[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.")]
|
||||
public async Task<List<McpSavedQueryDto>> ListQueriesAsync(
|
||||
public async Task<CallToolResult> ListQueriesAsync(
|
||||
[Description("Nesne adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> await queryAppService.GetSavedQueriesAsync(search);
|
||||
=> McpToolResult.From(await queryAppService.GetSavedQueriesAsync(search));
|
||||
|
||||
[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.")]
|
||||
public async Task<McpQueryResultDto> RunQueryAsync(
|
||||
public async Task<CallToolResult> RunQueryAsync(
|
||||
[Description("Nesne turu: view, procedure, tableFunction, scalarFunction ya da customEndpoint.")]
|
||||
string kind,
|
||||
[Description("Nesnenin ya da Custom Endpoint'in adi.")]
|
||||
|
|
@ -77,12 +78,12 @@ public sealed class McpQueryTools(IMcpQueryAppService queryAppService)
|
|||
Dictionary<string, object?>? parameters = null,
|
||||
[Description("Istenen en fazla satir sayisi; sunucu kaydindaki tavandan buyuk olamaz.")]
|
||||
int maxRows = 0)
|
||||
=> await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto
|
||||
=> McpToolResult.From(await queryAppService.RunSavedQueryAsync(new McpSavedQueryRequestDto
|
||||
{
|
||||
Kind = kind,
|
||||
Name = name,
|
||||
SchemaName = schemaName,
|
||||
Parameters = parameters ?? [],
|
||||
MaxRows = maxRows
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.ComponentModel;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
|
||||
|
|
@ -15,37 +16,37 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
{
|
||||
[McpServerTool(Name = McpToolNames.ListReportTemplates, ReadOnly = true)]
|
||||
[Description("Rapor kategorilerini ve rapor sablonlarini listeler; HTML dondurmez. Sablon kaydederken kategori adi buradan secilir.")]
|
||||
public async Task<McpReportCatalogDto> ListReportTemplatesAsync(
|
||||
public async Task<CallToolResult> ListReportTemplatesAsync(
|
||||
[Description("Sablon adi ya da aciklamasi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null)
|
||||
=> await reportAppService.GetReportTemplatesAsync(search);
|
||||
=> McpToolResult.From(await reportAppService.GetReportTemplatesAsync(search));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.GetReportTemplate, ReadOnly = true)]
|
||||
[Description("Bir rapor sablonunun HTML icerigini, yer tutucularini ve goruntuleme adresini dondurur. Degistirmeden once oku.")]
|
||||
public async Task<McpReportTemplateDto> GetReportTemplateAsync(
|
||||
public async Task<CallToolResult> GetReportTemplateAsync(
|
||||
[Description("Sablonun Id'si ya da adi.")]
|
||||
string template)
|
||||
=> await reportAppService.GetReportTemplateAsync(template);
|
||||
=> McpToolResult.From(await reportAppService.GetReportTemplateAsync(template));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveReportCategory, Destructive = true, Idempotent = true)]
|
||||
[Description("Rapor kategorisini ada gore kaydeder: yoksa ekler, varsa gunceller. Kayit bir ekran kaydindan basilan HTML sablonu ise var olan 'Raporlar' kategorisini kullan; yeni kategori yalnizca istenince acilir.")]
|
||||
public async Task<McpReportCategoryDto> SaveReportCategoryAsync(
|
||||
public async Task<CallToolResult> SaveReportCategoryAsync(
|
||||
[Description("Kategorinin adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Aciklama.")]
|
||||
string? description = null,
|
||||
[Description("Ikon adi.")]
|
||||
string? icon = null)
|
||||
=> await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
|
||||
=> McpToolResult.From(await reportAppService.SaveReportCategoryAsync(new McpReportCategorySaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Icon = icon
|
||||
});
|
||||
}));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.SaveReportTemplate, Destructive = true, Idempotent = true)]
|
||||
[Description("DynamicReport sablonunu ada gore kaydeder: yoksa ekler, varsa gunceller; guncellemede verilmeyen alanlar korunur. Sablon A4'e basilan HTML'dir (Standard Taahhutname gibi). listFormCode verilirse HTML icindeki {{AlanAdi}} yer tutuculari o ekranin alanlarina gore dogrulanir ve cevapta rowButton doner: bu butonu ekranin CommandColumnJson listesine ekle (deploy_wizard), VisibleExpression ile kosullandir. Sozlesme icin get_dev_guide 9.12.")]
|
||||
public async Task<McpReportTemplateDto> SaveReportTemplateAsync(
|
||||
public async Task<CallToolResult> SaveReportTemplateAsync(
|
||||
[Description("Sablonun adi; kimligidir.")]
|
||||
string name,
|
||||
[Description("Kategori adi ya da Id'si. Yeni sablonda zorunlu; kayit HTML'i icin 'Raporlar'.")]
|
||||
|
|
@ -58,9 +59,9 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
string? status = null,
|
||||
[Description("Sablonun basilacagi ekranin kodu (ornegin katilimci listesi). Verilirse yer tutucular dogrulanir ve satir butonu doner.")]
|
||||
string? listFormCode = null,
|
||||
[Description("Satir butonunun dil anahtari. Verilmezse App.Platform.View.")]
|
||||
[Description("Satir butonunun dil anahtari. Verilmezse Sas.Platform.View.")]
|
||||
string? buttonText = null)
|
||||
=> await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
|
||||
=> McpToolResult.From(await reportAppService.SaveReportTemplateAsync(new McpReportTemplateSaveRequestDto
|
||||
{
|
||||
Name = name,
|
||||
Category = category,
|
||||
|
|
@ -69,12 +70,12 @@ public sealed class McpReportTools(IMcpReportAppService reportAppService)
|
|||
Status = status,
|
||||
ListFormCode = listFormCode,
|
||||
ButtonText = buttonText
|
||||
});
|
||||
}));
|
||||
|
||||
[McpServerTool(Name = McpToolNames.DeleteReportTemplate, Destructive = true, Idempotent = false)]
|
||||
[Description("Rapor sablonunu siler. Sablonu acan satir butonlari ekranlardan ayrica kaldirilir.")]
|
||||
public async Task DeleteReportTemplateAsync(
|
||||
public async Task<CallToolResult> DeleteReportTemplateAsync(
|
||||
[Description("Sablonun Id'si ya da adi.")]
|
||||
string template)
|
||||
=> await reportAppService.DeleteReportTemplateAsync(template);
|
||||
=> McpToolResult.From(await reportAppService.DeleteReportTemplateAsync(template));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using Sozsoft.Mcp.Dtos;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
|
|
@ -31,29 +32,29 @@ public sealed class McpSchemaTools(IMcpSchemaAppService schemaAppService, ICurre
|
|||
}
|
||||
|
||||
[McpServerTool(Name = McpToolNames.ListLists, ReadOnly = true)]
|
||||
[Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner.")]
|
||||
public async Task<List<McpListSummaryDto>> ListListsAsync(
|
||||
[Description("Kiracinin listelerini (ekranlarini) ad, baslik, aciklama ve arkasindaki tablo ile dondurur. Arama metni verilmezse tumu doner. Gelistiriciye (host, Wizard yetkisi) DB Migrate bekleyen wizard ekranlari (sourceKind pending) ve yalnizca seed scripti olan tablolar (pendingTable) da doner; lookup, widget, view ve Query SQL'ini yazarken kolon adlarini describe_list ile buradan al, tahmin etme.")]
|
||||
public async Task<CallToolResult> ListListsAsync(
|
||||
[Description("Liste adi, basligi, aciklamasi ya da tablo adi icinde aranacak metin. Bos birakilabilir.")]
|
||||
string? search = null,
|
||||
[Description("Donecek en fazla liste sayisi. Varsayilan 200.")]
|
||||
int maxResults = 200)
|
||||
=> await schemaAppService.GetListsAsync(search, maxResults);
|
||||
=> McpToolResult.From(await schemaAppService.GetListsAsync(search, maxResults));
|
||||
|
||||
[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.")]
|
||||
public async Task<McpListDetailDto?> DescribeListAsync(
|
||||
public async Task<CallToolResult> DescribeListAsync(
|
||||
[Description("Listenin kodu; list_lists cikisindaki listFormCode degeri.")]
|
||||
string listFormCode)
|
||||
=> await schemaAppService.GetListAsync(listFormCode);
|
||||
=> McpToolResult.From(await schemaAppService.GetListAsync(listFormCode));
|
||||
|
||||
[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.")]
|
||||
public async Task<List<McpSchemaMatchDto>> SearchSchemaAsync(
|
||||
public async Task<CallToolResult> SearchSchemaAsync(
|
||||
[Description("Aranacak metin. Turkce karakterler ve aksanlar onemsizdir.")]
|
||||
string term,
|
||||
[Description("Donecek en fazla eslesme sayisi. Varsayilan 50.")]
|
||||
int maxResults = 50)
|
||||
=> await schemaAppService.SearchAsync(term, maxResults);
|
||||
=> McpToolResult.From(await schemaAppService.SearchAsync(term, maxResults));
|
||||
}
|
||||
|
||||
/// <param name="Scope">Istegin baglami: <c>host</c> ya da <c>tenant</c>. Gelistirme araclari yalnizca host'ta aciktir.</param>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Sozsoft.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Servis sonucunu arac sonucuna cevirir. Deger SDK'nin donus degerini yazdigi bicimde
|
||||
/// (<see cref="McpJsonUtilities.DefaultOptions"/>) JSON metnidir. <see cref="McpProblem"/> hata
|
||||
/// sonucudur (<c>IsError</c>): istisna atilmadan modele ulasir, model nedeni okuyup girdiyi duzeltir.
|
||||
/// </summary>
|
||||
internal static class McpToolResult
|
||||
{
|
||||
/// <summary>Degeri olmayan araclarin (not ekleme, silme) basari cevabi.</summary>
|
||||
private const string Done = "ok";
|
||||
|
||||
public static CallToolResult From<T>(McpOutcome<T> outcome)
|
||||
=> outcome.TryGetValue(out var value, out var problem)
|
||||
? Text(JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions))
|
||||
: Error(problem.Message);
|
||||
|
||||
public static CallToolResult From(McpProblem? problem)
|
||||
=> problem is null ? Text(Done) : Error(problem.Message);
|
||||
|
||||
/// <summary>Degeri metin disinda icerik (resim) tasiyan araclar icin.</summary>
|
||||
public static CallToolResult From<T>(McpOutcome<T> outcome, Func<T, IList<ContentBlock>> toContent)
|
||||
=> outcome.TryGetValue(out var value, out var problem)
|
||||
? new CallToolResult { Content = toContent(value) }
|
||||
: Error(problem.Message);
|
||||
|
||||
public static CallToolResult Error(string message)
|
||||
=> new() { IsError = true, Content = [new TextContentBlock { Text = message }] };
|
||||
|
||||
private static CallToolResult Text(string text)
|
||||
=> new() { Content = [new TextContentBlock { Text = text }] };
|
||||
}
|
||||
|
|
@ -12,14 +12,6 @@ public partial class NotificationTypeToNotificationTypeDtoMapper : MapperBase<No
|
|||
public override partial NotificationTypeDto Map(NotificationType source);
|
||||
|
||||
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)]
|
||||
|
|
@ -28,14 +20,6 @@ public partial class NotificationRuleToNotificationRuleDtoMapper : MapperBase<No
|
|||
public override partial NotificationRuleDto Map(NotificationRule source);
|
||||
|
||||
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)]
|
||||
|
|
@ -53,14 +37,6 @@ public partial class NotificationToNotificationDtoMapper : MapperBase<Notificati
|
|||
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))]
|
||||
[MapperIgnoreTarget(nameof(NotificationDto.TenantId))]
|
||||
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)]
|
||||
|
|
@ -95,12 +71,4 @@ public partial class CreateUpdateNotificationRuleDtoToNotificationRuleMapper : M
|
|||
[MapperIgnoreTarget(nameof(NotificationRule.TenantId))]
|
||||
[MapperIgnoreTarget(nameof(NotificationRule.RecipientType))]
|
||||
public override partial void Map(CreateUpdateNotificationRuleDto source, NotificationRule destination);
|
||||
|
||||
public override void BeforeMap(CreateUpdateNotificationRuleDto source)
|
||||
{
|
||||
}
|
||||
|
||||
public override void AfterMap(CreateUpdateNotificationRuleDto source, NotificationRule destination)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ public static class NotificationConsts
|
|||
|
||||
/// <summary>
|
||||
/// Elle yapilan gonderimlerin bildirim tipi. Kural motorundan gecmeyen her bildirim bu tiple
|
||||
/// yazilir; tip kaydi seed ile gelir (<c>App.Notifications.NotificationTypes.json</c>).
|
||||
/// yazilir; tip kaydi seed ile gelir (<c>Adm.Notifications.NotificationTypes.json</c>).
|
||||
/// </summary>
|
||||
public const string ManualTypeName = "Manuel";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.DependencyInjection;
|
||||
using Volo.Abp.Localization;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Notifications.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Bildirim metninin (tarih, sayi, dil anahtari) uretildigi kultur. Istek disinda (worker) is
|
||||
/// parcaciginin kulturu surec varsayilanidir; alicinin kendi dil tercihi platformda saklanmaz
|
||||
/// (dil istekte <c>Accept-Language</c> ile gelir), bu yuzden ulasilabilen en yakin deger
|
||||
/// kiracinin <c>Abp.Localization.DefaultLanguage</c> ayaridir (kiraci → global → varsayilan).
|
||||
/// </summary>
|
||||
public interface INotificationCulture
|
||||
{
|
||||
Task<IDisposable> UseAsync();
|
||||
}
|
||||
|
||||
public class NotificationCulture(ISettingProvider settingProvider) : INotificationCulture, ITransientDependency
|
||||
{
|
||||
public async Task<IDisposable> UseAsync()
|
||||
{
|
||||
var culture = await settingProvider.GetOrNullAsync(LocalizationSettingNames.DefaultLanguage);
|
||||
return culture.IsNullOrWhiteSpace() || !CultureHelper.IsValidCultureCode(culture!)
|
||||
? NullDisposable.Instance
|
||||
: CultureHelper.Use(culture!);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ public class NotificationManager : INotificationManager
|
|||
private readonly INotificationRealtimeNotifier realtimeNotifier;
|
||||
private readonly ICurrentTenant currentTenant;
|
||||
private readonly IClock clock;
|
||||
private readonly INotificationCulture notificationCulture;
|
||||
|
||||
public NotificationManager(
|
||||
IReadOnlyRepository<NotificationRule> repository,
|
||||
|
|
@ -80,7 +81,8 @@ public class NotificationManager : INotificationManager
|
|||
IOrganizationUnitRepository repositoryOrganizationUnit,
|
||||
INotificationRealtimeNotifier realtimeNotifier,
|
||||
ICurrentTenant currentTenant,
|
||||
IClock clock
|
||||
IClock clock,
|
||||
INotificationCulture notificationCulture
|
||||
)
|
||||
{
|
||||
this.repository = repository;
|
||||
|
|
@ -92,6 +94,7 @@ public class NotificationManager : INotificationManager
|
|||
this.realtimeNotifier = realtimeNotifier;
|
||||
this.currentTenant = currentTenant;
|
||||
this.clock = clock;
|
||||
this.notificationCulture = notificationCulture;
|
||||
}
|
||||
|
||||
public async Task SendNotificationAsync(Guid NotificationTypeId, string NotificationMessage)
|
||||
|
|
@ -208,30 +211,36 @@ public class NotificationManager : INotificationManager
|
|||
.ToListAsync();
|
||||
|
||||
var notifications = new List<Notification>();
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
|
||||
if (identifier.IsNullOrWhiteSpace())
|
||||
{
|
||||
result.SkippedByChannel[channel] = result.SkippedByChannel.GetOrDefault(channel) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
notifications.Add(new Notification
|
||||
// {Date}/{Time} yer tutuculari gonderenin istek kulturunde degil kiracinin dilinde yazilir;
|
||||
// alicinin kendi dil tercihi platformda saklanmadigi icin alici basina ayrim yapilamaz.
|
||||
using (await notificationCulture.UseAsync())
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
NotificationTypeId = request.NotificationTypeId,
|
||||
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
|
||||
});
|
||||
var identifier = await notificationIdentifierProvider.GetIdentifier(user.Id, channel);
|
||||
if (identifier.IsNullOrWhiteSpace())
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace Sozsoft.Sender.Localization;
|
||||
|
||||
[LocalizationResourceName("SozsoftSender")]
|
||||
public class ErpSenderResource
|
||||
public class SozsoftMailingResource
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
}
|
||||
|
||||
await BackgroundJobManager.EnqueueAsync(
|
||||
new ErpBackgroundEmailSendingJobArgs
|
||||
new SozsoftBackgroundEmailSendingJobArgs
|
||||
{
|
||||
TenantId = CurrentTenant.Id,
|
||||
To = to,
|
||||
|
|
@ -76,7 +76,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
);
|
||||
}
|
||||
|
||||
public async Task<EmailSendResult> SendEmailAsync(
|
||||
public async Task<SendResult> SendEmailAsync(
|
||||
string[] to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
@ -134,7 +134,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
|
||||
using var client = await amazonSesClientFactory.GetAmazonSesClient();
|
||||
var response = await client.SendEmailAsync(request);
|
||||
var result = new EmailSendResult();
|
||||
var result = new SendResult();
|
||||
if (response == null)
|
||||
{
|
||||
result.Success = false;
|
||||
|
|
@ -157,7 +157,7 @@ public class AmazonSesEmailSender : EmailSenderBase, ISozsoftEmailSender, ITrans
|
|||
return result;
|
||||
}
|
||||
|
||||
public async Task<EmailSendResult> SendEmailAsync(
|
||||
public async Task<SendResult> SendEmailAsync(
|
||||
string to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
public class EmailSendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string MessageId { get; set; } //Eger basarili ise gateway messageId
|
||||
public string ErrorMessage { get; set; } //Basarisiz ise hata mesaji
|
||||
}
|
||||
|
||||
|
|
@ -7,8 +7,7 @@ namespace Sozsoft.Sender.Mail;
|
|||
/// </summary>
|
||||
public interface ISozsoftEmailSender : IEmailSender
|
||||
{
|
||||
//ErpEmailTemplate template
|
||||
Task<EmailSendResult> SendEmailAsync(
|
||||
Task<SendResult> SendEmailAsync(
|
||||
string to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
@ -16,7 +15,7 @@ public interface ISozsoftEmailSender : IEmailSender
|
|||
string? subject = null,
|
||||
Dictionary<string, string>? attachments = null,
|
||||
bool IsBodyHtml = false);
|
||||
Task<EmailSendResult> SendEmailAsync(
|
||||
Task<SendResult> SendEmailAsync(
|
||||
string[] to,
|
||||
KeyValuePair<string, string>? sender,
|
||||
dynamic? @params,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ using Volo.Abp.MultiTenancy;
|
|||
|
||||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
public class ErpBackgroundEmailSendingJob :
|
||||
AsyncBackgroundJob<ErpBackgroundEmailSendingJobArgs>, ITransientDependency
|
||||
public class SozsoftBackgroundEmailSendingJob :
|
||||
AsyncBackgroundJob<SozsoftBackgroundEmailSendingJobArgs>, ITransientDependency
|
||||
{
|
||||
protected ISozsoftEmailSender EmailSender { get; }
|
||||
protected ICurrentTenant CurrentTenant { get; }
|
||||
|
||||
public ErpBackgroundEmailSendingJob(
|
||||
public SozsoftBackgroundEmailSendingJob(
|
||||
ISozsoftEmailSender emailSender,
|
||||
ICurrentTenant currentTenant)
|
||||
{
|
||||
|
|
@ -18,11 +18,10 @@ public class ErpBackgroundEmailSendingJob :
|
|||
CurrentTenant = currentTenant;
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(ErpBackgroundEmailSendingJobArgs args)
|
||||
public override async Task ExecuteAsync(SozsoftBackgroundEmailSendingJobArgs args)
|
||||
{
|
||||
using (CurrentTenant.Change(args.TenantId))
|
||||
{
|
||||
//await EmailSender.SendEmailAsync(args.Template, args.To, args.Params, args.Subject);
|
||||
await EmailSender.SendEmailAsync(
|
||||
args.To,
|
||||
args.Sender,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
namespace Sozsoft.Sender.Mail;
|
||||
|
||||
[Serializable]
|
||||
public class ErpBackgroundEmailSendingJobArgs : IMultiTenant
|
||||
public class SozsoftBackgroundEmailSendingJobArgs : IMultiTenant
|
||||
{
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ namespace Sozsoft.Sender.Rocket;
|
|||
|
||||
public interface ISozsoftRocketSender : ITransientDependency
|
||||
{
|
||||
Task<RocketSendResult> SendAsync(string Username, string Message);
|
||||
Task<SendResult> SendAsync(string Username, string Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,38 @@
|
|||
using System.Net.Http.Json;
|
||||
using Volo.Abp.Settings;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public interface IRocketHttpClient
|
||||
{
|
||||
Task<RocketSendResultDto> SendMessageAsync(string Username, string Message);
|
||||
Task<SendResult?> SendMessageAsync(string Username, string Message);
|
||||
}
|
||||
|
||||
public sealed class RocketHttpClient : IRocketHttpClient, IDisposable
|
||||
public sealed class RocketHttpClient : SettingHttpClientBase, IRocketHttpClient
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ISettingProvider settingProvider;
|
||||
|
||||
public RocketHttpClient(
|
||||
HttpClient httpClient,
|
||||
ISettingProvider settingProvider)
|
||||
public RocketHttpClient(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
: base(httpClient, settingProvider)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.settingProvider = settingProvider;
|
||||
}
|
||||
|
||||
public async Task<RocketSendResultDto> SendMessageAsync(string Username, string Message)
|
||||
public async Task<SendResult?> SendMessageAsync(string Username, string Message)
|
||||
{
|
||||
var url = await settingProvider.GetOrNullAsync(RocketSettingNames.Url);
|
||||
var userId = await settingProvider.GetOrNullAsync(RocketSettingNames.UserId);
|
||||
var token = await settingProvider.GetOrNullAsync(RocketSettingNames.Token);
|
||||
var url = await GetSettingAsync(RocketSettingNames.Url);
|
||||
var userId = await GetSettingAsync(RocketSettingNames.UserId);
|
||||
var token = await GetSettingAsync(RocketSettingNames.Token);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{url}/chat.postMessage");
|
||||
request.Headers.Add("X-Auth-Token", token);
|
||||
request.Headers.Add("X-User-Id", userId);
|
||||
request.Content = JsonContent.Create(new RocketChatPostMessageInput
|
||||
{
|
||||
Channel = $"@{Username}",
|
||||
Text = Message
|
||||
});
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode(); // Hata durumunda istisna atar.
|
||||
return await response.Content.ReadFromJsonAsync<RocketSendResultDto>();
|
||||
// Rocket.Chat cevabı yalnızca { success } taşır; SendResult aynı sözleşmedir, ayrı DTO gerekmez.
|
||||
return await PostJsonAsync<SendResult>(
|
||||
$"{url}/chat.postMessage",
|
||||
new RocketChatPostMessageInput
|
||||
{
|
||||
Channel = $"@{Username}",
|
||||
Text = Message
|
||||
},
|
||||
request =>
|
||||
{
|
||||
request.Headers.Add("X-Auth-Token", token);
|
||||
request.Headers.Add("X-User-Id", userId);
|
||||
},
|
||||
ensureSuccess: true);
|
||||
}
|
||||
|
||||
public void Dispose() => httpClient?.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public class RocketSendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
namespace Sozsoft.Sender.Rocket;
|
||||
|
||||
public class RocketSendResultDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -15,11 +15,12 @@ public class RocketSender : ISozsoftRocketSender
|
|||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RocketSendResult> SendAsync(string Username, string Message)
|
||||
public async Task<SendResult> SendAsync(string Username, string Message)
|
||||
{
|
||||
logger.LogInformation("Sending Rocket message. {Username}, {Message}", Username, Message);
|
||||
var result = await httpClient.SendMessageAsync(Username, Message);
|
||||
if (result.Success)
|
||||
var success = result?.Success == true;
|
||||
if (success)
|
||||
{
|
||||
logger.LogInformation("Rocket sent successfully. {Username}, {Message}", Username, Message);
|
||||
}
|
||||
|
|
@ -28,10 +29,6 @@ public class RocketSender : ISozsoftRocketSender
|
|||
logger.LogWarning("Failed to send Rocket message. {Username}, {Message}", Username, Message);
|
||||
}
|
||||
|
||||
return new RocketSendResult
|
||||
{
|
||||
Success = result.Success,
|
||||
};
|
||||
return new SendResult { Success = success };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
public static class RocketSettingNames
|
||||
{
|
||||
public const string Default = "App.Sender.Rocket.";
|
||||
public const string Default = "Adm.Sender.Rocket.";
|
||||
public const string Url = Default + "Url";
|
||||
public const string UserId = Default + "UserId";
|
||||
public const string Token = Default + "Token";
|
||||
|
|
|
|||
16
api/modules/Sozsoft.Sender/SendResult.cs
Normal file
16
api/modules/Sozsoft.Sender/SendResult.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace Sozsoft.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Her kanalın (e-posta, SMS, Rocket, WhatsApp, Telegram) tek gönderim sonucu. Kanal yalnızca
|
||||
/// başarı bilgisi veriyorsa <see cref="MessageId"/> ve <see cref="ErrorMessage"/> boş kalır.
|
||||
/// </summary>
|
||||
public class SendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Başarılı gönderimde sağlayıcının verdiği ileti kimliği.</summary>
|
||||
public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>Başarısız gönderimde sağlayıcının açıklaması.</summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
52
api/modules/Sozsoft.Sender/SettingHttpClientBase.cs
Normal file
52
api/modules/Sozsoft.Sender/SettingHttpClientBase.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System.Net.Http.Json;
|
||||
using Volo.Abp.Settings;
|
||||
|
||||
namespace Sozsoft.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Sağlayıcı istemcilerinin ortak iskeleti: <see cref="IHttpClientFactory"/> ile verilen
|
||||
/// <see cref="HttpClient"/>, ayarlardan okunan uç nokta ve JSON gövde ile POST. Sağlayıcıya özgü
|
||||
/// olan yalnızca istek gövdesi, başlıklar ve cevap tipidir.
|
||||
/// </summary>
|
||||
public abstract class SettingHttpClientBase : IDisposable
|
||||
{
|
||||
protected HttpClient HttpClient { get; }
|
||||
protected ISettingProvider SettingProvider { get; }
|
||||
|
||||
protected SettingHttpClientBase(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
{
|
||||
HttpClient = httpClient;
|
||||
SettingProvider = settingProvider;
|
||||
}
|
||||
|
||||
protected Task<string?> GetSettingAsync(string name) => SettingProvider.GetOrNullAsync(name);
|
||||
|
||||
/// <summary>
|
||||
/// Gövdeyi JSON olarak gönderir ve cevabı <typeparamref name="TResponse"/> olarak okur.
|
||||
/// <paramref name="ensureSuccess"/> kapalıyken 4xx gövdesi de okunur: bazı API'ler hatayı
|
||||
/// durum koduyla değil gövdedeki açıklamayla verir.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostJsonAsync<TResponse>(
|
||||
string url,
|
||||
object body,
|
||||
Action<HttpRequestMessage>? configureRequest = null,
|
||||
bool ensureSuccess = false)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
|
||||
configureRequest?.Invoke(request);
|
||||
|
||||
using var response = await HttpClient.SendAsync(request);
|
||||
if (ensureSuccess)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HttpClient.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,6 @@ namespace Sozsoft.Sender.Sms;
|
|||
|
||||
public interface ISozsoftSmsSender : ISmsSender, ITransientDependency
|
||||
{
|
||||
Task<SmsSendResult> SendAsync(string PhoneNumber, string Message);
|
||||
Task<SendResult> SendAsync(string PhoneNumber, string Message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,61 +7,51 @@ public interface IPostaGuverciniHttpClient
|
|||
Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message);
|
||||
}
|
||||
|
||||
public sealed class PostaGuverciniHttpClient : IPostaGuverciniHttpClient, IDisposable
|
||||
public sealed class PostaGuverciniHttpClient : SettingHttpClientBase, IPostaGuverciniHttpClient
|
||||
{
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ISettingProvider settingProvider;
|
||||
|
||||
public PostaGuverciniHttpClient(
|
||||
HttpClient httpClient,
|
||||
ISettingProvider settingProvider)
|
||||
public PostaGuverciniHttpClient(HttpClient httpClient, ISettingProvider settingProvider)
|
||||
: base(httpClient, settingProvider)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.settingProvider = settingProvider;
|
||||
}
|
||||
|
||||
public async Task<PostaGuverciniSmsSendResultDto> SendSmsAsync(string PhoneNumber, string Message)
|
||||
{
|
||||
var url = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Url);
|
||||
var u = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Username);
|
||||
var p = await settingProvider.GetOrNullAsync(PostaGuverciniSettingNames.Password);
|
||||
//httpClient.BaseAddress = new Uri(url);
|
||||
var response = await httpClient.GetStringAsync($"{url}/sendsms.asp?user={u}&password={p}&gsm={PhoneNumber}&text={Message}");
|
||||
// Gelen result başarı: errno=0&errtext=&message_id=EZ_2643D2B5-QCFD-40B7-E95F-4204CFCD0409&charge=-1
|
||||
// Gelen result hata: errno=10&errtext=HATA: Giriş bilgileri hatalı!
|
||||
var url = await GetSettingAsync(PostaGuverciniSettingNames.Url);
|
||||
var user = await GetSettingAsync(PostaGuverciniSettingNames.Username);
|
||||
var password = await GetSettingAsync(PostaGuverciniSettingNames.Password);
|
||||
|
||||
// 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={Uri.EscapeDataString(user ?? string.Empty)}&password={Uri.EscapeDataString(password ?? string.Empty)}" +
|
||||
$"&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();
|
||||
try
|
||||
var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
|
||||
foreach (var item in response.Split('&', options))
|
||||
{
|
||||
var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
|
||||
foreach (var item in response.Split('&', options))
|
||||
var token = item.Split('=', 2, options);
|
||||
var value = token.Length == 2 ? token[1] : null;
|
||||
switch (token[0])
|
||||
{
|
||||
var token = item.Split('=', options);
|
||||
switch (token[0])
|
||||
{
|
||||
case "errno":
|
||||
result.ErrNo = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "errtext":
|
||||
result.ErrText = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "message_id":
|
||||
result.MessageId = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
case "charge":
|
||||
result.Charge = token.Length == 2 ? token[1] : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
case "errno":
|
||||
result.ErrNo = value;
|
||||
break;
|
||||
case "errtext":
|
||||
result.ErrText = value;
|
||||
break;
|
||||
case "message_id":
|
||||
result.MessageId = value;
|
||||
break;
|
||||
case "charge":
|
||||
result.Charge = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose() => httpClient?.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
public static class PostaGuverciniSettingNames
|
||||
{
|
||||
public const string Default = "App.Sender.Sms.PostaGuvercini.";
|
||||
public const string Default = "Adm.Sender.Sms.PostaGuvercini.";
|
||||
public const string Url = Default + "Url";
|
||||
public const string Username = Default + "Username";
|
||||
public const string Password = Default + "Password";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public class PostaGuverciniSmsSender : ISozsoftSmsSender
|
|||
|
||||
public Task SendAsync(SmsMessage smsMessage) => SendAsync(smsMessage.PhoneNumber, smsMessage.Text);
|
||||
|
||||
public async Task<SmsSendResult> SendAsync(string PhoneNumber, string Message)
|
||||
public async Task<SendResult> SendAsync(string PhoneNumber, string Message)
|
||||
{
|
||||
PhoneNumber = Check.NotNullOrWhiteSpace(PhoneNumber, nameof(PhoneNumber), 11, 10);
|
||||
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}");
|
||||
}
|
||||
|
||||
return new SmsSendResult
|
||||
return new SendResult
|
||||
{
|
||||
Success = result.IsSuccess,
|
||||
ErrorMessage = $"{result.ErrNo}:{result.ErrText}",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue