Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
223b087
License Download Feature - Endpoints implementation
SobiyaChainee Jul 23, 2025
856e14e
Copilot review changes updated
SobiyaChainee Jul 24, 2025
9c6858b
Refactor licence download feature endpoints, service, repository and …
SobiyaChainee Jul 28, 2025
4b197ea
Refactor license download feature endpoints, service, repository. Add…
SobiyaChainee Jul 29, 2025
c2bdbdb
Applied suggested changes
SobiyaChainee Jul 31, 2025
55bceb6
Added client methods, Updated GetAllLicense responses with MvpName
SobiyaChainee Aug 6, 2025
d9ab260
Merge branch 'main' into License-feature
robearlam Aug 7, 2025
8046927
Revert Directory.Packages.props
SobiyaChainee Aug 7, 2025
09af04a
Revert Directory.Packages.props
SobiyaChainee Aug 7, 2025
bffe96e
Updated License model : PatchLicenseBody updated
SobiyaChainee Aug 7, 2025
8447fd1
Merge branch 'License-feature' of https://github.com/SobiyaChainee/Mv…
SobiyaChainee Aug 7, 2025
49a8f18
Revert Directory.Packages.props
SobiyaChainee Aug 7, 2025
edc2be4
GetLicenseAsync functionality added
SobiyaChainee Aug 7, 2025
100646c
Merge branch 'License-feature' of https://github.com/SobiyaChainee/Mv…
SobiyaChainee Aug 7, 2025
6236b15
Applied suggested changes
SobiyaChainee Aug 22, 2025
b79c764
Revert unintended changes in Directory.Packages.props
SobiyaChainee Aug 26, 2025
71b30f9
License Update client method updated.
SobiyaChainee Aug 28, 2025
14fecd9
Changes Applied
SobiyaChainee Sep 14, 2025
cf507ff
Updating license assignedUser by User Id changes.
SobiyaChainee Sep 16, 2025
217766a
License Upload Adoption
IvanLieckens Jan 23, 2026
77ad59e
Bump version
IvanLieckens Jan 23, 2026
621f3d4
Align version with latest
IvanLieckens Jan 23, 2026
3a931bf
Merge branch 'main' into feature/license
IvanLieckens Jan 23, 2026
e153747
Small fix
IvanLieckens Jan 23, 2026
67a6b54
Added auto assignment of available licenses
IvanLieckens Jan 26, 2026
22035ad
Added content type to file response type
IvanLieckens Jan 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Add steps that publish symbols, save build artifacts, deploy, and more:
# https://docs.microsoft.com/azure/devops/pipelines/apps/aspnet/build-aspnet-4

name: 4.24.1$(Rev:.r)
name: 4.25.0$(Rev:.r)

trigger:
branches:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Http;
using Mvp.Selections.Domain;

namespace Mvp.Selections.Api.Helpers.Interfaces;

public interface ILicenseZipParser
{
Task<IList<License>> ParseAsync(IFormFile zipFile);
}
99 changes: 99 additions & 0 deletions src/Mvp.Selections.Api/Helpers/LicenseZipParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Xml;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Mvp.Selections.Api.Helpers.Interfaces;
using Mvp.Selections.Domain;

namespace Mvp.Selections.Api.Helpers;

public class LicenseZipParser(ILogger<LicenseZipParser> logger)
: ILicenseZipParser
{
public async Task<IList<License>> ParseAsync(IFormFile zipFile)
{
List<License> licenses = [];
await using Stream zipStream = zipFile.OpenReadStream();
using ZipArchive archive = new(zipStream, ZipArchiveMode.Read);

foreach (ZipArchiveEntry entry in archive.Entries)
{
string? xmlContent = null;
if (entry.FullName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
await using Stream nestedStream = entry.Open();
using ZipArchive nestedArchive = new(nestedStream, ZipArchiveMode.Read);
ZipArchiveEntry? nestedXmlEntry = nestedArchive.Entries.FirstOrDefault(e => e.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));

if (nestedXmlEntry != null)
{
xmlContent = await ReadContentFromEntryAsync(nestedXmlEntry);
}
else
{
logger.LogWarning("No XML file found in nested zip: {EntryName}", entry.FullName);
}
}
else if (entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
xmlContent = await ReadContentFromEntryAsync(entry);
}
else
{
logger.LogInformation("Skipping unsupported file type: {EntryName}", entry.FullName);
}

if (!string.IsNullOrEmpty(xmlContent))
{
XmlDocument xmlDoc = new();
xmlDoc.LoadXml(xmlContent);

XmlNodeList expirationNode = xmlDoc.GetElementsByTagName("expiration");

if (expirationNode.Count > 0)
{
string expiration = expirationNode[0]!.InnerText;
if (!string.IsNullOrEmpty(expiration))
{
// ReSharper disable once StringLiteralTypo - This is the correct format
DateTime expiry = DateTime.ParseExact(expiration, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);

string base64Content = Convert.ToBase64String(Encoding.UTF8.GetBytes(xmlContent));

License license = new(Guid.NewGuid())
{
LicenseContent = base64Content,
ExpirationDate = expiry,
AssignedUser = null,
};

licenses.Add(license);
}
else
{
logger.LogWarning("Expiration date is empty in XML for entry: {EntryName}", entry.FullName);
}
}
else
{
logger.LogWarning("Expiration node not found in XML for entry: {EntryName}", entry.FullName);
}
}
else
{
logger.LogInformation("No XML content found in entry: {EntryName}", entry.FullName);
}
}

return licenses;
}

