Added the Attachments in job comments APIs and removed list of comments from job tickets details API

This commit is contained in:
ashutosh.nehete 2025-11-14 10:04:51 +05:30
parent 611d7753bb
commit 2f04339b4d
6 changed files with 203 additions and 125 deletions

View File

@ -1,8 +1,11 @@
namespace Marco.Pms.Model.Dtos.ServiceProject using Marco.Pms.Model.Utilities;
namespace Marco.Pms.Model.Dtos.ServiceProject
{ {
public class JobCommentDto public class JobCommentDto
{ {
public required Guid JobTicketId { get; set; } public required Guid JobTicketId { get; set; }
public required string Comment { get; set; } public required string Comment { get; set; }
public List<FileUploadModel>? Attachments { get; set; }
} }
} }

View File

@ -13,6 +13,6 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject
public BasicEmployeeVM? CreatedBy { get; set; } public BasicEmployeeVM? CreatedBy { get; set; }
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public BasicEmployeeVM? UpdatedBy { get; set; } public BasicEmployeeVM? UpdatedBy { get; set; }
public List<DocumentVM>? Documents { get; set; } public List<DocumentVM>? Attachments { get; set; }
} }
} }

View File

@ -19,6 +19,5 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject
public BasicEmployeeVM? CreatedBy { get; set; } public BasicEmployeeVM? CreatedBy { get; set; }
public List<TagVM>? Tags { get; set; } public List<TagVM>? Tags { get; set; }
public List<JobUpdateLogVM>? UpdateLogs { get; set; } public List<JobUpdateLogVM>? UpdateLogs { get; set; }
public List<JobCommentVM>? Comments { get; set; }
} }
} }

View File

