Files
marechai/Marechai.Server/Controllers/SoftwareReleasesController.cs
Natalia Portillo 2572e24e83 Refactor error handling in services and controllers to use ExtractDetail method for consistent error messages
- Updated SoundSynthVideosController to return ProblemDetails for bad requests and conflicts.
- Modified UsersController to return ProblemDetails for user not found and account purge failures.
- Enhanced BooksService, DocumentsService, GpusService, InstructionSetExtensionsService, MachineFamiliesService, MagazinesService, ResolutionsService, ScreensService, SoftwareFamiliesService, SoftwarePlatformsService, SoftwareVersionsService, SoundSynthsService, and WwpcImportsService to utilize ExtractDetail for error messages from ApiException.
- Introduced ExtractDetail method to standardize error message extraction from ApiException across multiple services.
2026-06-04 15:41:26 +01:00

981 lines
46 KiB
C#

/*******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ---------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] -----------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ---------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Marechai.Data;
using Marechai.Data.Dtos;
using Marechai.Database.Models;
using Marechai.Server.Helpers;
using Marechai.Server.Services;
using Marechai.Server.Suggestions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Marechai.Server.Controllers;
[Route("/software/releases")]
[ApiController]
public class SoftwareReleasesController(MarechaiContext context,
SoftwareAttributeTranslationCache attrCache) : ControllerBase
{
[HttpGet("count")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<int> GetReleasesCountAsync([FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases;
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Software != null && r.Software.Name.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
return query.CountAsync();
}
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<List<SoftwareReleaseDto>> GetAsync([FromQuery] int? skip = null, [FromQuery] int? take = null,
[FromQuery] string search = null,
[FromQuery] string sortBy = null,
[FromQuery] bool sortDescending = false)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases;
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Software != null && r.Software.Name.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
query = sortBy switch
{
"Title" => sortDescending ? query.OrderByDescending(r => MarechaiContext.NaturalSortKey(r.Title)) : query.OrderBy(r => MarechaiContext.NaturalSortKey(r.Title)),
"Software" => sortDescending ? query.OrderByDescending(r => MarechaiContext.NaturalSortKey(r.Software.Name)) : query.OrderBy(r => MarechaiContext.NaturalSortKey(r.Software.Name)),
"Platform" => sortDescending ? query.OrderByDescending(r => MarechaiContext.NaturalSortKey(r.Platform.Name)) : query.OrderBy(r => MarechaiContext.NaturalSortKey(r.Platform.Name)),
"Publisher" => sortDescending ? query.OrderByDescending(r => MarechaiContext.NaturalSortKey(r.Publisher.Name)) : query.OrderBy(r => MarechaiContext.NaturalSortKey(r.Publisher.Name)),
"ReleaseDate" => sortDescending ? query.OrderByDescending(r => r.ReleaseDate) : query.OrderBy(r => r.ReleaseDate),
_ => query.OrderBy(r => r.Software != null ? r.Software.Name : r.Title)
.ThenBy(r => r.SoftwareVersion != null ? r.SoftwareVersion.VersionString : null)
};
if(skip.HasValue) query = query.Skip(skip.Value);
if(take.HasValue) query = query.Take(take.Value);
return query.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
}).ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
}).ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
}
[HttpGet("/software/versions/{versionId:ulong}/releases/count")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<int> GetReleasesCountByVersionAsync(ulong versionId, [FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases.Where(r => r.SoftwareVersionId == versionId);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
return query.CountAsync();
}
[HttpGet("/software/versions/{versionId:ulong}/releases")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<List<SoftwareReleaseDto>> GetByVersionAsync(ulong versionId, [FromQuery] int? skip = null,
[FromQuery] int? take = null,
[FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases.Where(r => r.SoftwareVersionId == versionId);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
query = query.OrderBy(r => r.ReleaseDate);
if(skip.HasValue) query = query.Skip(skip.Value);
if(take.HasValue) query = query.Take(take.Value);
return query.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
}).ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
}).ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
}
[HttpGet("/software/{softwareId:ulong}/releases/count")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<int> GetReleasesCountBySoftwareAsync(ulong softwareId, [FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases
.Where(r => r.SoftwareId == softwareId && !r.IsCompilation);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
return query.CountAsync();
}
[HttpGet("/software/{softwareId:ulong}/releases")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<List<SoftwareReleaseDto>> GetBySoftwareAsync(ulong softwareId, [FromQuery] int? skip = null,
[FromQuery] int? take = null,
[FromQuery] string search = null)
{
IQueryable<SoftwareRelease> query = context.SoftwareReleases
.Where(r => r.SoftwareId == softwareId && !r.IsCompilation);
if(!string.IsNullOrWhiteSpace(search))
query = query.Where(r => (r.Title != null && r.Title.Contains(search)) ||
(r.Platform != null && r.Platform.Name.Contains(search)) ||
(r.Publisher != null && r.Publisher.Name.Contains(search)));
query = query.OrderBy(r => r.SoftwareVersion.VersionString)
.ThenBy(r => r.ReleaseDate);
if(skip.HasValue) query = query.Skip(skip.Value);
if(take.HasValue) query = query.Take(take.Value);
return query.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
}).ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
}).ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
}
[HttpGet("{id:ulong}")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public Task<SoftwareReleaseDto> GetAsync(ulong id) => context.SoftwareReleases.Where(r => r.Id == id)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
SoftwareVersion = r.SoftwareVersion.VersionString,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
}).ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
}).ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.FirstOrDefaultAsync();
[HttpGet("{releaseId:ulong}/attributes")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<List<SoftwareAttributeDto>> GetAttributesAsync(ulong releaseId,
[FromQuery] string lang = null)
{
List<SoftwareAttributeDto> attributes = await context.SoftwareAttributes
.Where(a => a.SoftwareReleaseId == releaseId)
.Select(a => new SoftwareAttributeDto
{
Id = a.Id,
SoftwareReleaseId = a.SoftwareReleaseId,
Category = a.Category,
Key = a.Key,
Value = a.Value,
PlatformName = a.SoftwareRelease.Platform.Name,
RegionNames = string.Join(", ", a.SoftwareRelease.Regions.Select(r => r.UnM49.Name))
})
.OrderBy(a => a.Category)
.ThenBy(a => a.Key)
.ToListAsync();
// Importer stores attribute keys/values with U+00A0 (non-breaking space) — normalise here
// before lookup. Rating-category attributes are returned verbatim (only normalised); every
// other category routes through the SoftwareAttributeTranslationCache which falls back to
// the original normalised text when no translation row exists for the requested language.
string resolvedLang = LanguageResolver.Resolve(HttpContext, lang);
const string ratingCategory = SoftwareReleaseSuggestionApplier.AttributeCategoryRating;
foreach(SoftwareAttributeDto a in attributes)
{
if(string.Equals(a.Category, ratingCategory, StringComparison.Ordinal))
{
a.Key = SoftwareAttributeTranslationCache.NormalizeText(a.Key);
a.Value = SoftwareAttributeTranslationCache.NormalizeText(a.Value);
continue;
}
a.Key = await attrCache.GetTranslatedAsync(a.Key, resolvedLang, HttpContext.RequestAborted);
a.Value = await attrCache.GetTranslatedAsync(a.Value, resolvedLang, HttpContext.RequestAborted);
}
return attributes;
}
[HttpPut("{id:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> UpdateAsync(ulong id, [FromBody] SoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease model = await context.SoftwareReleases.FindAsync(id);
if(model is null) return NotFound();
// Enforce mutual exclusivity: cannot change IsCompilation after creation
if(model.IsCompilation != dto.IsCompilation)
return Problem(detail: "Cannot change a release between single-release and compilation modes.", statusCode: StatusCodes.Status400BadRequest);
// Cannot change SoftwareId after creation
if(model.SoftwareId != dto.SoftwareId)
return Problem(detail: "Cannot change the software associated with a release.", statusCode: StatusCodes.Status400BadRequest);
model.Title = dto.Title;
model.SoftwareVersionId = dto.SoftwareVersionId;
model.PlatformId = dto.PlatformId;
model.PublisherId = dto.PublisherId;
model.ReleaseDate = dto.ReleaseDate;
model.ReleaseDatePrecision = dto.ReleaseDatePrecision;
string newsName = await BuildSoftwareReleaseNewsNameAsync(model);
await context.News.AddAsync(new News
{
AddedId = (long)model.Id,
Date = DateTime.UtcNow,
Type = NewsType.UpdatedSoftwareReleaseInDb,
Name = newsName
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpPost]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ulong>> CreateAsync([FromBody] SoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
var model = new SoftwareRelease
{
Title = dto.Title,
IsCompilation = dto.IsCompilation,
SoftwareId = dto.IsCompilation ? null : dto.SoftwareId,
SoftwareVersionId = dto.IsCompilation ? null : dto.SoftwareVersionId,
PlatformId = dto.PlatformId,
PublisherId = dto.PublisherId,
ReleaseDate = dto.ReleaseDate,
ReleaseDatePrecision = dto.ReleaseDatePrecision
};
await context.SoftwareReleases.AddAsync(model);
await context.SaveChangesWithUserAsync(userId);
string newsName = await BuildSoftwareReleaseNewsNameAsync(model);
await context.News.AddAsync(new News
{
AddedId = (long)model.Id,
Date = DateTime.UtcNow,
Type = NewsType.NewSoftwareReleaseInDb,
Name = newsName
});
await context.SaveChangesWithUserAsync(userId);
return model.Id;
}
[HttpDelete("{id:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> DeleteAsync(ulong id)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease item = await context.SoftwareReleases.FindAsync(id);
if(item is null) return NotFound();
// Capture entity-display label BEFORE Remove + SaveChanges so the stale-cascade
// notification can render a useful name. Mirrors MagazineIssuesController convention.
string entityName = item.Title;
context.SoftwareReleases.Remove(item);
await context.SaveChangesWithUserAsync(userId);
await Marechai.Server.Helpers.SuggestionsHelper.MarkStaleForEntityAsync(
context, Marechai.Data.SuggestionEntityType.SoftwareRelease, (long)id, entityName);
return Ok();
}
// --- Compilation junction endpoints ---
[HttpGet("{releaseId:ulong}/versions")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareVersionBySoftwareReleaseDto>> GetIncludedVersionsAsync(ulong releaseId) =>
context.SoftwareVersionBySoftwareRelease
.Where(x => x.ReleaseId == releaseId)
.OrderBy(x => x.SoftwareVersion.Software.Name)
.ThenBy(x => x.SoftwareVersion.VersionString)
.Select(x => new SoftwareVersionBySoftwareReleaseDto
{
ReleaseId = x.ReleaseId,
SoftwareVersionId = x.SoftwareVersionId,
SoftwareVersion = x.SoftwareVersion.VersionString,
SoftwareName = x.SoftwareVersion.Software.Name
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/versions")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedVersionAsync(ulong releaseId,
[FromBody] SoftwareVersionBySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot add included versions to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
// Ensure this is a versioned compilation (not a versionless one)
bool hasIncludedSoftware = await context.SoftwareBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId);
if(hasIncludedSoftware)
return Problem(detail: "Cannot add included versions to a versionless compilation. Use included software instead.", statusCode: StatusCodes.Status400BadRequest);
bool exists = await context.SoftwareVersionBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId
&& x.SoftwareVersionId == dto.SoftwareVersionId);
if(exists) return Problem(detail: "This version is already included in the release.", statusCode: StatusCodes.Status400BadRequest);
bool versionExists = await context.SoftwareVersions.AnyAsync(v => v.Id == dto.SoftwareVersionId);
if(!versionExists) return Problem(detail: "The specified software version does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareVersionBySoftwareRelease.AddAsync(new SoftwareVersionBySoftwareRelease
{
ReleaseId = releaseId,
SoftwareVersionId = dto.SoftwareVersionId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/versions/{versionId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedVersionAsync(ulong releaseId, ulong versionId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot remove included versions from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
SoftwareVersionBySoftwareRelease entry =
await context.SoftwareVersionBySoftwareRelease
.FirstOrDefaultAsync(x => x.ReleaseId == releaseId
&& x.SoftwareVersionId == versionId);
if(entry is null) return NotFound();
context.SoftwareVersionBySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
// --- Compilation listing endpoints ---
[HttpGet("compilations")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareReleaseDto>> GetCompilationsAsync() => context.SoftwareReleases
.Where(r => r.IsCompilation)
.OrderBy(r => r.Title)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
}).ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
}).ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
[HttpGet("/software/{softwareId:ulong}/compilations")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareReleaseDto>> GetCompilationsForSoftwareAsync(ulong softwareId) =>
// Single round-trip: compose the releaseId set as a subquery directly inside
// the projection's Where clause instead of materializing IDs first and then
// running a separate Contains(...) query.
context.SoftwareReleases
.Where(r => context.SoftwareVersionBySoftwareRelease
.Any(x => x.ReleaseId == r.Id && x.SoftwareVersion.SoftwareId == softwareId) ||
context.SoftwareBySoftwareRelease
.Any(x => x.ReleaseId == r.Id && x.SoftwareId == softwareId))
.OrderBy(r => r.Title)
.Select(r => new SoftwareReleaseDto
{
Id = r.Id,
Title = r.Title,
IsCompilation = r.IsCompilation,
SoftwareId = r.SoftwareId,
Software = r.Software.Name,
SoftwareVersionId = r.SoftwareVersionId,
PlatformId = r.PlatformId,
Platform = r.Platform.Name,
Regions = r.Regions.Select(rg => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = rg.SoftwareReleaseId,
UnM49Id = rg.UnM49Id,
RegionName = rg.UnM49.Name
})
.ToList(),
Languages = r.Languages.Select(lg => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = lg.SoftwareReleaseId,
LanguageCode = lg.LanguageCode,
Language = lg.Language.ReferenceName
})
.ToList(),
PublisherId = r.PublisherId,
Publisher = r.Publisher.Name,
ReleaseDate = r.ReleaseDate,
ReleaseDatePrecision = r.ReleaseDatePrecision
})
.ToListAsync();
// --- Versionless compilation junction endpoints ---
[HttpGet("{releaseId:ulong}/software")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<SoftwareBySoftwareReleaseDto>> GetIncludedSoftwareAsync(ulong releaseId) =>
context.SoftwareBySoftwareRelease
.Where(x => x.ReleaseId == releaseId)
.OrderBy(x => x.Software.Name)
.Select(x => new SoftwareBySoftwareReleaseDto
{
ReleaseId = x.ReleaseId,
SoftwareId = x.SoftwareId,
SoftwareName = x.Software.Name
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/software")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddIncludedSoftwareAsync(ulong releaseId,
[FromBody] SoftwareBySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot add included software to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
// Ensure this is a versionless compilation (not a versioned one)
bool hasIncludedVersions = await context.SoftwareVersionBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId);
if(hasIncludedVersions)
return Problem(detail: "Cannot add included software to a versioned compilation. Use included versions instead.", statusCode: StatusCodes.Status400BadRequest);
bool exists = await context.SoftwareBySoftwareRelease
.AnyAsync(x => x.ReleaseId == releaseId
&& x.SoftwareId == dto.SoftwareId);
if(exists) return Problem(detail: "This software is already included in the release.", statusCode: StatusCodes.Status400BadRequest);
bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == dto.SoftwareId);
if(!softwareExists) return Problem(detail: "The specified software does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.SoftwareBySoftwareRelease.AddAsync(new SoftwareBySoftwareRelease
{
ReleaseId = releaseId,
SoftwareId = dto.SoftwareId
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/software/{softwareId:ulong}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveIncludedSoftwareAsync(ulong releaseId, ulong softwareId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
if(!release.IsCompilation)
return Problem(detail: "Cannot remove included software from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest);
SoftwareBySoftwareRelease entry =
await context.SoftwareBySoftwareRelease
.FirstOrDefaultAsync(x => x.ReleaseId == releaseId
&& x.SoftwareId == softwareId);
if(entry is null) return NotFound();
context.SoftwareBySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
async Task<string> BuildSoftwareReleaseNewsNameAsync(SoftwareRelease model)
{
// Use Title if available
if(!string.IsNullOrWhiteSpace(model.Title))
{
if(model.PlatformId is not null)
{
SoftwarePlatform platform = await context.SoftwarePlatforms.FindAsync(model.PlatformId);
if(platform is not null) return $"{model.Title} ({platform.Name})";
}
return model.Title;
}
// Single-version release: build from version info
if(model.SoftwareVersionId is not null)
{
string name = "";
SoftwareVersion version = await context.SoftwareVersions.Include(v => v.Software)
.FirstOrDefaultAsync(v => v.Id == model.SoftwareVersionId);
if(version?.Software is not null)
name = $"{version.Software.Name} {version.VersionString}";
else if(version is not null)
name = version.VersionString;
if(model.PlatformId is not null)
{
SoftwarePlatform platform = await context.SoftwarePlatforms.FindAsync(model.PlatformId);
if(platform is not null)
name = string.IsNullOrEmpty(name) ? platform.Name : $"{name} ({platform.Name})";
}
return name;
}
// Versionless single release: build from software name
if(!model.IsCompilation && model.SoftwareId is not null)
{
Software software = await context.Softwares.FindAsync(model.SoftwareId);
string name = software?.Name ?? "";
if(model.PlatformId is not null)
{
SoftwarePlatform platform = await context.SoftwarePlatforms.FindAsync(model.PlatformId);
if(platform is not null)
name = string.IsNullOrEmpty(name) ? platform.Name : $"{name} ({platform.Name})";
}
return name;
}
// Compilation without title: build from included versions or software
List<string> versionNames = await context.SoftwareVersionBySoftwareRelease
.Where(x => x.ReleaseId == model.Id)
.OrderBy(x => x.SoftwareVersion.Software.Name)
.Select(x => $"{x.SoftwareVersion.Software.Name} {x.SoftwareVersion.VersionString}")
.ToListAsync();
List<string> softwareNames = await context.SoftwareBySoftwareRelease
.Where(x => x.ReleaseId == model.Id)
.OrderBy(x => x.Software.Name)
.Select(x => x.Software.Name)
.ToListAsync();
List<string> allNames = versionNames.Concat(softwareNames).ToList();
string compilationName = allNames.Count > 0 ? string.Join(" + ", allNames) : "Compilation";
if(model.PlatformId is not null)
{
SoftwarePlatform plat = await context.SoftwarePlatforms.FindAsync(model.PlatformId);
if(plat is not null) compilationName = $"{compilationName} ({plat.Name})";
}
return compilationName;
}
// --- Region junction endpoints ---
[HttpGet("{releaseId:ulong}/regions")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<UnM49BySoftwareReleaseDto>> GetRegionsAsync(ulong releaseId) =>
context.UnM49BySoftwareRelease
.Where(x => x.SoftwareReleaseId == releaseId)
.OrderBy(x => x.UnM49.Type)
.ThenBy(x => x.UnM49.Name)
.Select(x => new UnM49BySoftwareReleaseDto
{
SoftwareReleaseId = x.SoftwareReleaseId,
UnM49Id = x.UnM49Id,
RegionName = x.UnM49.Name
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/regions")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddRegionAsync(ulong releaseId,
[FromBody] UnM49BySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
bool exists = await context.UnM49BySoftwareRelease
.AnyAsync(x => x.SoftwareReleaseId == releaseId
&& x.UnM49Id == dto.UnM49Id);
if(exists) return Problem(detail: "This region is already assigned to the release.", statusCode: StatusCodes.Status400BadRequest);
bool regionExists = await context.UnM49.AnyAsync(r => r.Id == dto.UnM49Id);
if(!regionExists) return Problem(detail: "The specified region does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.UnM49BySoftwareRelease.AddAsync(new UnM49BySoftwareRelease
{
SoftwareReleaseId = releaseId,
UnM49Id = dto.UnM49Id
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/regions/{regionId:int}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveRegionAsync(ulong releaseId, short regionId)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
UnM49BySoftwareRelease entry =
await context.UnM49BySoftwareRelease
.FirstOrDefaultAsync(x => x.SoftwareReleaseId == releaseId
&& x.UnM49Id == regionId);
if(entry is null) return NotFound();
context.UnM49BySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
// --- Language junction endpoints ---
[HttpGet("{releaseId:ulong}/languages")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<List<LanguageBySoftwareReleaseDto>> GetLanguagesAsync(ulong releaseId) =>
context.LanguageBySoftwareRelease
.Where(x => x.SoftwareReleaseId == releaseId)
.OrderBy(x => x.Language.ReferenceName)
.Select(x => new LanguageBySoftwareReleaseDto
{
SoftwareReleaseId = x.SoftwareReleaseId,
LanguageCode = x.LanguageCode,
Language = x.Language.ReferenceName
})
.ToListAsync();
[HttpPost("{releaseId:ulong}/languages")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AddLanguageAsync(ulong releaseId,
[FromBody] LanguageBySoftwareReleaseDto dto)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareRelease release = await context.SoftwareReleases.FindAsync(releaseId);
if(release is null) return NotFound();
bool exists = await context.LanguageBySoftwareRelease
.AnyAsync(x => x.SoftwareReleaseId == releaseId
&& x.LanguageCode == dto.LanguageCode);
if(exists) return Problem(detail: "This language is already assigned to the release.", statusCode: StatusCodes.Status400BadRequest);
bool languageExists = await context.Iso639.AnyAsync(l => l.Id == dto.LanguageCode);
if(!languageExists) return Problem(detail: "The specified language does not exist.", statusCode: StatusCodes.Status400BadRequest);
await context.LanguageBySoftwareRelease.AddAsync(new LanguageBySoftwareRelease
{
SoftwareReleaseId = releaseId,
LanguageCode = dto.LanguageCode
});
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
[HttpDelete("{releaseId:ulong}/languages/{languageCode}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> RemoveLanguageAsync(ulong releaseId, string languageCode)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
LanguageBySoftwareRelease entry =
await context.LanguageBySoftwareRelease
.FirstOrDefaultAsync(x => x.SoftwareReleaseId == releaseId
&& x.LanguageCode == languageCode);
if(entry is null) return NotFound();
context.LanguageBySoftwareRelease.Remove(entry);
await context.SaveChangesWithUserAsync(userId);
return Ok();
}
}