using System; using System.Collections.Generic; using System.Data; using System.Globalization; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; 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.DeveloperKit; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; using Sozsoft.Platform.ListForms; using Sozsoft.Platform.ListForms.Select; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Domain.Repositories; using Volo.Abp.PermissionManagement; namespace Sozsoft.Mcp; /// /// 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 /// yalnizca MCP kapisidir, arkadaki yetkinin yerine gecmez. /// [Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)] public class McpDevelopAppService( IMcpServerConfigProvider serverConfigProvider, IMcpDevGuide devGuide, IMcpTableCreator tableCreator, IMcpSqlObjectWriter sqlObjectWriter, IListFormWizardAppService wizardAppService, ICustomComponentAppService componentAppService, IListFormSelectAppService listFormSelectAppService, IRepository menuRepository, IRepository permissionRepository, IRepository devRequestRepository, IMcpDevRequestTimesheetRecorder timesheetRecorder, IModuleDocAppService moduleDocAppService, IConfiguration configuration) : McpAppService(serverConfigProvider), IMcpDevelopAppService { private const string DefaultGuideDocument = "lowcode"; /// Index cevabinda listelenen en derin baslik; daha derini bolum istenince gorunur. private const int IndexHeadingLevel = 3; /// Baslikla eslesmeyen terim icin listelenen en fazla bolum. private const int GuideSearchMaxResults = 15; /// Custom Components ekraninin tek sayfada dondurebildigi ust sinir. private const int ComponentPageSize = 1000; private const string DevRequestUpdatePermission = PlatformConsts.AppCodes.DevRequests + ".Update"; /// Liste ekrani aciklamasinin (DescTr/DescEn) en az satir ve karakter sayisi. private const int MinDescriptionLines = 3; private const int MinDescriptionLength = 150; /// Talep ekranindaki alt gorev tarih kutusunun okudugu bicim. private const string SubTaskDateFormat = "yyyy-MM-dd"; /// verify_wizard cevabindaki kontrol adlari. 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"; } public async Task 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)] }; } 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)] }; } return new McpDevGuideDto { Heading = ToDto(result.Heading), Content = result.Content, Sections = [.. result.Children.Select(ToDto)] }; } /// /// Bolum once istenen belgede, sonra diger belgelerde numara ya da baslik metniyle aranir; /// model terimi hangi belgede oldugunu bilmeden isteyebilir. /// private async Task 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; } /// /// Tablo tasarimcisiyla ayni yetkiyi ister: ham DDL liste yetkilerini asar, Develop yalnizca /// MCP kapisidir. /// public async Task 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, [.. (input.Indexes ?? []).Select(index => new McpTableIndex(index.Columns ?? [], index.Unique))], [.. (input.ForeignKeys ?? []).Select(fk => new McpTableForeignKey(fk.Column, fk.ReferencedTable, fk.ReferencedColumn, fk.OnDelete))])); await timesheetRecorder.RecordAsync(McpTimesheetTexts.CreateTable, created.TableName); return new McpCreateTableResultDto { TableName = created.TableName, Script = created.Script, SeedFilePath = created.SeedFilePath }; } /// /// Tablo tasarimcisiyla ayni yetkiyi ister: SQL Query Manager'daki view/procedure/function /// kaydinin karsiligidir. Nesneyi DB Migrate kurar. /// public async Task 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] }; } /// /// SQL Query Manager'daki CRUD endpoint diyaloguyla ayni endpoint kumesini /// () yalnizca crud/{Entity}.json seed dosyasina /// yazar; kayitlari DB Migrate ekler (CrudEndpointSeedApplier). Yetki diyalogunkiyle aynidir. /// public async Task 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]")}")] }; } public async Task> 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 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 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(); EnsureDetailedDescription(wizard); // 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); // Tek gruplu duzenleme formunda grup basligi popup basligini tekrarlar; baslik yalnizca // birden fazla grubu birbirinden ayirmak icin yazilir. var editGroups = wizard.Groups .Where(group => group.Items.Any(item => item.IncludeInEditingForm && item.FieldName != wizard.KeyFieldName)) .ToList(); if (editGroups.Count == 1) editGroups[0].Caption = string.Empty; // Satirlar deploy'dan once dogrulanir; gecersiz girdi yarim bir deploy birakmaz. var seedRows = await ParseSeedRowsAsync(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(); await wizardAppService.Create(wizard); 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); await timesheetRecorder.RecordAsync(McpTimesheetTexts.DeployWizard, file?.FileName ?? wizard.WizardName, seedRows.Count); return file; } /// /// 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. /// 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]); } /// /// Demo satirlarini okur ve ekranin alanlarina gore dogrular. Satirlar yalnizca SeedData acik /// liste ekraninda kabul edilir: migrate onlari ekranin veri dosyasindan ekler. /// private async Task>> ParseSeedRowsAsync(string? json, ListFormWizardDto wizard) { if (string.IsNullOrWhiteSpace(json)) return []; if (wizard.ComponentKind == WizardComponentKindEnum.Custom || !wizard.SeedData) throw new UserFriendlyException(L[McpErrorCodes.SeedRowsNotAllowed]); List>? rows; try { rows = JsonSerializer.Deserialize>>(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]); } /// /// 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. /// private async Task>> FindSameTableScreensAsync(ListFormWizardDto wizard, List columns) { var owners = new Dictionary>(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; } return owners; } /// /// Demo satirlarini ekranin veri seed hedefine (ListForm.SeedFilePath; tanima gore tek /// dosya ya da satir klasoru) yazar; veritabanina dokunmaz. Migrate ekrani kurduktan sonra /// ListFormSeedDataApplier 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. /// private async Task WriteSeedRowsAsync(string fileName, List> rows) { var listForm = (await wizardAppService.GetFile(fileName)).ListForm; var target = ListFormSeedTarget.Create(SeedPathResolver.GetRootPath(configuration), listForm?.SeedFilePath); if (listForm == null || target == null) 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 Row)>(); 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)); } // 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]); } /// Satirin anahtar disindaki icerigi; tekrar deploy'da ayni satiri tanimak icin. private static string RowContent(Dictionary 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)}")); /// /// Ekranin tablosu ancak acikken ve Wizard Manager'daki onayla ayni /// kosullarda (secilebilir aday) dusurulur; onayi kullanicidan almak istemcinin isidir. /// public async Task DeleteWizardAsync(string fileName, bool dropTables) { 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; } /// /// 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. /// public async Task 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; } /// /// 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. /// public async Task 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 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); } /// /// 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. /// public async Task 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); } /// /// Aciklama talebin yazildigi haliyle kalir ve base64 gorsel tasir; uretim notlari analize /// eklenir. Ekleme sunucuda, var olan metne dokunmadan yapilir. /// 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); } /// /// 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. /// public async Task AddDevRequestSubTasksAsync(string request, List 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); } public async Task GetModuleDocAsync(string name) { await EnsureToolAllowedAsync(McpToolNames.GetModuleDoc); return await moduleDocAppService.GetByNameAsync(name); } public async Task 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 }; } /// Id ile ya da tam baslikla bulur; ayni basligi tasiyan birden fazla talep varsa Id istenir. private async Task 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))]) }; } /// /// Kapanis analizi wizard export'unun yetkisini ister; o yetki dogrulamanin kendisini /// dusurmesin diye yoksa kontrol atlanir ve nedeni raporlanir. /// private async Task 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 VerifyMenuAsync(ListFormWizardDto wizard, List 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; } /// /// Ekranin sorgusunu query_list ile ayni servisten calistirir; kolon, veri kaynagi ve yetki /// hatalari burada ortaya cikar. /// private async Task 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 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 }; public async Task> 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 GetComponentAsync(string name) { await EnsureToolAllowedAsync(McpToolNames.GetComponent); return await FindComponentAsync(name); } public async Task 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 { 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; } var updated = await componentAppService.UpdateAsync(existing.Id, new CreateUpdateCustomComponentDto { 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; } 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); } /// /// Wizard'i seed dosyasi biciminden ({"Wizard": {...}, "ListForm": {...}, "Fields": [...]}) /// ya da duz cevaplardan okur; Menu/Language bloklari ve WizardCode/ModuleId adlari dosyadakiyle /// ayni anlama gelir. ListForm/Fields bolumleri ekrana katman olarak uygulanir /// (). /// private ListFormWizardDto ParseWizard(string? json) { try { return (string.IsNullOrWhiteSpace(json) ? null : WizardSeedFileDto.FromDeployJson(json)) ?? throw new UserFriendlyException(L[McpErrorCodes.InvalidWizard]); } catch (JsonException ex) { // Genel mesajla model hangi degerin bozuk oldugunu bilemez ve ayni JSON'u tekrar gonderir. throw new UserFriendlyException(L[McpErrorCodes.InvalidWizardValue, ex.Path ?? "$", ex.Message]); } } private async Task> GetAllComponentsAsync() { var page = await componentAppService.GetListAsync(new PagedAndSortedResultRequestDto { MaxResultCount = ComponentPageSize, Sorting = nameof(CustomComponentDto.Name) }); return page.Items; } private async Task 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)); } /// /// Props icinde tasarimci belgesi varsa ve kip acikca code degilse kod belgeden /// uretilir; tasarimcinin varsayilan kipi visual'dir. /// 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 }; }