@ -499,6 +499,7 @@ namespace Marco.Pms.Services.MappingProfiles
#region ======================================================= Document ======================================================= #region ======================================================= Document =======================================================
CreateMap<Document, DocumentVM>();
CreateMap<DocumentMongoDB, BasicDocumentVM>() CreateMap<DocumentMongoDB, BasicDocumentVM>()
.ForMember( .ForMember(
dest => dest.DocumentId, dest => dest.DocumentId,

View File

@ -67,11 +67,7 @@ namespace Marco.Pms.Services.Service
/// <param name="loggedInEmployee">Employee requesting the statuses.</param> /// <param name="loggedInEmployee">Employee requesting the statuses.</param>
/// <param name="tenantId">Tenant identifier for multi-tenant scope.</param> /// <param name="tenantId">Tenant identifier for multi-tenant scope.</param>
/// <returns>ApiResponse containing list of job statuses or error details.</returns> /// <returns>ApiResponse containing list of job statuses or error details.</returns>
public async Task<ApiResponse<object>> GetJobStatusAsync( public async Task<ApiResponse<object>> GetJobStatusAsync(Guid? statusId, Guid? projectId, Employee loggedInEmployee, Guid tenantId)
Guid? statusId,
Guid? projectId,
Employee loggedInEmployee,
Guid tenantId)
{ {
_logger.LogDebug("GetJobStatusAsync called by employee {EmployeeId} in tenant {TenantId}", loggedInEmployee.Id, tenantId); _logger.LogDebug("GetJobStatusAsync called by employee {EmployeeId} in tenant {TenantId}", loggedInEmployee.Id, tenantId);

View File

@ -1,15 +1,16 @@
using AutoMapper; using AutoMapper;
using Marco.Pms.DataAccess.Data; using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Dtos.ServiceProject; using Marco.Pms.Model.Dtos.ServiceProject;
using Marco.Pms.Model.Employees; using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Master; using Marco.Pms.Model.Master;
using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.ServiceProject;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.DocumentManager;
using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Master;
using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.ServiceProject; using Marco.Pms.Model.ViewModels.ServiceProject;
using Marco.Pms.Services.Helpers;
using Marco.Pms.Services.Service.ServiceInterfaces; using Marco.Pms.Services.Service.ServiceInterfaces;
using MarcoBMS.Services.Service; using MarcoBMS.Services.Service;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -23,7 +24,7 @@ namespace Marco.Pms.Services.Service
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate
private readonly ILoggingService _logger; private readonly ILoggingService _logger;
private readonly CacheUpdateHelper _cache; private readonly S3UploadService _s3Service;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918"); private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918");
@ -33,13 +34,13 @@ namespace Marco.Pms.Services.Service
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
ApplicationDbContext context, ApplicationDbContext context,
ILoggingService logger, ILoggingService logger,
CacheUpdateHelper cache, S3UploadService s3Service,
IMapper mapper) IMapper mapper)
{ {
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_context = context; _context = context;
_logger = logger; _logger = logger;
_cache = cache; _s3Service = s3Service;
_mapper = mapper; _mapper = mapper;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@ -560,15 +561,6 @@ namespace Marco.Pms.Services.Service
.ToListAsync(); .ToListAsync();
}); });
var commentTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobComments
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(jc => jc.JobTicketId == id && jc.TenantId == tenantId)
.ToListAsync();
});
var updateLogTask = Task.Run(async () => var updateLogTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
@ -578,7 +570,7 @@ namespace Marco.Pms.Services.Service
.ToListAsync(); .ToListAsync();
}); });
await Task.WhenAll(assigneeTask, tagTask, commentTask, updateLogTask); await Task.WhenAll(assigneeTask, tagTask, updateLogTask);
// Map update logs with status descriptions // Map update logs with status descriptions
var jobUpdateLogVMs = updateLogTask.Result.Select(ul => var jobUpdateLogVMs = updateLogTask.Result.Select(ul =>
@ -595,13 +587,7 @@ namespace Marco.Pms.Services.Service
}; };
}).ToList(); }).ToList();
// Map comments, assignees, and tags to their respective viewmodels // Map assignees, and tags to their respective viewmodels
var commentVMs = _mapper.Map<List<JobCommentVM>>(commentTask.Result);
commentVMs = commentVMs.Select(vm =>
{
vm.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket);
return vm;
}).ToList();
var assigneeVMs = _mapper.Map<List<BasicEmployeeVM>>(assigneeTask.Result); var assigneeVMs = _mapper.Map<List<BasicEmployeeVM>>(assigneeTask.Result);
var tagVMs = _mapper.Map<List<TagVM>>(tagTask.Result); var tagVMs = _mapper.Map<List<TagVM>>(tagTask.Result);
@ -610,7 +596,6 @@ namespace Marco.Pms.Services.Service
response.Assignees = assigneeVMs; response.Assignees = assigneeVMs;
response.Tags = tagVMs; response.Tags = tagVMs;
response.UpdateLogs = jobUpdateLogVMs; response.UpdateLogs = jobUpdateLogVMs;
response.Comments = commentVMs;
_logger.LogInfo("Job ticket details assembled successfully for JobTicketId: {JobTicketId}", id); _logger.LogInfo("Job ticket details assembled successfully for JobTicketId: {JobTicketId}", id);
@ -623,91 +608,6 @@ namespace Marco.Pms.Services.Service
} }
} }
/// <summary>
/// Retrieves a paginated list of comments for a specified job ticket within a tenant context.
/// </summary>
/// <param name="jobTicketId">Optional job ticket identifier to filter comments.</param>
/// <param name="pageNumber">Page number (1-based index) for pagination.</param>
/// <param name="pageSize">Page size for pagination.</param>
/// <param name="loggedInEmployee">Employee making the request (for logging).</param>
/// <param name="tenantId">Tenant context to scope data.</param>
/// <returns>ApiResponse with paged comment view models or error details.</returns>
public async Task<ApiResponse<object>> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("TenantId missing in comment list request by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid pagination parameters in comment list request. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400);
}
try
{
_logger.LogInfo("Fetching comment list for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
var commentQuery = _context.JobComments
.Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status)
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(jc =>
jc.TenantId == tenantId &&
jc.JobTicket != null &&
jc.CreatedBy != null &&
jc.CreatedBy.JobRole != null);
// Validate and filter by job ticket if specified
if (jobTicketId.HasValue)
{
var jobTicketExists = await _context.JobTickets.AnyAsync(jt =>
jt.Id == jobTicketId && jt.TenantId == tenantId);
if (!jobTicketExists)
{
_logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found.", 404);
}
commentQuery = commentQuery.Where(jc => jc.JobTicketId == jobTicketId.Value);
}
var totalEntities = await commentQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
var comments = await commentQuery
.OrderByDescending(jc => jc.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var commentVMs = _mapper.Map<List<JobCommentVM>>(comments);
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = commentVMs,
};
_logger.LogInfo("{Count} comments fetched successfully for jobTicketId {JobTicketId} by employee {EmployeeId}",
commentVMs.Count, jobTicketId ?? Guid.Empty, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, $"{commentVMs.Count} record(s) fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching comments for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to fetch comments. Please try again later.", 500);
}
}
/// <summary> /// <summary>
/// Retrieves all job tags associated with the tenant, ordered alphabetically by name. /// Retrieves all job tags associated with the tenant, ordered alphabetically by name.
/// </summary> /// </summary>
@ -928,21 +828,145 @@ namespace Marco.Pms.Services.Service
} }
} }
#endregion
#region =================================================================== Job Comments Functions ===================================================================
/// <summary> /// <summary>
/// Adds a new comment to an existing job ticket within the tenant context. /// Retrieves a paginated list of comments with attachments for a specified job ticket within a tenant context.
/// </summary> /// </summary>
/// <param name="model">Comment data transfer object containing the job ticket ID and comment text.</param> /// <param name="jobTicketId">Optional job ticket ID to filter comments.</param>
/// <param name="loggedInEmployee">Employee adding the comment (for auditing).</param> /// <param name="pageNumber">Page number (1-based) for pagination.</param>
/// <param name="tenantId">Tenant identifier to enforce multi-tenancy.</param> /// <param name="pageSize">Page size for pagination.</param>
/// <returns>ApiResponse with the created comment view or error details.</returns> /// <param name="loggedInEmployee">Employee making the request (for authorization and logging).</param>
/// <param name="tenantId">Tenant context ID for multi-tenancy.</param>
/// <returns>ApiResponse with paged comments, attachments, and metadata, or error details.</returns>
public async Task<ApiResponse<object>> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("TenantId missing in comment list request by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid pagination parameters in comment list request. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400);
}
try
{
_logger.LogInfo("Fetching comment list for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
var commentQuery = _context.JobComments
.Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status)
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(jc => jc.TenantId == tenantId && jc.JobTicket != null && jc.CreatedBy != null && jc.CreatedBy.JobRole != null);
// Filter by jobTicketId if provided after verifying existence
if (jobTicketId.HasValue)
{
var jobTicketExists = await _context.JobTickets.AnyAsync(jt =>
jt.Id == jobTicketId && jt.TenantId == tenantId);
if (!jobTicketExists)
{
_logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found.", 404);
}
commentQuery = commentQuery.Where(jc => jc.JobTicketId == jobTicketId.Value);
}
// Calculate total count for pagination
var totalEntities = await commentQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paged comments ordered by creation date descending
var comments = await commentQuery
.OrderByDescending(jc => jc.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var commentIds = comments.Select(jc => jc.Id).ToList();
// Fetch attachments for current page comments
var attachments = await _context.JobAttachments
.Include(ja => ja.Document)
.Where(ja => ja.JobCommentId.HasValue &&
ja.Document != null &&
commentIds.Contains(ja.JobCommentId.Value) &&
ja.TenantId == tenantId)
.ToListAsync();
// Map comments and attach corresponding documents with pre-signed URLs for access
var commentVMs = comments.Select(jc =>
{
var relatedDocuments = attachments
.Where(ja => ja.JobCommentId == jc.Id)
.Select(ja => ja.Document!)
.ToList();
var mappedComment = _mapper.Map<JobCommentVM>(jc);
if (relatedDocuments.Any())
{
mappedComment.Attachments = relatedDocuments.Select(doc =>
{
var docVM = _mapper.Map<DocumentVM>(doc);
docVM.PreSignedUrl = _s3Service.GeneratePreSignedUrl(doc.S3Key);
docVM.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(doc.ThumbS3Key) ?
_s3Service.GeneratePreSignedUrl(doc.S3Key) :
_s3Service.GeneratePreSignedUrl(doc.ThumbS3Key);
return docVM;
}).ToList();
}
return mappedComment;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = commentVMs
};
_logger.LogInfo("{Count} comments fetched successfully for jobTicketId {JobTicketId} by employee {EmployeeId}",
commentVMs.Count, jobTicketId ?? Guid.Empty, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, $"{commentVMs.Count} record(s) fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching comments for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to fetch comments. Please try again later.", 500);
}
}
/// <summary>
/// Adds a comment with optional attachments to a job ticket within the specified tenant context.
/// </summary>
/// <param name="model">DTO containing comment content, job ticket ID, and attachments.</param>
/// <param name="loggedInEmployee">Employee making the comment (for auditing).</param>
/// <param name="tenantId">Tenant context for data isolation.</param>
/// <returns>ApiResponse containing created comment details or relevant error information.</returns>
public async Task<ApiResponse<object>> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId) public async Task<ApiResponse<object>> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId)
{ {
// Validate tenant context
if (tenantId == Guid.Empty) if (tenantId == Guid.Empty)
{ {
_logger.LogWarning("Add comment attempt with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); _logger.LogWarning("Add comment attempt with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403); return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
} }
// Validate input DTO
if (model == null || model.JobTicketId == Guid.Empty || string.IsNullOrWhiteSpace(model.Comment)) if (model == null || model.JobTicketId == Guid.Empty || string.IsNullOrWhiteSpace(model.Comment))
{ {
_logger.LogInfo("Invalid comment model provided by employee {EmployeeId}", loggedInEmployee.Id); _logger.LogInfo("Invalid comment model provided by employee {EmployeeId}", loggedInEmployee.Id);
@ -953,7 +977,7 @@ namespace Marco.Pms.Services.Service
{ {
_logger.LogInfo("Attempting to add comment to job ticket {JobTicketId} by employee {EmployeeId}", model.JobTicketId, loggedInEmployee.Id); _logger.LogInfo("Attempting to add comment to job ticket {JobTicketId} by employee {EmployeeId}", model.JobTicketId, loggedInEmployee.Id);
// Verify if the job ticket exists within tenant and load status for response mapping // Verify the job ticket's existence and load minimal required info
var jobTicket = await _context.JobTickets var jobTicket = await _context.JobTickets
.Include(jt => jt.Status) .Include(jt => jt.Status)
.AsNoTracking() .AsNoTracking()
@ -965,22 +989,78 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404); return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404);
} }
// Create and save new comment entity // Create new comment entity
var comment = new JobComment var comment = new JobComment
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
JobTicketId = jobTicket.Id, JobTicketId = jobTicket.Id,
Comment = model.Comment.Trim(), Comment = model.Comment.Trim(),
IsActive = true,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedById = loggedInEmployee.Id, CreatedById = loggedInEmployee.Id,
TenantId = tenantId TenantId = tenantId
}; };
_context.JobComments.Add(comment); _context.JobComments.Add(comment);
// Handle attachments if provided
if (model.Attachments?.Any() ?? false)
{
var batchId = Guid.NewGuid();
var documents = new List<Document>();
var attachments = new List<JobAttachment>();
foreach (var attachment in model.Attachments)
{
string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(base64))
{
_logger.LogWarning("Attachment missing base64 data in comment for job ticket {JobTicketId} by employee {EmployeeId}", jobTicket.Id, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Attachment image data is missing or invalid.", 400);
}
// Determine content type and generate storage keys
var fileType = _s3Service.GetContentTypeFromBase64(base64);
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job");
var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/{fileName}";
// Upload file asynchronously to S3
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
// Create document record for uploaded file
var document = new Document
{
Id = Guid.NewGuid(),
BatchId = batchId,
FileName = attachment.FileName ?? fileName,
ContentType = fileType,
S3Key = objectKey,
FileSize = attachment.FileSize,
UploadedAt = DateTime.UtcNow,
UploadedById = loggedInEmployee.Id,
TenantId = tenantId
};
documents.Add(document);
// Link document as attachment to the comment
attachments.Add(new JobAttachment
{
Id = Guid.NewGuid(),
DocumentId = document.Id,
StatusId = jobTicket.StatusId,
JobCommentId = comment.Id,
TenantId = tenantId
});
}
_context.Documents.AddRange(documents);
_context.JobAttachments.AddRange(attachments);
}
// Persist all inserts in database
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Map response including basic job ticket info // Prepare response with mapped comment, creator info, and basic job ticket info
var response = _mapper.Map<JobCommentVM>(comment); var response = _mapper.Map<JobCommentVM>(comment);
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket); response.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket);
_logger.LogInfo("Successfully added comment {CommentId} to job ticket {JobTicketId} by employee {EmployeeId}", _logger.LogInfo("Successfully added comment {CommentId} to job ticket {JobTicketId} by employee {EmployeeId}",
@ -996,7 +1076,6 @@ namespace Marco.Pms.Services.Service
} }
} }
#endregion #endregion
} }
} }