Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#nullable enable
using System.ComponentModel.DataAnnotations;

namespace WalkingTec.Mvvm.Core.Analysis
{
/// <summary>
/// 儲存的 Analysis 查詢設定(維度、度量、篩選條件),支援 private/public 共享。
/// </summary>
public class AnalysisSavedQuery : BasePoco
{
[Required]
[StringLength(100, ErrorMessage = "Validate.{0}stringmax{1}")]
public string Name { get; set; } = string.Empty;

/// <summary>對應的 ListVM FullName(在 AnalysisVmRegistry 白名單中查找)。</summary>
[Required]
[StringLength(500)]
public string ListVmType { get; set; } = string.Empty;

/// <summary>JSON-serialized AnalysisQueryRequest(dims、msrs、filters、hierarchies)。</summary>
[Required]
public string ConfigJson { get; set; } = string.Empty;

/// <summary>擁有者的 ITCode(登入代碼)。</summary>
[StringLength(50)]
public string? OwnerCode { get; set; }

/// <summary>是否公開(其他使用者可載入,但不能刪除)。</summary>
public bool IsPublic { get; set; } = false;
}
}
35 changes: 35 additions & 0 deletions src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#nullable enable
using System;
using System.ComponentModel.DataAnnotations;

namespace WalkingTec.Mvvm.Core.Analysis
{
/// <summary>
/// 儲存查詢請求,包含查詢設定與顯示名稱。
/// </summary>
public class SaveQueryRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;

public bool IsPublic { get; set; } = false;

[Required]
public AnalysisQueryRequest Config { get; set; } = new();
}

/// <summary>
/// 儲存查詢清單項目 DTO(不含 ConfigJson,節省傳輸量)。
/// </summary>
public class SavedQuerySummaryDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string ListVmType { get; set; } = string.Empty;
public string OwnerCode { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public bool IsOwner { get; set; }
public DateTime? CreatedAt { get; set; }
}
}
3 changes: 3 additions & 0 deletions src/WalkingTec.Mvvm.Core/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using WalkingTec.Mvvm.Core.Analysis;
using WalkingTec.Mvvm.Core.Extensions;
using WalkingTec.Mvvm.Core.Models;
using WalkingTec.Mvvm.Core.Support.Json;
Expand Down Expand Up @@ -53,6 +54,7 @@
// public DbSet<Elsa_WorkflowExecutionLogRecord> Elsa_WorkflowExecutionLogRecords { get; set; }
// public DbSet<Elsa_WorkflowInstance> Elsa_WorkflowInstances { get; set; }
public DbSet<RefreshTokenEntity> FrameworkRefreshTokens { get; set; } = null!;
public DbSet<AnalysisSavedQuery> AnalysisSavedQueries { get; set; } = null!;

Check warning on line 57 in src/WalkingTec.Mvvm.Core/DataContext.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'FrameworkContext.AnalysisSavedQueries' hides inherited member 'EmptyContext.AnalysisSavedQueries'. Use the new keyword if hiding was intended.

Check warning on line 57 in src/WalkingTec.Mvvm.Core/DataContext.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'FrameworkContext.AnalysisSavedQueries' hides inherited member 'EmptyContext.AnalysisSavedQueries'. Use the new keyword if hiding was intended.

/// <summary>
/// FrameworkContext
Expand Down Expand Up @@ -448,6 +450,7 @@

public string? Version { get; set; }
public CS ConnectionString { get; set; } = null!;
public DbSet<AnalysisSavedQuery> AnalysisSavedQueries { get; set; } = null!;


/// <summary>
Expand Down
121 changes: 121 additions & 0 deletions src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,127 @@ public async Task<IActionResult> PivotExport([FromBody] AnalysisPivotRequest? re
"analysis_pivot.xlsx");
}

// ─── Saved Queries ─────────────────────────────────────────────────

/// <summary>
/// GET /_analysis/savedqueries?listVmType=Foo.BarListVM
/// 回傳目前使用者的私有查詢 + 所有公開查詢(依建立時間降序)。
/// </summary>
[HttpGet("savedqueries")]
[ProducesResponseType(typeof(IEnumerable<SavedQuerySummaryDto>), StatusCodes.Status200OK)]
public IActionResult ListSavedQueries([FromQuery] string listVmType)
{
if (string.IsNullOrWhiteSpace(listVmType))
return BadRequest("listVmType is required.");

var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty;

var rows = Wtm!.DC.Set<AnalysisSavedQuery>()
.Where(q => q.ListVmType == listVmType && (q.OwnerCode == userCode || q.IsPublic))
.OrderByDescending(q => q.CreateTime)
.Select(q => new SavedQuerySummaryDto
{
Id = q.ID,
Name = q.Name,
ListVmType = q.ListVmType,
OwnerCode = q.OwnerCode ?? string.Empty,
IsPublic = q.IsPublic,
IsOwner = q.OwnerCode == userCode,
CreatedAt = q.CreateTime
})
.ToList();

return Ok(rows);
}

