forked from xiongyuxing/tiku-backend.net
feat: add tenant admin direct operations endpoints
This commit is contained in:
217
Tiku.Api/Contracts/TenantAdminDirectDtos.cs
Normal file
217
Tiku.Api/Contracts/TenantAdminDirectDtos.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class TenantAdminClassQueryDto
|
||||
{
|
||||
public Guid? RegionId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public TenantAdminClassFilter ToFilter()
|
||||
{
|
||||
return new TenantAdminClassFilter(RegionId, Status, Keyword, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminClassMemberQueryDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ClassId { get; set; }
|
||||
|
||||
public string? MemberType { get; set; }
|
||||
public string? Status { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public TenantAdminClassMemberFilter ToFilter()
|
||||
{
|
||||
return new TenantAdminClassMemberFilter(ClassId, MemberType, Status, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminStudentQueryDto
|
||||
{
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? ClassId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public TenantAdminStudentFilter ToFilter()
|
||||
{
|
||||
return new TenantAdminStudentFilter(RegionId, ClassId, Status, Keyword, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminStudentActivityQueryDto
|
||||
{
|
||||
public Guid? StudentUserId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public TenantAdminStudentActivityFilter ToFilter()
|
||||
{
|
||||
return new TenantAdminStudentActivityFilter(StudentUserId, Status, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminClassDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? RegionId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantAdminClassCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantAdminClassCommand(Id, RegionId, LegacyId, Code, Name, Description, Status, Order, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminUserLookupDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
|
||||
public UserLookupCommand ToCommand()
|
||||
{
|
||||
return new UserLookupCommand(UserId, Username, Email, Phone, Name, AvatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminClassMemberDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ClassId { get; set; }
|
||||
|
||||
public TenantAdminUserLookupDto User { get; set; } = new();
|
||||
public string? MemberType { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantAdminClassMemberCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantAdminClassMemberCommand(ClassId, User.ToCommand(), MemberType, Status, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RemoveTenantAdminClassMemberDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ClassMemberId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminStudentDto
|
||||
{
|
||||
public TenantAdminUserLookupDto User { get; set; } = new();
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? SelectedSchoolId { get; set; }
|
||||
public Guid? SelectedMajorId { get; set; }
|
||||
public string? AvatarPreset { get; set; }
|
||||
public JsonElement Stats { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Progress { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantAdminStudentCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantAdminStudentCommand(
|
||||
User.ToCommand(),
|
||||
RegionId,
|
||||
SelectedSchoolId,
|
||||
SelectedMajorId,
|
||||
AvatarPreset,
|
||||
Stats,
|
||||
Progress,
|
||||
ModuleSelections,
|
||||
RawProfile);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTenantAdminStudentStatusDto
|
||||
{
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdateTenantAdminStudentStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdateTenantAdminStudentStatusCommand(UserId, Status, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminStudentNoteDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid StudentUserId { get; set; }
|
||||
|
||||
public string? NoteType { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public string? Visibility { get; set; }
|
||||
public bool? IsPinned { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantAdminStudentNoteCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantAdminStudentNoteCommand(Id, StudentUserId, NoteType, Content, Visibility, IsPinned, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminStudentFollowupDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid StudentUserId { get; set; }
|
||||
|
||||
public Guid? AssignedToUserId { get; set; }
|
||||
public Guid? ClassId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? FollowupType { get; set; }
|
||||
public string? Priority { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantAdminStudentFollowupCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantAdminStudentFollowupCommand(
|
||||
Id,
|
||||
StudentUserId,
|
||||
AssignedToUserId,
|
||||
ClassId,
|
||||
Title,
|
||||
Description,
|
||||
FollowupType,
|
||||
Priority,
|
||||
Status,
|
||||
DueAt,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
159
Tiku.Api/Controllers/TenantAdminDirectController.cs
Normal file
159
Tiku.Api/Controllers/TenantAdminDirectController.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-admin")]
|
||||
public sealed class TenantAdminDirectController(
|
||||
ITenantAdminDirectService tenantAdminService,
|
||||
ICurrentUser currentUser,
|
||||
ICurrentTenant currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("classes")]
|
||||
[EndpointSummary("查询租户班级")]
|
||||
[ProducesResponseType<TenantAdminClassList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminClassList>> GetClasses(
|
||||
[FromQuery] TenantAdminClassQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetClassesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("classes")]
|
||||
[EndpointSummary("新增或更新租户班级")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> UpsertClass(
|
||||
UpsertTenantAdminClassDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertClassAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("classes/disable")]
|
||||
[EndpointSummary("停用租户班级")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassItem>>> DisableClass(
|
||||
[FromBody] Guid classId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.DisableClassAsync(ResolveActor(), classId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("classes/members")]
|
||||
[EndpointSummary("查询班级成员")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminClassMemberItem>>> GetClassMembers(
|
||||
[FromQuery] TenantAdminClassMemberQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetClassMembersAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("classes/members")]
|
||||
[EndpointSummary("新增或更新班级成员")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> UpsertClassMember(
|
||||
UpsertTenantAdminClassMemberDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertClassMemberAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("classes/members/remove")]
|
||||
[EndpointSummary("移除班级成员")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminClassMemberItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminClassMemberItem>>> RemoveClassMember(
|
||||
RemoveTenantAdminClassMemberDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.RemoveClassMemberAsync(ResolveActor(), request.ClassMemberId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("students")]
|
||||
[EndpointSummary("查询租户学生")]
|
||||
[ProducesResponseType<TenantAdminStudentList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminStudentList>> GetStudents(
|
||||
[FromQuery] TenantAdminStudentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetStudentsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("students")]
|
||||
[EndpointSummary("新增或更新租户学生档案")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentItem>>> UpsertStudent(
|
||||
UpsertTenantAdminStudentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertStudentAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/status")]
|
||||
[EndpointSummary("更新租户学生状态")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentStatusItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentStatusItem>>> UpdateStudentStatus(
|
||||
UpdateTenantAdminStudentStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("student-notes")]
|
||||
[EndpointSummary("查询学生备注")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminStudentNoteItem>>> GetStudentNotes(
|
||||
[FromQuery] TenantAdminStudentActivityQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetStudentNotesAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("student-notes")]
|
||||
[EndpointSummary("新增或更新学生备注")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentNoteItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentNoteItem>>> UpsertStudentNote(
|
||||
UpsertTenantAdminStudentNoteDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertStudentNoteAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("student-followups")]
|
||||
[EndpointSummary("查询学生跟进")]
|
||||
[ProducesResponseType<CatalogList<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantAdminStudentFollowupItem>>> GetStudentFollowups(
|
||||
[FromQuery] TenantAdminStudentActivityQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetStudentFollowupsAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("student-followups")]
|
||||
[EndpointSummary("新增或更新学生跟进")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantAdminStudentFollowupItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantAdminStudentFollowupItem>>> UpsertStudentFollowup(
|
||||
UpsertTenantAdminStudentFollowupDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertStudentFollowupAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private TenantAdminActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("Tenant admin actor was not resolved.", "tenant_admin_access_denied");
|
||||
}
|
||||
|
||||
return new TenantAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
@@ -161,6 +162,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantAdminDirectException tenantAdminDirectException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
tenantAdminDirectException.Message,
|
||||
TenantAdminDirectStatusCode(tenantAdminDirectException.Code),
|
||||
tenantAdminDirectException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ObjectStorageException storageException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -285,4 +296,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int TenantAdminDirectStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"tenant_admin_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"class_not_found" or "class_member_not_found" or "student_not_found" or "user_not_found" => StatusCodes.Status404NotFound,
|
||||
"tenant_member_not_found" => StatusCodes.Status400BadRequest,
|
||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status400BadRequest,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
72
Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs
Normal file
72
Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
|
||||
namespace Tiku.Application.TenantAdmin;
|
||||
|
||||
public interface ITenantAdminDirectService
|
||||
{
|
||||
Task<TenantAdminClassList> GetClassesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminClassCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminClassItem>> DisableClassAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminClassMemberItem>> GetClassMembersAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassMemberFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminClassMemberCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminClassMemberItem>> RemoveClassMemberAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classMemberId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminStudentList> GetStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminStudentStatusItem>> UpdateStudentStatusAsync(
|
||||
TenantAdminActor actor,
|
||||
UpdateTenantAdminStudentStatusCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminStudentNoteItem>> UpsertStudentNoteAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentNoteCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminStudentFollowupItem>> GetStudentFollowupsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminStudentFollowupItem>> UpsertStudentFollowupAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentFollowupCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
210
Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs
Normal file
210
Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.TenantAdmin;
|
||||
|
||||
public sealed record TenantAdminActor(Guid TenantId, Guid UserId);
|
||||
|
||||
public sealed record TenantAdminClassFilter(
|
||||
Guid? RegionId = null,
|
||||
string? Status = null,
|
||||
string? Keyword = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminClassMemberFilter(
|
||||
Guid ClassId,
|
||||
string? MemberType = null,
|
||||
string? Status = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminStudentFilter(
|
||||
Guid? RegionId = null,
|
||||
Guid? ClassId = null,
|
||||
string? Status = null,
|
||||
string? Keyword = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminStudentActivityFilter(
|
||||
Guid? StudentUserId = null,
|
||||
string? Status = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record UpsertTenantAdminClassCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
string? LegacyId,
|
||||
string? Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Status,
|
||||
int? Order,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UserLookupCommand(
|
||||
Guid? UserId,
|
||||
string? Username,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? Name,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record UpsertTenantAdminClassMemberCommand(
|
||||
Guid ClassId,
|
||||
UserLookupCommand User,
|
||||
string? MemberType,
|
||||
string? Status,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpsertTenantAdminStudentCommand(
|
||||
UserLookupCommand User,
|
||||
Guid? RegionId,
|
||||
Guid? SelectedSchoolId,
|
||||
Guid? SelectedMajorId,
|
||||
string? AvatarPreset,
|
||||
JsonElement Stats,
|
||||
JsonElement Progress,
|
||||
JsonElement ModuleSelections,
|
||||
JsonElement RawProfile);
|
||||
|
||||
public sealed record UpdateTenantAdminStudentStatusCommand(
|
||||
Guid UserId,
|
||||
string? Status,
|
||||
string? Reason);
|
||||
|
||||
public sealed record UpsertTenantAdminStudentNoteCommand(
|
||||
Guid? Id,
|
||||
Guid StudentUserId,
|
||||
string? NoteType,
|
||||
string Content,
|
||||
string? Visibility,
|
||||
bool? IsPinned,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpsertTenantAdminStudentFollowupCommand(
|
||||
Guid? Id,
|
||||
Guid StudentUserId,
|
||||
Guid? AssignedToUserId,
|
||||
Guid? ClassId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? FollowupType,
|
||||
string? Priority,
|
||||
string? Status,
|
||||
DateTimeOffset? DueAt,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record TenantAdminClassList(
|
||||
IReadOnlyCollection<TenantAdminClassItem> Items,
|
||||
bool Scoped);
|
||||
|
||||
public sealed record TenantAdminStudentList(
|
||||
IReadOnlyCollection<TenantAdminStudentItem> Items,
|
||||
bool Scoped);
|
||||
|
||||
public sealed record TenantAdminClassItem(
|
||||
Guid Id,
|
||||
Guid? RegionId,
|
||||
string? RegionName,
|
||||
string? LegacyId,
|
||||
string? Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
TenantRecordStatus Status,
|
||||
int Order,
|
||||
JsonElement Metadata,
|
||||
int StudentCount,
|
||||
int StaffCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TenantAdminClassMemberItem(
|
||||
Guid Id,
|
||||
Guid ClassId,
|
||||
Guid UserId,
|
||||
TenantClassMemberType MemberType,
|
||||
TenantClassMemberStatus Status,
|
||||
DateTimeOffset JoinedAt,
|
||||
DateTimeOffset? LeftAt,
|
||||
JsonElement Metadata,
|
||||
TenantAdminUserSummary User);
|
||||
|
||||
public sealed record TenantAdminUserSummary(
|
||||
Guid Id,
|
||||
string? Username,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? Name,
|
||||
string? AvatarUrl,
|
||||
string PrimaryRole);
|
||||
|
||||
public sealed record TenantAdminStudentItem(
|
||||
Guid MembershipId,
|
||||
Guid UserId,
|
||||
MembershipStatus Status,
|
||||
TenantAdminUserSummary User,
|
||||
Guid? ProfileId,
|
||||
string AvatarPreset,
|
||||
Guid? RegionId,
|
||||
string? RegionName,
|
||||
Guid? SelectedSchoolId,
|
||||
string? SelectedSchoolName,
|
||||
Guid? SelectedMajorId,
|
||||
string? SelectedMajorName,
|
||||
JsonElement Stats,
|
||||
JsonElement Progress,
|
||||
JsonElement ModuleSelections,
|
||||
IReadOnlyCollection<TenantAdminStudentClassSummary> Classes,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TenantAdminStudentClassSummary(
|
||||
Guid ClassId,
|
||||
string ClassName,
|
||||
string? ClassCode,
|
||||
TenantClassMemberType MemberType,
|
||||
DateTimeOffset JoinedAt);
|
||||
|
||||
public sealed record TenantAdminStudentStatusItem(
|
||||
Guid MembershipId,
|
||||
Guid UserId,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TenantAdminStudentNoteItem(
|
||||
Guid Id,
|
||||
Guid StudentUserId,
|
||||
StudentNoteType NoteType,
|
||||
string Content,
|
||||
StudentNoteVisibility Visibility,
|
||||
bool IsPinned,
|
||||
JsonElement Metadata,
|
||||
Guid? CreatedBy,
|
||||
Guid? UpdatedBy,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TenantAdminStudentFollowupItem(
|
||||
Guid Id,
|
||||
Guid StudentUserId,
|
||||
Guid? AssignedToUserId,
|
||||
Guid? ClassId,
|
||||
string Title,
|
||||
string? Description,
|
||||
StudentFollowupType FollowupType,
|
||||
StudentFollowupPriority Priority,
|
||||
StudentFollowupStatus Status,
|
||||
DateTimeOffset? DueAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
Guid? CompletedBy,
|
||||
JsonElement Metadata,
|
||||
Guid? CreatedBy,
|
||||
Guid? UpdatedBy,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed class TenantAdminDirectException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Tiku.Application.Profile;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
@@ -22,6 +23,7 @@ using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.StudyContent;
|
||||
using Tiku.Infrastructure.TenantAdmin;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -58,6 +60,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
services.AddScoped<IAssetManagementService, AssetManagementService>();
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate(
|
||||
AliyunOssOptions.BeValid,
|
||||
|
||||
896
Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs
Normal file
896
Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs
Normal file
@@ -0,0 +1,896 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.TenantAdmin;
|
||||
|
||||
public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminClassList> GetClassesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.TenantClasses.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Status))
|
||||
{
|
||||
query = query.Where(item => item.Status == ParseEnum<TenantRecordStatus>(filter.Status, "invalid_class_status"));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(item => item.Status != TenantRecordStatus.Archived);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item =>
|
||||
item.Name.Contains(keyword) ||
|
||||
(item.Code != null && item.Code.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new
|
||||
{
|
||||
Class = item,
|
||||
RegionName = dbContext.Regions
|
||||
.Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId)
|
||||
.Select(region => region.Name)
|
||||
.FirstOrDefault(),
|
||||
StudentCount = dbContext.TenantClassMembers.Count(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == item.Id &&
|
||||
member.Status == TenantClassMemberStatus.Active &&
|
||||
member.MemberType == TenantClassMemberType.Student),
|
||||
StaffCount = dbContext.TenantClassMembers.Count(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == item.Id &&
|
||||
member.Status == TenantClassMemberStatus.Active &&
|
||||
member.MemberType != TenantClassMemberType.Student)
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new TenantAdminClassList(
|
||||
items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(),
|
||||
Scoped: false);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminClassCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
|
||||
var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Code = Normalize(command.Code);
|
||||
item.Name = command.Name.Trim();
|
||||
item.Description = Normalize(command.Description);
|
||||
item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status");
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
item.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.TenantClasses.Add(item);
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var regionName = item.RegionId.HasValue
|
||||
? await dbContext.Regions
|
||||
.Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value)
|
||||
.Select(region => region.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
return new ContentManagementResult<TenantAdminClassItem>(ToClassItem(item, regionName, 0, 0));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminClassItem>> DisableClassAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await dbContext.TenantClasses
|
||||
.FirstOrDefaultAsync(entity => entity.TenantId == actor.TenantId && entity.Id == classId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
||||
}
|
||||
|
||||
item.Status = TenantRecordStatus.Disabled;
|
||||
item.UpdatedBy = actor.UserId;
|
||||
await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminClassItem>(ToClassItem(item, null, 0, 0));
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantAdminClassMemberItem>> GetClassMembersAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassMemberFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertClassAsync(actor.TenantId, filter.ClassId, cancellationToken);
|
||||
var query = dbContext.TenantClassMembers.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.MemberType))
|
||||
{
|
||||
query = query.Where(item => item.MemberType == ParseEnum<TenantClassMemberType>(filter.MemberType, "invalid_class_member_type"));
|
||||
}
|
||||
|
||||
query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"));
|
||||
|
||||
var items = await query
|
||||
.OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 :
|
||||
item.MemberType == TenantClassMemberType.Teacher ? 1 :
|
||||
item.MemberType == TenantClassMemberType.Assistant ? 2 : 9)
|
||||
.ThenBy(item => item.JoinedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Join(
|
||||
dbContext.Users.AsNoTracking(),
|
||||
member => member.UserId,
|
||||
user => user.Id,
|
||||
(member, user) => ToClassMemberItem(member, ToUserSummary(user)))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<TenantAdminClassMemberItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminClassMemberCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken);
|
||||
var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type");
|
||||
var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status");
|
||||
var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken);
|
||||
await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken);
|
||||
if (memberType == TenantClassMemberType.Student)
|
||||
{
|
||||
await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken);
|
||||
}
|
||||
|
||||
var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == command.ClassId &&
|
||||
member.UserId == user.Id &&
|
||||
member.MemberType == memberType,
|
||||
cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new TenantClassMember
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ClassId = command.ClassId,
|
||||
UserId = user.Id,
|
||||
MemberType = memberType,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
item.Status = status;
|
||||
item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
item.UpdatedBy = actor.UserId;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.TenantClassMembers.Add(item);
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> RemoveClassMemberAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classMemberId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await dbContext.TenantClassMembers
|
||||
.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.Id == classMemberId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found");
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken);
|
||||
item.Status = TenantClassMemberStatus.Removed;
|
||||
item.LeftAt = DateTimeOffset.UtcNow;
|
||||
item.UpdatedBy = actor.UserId;
|
||||
await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||||
}
|
||||
|
||||
public async Task<TenantAdminStudentList> GetStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status");
|
||||
var query = dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status);
|
||||
|
||||
if (filter.ClassId.HasValue)
|
||||
{
|
||||
await AssertClassAsync(actor.TenantId, filter.ClassId.Value, cancellationToken);
|
||||
query = query.Where(item => dbContext.TenantClassMembers.Any(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == filter.ClassId.Value &&
|
||||
member.UserId == item.UserId &&
|
||||
member.MemberType == TenantClassMemberType.Student &&
|
||||
member.Status == TenantClassMemberStatus.Active));
|
||||
}
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => dbContext.StudentProfiles.Any(profile =>
|
||||
profile.TenantId == actor.TenantId &&
|
||||
profile.UserId == item.UserId &&
|
||||
profile.RegionId == filter.RegionId.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => dbContext.Users.Any(user =>
|
||||
user.Id == item.UserId &&
|
||||
((user.Username != null && user.Username.Contains(keyword)) ||
|
||||
(user.Phone != null && user.Phone.Contains(keyword)) ||
|
||||
(user.Email != null && user.Email.Contains(keyword)) ||
|
||||
(user.Name != null && user.Name.Contains(keyword)))));
|
||||
}
|
||||
|
||||
var memberships = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var userIds = memberships.Select(item => item.UserId).ToArray();
|
||||
var users = await dbContext.Users.AsNoTracking()
|
||||
.Where(user => userIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
||||
var profiles = await dbContext.StudentProfiles.AsNoTracking()
|
||||
.Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId))
|
||||
.ToDictionaryAsync(profile => profile.UserId, cancellationToken);
|
||||
var regions = await dbContext.Regions.AsNoTracking()
|
||||
.Where(region => region.TenantId == actor.TenantId)
|
||||
.ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken);
|
||||
var schools = await dbContext.Schools.AsNoTracking()
|
||||
.Where(school => school.TenantId == actor.TenantId)
|
||||
.ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken);
|
||||
var majors = await dbContext.Majors.AsNoTracking()
|
||||
.Where(major => major.TenantId == actor.TenantId)
|
||||
.ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken);
|
||||
var classes = await (
|
||||
from member in dbContext.TenantClassMembers.AsNoTracking()
|
||||
join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id
|
||||
where member.TenantId == actor.TenantId &&
|
||||
tenantClass.TenantId == actor.TenantId &&
|
||||
userIds.Contains(member.UserId) &&
|
||||
member.Status == TenantClassMemberStatus.Active &&
|
||||
member.MemberType == TenantClassMemberType.Student
|
||||
orderby tenantClass.SortOrder, tenantClass.CreatedAt descending
|
||||
select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var classLookup = classes
|
||||
.GroupBy(item => item.UserId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(item => new TenantAdminStudentClassSummary(
|
||||
item.ClassId,
|
||||
item.Name,
|
||||
item.Code,
|
||||
item.MemberType,
|
||||
item.JoinedAt)).ToArray() as IReadOnlyCollection<TenantAdminStudentClassSummary>);
|
||||
|
||||
return new TenantAdminStudentList(
|
||||
memberships.Select(membership =>
|
||||
{
|
||||
users.TryGetValue(membership.UserId, out var user);
|
||||
profiles.TryGetValue(membership.UserId, out var profile);
|
||||
classLookup.TryGetValue(membership.UserId, out var studentClasses);
|
||||
return ToStudentItem(
|
||||
membership,
|
||||
user ?? new User { Id = membership.UserId },
|
||||
profile,
|
||||
regions,
|
||||
schools,
|
||||
majors,
|
||||
studentClasses ?? []);
|
||||
}).ToArray(),
|
||||
Scoped: false);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
|
||||
var user = await ResolveUserAsync(command.User, "student", cancellationToken);
|
||||
if (command.RawProfile.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
user.RawProfile = command.RawProfile.Clone();
|
||||
}
|
||||
|
||||
var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
|
||||
var profile = await EnsureStudentProfileAsync(
|
||||
actor.TenantId,
|
||||
user.Id,
|
||||
command.RegionId,
|
||||
command.SelectedSchoolId,
|
||||
command.SelectedMajorId,
|
||||
Normalize(command.AvatarPreset),
|
||||
command.Stats,
|
||||
command.Progress,
|
||||
command.ModuleSelections,
|
||||
cancellationToken);
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ContentManagementResult<TenantAdminStudentItem>(
|
||||
ToStudentItem(
|
||||
membership,
|
||||
user,
|
||||
profile,
|
||||
new Dictionary<Guid, string>(),
|
||||
new Dictionary<Guid, string>(),
|
||||
new Dictionary<Guid, string>(),
|
||||
[]));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminStudentStatusItem>> UpdateStudentStatusAsync(
|
||||
TenantAdminActor actor,
|
||||
UpdateTenantAdminStudentStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status");
|
||||
var membership = await dbContext.TenantMemberships
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("Student membership was not found.", "student_not_found");
|
||||
}
|
||||
|
||||
membership.Status = status;
|
||||
if (status != MembershipStatus.Active)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessions = await dbContext.AuthSessions
|
||||
.Where(session => session.TenantId == actor.TenantId && session.UserId == command.UserId && session.RevokedAt == null)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
session.RevokedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminStudentStatusItem>(
|
||||
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.TenantStudentNotes.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.StudentUserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.IsPinned)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => ToNoteItem(item))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<TenantAdminStudentNoteItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminStudentNoteItem>> UpsertStudentNoteAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentNoteCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Content);
|
||||
await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken);
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.TenantStudentNotes.FirstOrDefaultAsync(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value, cancellationToken)
|
||||
: null;
|
||||
var isNew = item is null;
|
||||
item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId };
|
||||
item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type");
|
||||
item.Content = command.Content.Trim();
|
||||
item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility");
|
||||
item.IsPinned = command.IsPinned ?? item.IsPinned;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
item.UpdatedBy = actor.UserId;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.TenantStudentNotes.Add(item);
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminStudentNoteItem>(ToNoteItem(item));
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantAdminStudentFollowupItem>> GetStudentFollowupsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.TenantStudentFollowups.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.StudentUserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Status))
|
||||
{
|
||||
query = query.Where(item => item.Status == ParseEnum<StudentFollowupStatus>(filter.Status, "invalid_student_followup_status"));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled)
|
||||
.ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => ToFollowupItem(item))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<TenantAdminStudentFollowupItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantAdminStudentFollowupItem>> UpsertStudentFollowupAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminStudentFollowupCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||||
await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken);
|
||||
await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken);
|
||||
await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.TenantStudentFollowups.FirstOrDefaultAsync(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value, cancellationToken)
|
||||
: null;
|
||||
var isNew = item is null;
|
||||
item ??= new TenantStudentFollowup
|
||||
{
|
||||
Id = command.Id ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
StudentUserId = command.StudentUserId,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
item.AssignedToUserId = command.AssignedToUserId;
|
||||
item.ClassId = command.ClassId;
|
||||
item.Title = command.Title.Trim();
|
||||
item.Description = Normalize(command.Description);
|
||||
item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type");
|
||||
item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority");
|
||||
item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status");
|
||||
item.DueAt = command.DueAt;
|
||||
item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null;
|
||||
item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
item.UpdatedBy = actor.UserId;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.TenantStudentFollowups.Add(item);
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminStudentFollowupItem>(ToFollowupItem(item));
|
||||
}
|
||||
|
||||
private async Task<User> ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken)
|
||||
{
|
||||
User? user = null;
|
||||
if (command.UserId.HasValue)
|
||||
{
|
||||
user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("User was not found.", "user_not_found");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var phone = Normalize(command.Phone);
|
||||
var email = Normalize(command.Email);
|
||||
var username = Normalize(command.Username);
|
||||
user = await dbContext.Users.FirstOrDefaultAsync(item =>
|
||||
(phone != null && item.Phone == phone) ||
|
||||
(email != null && item.Email == email) ||
|
||||
(username != null && item.Username == username),
|
||||
cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
if (phone is null && email is null && username is null && Normalize(command.Name) is null)
|
||||
{
|
||||
throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required");
|
||||
}
|
||||
|
||||
user = new User
|
||||
{
|
||||
Username = username ?? phone ?? email,
|
||||
Email = email,
|
||||
Phone = phone,
|
||||
Name = Normalize(command.Name) ?? username ?? phone ?? email,
|
||||
PrimaryRole = primaryRole,
|
||||
RawProfile = JsonDefaults.Object()
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
}
|
||||
}
|
||||
|
||||
user.Username = Normalize(command.Username) ?? user.Username;
|
||||
user.Email = Normalize(command.Email) ?? user.Email;
|
||||
user.Phone = Normalize(command.Phone) ?? user.Phone;
|
||||
user.Name = Normalize(command.Name) ?? user.Name;
|
||||
user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl;
|
||||
user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole;
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<TenantMembership> EnsureMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
TenantRole role,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId && item.UserId == userId && item.Role == role,
|
||||
cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
membership = new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active,
|
||||
Permissions = JsonDefaults.Object()
|
||||
};
|
||||
dbContext.TenantMemberships.Add(membership);
|
||||
}
|
||||
else
|
||||
{
|
||||
membership.Status = MembershipStatus.Active;
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private async Task<StudentProfile> EnsureStudentProfileAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
Guid? regionId,
|
||||
Guid? schoolId,
|
||||
Guid? majorId,
|
||||
string? avatarPreset,
|
||||
JsonElement stats,
|
||||
JsonElement progress,
|
||||
JsonElement moduleSelections,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId && item.UserId == userId,
|
||||
cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new StudentProfile
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Stats = JsonDefaults.Object(),
|
||||
Progress = JsonDefaults.Object(),
|
||||
ModuleSelections = JsonDefaults.Object(),
|
||||
RecentActivities = JsonDefaults.Array()
|
||||
};
|
||||
dbContext.StudentProfiles.Add(profile);
|
||||
}
|
||||
|
||||
profile.RegionId = regionId ?? profile.RegionId;
|
||||
profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId;
|
||||
profile.SelectedMajorId = majorId ?? profile.SelectedMajorId;
|
||||
profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset;
|
||||
profile.Stats = JsonObjectOrDefault(stats);
|
||||
profile.Progress = JsonObjectOrDefault(progress);
|
||||
profile.ModuleSelections = JsonObjectOrDefault(moduleSelections);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new TenantAdminDirectException("Student was not found.", "student_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!classId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.TenantClasses.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.Id == classId.Value,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertReferenceAsync<TEntity>(
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : TenantEntity
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.Set<TEntity>().AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TEntity?> ResolveTenantEntityAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string? legacyId,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : AuditableTenantEntity
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken);
|
||||
}
|
||||
|
||||
legacyId = Normalize(legacyId);
|
||||
if (legacyId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof(TEntity).GetProperty("LegacyId") is null
|
||||
? null
|
||||
: await set.FirstOrDefaultAsync(
|
||||
entity => entity.TenantId == tenantId && EF.Property<string?>(entity, "LegacyId") == legacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddAuditAsync(
|
||||
TenantAdminActor actor,
|
||||
string action,
|
||||
string targetType,
|
||||
Guid targetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ActorUserId = actor.UserId,
|
||||
Action = action,
|
||||
TargetType = targetType,
|
||||
TargetId = targetId.ToString(),
|
||||
Details = JsonDefaults.Object()
|
||||
});
|
||||
await Task.CompletedTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount)
|
||||
{
|
||||
return new TenantAdminClassItem(
|
||||
item.Id,
|
||||
item.RegionId,
|
||||
regionName,
|
||||
item.LegacyId,
|
||||
item.Code,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.Status,
|
||||
item.SortOrder,
|
||||
item.Metadata,
|
||||
studentCount,
|
||||
staffCount,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user)
|
||||
{
|
||||
return new TenantAdminClassMemberItem(
|
||||
item.Id,
|
||||
item.ClassId,
|
||||
item.UserId,
|
||||
item.MemberType,
|
||||
item.Status,
|
||||
item.JoinedAt,
|
||||
item.LeftAt,
|
||||
item.Metadata,
|
||||
user);
|
||||
}
|
||||
|
||||
private static TenantAdminStudentItem ToStudentItem(
|
||||
TenantMembership membership,
|
||||
User user,
|
||||
StudentProfile? profile,
|
||||
IReadOnlyDictionary<Guid, string> regions,
|
||||
IReadOnlyDictionary<Guid, string> schools,
|
||||
IReadOnlyDictionary<Guid, string> majors,
|
||||
IReadOnlyCollection<TenantAdminStudentClassSummary> classes)
|
||||
{
|
||||
return new TenantAdminStudentItem(
|
||||
membership.Id,
|
||||
membership.UserId,
|
||||
membership.Status,
|
||||
ToUserSummary(user),
|
||||
profile?.Id,
|
||||
profile?.AvatarPreset ?? "male",
|
||||
profile?.RegionId,
|
||||
profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null,
|
||||
profile?.SelectedSchoolId,
|
||||
profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null,
|
||||
profile?.SelectedMajorId,
|
||||
profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null,
|
||||
profile?.Stats ?? JsonDefaults.Object(),
|
||||
profile?.Progress ?? JsonDefaults.Object(),
|
||||
profile?.ModuleSelections ?? JsonDefaults.Object(),
|
||||
classes,
|
||||
membership.CreatedAt,
|
||||
membership.UpdatedAt);
|
||||
}
|
||||
|
||||
private static TenantAdminUserSummary ToUserSummary(User user)
|
||||
{
|
||||
return new TenantAdminUserSummary(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Email,
|
||||
user.Phone,
|
||||
user.Name,
|
||||
user.AvatarUrl,
|
||||
user.PrimaryRole);
|
||||
}
|
||||
|
||||
private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item)
|
||||
{
|
||||
return new TenantAdminStudentNoteItem(
|
||||
item.Id,
|
||||
item.StudentUserId,
|
||||
item.NoteType,
|
||||
item.Content,
|
||||
item.Visibility,
|
||||
item.IsPinned,
|
||||
item.Metadata,
|
||||
item.CreatedBy,
|
||||
item.UpdatedBy,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item)
|
||||
{
|
||||
return new TenantAdminStudentFollowupItem(
|
||||
item.Id,
|
||||
item.StudentUserId,
|
||||
item.AssignedToUserId,
|
||||
item.ClassId,
|
||||
item.Title,
|
||||
item.Description,
|
||||
item.FollowupType,
|
||||
item.Priority,
|
||||
item.Status,
|
||||
item.DueAt,
|
||||
item.CompletedAt,
|
||||
item.CompletedBy,
|
||||
item.Metadata,
|
||||
item.CreatedBy,
|
||||
item.UpdatedBy,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
return Math.Clamp(limit ?? 100, 1, 500);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback, string code)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum<TEnum>(value, code);
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string value, string code)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
foreach (var candidate in Enum.GetValues<TEnum>())
|
||||
{
|
||||
if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new TenantAdminDirectException("Enum value was invalid.", code);
|
||||
}
|
||||
}
|
||||
242
Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs
Normal file
242
Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class TenantAdminDirectEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_manage_classes_members_and_students()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var regionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Region { Id = regionId, TenantId = seed.TenantId, Name = "四川" });
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var classResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/classes",
|
||||
new UpsertTenantAdminClassDto
|
||||
{
|
||||
RegionId = regionId,
|
||||
Code = "A001",
|
||||
Name = "冲刺一班",
|
||||
Order = 1
|
||||
});
|
||||
var classJson = await ReadJsonAsync(classResponse);
|
||||
var classId = classJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
||||
|
||||
var memberResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/classes/members",
|
||||
new UpsertTenantAdminClassMemberDto
|
||||
{
|
||||
ClassId = classId,
|
||||
MemberType = "student",
|
||||
User = new TenantAdminUserLookupDto
|
||||
{
|
||||
Phone = "13900000001",
|
||||
Name = "张同学"
|
||||
},
|
||||
Metadata = JsonSerializer.SerializeToElement(new { source = "test" })
|
||||
});
|
||||
var memberJson = await ReadJsonAsync(memberResponse);
|
||||
var studentUserId = memberJson.RootElement.GetProperty("item").GetProperty("userId").GetGuid();
|
||||
|
||||
var listResponse = await client.GetAsync($"/api/tenant-admin/students?classId={classId}");
|
||||
var listJson = await ReadJsonAsync(listResponse);
|
||||
var classesResponse = await client.GetAsync("/api/tenant-admin/classes");
|
||||
var classesJson = await ReadJsonAsync(classesResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, classResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, memberResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
Assert.Equal(studentUserId, listJson.RootElement.GetProperty("items")[0].GetProperty("userId").GetGuid());
|
||||
Assert.Equal(1, classesJson.RootElement.GetProperty("items")[0].GetProperty("studentCount").GetInt32());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId));
|
||||
Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.TenantId == seed.TenantId && log.Action == "tenant.class_member.upserted"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_write_student_notes_and_followups()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var studentId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new User { Id = studentId, Phone = "13900000002", Name = "李同学" },
|
||||
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
|
||||
new StudentProfile { TenantId = seed.TenantId, UserId = studentId });
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var noteResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/student-notes",
|
||||
new UpsertTenantAdminStudentNoteDto
|
||||
{
|
||||
StudentUserId = studentId,
|
||||
NoteType = "learning",
|
||||
Content = "需要关注英语词汇",
|
||||
IsPinned = true
|
||||
});
|
||||
var followupResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/student-followups",
|
||||
new UpsertTenantAdminStudentFollowupDto
|
||||
{
|
||||
StudentUserId = studentId,
|
||||
AssignedToUserId = seed.UserId,
|
||||
Title = "电话回访",
|
||||
FollowupType = "service",
|
||||
Priority = "high",
|
||||
Status = "in_progress"
|
||||
});
|
||||
var notesResponse = await client.GetAsync($"/api/tenant-admin/student-notes?studentUserId={studentId}");
|
||||
var followupsResponse = await client.GetAsync($"/api/tenant-admin/student-followups?studentUserId={studentId}");
|
||||
var notesJson = await ReadJsonAsync(notesResponse);
|
||||
var followupsJson = await ReadJsonAsync(followupsResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, noteResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, followupResponse.StatusCode);
|
||||
Assert.Single(notesJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Single(followupsJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal("电话回访", followupsJson.RootElement.GetProperty("items")[0].GetProperty("title").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Student_status_update_revokes_active_sessions()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var studentId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new User { Id = studentId, Phone = "13900000003", Name = "王同学" },
|
||||
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
|
||||
new AuthSession
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = studentId,
|
||||
TokenHash = "student-session",
|
||||
Provider = "password",
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var statusResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/status",
|
||||
new UpdateTenantAdminStudentStatusDto
|
||||
{
|
||||
UserId = studentId,
|
||||
Status = "disabled",
|
||||
Reason = "测试禁用"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_operator_cannot_access_tenant_admin_direct_endpoints()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory, TenantRole.TenantOperator);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.GetAsync("/api/tenant-admin/students");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(
|
||||
ApiTestFactory factory,
|
||||
TenantRole role = TenantRole.TenantAdmin)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"137{Random.Shared.Next(10000000, 99999999)}";
|
||||
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Test Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
},
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = role,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new UserIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = "password",
|
||||
ProviderSubject = phone,
|
||||
Phone = phone,
|
||||
SecretPayload = CreateSecretPayload(passwordHash)
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement
|
||||
.GetProperty("tokens")
|
||||
.GetProperty("accessToken")
|
||||
.GetString();
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
|
||||
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user