private static async Task<string> ReadContentFromEntryAsync(ZipArchiveEntry entry)
{
await using Stream entryStream = entry.Open();
using StreamReader reader = new(entryStream);
return await reader.ReadToEndAsync();
}
}
119 changes: 119 additions & 0 deletions src/Mvp.Selections.Api/Licenses.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Mvp.Selections.Api.Extensions;
using Mvp.Selections.Api.Helpers.Interfaces;
using Mvp.Selections.Api.Model.Request;
using Mvp.Selections.Api.Serialization;
using Mvp.Selections.Api.Serialization.ContractResolvers;
using Mvp.Selections.Api.Serialization.Interfaces;
using Mvp.Selections.Api.Services.Interfaces;
using Mvp.Selections.Domain;

namespace Mvp.Selections.Api;

// ReSharper disable once ClassNeverInstantiated.Global - Instantiated by Azure Functions
public class Licenses(
ILogger<Licenses> logger,
ISerializer serializer,
IAuthService authService,
ILicenseService licenseService,
ILicenseZipParser licenseZipParser) : Base<Licenses>(logger, serializer, authService)
{
private const string UserIdQueryStringKey = "userId";

private const string ActivePastDateTimeQueryStringKey = "activePastDateTime";

[Function("UploadLicenses")]
public Task<IActionResult> Upload(
[HttpTrigger(AuthorizationLevel.Anonymous, PostMethod, Route = "v1/licenses/upload")]
HttpRequest req)
{
return ExecuteSafeSecurityValidatedAsync(req, [Right.Admin], async _ =>
{
IFormFile? file = req.Form.Files.Count > 0 ? req.Form.Files[0] : null;
IList<License> licenseList = file != null ? await licenseZipParser.ParseAsync(file) : [];
OperationResult<IList<License>> result = await licenseService.AddAsync(licenseList);

return ContentResult(result, LicenseContractResolver.Instance);
});
}

[Function("UpdateLicense")]
public async Task<IActionResult> Update(
[HttpTrigger(AuthorizationLevel.Anonymous, PatchMethod, Route = "v1/licenses/{id:Guid}")]
HttpRequest req,
Guid id)
{
return await ExecuteSafeSecurityValidatedAsync(req, [Right.Admin], async _ =>
{
DeserializationResult<License> deserializationResult = await Serializer.DeserializeAsync<License>(req.Body, true);

OperationResult<License> result = deserializationResult.Object != null
? await licenseService.UpdateAsync(id, deserializationResult.Object, deserializationResult.PropertyKeys)
: new OperationResult<License>
{
StatusCode = HttpStatusCode.BadRequest,
Messages = { "Invalid license data." }
};

return ContentResult(result, LicenseContractResolver.Instance);
});
}

[Function("GetAllLicenses")]
public async Task<IActionResult> GetAll(
[HttpTrigger(AuthorizationLevel.Anonymous, GetMethod, Route = "v1/licenses")]
HttpRequest req)
{
return await ExecuteSafeSecurityValidatedAsync(req, [Right.Admin], async _ =>
{
ListParameters listParameters = new(req);
Guid? userId = req.Query.GetFirstValueOrDefault<Guid?>(UserIdQueryStringKey);
DateTime? activePastDate = req.Query.GetFirstValueOrDefault<DateTime?>(ActivePastDateTimeQueryStringKey);
IList<License> licenses = await licenseService.GetAllAsync(activePastDate, userId, listParameters.Page, listParameters.PageSize);
return ContentResult(licenses, LicenseContractResolver.Instance);
});
}

[Function("GetLicense")]
public async Task<IActionResult> Get(
[HttpTrigger(AuthorizationLevel.Anonymous, GetMethod, Route = "v1/licenses/{id:Guid}")]
HttpRequest req,
Guid id)
{
return await ExecuteSafeSecurityValidatedAsync(req, [Right.Admin], async _ =>
{
OperationResult<License> getResult = await licenseService.GetAsync(id);
return ContentResult(getResult, LicenseContractResolver.Instance);
});
}

[Function("DownloadLicense")]
public Task<IActionResult> Download(
[HttpTrigger(AuthorizationLevel.Anonymous, GetMethod, Route = "v1/users/current/licenses/current/download")]
HttpRequest req)
{
return ExecuteSafeSecurityValidatedAsync(req, [Right.Any], async authResult =>
{
IActionResult result;
OperationResult<License> getResult = await licenseService.GetActiveForUserAsync(authResult.User!);
if (getResult is { StatusCode: HttpStatusCode.OK, Result: not null })
{
byte[] contentBytes = Convert.FromBase64String(getResult.Result.LicenseContent);
result = new FileContentResult(contentBytes, "application/xml")
{
FileDownloadName = "license.xml"
};
}
else
{
result = ContentResult(getResult);
}

return result;
});
}
}
3 changes: 3 additions & 0 deletions src/Mvp.Selections.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static void Main(string[] args)
services.AddScoped<ISerializer, JsonSerializer>();
services.AddScoped<ICurrentUserNameProvider, CurrentUserNameProvider>();
services.AddScoped<Data.Interfaces.ICurrentUserNameProvider>(s => s.GetRequiredService<ICurrentUserNameProvider>());
services.AddScoped<ILicenseZipParser, LicenseZipParser>();

