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

968 lines
39 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-25 07:52:06 +00:00
using System.IO;
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,
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,
input.MultiTenant));
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
};
}
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 = ParseSeedRows(input.SeedRows, wizard);
// 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 List<Dictionary<string, JsonElement>> ParseSeedRows(string? json, ListFormWizardDto wizard)
{
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)
throw new UserFriendlyException(L[McpErrorCodes.SeedRowsUnknownFields, string.Join(", ", unknown), string.Join(", ", fields)]);
return rows;
}
/// <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.
/// </summary>
private async Task WriteSeedRowsAsync(string fileName, List<Dictionary<string, JsonElement>> rows)
{
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]);
2026-10-05 12:58:59 +00:00
var path = SeedPathResolver.GetPath(configuration, relativePath.Split('/'));
2026-09-25 07:52:06 +00:00
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 keyFieldName = seedFile.KeyFieldName;
var existing = seedFile.Rows.Select(row => RowContent(row, keyFieldName)).ToHashSet(StringComparer.Ordinal);
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();
seedFile.Rows.Add(values);
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, ListFormSeedDataFileDto.Serialize(seedFile));
}
/// <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 08:17:46 +00:00
/// kullanici alir. Analiz modulun dokumanina da tasinir: talep ilk kez bir menude yapiliyorsa
/// dokuman ve dalin bolumu burada acilir, uretim beklenmez.
/// </summary>
2026-10-07 08:17:46 +00:00
public async Task<McpDevRequestDto> SaveDevRequestAnalysisAsync(string request, string? menuCode, string html)
{
await EnsureToolAllowedAsync(McpToolNames.SaveDevRequestAnalysis);
await AuthorizationService.CheckAsync(DevRequestUpdatePermission);
if (string.IsNullOrWhiteSpace(html))
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestAnalysisRequired]);
}
2026-10-07 08:17:46 +00:00
// Arac listesini onbellekten okuyan istemci parametreyi hic gondermeyebilir; marshaller'in
// ArgumentException'i modele ulasmaz, bu mesaj ise ulasir ve model cagriyi kodla tekrarlar.
if (string.IsNullOrWhiteSpace(menuCode))
{
throw new UserFriendlyException(L[McpErrorCodes.DevRequestMenuCodeRequired]);
}
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]);
}
2026-10-07 08:17:46 +00:00
// Dokuman once yazilir: menu kodu gecersizse analiz de kaydedilmez, model kodu duzeltip tekrar dener.
await moduleDocAppService.SaveAnalysisAsync(new ModuleDocAnalysisInput
{
MenuCode = menuCode,
DevRequestId = devRequest.Id,
DevRequestTitle = devRequest.Title,
Html = html,
});
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>
/// 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.
/// </summary>
private ListFormWizardDto ParseWizard(string? json)
{
JsonObject? root;
try
{
root = string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json) as JsonObject;
}
catch (JsonException)
{
root = null;
}
if (root is null)
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
var seedJson = root.ContainsKey(nameof(WizardSeedFileDto.Wizard))
? root.ToJsonString()
: new JsonObject { [nameof(WizardSeedFileDto.Wizard)] = root }.ToJsonString();
try
{
return WizardSeedFileDto.FromSeedJson(seedJson)?.Wizard
?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
catch (JsonException)
{
throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]);
}
}
private async Task<IReadOnlyList<CustomComponentDto>> GetAllComponentsAsync()
{
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
};
}