sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Application/McpDevelopAppService.cs

1024 lines
43 KiB
C#
Raw Normal View History

using System;
2026-09-18 13:47:23 +00:00
using System.Collections.Generic;
2026-09-25 07:52:06 +00:00
using System.Data;
using System.Globalization;
2026-09-18 13:47:23 +00:00
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
2026-09-25 07:52:06 +00:00
using Microsoft.Extensions.Configuration;
2026-09-18 13:47:23 +00:00
using Sozsoft.Mcp.Dtos;
using Sozsoft.Mcp.Services;
using Sozsoft.Platform;
2026-09-25 07:52:06 +00:00
using Sozsoft.Platform.Data.Seeds;
2026-09-18 13:47:23 +00:00
using Sozsoft.Platform.DeveloperKit;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums;
2026-09-18 13:47:23 +00:00
using Sozsoft.Platform.ListForms;
using Sozsoft.Platform.ListForms.Select;
2026-09-18 13:47:23 +00:00
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.PermissionManagement;
2026-09-18 13:47:23 +00:00
namespace Sozsoft.Mcp;
/// <summary>
/// Ekran ve bilesen gelistirme yuzeyi. Uretim Wizard ve Custom Components servislerinden
/// gecer: menu, yetki, dil anahtari, seed dosyasi ve bagimlilik kurallari ekrandaki yolla
/// aynidir, yetki kontrolu de o servislerin kendi politikalarindan gelir. Invoke/Write yetkisi
2026-09-18 13:47:23 +00:00
/// yalnizca MCP kapisidir, arkadaki yetkinin yerine gecmez.
/// </summary>
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
2026-09-18 13:47:23 +00:00
public class McpDevelopAppService(
IMcpServerConfigProvider serverConfigProvider,
IMcpDevGuide devGuide,
IMcpTableCreator tableCreator,
2026-10-07 18:46:45 +00:00
IMcpSqlObjectWriter sqlObjectWriter,
2026-09-18 13:47:23 +00:00
IListFormWizardAppService wizardAppService,
ICustomComponentAppService componentAppService,
IListFormSelectAppService listFormSelectAppService,
IRepository<Menu, Guid> menuRepository,
IRepository<PermissionDefinitionRecord, Guid> permissionRepository,
2026-09-25 07:52:06 +00:00
IRepository<DevRequest, Guid> devRequestRepository,
IMcpDevRequestTimesheetRecorder timesheetRecorder,
2026-10-04 15:04:16 +00:00
IModuleDocAppService moduleDocAppService,
2026-09-25 07:52:06 +00:00
IConfiguration configuration)
2026-09-18 13:47:23 +00:00
: McpAppService(serverConfigProvider), IMcpDevelopAppService
{
private const string DefaultGuideDocument = "lowcode";
/// <summary>Index cevabinda listelenen en derin baslik; daha derini bolum istenince gorunur.</summary>
private const int IndexHeadingLevel = 3;
2026-10-02 06:07:01 +00:00
/// <summary>Baslikla eslesmeyen terim icin listelenen en fazla bolum.</summary>
private const int GuideSearchMaxResults = 15;
2026-09-18 13:47:23 +00:00
/// <summary>Custom Components ekraninin tek sayfada dondurebildigi ust sinir.</summary>
private const int ComponentPageSize = 1000;
private const string DevRequestUpdatePermission = PlatformConsts.AppCodes.DevRequests + ".Update";
2026-10-04 15:04:16 +00:00
/// <summary>Liste ekrani aciklamasinin (DescTr/DescEn) en az satir ve karakter sayisi.</summary>
private const int MinDescriptionLines = 3;
private const int MinDescriptionLength = 150;
/// <summary>Talep ekranindaki alt gorev tarih kutusunun okudugu bicim.</summary>
private const string SubTaskDateFormat = "yyyy-MM-dd";
/// <summary>verify_wizard cevabindaki kontrol adlari.</summary>
private static class VerifyChecks
{
public const string WizardFile = "wizard_file";
public const string Install = "install";
public const string Menu = "menu";
public const string Permission = "permission";
public const string Select = "select";
public const string Component = "component";
public const string SeedClosure = "seed_closure";
}
2026-09-18 13:47:23 +00:00
public async Task<McpDevGuideDto> GetDevGuideAsync(string? document, string? section)
{
await EnsureToolAllowedAsync(McpToolNames.GetDevGuide);
if (string.IsNullOrWhiteSpace(section))
{
var headings = await devGuide.GetHeadingsAsync();
if (headings.Count == 0)
{
throw new UserFriendlyException(L[McpErrorCodes.DevGuideUnavailable, McpDevGuide.ConfigurationKey]);
}
return new McpDevGuideDto
{
Sections = [.. headings
.Where(h => h.Level <= IndexHeadingLevel)
.Where(h => string.IsNullOrWhiteSpace(document) || string.Equals(h.Document, document, StringComparison.OrdinalIgnoreCase))
.Select(ToDto)]
};
}
2026-10-02 06:07:01 +00:00
var result = await FindGuideSectionAsync(document, section);
if (result is null)
{
// Baslik olmayan bir terim (editor, alan adi) istenebilir; hata yerine terimin gectigi
// bolumler, o da yoksa baslik listesi doner ki model bir sonraki istegi secebilsin.
var matches = await devGuide.SearchAsync(section, GuideSearchMaxResults);
if (matches.Count > 0)
{
return new McpDevGuideDto
{
Content = L[McpErrorCodes.DevGuideSearchMatches, section],
Sections = [.. matches.Select(ToDto)]
};
}
var headings = await devGuide.GetHeadingsAsync();
return new McpDevGuideDto
{
Content = L[McpErrorCodes.DevGuideSectionNotFound, section],
Sections = [.. headings.Where(h => h.Level <= IndexHeadingLevel).Select(ToDto)]
};
}
2026-09-18 13:47:23 +00:00
return new McpDevGuideDto
{
Heading = ToDto(result.Heading),
Content = result.Content,
Sections = [.. result.Children.Select(ToDto)]
};
}
2026-10-02 06:07:01 +00:00
/// <summary>
/// Bolum once istenen belgede, sonra diger belgelerde numara ya da baslik metniyle aranir;
/// model terimi hangi belgede oldugunu bilmeden isteyebilir.
/// </summary>
private async Task<McpDevGuideSection?> FindGuideSectionAsync(string? document, string section)
{
var first = string.IsNullOrWhiteSpace(document) ? DefaultGuideDocument : document;
foreach (var candidate in McpDevGuide.Documents.Keys.OrderBy(key => !string.Equals(key, first, StringComparison.OrdinalIgnoreCase)))
{
var result = await devGuide.GetSectionAsync(candidate, section);
if (result is not null)
{
return result;
}
}
return null;
}
2026-09-18 13:47:23 +00:00
/// <summary>
/// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca
/// MCP kapisidir.
/// </summary>
public async Task<McpCreateTableResultDto> CreateTableAsync(McpCreateTableRequestDto input)
{
Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.CreateTable);
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default);
var created = 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,
2026-10-07 18:46:45 +00:00
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))]));
2026-09-18 13:47:23 +00:00
await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName);
2026-09-18 13:47:23 +00:00
return new McpCreateTableResultDto
{
TableName = created.TableName,
Script = created.Script,
SeedFilePath = created.SeedFilePath
};
}
2026-10-07 18:46:45 +00:00
/// <summary>
/// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function
/// kaydinin karsiligidir. Nesneyi DB Migrate kurar.
/// </summary>
public async Task<McpSqlObjectResultDto> SaveSqlObjectAsync(string sqlServerScript, string postgreSqlScript)
{
await EnsureToolAllowedAsync(McpToolNames.SaveSqlObject);
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.Default);
var saved = sqlObjectWriter.Save(sqlServerScript, postgreSqlScript);
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<McpCrudEndpointsResultDto> GenerateCrudEndpointsAsync(string entityName, string? operationTypes, string? objectType)
{
await EnsureToolAllowedAsync(McpToolNames.GenerateCrudEndpoints);
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints);
// 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)
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidIdentifier, entityName ?? string.Empty]);
}
var seedFile = CrudEndpointSeedFileDto.Generate(entity, operationTypes, 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]")}")]
};
}
2026-09-18 13:47:23 +00:00
public async Task<List<WizardFileInfoDto>> GetWizardsAsync(string? search)
{
await EnsureToolAllowedAsync(McpToolNames.ListWizards);
var files = await wizardAppService.GetFiles();
if (string.IsNullOrWhiteSpace(search))
{
return files;
}
return [.. files.Where(file =>
Contains(file.FileName, search) || Contains(file.WizardName, search) ||
Contains(file.ListFormCode, search) || Contains(file.CustomComponentName, search))];
}
public async Task<McpWizardFileDto> GetWizardAsync(string fileName)
{
await EnsureToolAllowedAsync(McpToolNames.GetWizard);
var seed = await wizardAppService.GetFile(fileName);
// Model dosyayi Claude Code'un depoda gordugu bicimde gorur; deploy_wizard ayni bicimi
// geri kabul eder.
using var document = JsonDocument.Parse(seed.ToSeedJson());
return new McpWizardFileDto { FileName = fileName, Seed = document.RootElement.Clone() };
}
public async Task<WizardFileInfoDto?> DeployWizardAsync(McpWizardDeployRequestDto input)
{
Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.DeployWizard);
var wizard = ParseWizard(input.Wizard);
wizard.EditFileName = string.IsNullOrWhiteSpace(input.ReplaceFileName) ? null : input.ReplaceFileName.Trim();
2026-10-04 15:04:16 +00:00
EnsureDetailedDescription(wizard);
2026-09-18 13:47:23 +00:00
// Menu sirasi modelin karari degildir: yeni ekran kardeslerinin sonuna eklenir (deploy
// bagimlilik sirasiyla yapildigi icin lookup kaynagi tuketen ekranin ustunde kalir),
// guncelleme ekranin mevcut yerini korur.
var previous = wizard.EditFileName is null ? null : (await wizardAppService.GetFile(wizard.EditFileName)).Wizard;
wizard.MenuOrder = previous?.MenuOrder ?? 0;
// Alt form sekmesi ana kayit her degistiginde yeniden sorgulanmaz; MCP yeni baglanti
// acarken yenilemeyi kapali yazar. Var olan baglantida kullanicinin ekrandan verdigi
// deger korunur.
var previousRefresh = (previous?.SubForms ?? [])
.Where(subForm => !string.IsNullOrWhiteSpace(subForm.Code))
.GroupBy(subForm => subForm.Code, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().IsRefresh, StringComparer.OrdinalIgnoreCase);
foreach (var subForm in wizard.SubForms ?? [])
subForm.IsRefresh = subForm.Code is not null && previousRefresh.GetValueOrDefault(subForm.Code);
2026-09-30 14:57:25 +00:00
// Tek gruplu duzenleme formunda grup basligi popup basligini tekrarlar; baslik yalnizca
// birden fazla grubu birbirinden ayirmak icin yazilir.
var editGroups = wizard.Groups
.Where(group => group.Items.Any(item => item.IncludeInEditingForm && item.FieldName != wizard.KeyFieldName))
.ToList();
if (editGroups.Count == 1)
editGroups[0].Caption = string.Empty;
2026-09-25 07:52:06 +00:00
// Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz.
var seedRows = await ParseSeedRowsAsync(input.SeedRows, wizard);
2026-09-25 07:52:06 +00:00
// Wizard dosyasi onu ureten talebi tasir; Wizard Manager talebi gosterir ve talebin
// butun wizard'larini birlikte export eder. Bag modelin girdisinden degil etkin talepten gelir.
wizard.DevRequestId = await timesheetRecorder.GetActiveRequestIdAsync();
2026-09-18 13:47:23 +00:00
await wizardAppService.Create(wizard);
var files = await wizardAppService.GetFiles();
2026-09-25 07:52:06 +00:00
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);
2026-09-18 13:47:23 +00:00
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count);
2026-09-25 07:52:06 +00:00
return file;
2026-09-18 13:47:23 +00:00
}
2026-10-04 15:04:16 +00:00
/// <summary>
/// Liste ekraninin aciklamasi basliktaki bilgi simgesinde (ⓘ) gosterilir ve ekranin kullanim
/// kilavuzudur: ne ise yaradigi, menude/hangi ekranin sekmesinde durdugu, ana alanlari, otomatik
/// hesaplar ve iliskili ekranlar (lowcode §4.4.2). Tek satirlik aciklama bu ihtiyaci karsilamadigi
/// icin MCP deploy'u kisa aciklamayi reddeder.
/// </summary>
private void EnsureDetailedDescription(ListFormWizardDto wizard)
{
if (wizard.ComponentKind == WizardComponentKindEnum.Custom)
return;
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]);
}
2026-09-25 07:52:06 +00:00
/// <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 async Task<List<Dictionary<string, JsonElement>>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard)
2026-09-25 07:52:06 +00:00
{
if (string.IsNullOrWhiteSpace(json))
return [];
if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
List<Dictionary<string, JsonElement>>? rows;
try
{
rows = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(json);
}
catch (JsonException)
{
rows = null;
}
if (rows is null || rows.Any(row => row is null))
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]);
var fields = wizard.Groups.SelectMany(group => group.Items).Select(item => item.FieldName)
.Append(wizard.KeyFieldName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unknown = rows.SelectMany(row => row.Keys).Where(key => !fields.Contains(key))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (unknown.Count == 0)
return rows;
var screenFields = string.Join(", ", fields);
var owners = await FindSameTableScreensAsync(wizard, unknown);
if (owners.Count == 0)
throw new UserFriendlyException(L[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)}"));
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsFieldsOnOtherScreens, string.Join(", ", unknown), placement, screenFields]);
}
/// <summary>
/// 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<Dictionary<string, List<string>>> FindSameTableScreensAsync(ListFormWizardDto wizard, List<string> columns)
{
var owners = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
var table = wizard.SelectCommand?.Trim();
if (string.IsNullOrWhiteSpace(table))
return owners;
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 seed = await wizardAppService.GetFile(file.FileName);
if (!string.Equals(seed.Wizard?.SelectCommand?.Trim(), table, StringComparison.OrdinalIgnoreCase))
continue;
var found = columns
.Where(column => (seed.Fields ?? []).Any(field => string.Equals(field.FieldName, column, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (found.Count > 0)
owners[seed.ListForm?.ListFormCode ?? file.ListFormCode ?? file.WizardName] = found;
}
2026-09-25 07:52:06 +00:00
return owners;
2026-09-25 07:52:06 +00:00
}
/// <summary>
/// Demo satirlarini ekranin veri seed hedefine (<c>ListForm.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.
2026-09-25 07:52:06 +00:00
/// </summary>
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
{
var listForm = (await wizardAppService.GetFile(fileName)).ListForm;
var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath);
if (listForm == null || target == null)
2026-09-25 07:52:06 +00:00
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]);
var keyFieldName = listForm.KeyFieldName;
var existing = (await target.ReadRowsAsync()).Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
var added = new List<(object Key, Dictionary<string, object> Row)>();
2026-09-25 07:52:06 +00:00
foreach (var row in rows)
{
var values = row.ToDictionary(item => item.Key, item => (object)item.Value);
if (!existing.Add(RowContent(values, keyFieldName)))
continue;
if (listForm.KeyFieldDbSourceType == DbType.Guid && !values.Keys.Contains(keyFieldName, StringComparer.OrdinalIgnoreCase))
values[keyFieldName] = GuidGenerator.Create();
added.Add((values.GetValueOrDefault(keyFieldName) ?? string.Empty, values));
2026-09-25 07:52:06 +00:00
}
// Satir klasorunde her satir anahtariyla adlanan dosyadadir; anahtarsiz satir yazilamaz.
if (added.Count > 0 && (await target.UpsertRowsAsync(listForm.ListFormCode, keyFieldName, added)).Count > 0)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsInvalid]);
2026-09-25 07:52:06 +00:00
}
/// <summary>Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin.</summary>
private static string RowContent(Dictionary<string, object> row, string keyFieldName) =>
string.Join('\u001f', row
.Where(item => !string.Equals(item.Key, keyFieldName, StringComparison.OrdinalIgnoreCase))
.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
.Select(item => $"{item.Key.ToLowerInvariant()}={JsonSerializer.Serialize(item.Value)}"));
/// <summary>
/// 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)
2026-09-18 13:47:23 +00:00
{
await EnsureToolAllowedAsync(McpToolNames.DeleteWizard);
var input = new WizardDeleteInput { FileName = fileName };
if (dropTables)
{
var analysis = await wizardAppService.DeleteAnalysis(fileName);
input.DropTables = [.. analysis.Tables.Where(t => t.CanDrop).Select(t => t.TableName)];
}
var result = await wizardAppService.DeleteFile(input);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteWizard, fileName);
return result;
2026-09-18 13:47:23 +00:00
}
/// <summary>
/// Ekrani kullanicinin gorecegi yoldan dener: menu kaydi, menunun istedigi yetki, ekranin
/// sorgusu (cagiranin yetkisi ve kiraci filtresiyle, tek satir) ve Custom yolunda bilesen.
/// Hicbir kontrol veri yazmaz; test satiri eklemek SeedSync acik ekranlarda seed dosyasina
/// 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)
{
await EnsureToolAllowedAsync(McpToolNames.VerifyWizard);
var result = new McpVerifyResultDto { FileName = fileName };
ListFormWizardDto? wizard;
try
{
wizard = (await wizardAppService.GetFile(fileName)).Wizard;
}
catch (Exception ex)
{
result.Checks.Add(Failed(VerifyChecks.WizardFile, L[McpErrorCodes.VerifyWizardFileInvalid, fileName, ex.Message]));
return result;
}
if (wizard is null)
{
result.Checks.Add(Failed(VerifyChecks.WizardFile, L[McpErrorCodes.InvalidWizard]));
return result;
}
var listFormCode = wizard.ComponentKind == WizardComponentKindEnum.Custom ? null : wizard.ResolveListFormCode();
result.ListFormCode = listFormCode;
result.MenuUrl = wizard.MenuUrl;
result.Checks.Add(new McpVerifyCheckDto { Name = VerifyChecks.WizardFile, Passed = true });
// Deploy yalnizca seed dosyalarini yazar; menu, yetki ve ekran DB Migrate'ten sonra vardir.
// Kurulmamis bir wizard'in geri kalan kontrolleri anlamsiz hatalar uretirdi.
var file = (await wizardAppService.GetFiles())
.FirstOrDefault(f => string.Equals(f.FileName, fileName, StringComparison.OrdinalIgnoreCase));
if (file?.IsPending == true)
{
result.Checks.Add(Failed(VerifyChecks.Install, L[McpErrorCodes.VerifyWizardPending, fileName]));
return result;
}
var permissionName = listFormCode;
if (wizard.CreateMenu)
{
var menu = await VerifyMenuAsync(wizard, result.Checks);
permissionName = string.IsNullOrWhiteSpace(menu?.RequiredPermissionName) ? permissionName : menu.RequiredPermissionName;
}
if (!string.IsNullOrWhiteSpace(permissionName))
{
var permission = await permissionRepository.FirstOrDefaultAsync(p => p.Name == permissionName);
result.Checks.Add(permission is { IsEnabled: true }
? new McpVerifyCheckDto { Name = VerifyChecks.Permission, Passed = true, Message = permissionName }
: Failed(VerifyChecks.Permission, L[McpErrorCodes.VerifyPermissionMissing, permissionName]));
}
result.Checks.Add(listFormCode is null
? await VerifyComponentAsync(wizard)
: await VerifySelectAsync(listFormCode));
result.Checks.Add(await VerifySeedClosureAsync(fileName));
result.Passed = result.Checks.TrueForAll(check => check.Passed || !check.Blocking);
return result;
}
/// <summary>
/// Durum kurali modelin dikkatine birakilmaz: talep Ready to Start degilse reddedilir, uygunsa
/// 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)
{
await EnsureToolAllowedAsync(McpToolNames.StartDevRequest);
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
var devRequest = await FindDevRequestAsync(request);
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]);
}
devRequest.Status = McpConsts.DevRequestStatuses.InProgress;
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.BeginAsync(devRequest.Id);
return ToDto(devRequest);
}
public async Task<McpDevRequestDto> GetDevRequestAsync(string request)
{
await EnsureToolAllowedAsync(McpToolNames.GetDevRequest);
await AuthorizationService.CheckAsync(PlatformConsts.AppCodes.DevRequests);
var devRequest = await FindDevRequestAsync(request);
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
2026-10-07 12:45:18 +00:00
/// kullanici alir.
/// </summary>
2026-10-07 12:45:18 +00:00
public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string html)
{
await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis);
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
if (string.IsNullOrWhiteSpace(html))
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]);
}
var devRequest = await FindDevRequestAsync(request);
if (devRequest.Status is not (McpConsts.DevRequestStatuses.Backlog or McpConsts.DevRequestStatuses.Analysis))
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotAnalyzable, devRequest.Title ?? devRequest.Id.ToString(), devRequest.Status ?? string.Empty]);
}
devRequest.Analysis = html.Trim();
devRequest.Status = McpConsts.DevRequestStatuses.Analysis;
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.Analysis);
return ToDto(devRequest);
}
/// <summary>
/// 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)
{
await EnsureToolAllowedAsync(McpToolNames.AppendDevRequestNote);
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
if (string.IsNullOrWhiteSpace(html))
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestNoteRequired]);
}
var devRequest = await FindDevRequestAsync(request);
devRequest.Analysis = string.Concat(devRequest.Analysis, html.Trim());
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AppendNote);
}
/// <summary>
/// Atanan kisi ve tarih modelin dikkatine birakilmaz: satiri yazan cagiran kullanicidir
/// (ekranin atanan listesi kullanici adiyla calisir), bitis tarihi yazildigi gundur.
/// Var olan satirlara dokunulmaz.
/// </summary>
public async Task AddDevRequestSubTasksAsync(string request, List<string> titles)
{
await EnsureToolAllowedAsync(McpToolNames.AddDevRequestSubTasks);
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
var items = (titles ?? []).Select(title => title?.Trim()).Where(title => !string.IsNullOrEmpty(title)).ToList();
if (items.Count == 0)
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksRequired]);
}
var devRequest = await FindDevRequestAsync(request);
JsonArray subTasks;
try
{
subTasks = string.IsNullOrWhiteSpace(devRequest.SubTasks)
? []
: JsonNode.Parse(devRequest.SubTasks) as JsonArray ?? throw new JsonException();
}
catch (JsonException)
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestSubTasksInvalid, devRequest.Title ?? devRequest.Id.ToString()]);
}
var assignee = CurrentUser.UserName ?? string.Empty;
var dueDate = Clock.Now.ToString(SubTaskDateFormat, CultureInfo.InvariantCulture);
foreach (var title in items)
{
subTasks.Add(new JsonObject
{
["id"] = GuidGenerator.Create().ToString(),
["title"] = title,
["assignee"] = assignee,
["dueDate"] = dueDate,
["completed"] = true
});
}
devRequest.SubTasks = subTasks.ToJsonString();
await devRequestRepository.UpdateAsync(devRequest, autoSave: true);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.AddSubTasks, items.Count);
}
2026-10-04 15:04:16 +00:00
public async Task<ModuleDocDto> GetModuleDocAsync(string name)
{
await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc);
2026-10-05 19:17:29 +00:00
return await moduleDocAppService.GetByNameAsync(name);
2026-10-04 15:04:16 +00:00
}
public async Task<ModuleDocDto> SaveModuleDocAsync(string name, string content)
{
await EnsureToolAllowedAsync(McpToolNames.SaveModuleDoc);
var doc = await moduleDocAppService.SaveAsync(new ModuleDocSaveInput { Name = name, Content = content });
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveModuleDoc, doc.Name);
return doc;
}
private static McpDevRequestDto ToDto(DevRequest devRequest)
{
var (description, images) = McpDevRequestImages.Extract(devRequest.Description);
return new McpDevRequestDto
{
Id = devRequest.Id,
Title = devRequest.Title,
Status = devRequest.Status ?? string.Empty,
Description = description,
Images = images,
Analysis = devRequest.Analysis,
Priority = devRequest.Priority,
Tags = devRequest.Tags,
DueDate = devRequest.DueDate,
UserName = devRequest.UserName,
TenantId = devRequest.TenantId,
SubTasks = devRequest.SubTasks
};
}
/// <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)
{
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 matches = value.Length == 0 ? [] : await devRequestRepository.GetListAsync(r => r.Title == value);
return matches.Count switch
{
0 => throw new UserFriendlyException(L[McpErrorCodes.DevRequestNotFound, value]),
1 => matches[0],
_ => throw new UserFriendlyException(L[McpErrorCodes.DevRequestAmbiguous, value, string.Join(", ", matches.Select(r => r.Id))])
};
}
/// <summary>
/// Kapanis analizi wizard export'unun yetkisini ister; o yetki dogrulamanin kendisini
/// dusurmesin diye yoksa kontrol atlanir ve nedeni raporlanir.
/// </summary>
private async Task<McpVerifyCheckDto> VerifySeedClosureAsync(string fileName)
{
var check = new McpVerifyCheckDto { Name = VerifyChecks.SeedClosure, Blocking = false };
if (!await AuthorizationService.IsGrantedAsync(PlatformConsts.AppCodes.Listforms.Wizards.Export))
{
check.Message = L[McpErrorCodes.VerifySeedClosureSkipped, PlatformConsts.AppCodes.Listforms.Wizards.Export];
return check;
}
var warnings = (await wizardAppService.ExportAnalysis(fileName)).Warnings;
if (warnings.Count == 0)
{
check.Passed = true;
return check;
}
check.Message = L[McpErrorCodes.VerifySeedClosureIncomplete, string.Join(", ", warnings)];
return check;
}
private async Task<Menu?> VerifyMenuAsync(ListFormWizardDto wizard, List<McpVerifyCheckDto> checks)
{
var menuCode = wizard.ResolveMenuCode();
var menu = await menuRepository.FirstOrDefaultAsync(m => m.Code == menuCode);
if (menu is null)
{
checks.Add(Failed(VerifyChecks.Menu, L[McpErrorCodes.VerifyMenuMissing, menuCode]));
}
else if (menu.IsDisabled)
{
checks.Add(Failed(VerifyChecks.Menu, L[McpErrorCodes.VerifyMenuDisabled, menuCode]));
}
else if (!string.Equals(menu.Url, wizard.MenuUrl, StringComparison.OrdinalIgnoreCase))
{
checks.Add(Failed(VerifyChecks.Menu, L[McpErrorCodes.VerifyMenuUrlMismatch, menuCode, menu.Url, wizard.MenuUrl]));
}
else
{
checks.Add(new McpVerifyCheckDto { Name = VerifyChecks.Menu, Passed = true, Message = menu.Url });
}
return menu;
}
/// <summary>
/// Ekranin sorgusunu query_list ile ayni servisten calistirir; kolon, veri kaynagi ve yetki
/// hatalari burada ortaya cikar.
/// </summary>
private async Task<McpVerifyCheckDto> VerifySelectAsync(string listFormCode)
{
try
{
var select = await listFormSelectAppService.GetSelectAsync(new SelectRequestDto
{
ListFormCode = listFormCode,
Take = 1,
RequireTotalCount = true
});
return new McpVerifyCheckDto
{
Name = VerifyChecks.Select,
Passed = true,
Message = L[McpErrorCodes.VerifySelectPassed, select.TotalCount]
};
}
catch (Exception ex)
{
return Failed(VerifyChecks.Select, L[McpErrorCodes.VerifySelectFailed, ex.Message]);
}
}
private async Task<McpVerifyCheckDto> VerifyComponentAsync(ListFormWizardDto wizard)
{
var component = await FindComponentAsync(wizard.CustomComponentName);
if (component is null)
{
return Failed(VerifyChecks.Component, L[McpErrorCodes.ComponentNotFound, wizard.CustomComponentName]);
}
if (!component.IsActive)
{
return Failed(VerifyChecks.Component, L[McpErrorCodes.VerifyComponentInactive, component.Name]);
}
if (wizard.CreateMenu && !string.Equals(component.RoutePath, wizard.MenuUrl, StringComparison.OrdinalIgnoreCase))
{
return Failed(VerifyChecks.Component, L[McpErrorCodes.VerifyComponentRouteMismatch, component.Name, component.RoutePath ?? string.Empty, wizard.MenuUrl]);
}
return new McpVerifyCheckDto { Name = VerifyChecks.Component, Passed = true, Message = component.RoutePath };
}
private static McpVerifyCheckDto Failed(string name, string message)
=> new() { Name = name, Passed = false, Message = message };
2026-09-18 13:47:23 +00:00
public async Task<List<McpComponentSummaryDto>> GetComponentsAsync(string? search)
{
await EnsureToolAllowedAsync(McpToolNames.ListComponents);
var components = await GetAllComponentsAsync();
return [.. components
.Where(component => string.IsNullOrWhiteSpace(search)
|| Contains(component.Name, search)
|| Contains(component.RoutePath, search)
|| Contains(component.Description, search))
.Select(component => new McpComponentSummaryDto
{
Id = component.Id,
Name = component.Name,
RoutePath = component.RoutePath,
Description = component.Description,
IsActive = component.IsActive,
IsVisualDesigner = IsVisualDesignerDocument(component.Props)
})];
}
public async Task<CustomComponentDto?> GetComponentAsync(string name)
{
await EnsureToolAllowedAsync(McpToolNames.GetComponent);
return await FindComponentAsync(name);
}
public async Task<CustomComponentDto> SaveComponentAsync(McpComponentSaveRequestDto input)
{
Check.NotNull(input, nameof(input));
await EnsureToolAllowedAsync(McpToolNames.SaveComponent);
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new UserFriendlyException(L[McpErrorCodes.ComponentNameRequired]);
}
var existing = await FindComponentAsync(input.Name);
var props = input.Props ?? existing?.Props;
// Tasarimci belgesinde kod belgeden uretilir; buraya yazilan kod tasarimcidaki ilk
// kayitta sessizce ezilirdi. Belge MCP'de uretilemedigi icin bu bilesenler tasarimcida kalir.
if (IsVisualDesignerDocument(props))
{
throw new UserFriendlyException(L[McpErrorCodes.VisualDesignerComponent, input.Name]);
}
if (existing is null)
{
if (string.IsNullOrWhiteSpace(input.Code))
{
throw new UserFriendlyException(L[McpErrorCodes.ComponentCodeRequired]);
}
var created = await componentAppService.CreateAsync(new CreateUpdateCustomComponentDto
2026-09-18 13:47:23 +00:00
{
Name = input.Name.Trim(),
Code = input.Code,
RoutePath = input.RoutePath,
Description = input.Description,
Props = props,
Dependencies = input.Dependencies,
IsActive = input.IsActive ?? true
});
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveComponent, created.Name);
return created;
2026-09-18 13:47:23 +00:00
}
var updated = await componentAppService.UpdateAsync(existing.Id, new CreateUpdateCustomComponentDto
2026-09-18 13:47:23 +00:00
{
Name = existing.Name,
Code = string.IsNullOrWhiteSpace(input.Code) ? existing.Code : input.Code,
RoutePath = input.RoutePath ?? existing.RoutePath,
Description = input.Description ?? existing.Description,
Props = props,
Dependencies = input.Dependencies ?? existing.Dependencies,
IsActive = input.IsActive ?? existing.IsActive
});
await timesheetRecorder.RecordAsync(McpTimesheetTexts.SaveComponent, updated.Name);
return updated;
2026-09-18 13:47:23 +00:00
}
public async Task DeleteComponentAsync(string name)
{
await EnsureToolAllowedAsync(McpToolNames.DeleteComponent);
var component = await FindComponentAsync(name)
?? throw new UserFriendlyException(L[McpErrorCodes.ComponentNotFound, name]);
await componentAppService.DeleteAsync(component.Id);
await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeleteComponent, component.Name);
2026-09-18 13:47:23 +00:00
}
/// <summary>
2026-10-07 18:46:45 +00:00
/// Wizard'i seed dosyasi biciminden (<c>{"Wizard": {...}, "ListForm": {...}, "Fields": [...]}</c>)
/// ya da duz cevaplardan okur; Menu/Language bloklari ve WizardCode/ModuleId adlari dosyadakiyle
/// ayni anlama gelir. ListForm/Fields bolumleri ekrana katman olarak uygulanir
/// (<see cref="WizardSeedFileDto.FromDeployJson"/>).
2026-09-18 13:47:23 +00:00
/// </summary>
private ListFormWizardDto ParseWizard(string? json)
{
try
{
2026-10-07 18:46:45 +00:00
return (string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json))
2026-09-18 13:47:23 +00:00
?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
2026-10-07 18:46:45 +00:00
catch (JsonException ex)
2026-09-18 13:47:23 +00:00
{
2026-10-07 18:46:45 +00:00
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizardJson, ex.Message]);
2026-09-18 13:47:23 +00:00
}
}
private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync()
{
var page = await componentAppService.GetListAsync(new PagedAndSortedResultRequestDto
{
MaxResultCount = ComponentPageSize,
Sorting = nameof(CustomComponentDto.Name)
});
return page.Items;
}
private async Task<CustomComponentDto?> FindComponentAsync(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var components = await GetAllComponentsAsync();
return components.FirstOrDefault(component => string.Equals(component.Name, name.Trim(), StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Props icinde tasarimci belgesi varsa ve kip acikca <c>code</c> degilse kod belgeden
/// uretilir; tasarimcinin varsayilan kipi <c>visual</c>'dir.
/// </summary>
private static bool IsVisualDesignerDocument(string? props)
{
if (string.IsNullOrWhiteSpace(props))
{
return false;
}
try
{
using var document = JsonDocument.Parse(props);
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("visualDesigner", out var designer) ||
designer.ValueKind != JsonValueKind.Object)
{
return false;
}
return !designer.TryGetProperty("sourceMode", out var mode) ||
mode.ValueKind != JsonValueKind.String ||
!string.Equals(mode.GetString(), "code", StringComparison.Ordinal);
}
catch (JsonException)
{
return false;
}
}
private static bool Contains(string? value, string search)
=> value?.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase) == true;
private static McpDevGuideHeadingDto ToDto(McpDevGuideHeading heading) => new()
{
Document = heading.Document,
Id = heading.Id,
Title = heading.Title,
Level = heading.Level
};
}