// Services
services.AddScoped<IAuthService, AuthService>();
Expand All @@ -85,6 +86,7 @@ public static void Main(string[] args)
services.AddScoped<ITitleService, TitleService>();
services.AddScoped<IMvpProfileService, UserService>();
services.AddScoped<IMentorService, UserService>();
services.AddScoped<ILicenseService, LicenseService>();

// Repositories
services.AddScoped<IUserRepository, UserRepository>();
Expand All @@ -104,6 +106,7 @@ public static void Main(string[] args)
services.AddScoped<ICommentRepository, CommentRepository>();
services.AddScoped<ITitleRepository, TitleRepository>();
services.AddScoped<IDispatchRepository, DispatchRepository>();
services.AddScoped<ILicenseRepository, LicenseRepository>();

// Database
services.AddDbContextPool<Context>(options =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Mvp.Selections.Api.Serialization.ContractResolvers;

internal class CommentsContractResolver : CamelCasePropertyNamesContractResolver
public class CommentsContractResolver : CamelCasePropertyNamesContractResolver
{
public static readonly CommentsContractResolver Instance = new();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Reflection;
using Mvp.Selections.Domain;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace Mvp.Selections.Api.Serialization.ContractResolvers;

public class LicenseContractResolver : CamelCasePropertyNamesContractResolver
{
public static readonly LicenseContractResolver Instance = new();
private readonly string[] _licenseExcludedMembers = [nameof(License.LicenseContent)];
private readonly string[] _userIncludedMembers = [nameof(User.Id), nameof(User.Name), nameof(User.Email)];

protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
if (member.DeclaringType == typeof(License) && _licenseExcludedMembers.Contains(member.Name))
{
return null!;
}

if (member.ReflectedType == typeof(User) && !_userIncludedMembers.Contains(member.Name))
{
return null!;
}

return base.CreateProperty(member, memberSerialization);
}
}
19 changes: 19 additions & 0 deletions src/Mvp.Selections.Api/Services/Interfaces/ILicenseService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Mvp.Selections.Api.Model.Request;
using Mvp.Selections.Domain;

namespace Mvp.Selections.Api.Services.Interfaces;

public interface ILicenseService
{
Task<OperationResult<IList<License>>> AddAsync(IList<License> licenses);

Task<OperationResult<License>> AddAsync(License license);

Task<OperationResult<License>> UpdateAsync(Guid licenseId, License license, IList<string> propertyKeys);

Task<IList<License>> GetAllAsync(DateTime? activePastDateTime = null, Guid? userId = null, int page = 1, short pageSize = 100);

Task<OperationResult<License>> GetAsync(Guid id);

Task<OperationResult<License>> GetActiveForUserAsync(User user);
}
2 changes: 2 additions & 0 deletions src/Mvp.Selections.Api/Services/Interfaces/ITitleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ Task<IList<Title>> GetAllAsync(
Task RemoveAsync(Guid id);

Task<Title?> GetAsync(Guid id);

Task<Title?> GetForUserInYearAsync(Guid userId, short year);
}
Loading
Loading