/// <summary>
/// POST /_analysis/savedqueries
/// 儲存一個查詢設定,回傳新建立的記錄 ID。
/// </summary>
[HttpPost("savedqueries")]
[ProducesResponseType(typeof(object), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
public IActionResult SaveQuery([FromBody] SaveQueryRequest? req)
{
if (req == null) return BadRequest("Request body is required.");
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("查詢名稱不可為空。");
if (string.IsNullOrWhiteSpace(req.Config?.ListVmType)) return BadRequest("Config.ListVmType is required.");

try { _registry.Resolve(req.Config.ListVmType); }
catch (AnalysisVmNotFoundException ex) { return NotFound(ex.Message); }

var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty;
var configJson = JsonSerializer.Serialize(req.Config, _camelCase);

var entity = new AnalysisSavedQuery
{
Name = req.Name.Trim(),
ListVmType = req.Config.ListVmType,
ConfigJson = configJson,
OwnerCode = userCode,
IsPublic = req.IsPublic,
CreateTime = DateTime.Now,
CreateBy = userCode
};

Wtm!.DC.Set<AnalysisSavedQuery>().Add(entity);
Wtm.DC.SaveChanges();

_logger.LogInformation("Analysis saved query created Id={Id} Name={Name} ListVm={ListVm} Owner={Owner} IsPublic={IsPublic}",
entity.ID, entity.Name, entity.ListVmType, entity.OwnerCode, entity.IsPublic);

return CreatedAtAction(nameof(GetSavedQuery), new { id = entity.ID },
new { id = entity.ID, name = entity.Name });
}

/// <summary>
/// GET /_analysis/savedqueries/{id}
/// 載入指定 ID 的查詢設定(回傳 AnalysisQueryRequest)。
/// </summary>
[HttpGet("savedqueries/{id:guid}")]
[ProducesResponseType(typeof(AnalysisQueryRequest), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetSavedQuery(Guid id)
{
var entity = Wtm!.DC.Set<AnalysisSavedQuery>().FirstOrDefault(q => q.ID == id);
if (entity == null) return NotFound();

var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty;
if (!entity.IsPublic && entity.OwnerCode != userCode) return Forbid();

AnalysisQueryRequest? config;
try { config = JsonSerializer.Deserialize<AnalysisQueryRequest>(entity.ConfigJson, _camelCase); }
catch (JsonException) { return BadRequest("儲存的查詢格式無效。"); }

if (config == null) return BadRequest("儲存的查詢格式無效。");

return new JsonResult(config, _camelCase);
}

/// <summary>
/// DELETE /_analysis/savedqueries/{id}
/// 刪除儲存的查詢(只有擁有者可刪除)。
/// </summary>
[HttpDelete("savedqueries/{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult DeleteSavedQuery(Guid id)
{
var entity = Wtm!.DC.Set<AnalysisSavedQuery>().FirstOrDefault(q => q.ID == id);
if (entity == null) return NotFound();

var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty;
if (entity.OwnerCode != userCode) return Forbid();

Wtm.DC.Set<AnalysisSavedQuery>().Remove(entity);
Wtm.DC.SaveChanges();

_logger.LogInformation("Analysis saved query deleted Id={Id} Owner={Owner}", id, userCode);
return NoContent();
}

// ─── Helpers ───────────────────────────────────────────────────────

/// <summary>
Expand Down
145 changes: 145 additions & 0 deletions test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,7 @@ public async Task Export_happy_path_writes_ActionLog()
StringAssert.Contains(logMsg, "Export", "ActionLog 應記錄 ActionName 包含 Export");
}


[TestMethod]
public async Task Query_validation_failure_does_not_write_ActionLog()
{
Expand All @@ -2432,5 +2433,149 @@ public async Task Query_validation_failure_does_not_write_ActionLog()
Assert.AreEqual(0, actionLogger.Messages.Count,
"驗證失敗時不應寫入 ActionLog");
}

// ─── Saved Queries CRUD & 權限測試 ───────────────────────────────────────

[TestMethod]
public void SaveQuery_creates_new_record()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "testuser";

var req = new SaveQueryRequest
{
Name = "My Sales Query",
IsPublic = true,
Config = new AnalysisQueryRequest
{
ListVmType = typeof(SaleRecordListVM).FullName,
Dimensions = new List<string> { "Region" },
Measures = new List<MeasureRequest> { new MeasureRequest { Field = "Amount", Func = AggregateFunc.Sum } }
}
};

var result = controller.SaveQuery(req) as CreatedAtActionResult;
Assert.IsNotNull(result);

// Verify DB
var query = controller.Wtm.DC.Set<AnalysisSavedQuery>().FirstOrDefault();
Assert.IsNotNull(query);
Assert.AreEqual("My Sales Query", query.Name);
Assert.AreEqual(typeof(SaleRecordListVM).FullName, query.ListVmType);
Assert.AreEqual("testuser", query.OwnerCode);
Assert.IsTrue(query.IsPublic);
}

[TestMethod]
public void ListSavedQueries_returns_public_and_owned_queries()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "userA";

var vmType = typeof(SaleRecordListVM).FullName;
var configJson = "{}";

controller.Wtm.DC.Set<AnalysisSavedQuery>().AddRange(
new AnalysisSavedQuery { Name = "A_Private", ListVmType = vmType, OwnerCode = "userA", IsPublic = false, ConfigJson = configJson },
new AnalysisSavedQuery { Name = "A_Public", ListVmType = vmType, OwnerCode = "userA", IsPublic = true, ConfigJson = configJson },
new AnalysisSavedQuery { Name = "B_Private", ListVmType = vmType, OwnerCode = "userB", IsPublic = false, ConfigJson = configJson },
new AnalysisSavedQuery { Name = "B_Public", ListVmType = vmType, OwnerCode = "userB", IsPublic = true, ConfigJson = configJson }
);
controller.Wtm.DC.SaveChanges();

var result = controller.ListSavedQueries(vmType) as OkObjectResult;
Assert.IsNotNull(result);

var list = result.Value as IEnumerable<SavedQuerySummaryDto>;
Assert.IsNotNull(list);

var names = list.Select(x => x.Name).ToList();
Assert.IsTrue(names.Contains("A_Private"), "UserA 應能看到自己的 Private 查詢");
Assert.IsTrue(names.Contains("A_Public"));
Assert.IsFalse(names.Contains("B_Private"), "UserA 不應看到 UserB 的 Private 查詢");
Assert.IsTrue(names.Contains("B_Public"), "UserA 應能看到 UserB 的 Public 查詢");
}

[TestMethod]
public void GetSavedQuery_returns_config_for_authorized_user()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "userA";
var vmType = typeof(SaleRecordListVM).FullName;

var id = Guid.NewGuid();
controller.Wtm.DC.Set<AnalysisSavedQuery>().Add(
new AnalysisSavedQuery
{
ID = id,
Name = "Test",
ListVmType = vmType,
OwnerCode = "userB",
IsPublic = true,
ConfigJson = "{\"listVmType\":\"" + vmType + "\"}"
}
);
controller.Wtm.DC.SaveChanges();

var result = controller.GetSavedQuery(id) as JsonResult;
Assert.IsNotNull(result, "存取公開查詢應回傳 200");
var config = result.Value as AnalysisQueryRequest;
Assert.IsNotNull(config);
Assert.AreEqual(vmType, config.ListVmType);
}

[TestMethod]
public void GetSavedQuery_returns_403_for_unauthorized_user()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "userA";

var id = Guid.NewGuid();
controller.Wtm.DC.Set<AnalysisSavedQuery>().Add(
new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", IsPublic = false, ConfigJson = "{}" }
);
controller.Wtm.DC.SaveChanges();

var result = controller.GetSavedQuery(id) as ForbidResult;
Assert.IsNotNull(result, "存取他人的私有查詢應回傳 403 Forbid");
}

[TestMethod]
public void DeleteSavedQuery_removes_record_if_owner()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "userA";

var id = Guid.NewGuid();
controller.Wtm.DC.Set<AnalysisSavedQuery>().Add(
new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userA", ConfigJson = "{}" }
);
controller.Wtm.DC.SaveChanges();

var result = controller.DeleteSavedQuery(id) as NoContentResult;
Assert.IsNotNull(result);

var query = controller.Wtm.DC.Set<AnalysisSavedQuery>().FirstOrDefault(q => q.ID == id);
Assert.IsNull(query, "查詢應被刪除");
}

[TestMethod]
public void DeleteSavedQuery_returns_403_if_not_owner()
{
var controller = CreateController();
controller.Wtm.LoginUserInfo.ITCode = "userA";

var id = Guid.NewGuid();
controller.Wtm.DC.Set<AnalysisSavedQuery>().Add(
new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", ConfigJson = "{}" }
);
controller.Wtm.DC.SaveChanges();

var result = controller.DeleteSavedQuery(id) as ForbidResult;
Assert.IsNotNull(result, "刪除他人查詢應回傳 403 Forbid");

var query = controller.Wtm.DC.Set<AnalysisSavedQuery>().FirstOrDefault(q => q.ID == id);
Assert.IsNotNull(query, "查詢不應被刪除");
}
}
}
Loading