899 lines
36 KiB
C#
899 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Data;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
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;
|
|
|
|
/// <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
|
|
/// yalnizca MCP kapisidir, arkadaki yetkinin yerine gecmez.
|
|
/// </summary>
|
|
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
|
|
public class McpDevelopAppService(
|
|
IMcpServerConfigProvider serverConfigProvider,
|
|
IMcpDevGuide devGuide,
|
|
IMcpTableCreator tableCreator,
|
|
IListFormWizardAppService wizardAppService,
|
|
ICustomComponentAppService componentAppService,
|
|
IListFormSelectAppService listFormSelectAppService,
|
|
IRepository<Menu, Guid> menuRepository,
|
|
IRepository<PermissionDefinitionRecord, Guid> permissionRepository,
|
|
IRepository<DevRequest, Guid> devRequestRepository,
|
|
IMcpDevRequestTimesheetRecorder timesheetRecorder,
|
|
IConfiguration configuration)
|
|
: 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;
|
|
|
|
/// <summary>Baslikla eslesmeyen terim icin listelenen en fazla bolum.</summary>
|
|
private const int GuideSearchMaxResults = 15;
|
|
|
|
/// <summary>Custom Components ekraninin tek sayfada dondurebildigi ust sinir.</summary>
|
|
private const int ComponentPageSize = 1000;
|
|
|
|
private const string DevRequestUpdatePermission = PlatformConsts.AppCodes.DevRequests + ".Update";
|
|
|
|
/// <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";
|
|
}
|
|
|
|
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)]
|
|
};
|
|
}
|
|
|
|
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)]
|
|
};
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <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);
|
|
|
|
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();
|
|
|
|
// 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.
|
|
wizard.MenuOrder = wizard.EditFileName is null
|
|
? 0
|
|
: (await wizardAppService.GetFile(wizard.EditFileName)).Wizard?.MenuOrder ?? 0;
|
|
|
|
// 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 = 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();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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]);
|
|
|
|
var path = SeedPathResolver.GetScopePath(configuration, CurrentTenant.Id, relativePath.Split('/'));
|
|
var seedFile = File.Exists(path)
|
|
? JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions)
|
|
: null;
|
|
seedFile ??= new ListFormSeedDataFileDto { ListFormCode = listForm.ListFormCode, KeyFieldName = listForm.KeyFieldName };
|
|
|
|
var 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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <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
|
|
/// kullanici alir.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
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,
|
|
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 };
|
|
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
|
|
/// <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
|
|
};
|
|
}
|