From ece9cedb373038d740558abe57da55ac62e8703c Mon Sep 17 00:00:00 2001 From: ereshk1gal Date: Wed, 1 Oct 2025 01:42:45 +0300 Subject: [PATCH] feat: Project peready 2.0 --- .../Application/Options/S3StorageOptions.cs | 12 + LctMonolith/Controllers/ProfileController.cs | 67 ++ LctMonolith/LctMonolith.csproj | 1 + LctMonolith/Models/Database/Profile.cs | 21 + .../Services/Contracts/IFileStorageService.cs | 8 + .../Services/Contracts/IProfileService.cs | 11 + LctMonolith/Services/ProfileService.cs | 121 +++ LctMonolith/Services/S3FileStorageService.cs | 102 ++ LctMonolith/openapi-gamification.yaml | 927 ++++++++++++++++++ 9 files changed, 1270 insertions(+) create mode 100644 LctMonolith/Application/Options/S3StorageOptions.cs create mode 100644 LctMonolith/Controllers/ProfileController.cs create mode 100644 LctMonolith/Models/Database/Profile.cs create mode 100644 LctMonolith/Services/Contracts/IFileStorageService.cs create mode 100644 LctMonolith/Services/Contracts/IProfileService.cs create mode 100644 LctMonolith/Services/ProfileService.cs create mode 100644 LctMonolith/Services/S3FileStorageService.cs create mode 100644 LctMonolith/openapi-gamification.yaml diff --git a/LctMonolith/Application/Options/S3StorageOptions.cs b/LctMonolith/Application/Options/S3StorageOptions.cs new file mode 100644 index 0000000..e0dcf09 --- /dev/null +++ b/LctMonolith/Application/Options/S3StorageOptions.cs @@ -0,0 +1,12 @@ +namespace LctMonolith.Application.Options; + +public class S3StorageOptions +{ + public string Endpoint { get; set; } = string.Empty; + public bool UseSsl { get; set; } = true; + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string Bucket { get; set; } = "avatars"; + public string? PublicBaseUrl { get; set; } // optional CDN / reverse proxy base + public int PresignExpirationMinutes { get; set; } = 60; +} diff --git a/LctMonolith/Controllers/ProfileController.cs b/LctMonolith/Controllers/ProfileController.cs new file mode 100644 index 0000000..d718a0c --- /dev/null +++ b/LctMonolith/Controllers/ProfileController.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/profile")] +[Authorize] +public class ProfileController : ControllerBase +{ + private readonly IProfileService _profiles; + public ProfileController(IProfileService profiles) => _profiles = profiles; + + private Guid CurrentUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public class UpdateProfileDto + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public DateOnly? BirthDate { get; set; } + public string? About { get; set; } + public string? Location { get; set; } + } + + [HttpGet("me")] + public async Task GetMe(CancellationToken ct) + { + var p = await _profiles.GetByUserIdAsync(CurrentUserId(), ct); + if (p == null) return NotFound(); + return Ok(p); + } + + [HttpGet("{userId:guid}")] + [Authorize(Roles = "Admin")] + public async Task GetByUser(Guid userId, CancellationToken ct) + { + var p = await _profiles.GetByUserIdAsync(userId, ct); + return p == null ? NotFound() : Ok(p); + } + + [HttpPut] + public async Task Upsert(UpdateProfileDto dto, CancellationToken ct) + { + var p = await _profiles.UpsertAsync(CurrentUserId(), dto.FirstName, dto.LastName, dto.BirthDate, dto.About, dto.Location, ct); + return Ok(p); + } + + [HttpPost("avatar")] + [RequestSizeLimit(7_000_000)] // ~7MB + public async Task UploadAvatar(IFormFile file, CancellationToken ct) + { + if (file == null || file.Length == 0) return BadRequest("File required"); + await using var stream = file.OpenReadStream(); + var p = await _profiles.UpdateAvatarAsync(CurrentUserId(), stream, file.ContentType, file.FileName, ct); + return Ok(new { p.AvatarUrl }); + } + + [HttpDelete("avatar")] + public async Task DeleteAvatar(CancellationToken ct) + { + var ok = await _profiles.DeleteAvatarAsync(CurrentUserId(), ct); + return ok ? NoContent() : NotFound(); + } +} diff --git a/LctMonolith/LctMonolith.csproj b/LctMonolith/LctMonolith.csproj index 6451f74..2227e67 100644 --- a/LctMonolith/LctMonolith.csproj +++ b/LctMonolith/LctMonolith.csproj @@ -31,6 +31,7 @@ + diff --git a/LctMonolith/Models/Database/Profile.cs b/LctMonolith/Models/Database/Profile.cs new file mode 100644 index 0000000..cc82d98 --- /dev/null +++ b/LctMonolith/Models/Database/Profile.cs @@ -0,0 +1,21 @@ +namespace LctMonolith.Models.Database; + +public class Profile +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public AppUser User { get; set; } = null!; + + public string? FirstName { get; set; } + public string? LastName { get; set; } + public DateOnly? BirthDate { get; set; } + public string? About { get; set; } + public string? Location { get; set; } + + // Avatar in S3 / MinIO + public string? AvatarS3Key { get; set; } + public string? AvatarUrl { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/LctMonolith/Services/Contracts/IFileStorageService.cs b/LctMonolith/Services/Contracts/IFileStorageService.cs new file mode 100644 index 0000000..3a97e97 --- /dev/null +++ b/LctMonolith/Services/Contracts/IFileStorageService.cs @@ -0,0 +1,8 @@ +namespace LctMonolith.Services.Interfaces; + +public interface IFileStorageService +{ + Task UploadAsync(Stream content, string contentType, string keyPrefix, CancellationToken ct = default); + Task DeleteAsync(string key, CancellationToken ct = default); + Task GetPresignedUrlAsync(string key, TimeSpan? expires = null, CancellationToken ct = default); +} diff --git a/LctMonolith/Services/Contracts/IProfileService.cs b/LctMonolith/Services/Contracts/IProfileService.cs new file mode 100644 index 0000000..11e2cde --- /dev/null +++ b/LctMonolith/Services/Contracts/IProfileService.cs @@ -0,0 +1,11 @@ +namespace LctMonolith.Services.Interfaces; + +using LctMonolith.Models.Database; + +public interface IProfileService +{ + Task GetByUserIdAsync(Guid userId, CancellationToken ct = default); + Task UpsertAsync(Guid userId, string? firstName, string? lastName, DateOnly? birthDate, string? about, string? location, CancellationToken ct = default); + Task UpdateAvatarAsync(Guid userId, Stream fileStream, string contentType, string? fileName, CancellationToken ct = default); + Task DeleteAvatarAsync(Guid userId, CancellationToken ct = default); +} diff --git a/LctMonolith/Services/ProfileService.cs b/LctMonolith/Services/ProfileService.cs new file mode 100644 index 0000000..430fcf1 --- /dev/null +++ b/LctMonolith/Services/ProfileService.cs @@ -0,0 +1,121 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Serilog; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace LctMonolith.Services; + +public class ProfileService : IProfileService +{ + private readonly IUnitOfWork _uow; + private readonly IFileStorageService _storage; + + public ProfileService(IUnitOfWork uow, IFileStorageService storage) + { + _uow = uow; + _storage = storage; + } + + public async Task GetByUserIdAsync(Guid userId, CancellationToken ct = default) + { + try + { + return await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct); + } + catch (Exception ex) + { + Log.Error(ex, "Profile get failed {UserId}", userId); + throw; + } + } + + public async Task UpsertAsync(Guid userId, string? firstName, string? lastName, DateOnly? birthDate, string? about, string? location, CancellationToken ct = default) + { + try + { + var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct); + if (profile == null) + { + profile = new Profile + { + Id = Guid.NewGuid(), + UserId = userId, + FirstName = firstName, + LastName = lastName, + BirthDate = birthDate, + About = about, + Location = location + }; + await _uow.Profiles.AddAsync(profile, ct); + } + else + { + profile.FirstName = firstName; + profile.LastName = lastName; + profile.BirthDate = birthDate; + profile.About = about; + profile.Location = location; + profile.UpdatedAt = DateTime.UtcNow; + _uow.Profiles.Update(profile); + } + await _uow.SaveChangesAsync(ct); + return profile; + } + catch (Exception ex) + { + Log.Error(ex, "Profile upsert failed {UserId}", userId); + throw; + } + } + + public async Task UpdateAvatarAsync(Guid userId, Stream fileStream, string contentType, string? fileName, CancellationToken ct = default) + { + try + { + var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct) ?? + await UpsertAsync(userId, null, null, null, null, null, ct); + + // Delete old if exists + if (!string.IsNullOrWhiteSpace(profile.AvatarS3Key)) + { + await _storage.DeleteAsync(profile.AvatarS3Key!, ct); + } + var key = await _storage.UploadAsync(fileStream, contentType, $"avatars/{userId}", ct); + var url = await _storage.GetPresignedUrlAsync(key, TimeSpan.FromHours(6), ct); + profile.AvatarS3Key = key; + profile.AvatarUrl = url; + profile.UpdatedAt = DateTime.UtcNow; + _uow.Profiles.Update(profile); + await _uow.SaveChangesAsync(ct); + return profile; + } + catch (Exception ex) + { + Log.Error(ex, "Avatar update failed {UserId}", userId); + throw; + } + } + + public async Task DeleteAvatarAsync(Guid userId, CancellationToken ct = default) + { + try + { + var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct); + if (profile == null || string.IsNullOrWhiteSpace(profile.AvatarS3Key)) return false; + await _storage.DeleteAsync(profile.AvatarS3Key!, ct); + profile.AvatarS3Key = null; + profile.AvatarUrl = null; + profile.UpdatedAt = DateTime.UtcNow; + _uow.Profiles.Update(profile); + await _uow.SaveChangesAsync(ct); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Delete avatar failed {UserId}", userId); + throw; + } + } +} diff --git a/LctMonolith/Services/S3FileStorageService.cs b/LctMonolith/Services/S3FileStorageService.cs new file mode 100644 index 0000000..375e0ce --- /dev/null +++ b/LctMonolith/Services/S3FileStorageService.cs @@ -0,0 +1,102 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon; +using Microsoft.Extensions.Options; +using LctMonolith.Application.Options; +using LctMonolith.Services.Interfaces; +using Serilog; + +namespace LctMonolith.Services; + +public class S3FileStorageService : IFileStorageService, IDisposable +{ + private readonly S3StorageOptions _opts; + private readonly IAmazonS3 _client; + private bool _bucketChecked; + + public S3FileStorageService(IOptions options) + { + _opts = options.Value; + var cfg = new AmazonS3Config + { + ServiceURL = _opts.Endpoint, + ForcePathStyle = true, + UseHttp = !_opts.UseSsl, + Timeout = TimeSpan.FromSeconds(30), + MaxErrorRetry = 2, + }; + _client = new AmazonS3Client(_opts.AccessKey, _opts.SecretKey, cfg); + } + + private async Task EnsureBucketAsync(CancellationToken ct) + { + if (_bucketChecked) return; + try + { + var list = await _client.ListBucketsAsync(ct); + if (!list.Buckets.Any(b => string.Equals(b.BucketName, _opts.Bucket, StringComparison.OrdinalIgnoreCase))) + { + await _client.PutBucketAsync(new PutBucketRequest { BucketName = _opts.Bucket }, ct); + } + _bucketChecked = true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed ensuring bucket {Bucket}", _opts.Bucket); + throw; + } + } + + public async Task UploadAsync(Stream content, string contentType, string keyPrefix, CancellationToken ct = default) + { + await EnsureBucketAsync(ct); + var key = $"{keyPrefix.Trim('/')}/{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid():N}"; + var putReq = new PutObjectRequest + { + BucketName = _opts.Bucket, + Key = key, + InputStream = content, + ContentType = contentType + }; + await _client.PutObjectAsync(putReq, ct); + Log.Information("Uploaded object {Key} to bucket {Bucket}", key, _opts.Bucket); + return key; + } + + public async Task DeleteAsync(string key, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(key)) return; + try + { + await _client.DeleteObjectAsync(_opts.Bucket, key, ct); + Log.Information("Deleted object {Key}", key); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // ignore + } + } + + public Task GetPresignedUrlAsync(string key, TimeSpan? expires = null, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + if (!string.IsNullOrWhiteSpace(_opts.PublicBaseUrl)) + { + var url = _opts.PublicBaseUrl!.TrimEnd('/') + "/" + key; + return Task.FromResult(url); + } + var req = new GetPreSignedUrlRequest + { + BucketName = _opts.Bucket, + Key = key, + Expires = DateTime.UtcNow.Add(expires ?? TimeSpan.FromMinutes(_opts.PresignExpirationMinutes)) + }; + var urlSigned = _client.GetPreSignedURL(req); + return Task.FromResult(urlSigned); + } + + public void Dispose() + { + _client.Dispose(); + } +} diff --git a/LctMonolith/openapi-gamification.yaml b/LctMonolith/openapi-gamification.yaml new file mode 100644 index 0000000..46d6a23 --- /dev/null +++ b/LctMonolith/openapi-gamification.yaml @@ -0,0 +1,927 @@ +openapi: 3.0.3 +info: + title: LctMonolith Gamification API + version: 1.0.0 + description: | + Comprehensive REST API for gamification module (players, missions, ranks, skills, rewards, dialogue, inventory, store, analytics, notifications, auth). + Authentication via JWT Bearer (Authorization: Bearer ). Admin endpoints require role=Admin. +servers: + - url: https://localhost:5001 + description: Local HTTPS + - url: http://localhost:5000 + description: Local HTTP +security: + - bearerAuth: [] +tags: + - name: Auth + - name: Players + - name: Ranks + - name: Skills + - name: MissionCategories + - name: Missions + - name: Rewards + - name: Dialogue + - name: Inventory + - name: Store + - name: Notifications + - name: Analytics + - name: Profile +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + Id: { type: string, format: uuid } + TokenPair: + type: object + properties: + accessToken: { type: string } + refreshToken: { type: string } + expiresAt: { type: string, format: date-time } + refreshExpiresAt: { type: string, format: date-time, nullable: true } + Rank: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + title: { type: string } + expNeeded: { type: integer } + Skill: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + title: { type: string } + Player: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + userId: { $ref: '#/components/schemas/Id' } + rankId: { $ref: '#/components/schemas/Id' } + experience: { type: integer } + mana: { type: integer } + PlayerSkill: + type: object + properties: + playerId: { $ref: '#/components/schemas/Id' } + skillId: { $ref: '#/components/schemas/Id' } + score: { type: integer } + Mission: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + title: { type: string } + description: { type: string } + missionCategoryId: { $ref: '#/components/schemas/Id' } + parentMissionId: { $ref: '#/components/schemas/Id', nullable: true } + expReward: { type: integer } + manaReward: { type: integer } + MissionSkillReward: + type: object + properties: + missionId: { $ref: '#/components/schemas/Id' } + skillId: { $ref: '#/components/schemas/Id' } + value: { type: integer } + MissionItemReward: + type: object + properties: + missionId: { $ref: '#/components/schemas/Id' } + itemId: { $ref: '#/components/schemas/Id' } + SkillProgress: + type: object + properties: + skillId: { $ref: '#/components/schemas/Id' } + skillTitle: { type: string } + previousLevel: { type: integer } + newLevel: { type: integer } + MissionCompletionResult: + type: object + properties: + success: { type: boolean } + message: { type: string } + experienceGained: { type: integer } + manaGained: { type: integer } + skillsProgress: { type: array, items: { $ref: '#/components/schemas/SkillProgress' } } + unlockedMissions: { type: array, items: { $ref: '#/components/schemas/Id' } } + PlayerProgress: + type: object + properties: + playerId: { $ref: '#/components/schemas/Id' } + playerName: { type: string } + currentRank: { $ref: '#/components/schemas/Rank', nullable: true } + totalExperience: { type: integer } + totalMana: { type: integer } + completedMissions: { type: integer } + totalAvailableMissions: { type: integer } + skillLevels: + type: object + additionalProperties: { type: integer } + StoreItem: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + name: { type: string } + description: { type: string, nullable: true } + price: { type: integer } + isActive: { type: boolean } + stock: { type: integer, nullable: true } + UserInventoryItem: + type: object + properties: + userId: { $ref: '#/components/schemas/Id' } + storeItemId: { $ref: '#/components/schemas/Id' } + quantity: { type: integer } + acquiredAt: { type: string, format: date-time } + Notification: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + type: { type: string } + title: { type: string } + message: { type: string } + isRead: { type: boolean } + createdAt: { type: string, format: date-time } + readAt: { type: string, format: date-time, nullable: true } + AnalyticsSummary: + type: object + properties: + totalUsers: { type: integer } + totalMissions: { type: integer } + totalStoreItems: { type: integer } + totalExperience: { type: integer } + completedMissions: { type: integer } + generatedAtUtc: { type: string, format: date-time } + Profile: + type: object + properties: + id: { $ref: '#/components/schemas/Id' } + userId: { $ref: '#/components/schemas/Id' } + firstName: { type: string, nullable: true } + lastName: { type: string, nullable: true } + birthDate: { type: string, format: date, nullable: true } + about: { type: string, nullable: true } + location: { type: string, nullable: true } + avatarUrl: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } +paths: + /api/auth/register: + post: + tags: [Auth] + summary: Register user + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: { type: string, format: email } + password: { type: string } + firstName: { type: string } + lastName: { type: string } + required: [email,password] + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/TokenPair' } } } } + /api/auth/login: + post: + tags: [Auth] + summary: Login + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: { type: string, format: email } + password: { type: string } + required: [email,password] + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/TokenPair' } } } } + /api/auth/refresh: + post: + tags: [Auth] + summary: Refresh access token + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + refreshToken: { type: string } + required: [refreshToken] + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/TokenPair' } } } } + /api/auth/revoke: + post: + tags: [Auth] + security: [ { bearerAuth: [] } ] + summary: Revoke refresh token + requestBody: + content: + application/json: + schema: + type: object + properties: + refreshToken: { type: string } + responses: + '204': { description: No Content } + /api/auth/me: + get: + tags: [Auth] + security: [ { bearerAuth: [] } ] + summary: Current user id + responses: + '200': { description: OK } + /api/analytics/summary: + get: + tags: [Analytics] + security: [ { bearerAuth: [] } ] + summary: Aggregated analytics summary + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/AnalyticsSummary' } } } } + /api/players/{playerId}: + get: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Get player by id + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK } + '404': { description: Not Found } + /api/players/{playerId}/progress: + get: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Get player overall progress + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/PlayerProgress' } } } } + '404': { description: Not Found } + /api/players/user/{userId}: + get: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Get player by user id + parameters: + - in: path + name: userId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK } + '404': { description: Not Found } + /api/players: + post: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Create player (Admin) + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + userId: { $ref: '#/components/schemas/Id' } + username: { type: string } + required: [userId,username] + responses: + '201': { description: Created } + x-roles: [Admin] + /api/players/{playerId}/experience: + post: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Adjust player experience (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + value: { type: integer } + required: [value] + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK } + x-roles: [Admin] + /api/players/{playerId}/mana: + post: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Adjust player mana (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + value: { type: integer } + required: [value] + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK } + x-roles: [Admin] + /api/players/top: + get: + tags: [Players] + security: [ { bearerAuth: [] } ] + summary: Top players by experience + parameters: + - in: query + name: count + schema: { type: integer, default: 10 } + responses: + '200': { description: OK } + /api/ranks: + get: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: List ranks + responses: { '200': { description: OK } } + post: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: Create rank (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + expNeeded: { type: integer } + required: [title,expNeeded] + responses: { '201': { description: Created } } + x-roles: [Admin] + /api/ranks/{id}: + get: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: Get rank + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + put: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: Update rank (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + expNeeded: { type: integer } + required: [title,expNeeded] + responses: { '200': { description: OK }, '404': { description: Not Found } } + x-roles: [Admin] + delete: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: Delete rank (Admin) + responses: { '204': { description: No Content }, '404': { description: Not Found } } + x-roles: [Admin] + /api/ranks/validate-advance/{playerId}/{targetRankId}: + get: + tags: [Ranks] + security: [ { bearerAuth: [] } ] + summary: Validate advancement + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + - in: path + name: targetRankId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/skills: + get: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: List skills + responses: { '200': { description: OK } } + post: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: Create skill (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + required: [title] + responses: { '201': { description: Created } } + x-roles: [Admin] + /api/skills/{id}: + get: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: Get skill + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + put: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: Update skill (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + required: [title] + responses: { '200': { description: OK }, '404': { description: Not Found } } + x-roles: [Admin] + delete: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: Delete skill (Admin) + responses: { '204': { description: No Content }, '404': { description: Not Found } } + x-roles: [Admin] + /api/skills/player/{playerId}: + get: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: List player skills + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/skills/player/{playerId}/{skillId}: + post: + tags: [Skills] + security: [ { bearerAuth: [] } ] + summary: Update player skill (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + level: { type: integer } + required: [level] + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + - in: path + name: skillId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + x-roles: [Admin] + /api/mission-categories: + get: + tags: [MissionCategories] + security: [ { bearerAuth: [] } ] + summary: List mission categories + responses: { '200': { description: OK } } + post: + tags: [MissionCategories] + security: [ { bearerAuth: [] } ] + summary: Create mission category (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + required: [title] + responses: { '201': { description: Created } } + x-roles: [Admin] + /api/mission-categories/{id}: + get: + tags: [MissionCategories] + security: [ { bearerAuth: [] } ] + summary: Get mission category + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + put: + tags: [MissionCategories] + security: [ { bearerAuth: [] } ] + summary: Update mission category (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + required: [title] + responses: { '200': { description: OK }, '404': { description: Not Found } } + x-roles: [Admin] + delete: + tags: [MissionCategories] + security: [ { bearerAuth: [] } ] + summary: Delete mission category (Admin) + responses: { '204': { description: No Content }, '404': { description: Not Found } } + x-roles: [Admin] + /api/missions/{id}: + get: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Get mission + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + put: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Update mission (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + description: { type: string } + missionCategoryId: { $ref: '#/components/schemas/Id' } + parentMissionId: { $ref: '#/components/schemas/Id', nullable: true } + expReward: { type: integer } + manaReward: { type: integer } + required: [title,missionCategoryId,expReward,manaReward] + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + x-roles: [Admin] + delete: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Delete mission (Admin) + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '204': { description: No Content }, '404': { description: Not Found } } + x-roles: [Admin] + /api/missions/category/{categoryId}: + get: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Missions by category + parameters: + - in: path + name: categoryId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/missions/player/{playerId}/available: + get: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Available missions for player + parameters: + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/missions/{missionId}/rank-rules: + get: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Rank rules for mission + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/missions: + post: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Create mission (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + description: { type: string } + missionCategoryId: { $ref: '#/components/schemas/Id' } + parentMissionId: { $ref: '#/components/schemas/Id', nullable: true } + expReward: { type: integer } + manaReward: { type: integer } + required: [title,missionCategoryId,expReward,manaReward] + responses: { '201': { description: Created } } + x-roles: [Admin] + /api/missions/{id}: + put: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Update mission (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + description: { type: string } + missionCategoryId: { $ref: '#/components/schemas/Id' } + parentMissionId: { $ref: '#/components/schemas/Id', nullable: true } + expReward: { type: integer } + manaReward: { type: integer } + required: [title,missionCategoryId,expReward,manaReward] + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + x-roles: [Admin] + delete: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Delete mission (Admin) + parameters: + - in: path + name: id + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '204': { description: No Content }, '404': { description: Not Found } } + x-roles: [Admin] + /api/missions/{missionId}/complete: + post: + tags: [Missions] + security: [ { bearerAuth: [] } ] + summary: Complete mission + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + requestBody: + content: + application/json: + schema: + type: object + properties: + playerId: { $ref: '#/components/schemas/Id' } + proof: { nullable: true } + required: [playerId] + responses: { '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/MissionCompletionResult' } } } }, '400': { description: Bad Request } } + /api/rewards/mission/{missionId}/skills: + get: + tags: [Rewards] + security: [ { bearerAuth: [] } ] + summary: Mission skill rewards + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/rewards/mission/{missionId}/items: + get: + tags: [Rewards] + security: [ { bearerAuth: [] } ] + summary: Mission item rewards + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/rewards/mission/{missionId}/can-claim/{playerId}: + get: + tags: [Rewards] + security: [ { bearerAuth: [] } ] + summary: Can claim mission rewards + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + - in: path + name: playerId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/rewards/mission/{missionId}/claim: + post: + tags: [Rewards] + security: [ { bearerAuth: [] } ] + summary: Claim mission rewards + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + requestBody: + content: + application/json: + schema: + type: object + properties: + playerId: { $ref: '#/components/schemas/Id' } + required: [playerId] + responses: { '200': { description: OK }, '409': { description: Conflict } } + /api/rewards/mission/{missionId}/force-distribute: + post: + tags: [Rewards] + security: [ { bearerAuth: [] } ] + summary: Force distribute mission rewards (Admin) + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + requestBody: + content: + application/json: + schema: + type: object + properties: + playerId: { $ref: '#/components/schemas/Id' } + required: [playerId] + responses: { '200': { description: OK } } + x-roles: [Admin] + /api/dialogue/mission/{missionId}: + get: + tags: [Dialogue] + security: [ { bearerAuth: [] } ] + summary: Get dialogue by mission + parameters: + - in: path + name: missionId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + /api/dialogue/message/{messageId}: + get: + tags: [Dialogue] + security: [ { bearerAuth: [] } ] + summary: Get dialogue message + parameters: + - in: path + name: messageId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK }, '404': { description: Not Found } } + /api/dialogue/message/{messageId}/options: + get: + tags: [Dialogue] + security: [ { bearerAuth: [] } ] + summary: Get dialogue response options + parameters: + - in: path + name: messageId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: { '200': { description: OK } } + /api/dialogue/message/{messageId}/respond: + post: + tags: [Dialogue] + security: [ { bearerAuth: [] } ] + summary: Respond to dialogue + parameters: + - in: path + name: messageId + required: true + schema: { $ref: '#/components/schemas/Id' } + requestBody: + content: + application/json: + schema: + type: object + properties: + responseOptionId: { $ref: '#/components/schemas/Id' } + playerId: { $ref: '#/components/schemas/Id' } + required: [responseOptionId,playerId] + responses: { '200': { description: OK } } + /api/dialogue: + post: + tags: [Dialogue] + security: [ { bearerAuth: [] } ] + summary: Create dialogue (Admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + missionId: { $ref: '#/components/schemas/Id' } + initialDialogueMessageId: { $ref: '#/components/schemas/Id' } + interimDialogueMessageId: { $ref: '#/components/schemas/Id' } + endDialogueMessageId: { $ref: '#/components/schemas/Id' } + required: [missionId,initialDialogueMessageId,interimDialogueMessageId,endDialogueMessageId] + responses: { '201': { description: Created } } + x-roles: [Admin] + /api/profile/me: + get: + tags: [Profile] + security: [ { bearerAuth: [] } ] + summary: Get current user profile + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Profile' } } } } + '404': { description: Not Found } + /api/profile/{userId}: + get: + tags: [Profile] + security: [ { bearerAuth: [] } ] + summary: Get profile by user id (Admin) + parameters: + - in: path + name: userId + required: true + schema: { $ref: '#/components/schemas/Id' } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Profile' } } } } + '404': { description: Not Found } + x-roles: [Admin] + /api/profile: + put: + tags: [Profile] + security: [ { bearerAuth: [] } ] + summary: Upsert current user profile + requestBody: + content: + application/json: + schema: + type: object + properties: + firstName: { type: string, nullable: true } + lastName: { type: string, nullable: true } + birthDate: { type: string, format: date, nullable: true } + about: { type: string, nullable: true } + location: { type: string, nullable: true } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Profile' } } } } + /api/profile/avatar: + post: + tags: [Profile] + security: [ { bearerAuth: [] } ] + summary: Upload avatar image (multipart/form-data) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': { description: OK, content: { application/json: { schema: { type: object, properties: { avatarUrl: { type: string } } } } } } + '400': { description: Bad Request } + delete: + tags: [Profile] + security: [ { bearerAuth: [] } ] + summary: Delete avatar image + responses: + '204': { description: No Content } + '404': { description: Not Found }