937 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			937 lines
		
	
	
		
			33 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.ComponentModel.DataAnnotations;
 | 
						|
using DysonNetwork.Pass.Permission;
 | 
						|
using DysonNetwork.Pass.Wallet;
 | 
						|
using DysonNetwork.Shared.Http;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Microsoft.AspNetCore.Authorization;
 | 
						|
using Microsoft.AspNetCore.Mvc;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using NodaTime;
 | 
						|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | 
						|
using SnAuthSession = DysonNetwork.Shared.Models.SnAuthSession;
 | 
						|
 | 
						|
namespace DysonNetwork.Pass.Account;
 | 
						|
 | 
						|
[Authorize]
 | 
						|
[ApiController]
 | 
						|
[Route("/api/accounts/me")]
 | 
						|
public class AccountCurrentController(
 | 
						|
    AppDatabase db,
 | 
						|
    AccountService accounts,
 | 
						|
    SubscriptionService subscriptions,
 | 
						|
    AccountEventService events,
 | 
						|
    AuthService auth,
 | 
						|
    FileService.FileServiceClient files,
 | 
						|
    FileReferenceService.FileReferenceServiceClient fileRefs,
 | 
						|
    Credit.SocialCreditService creditService
 | 
						|
) : ControllerBase
 | 
						|
{
 | 
						|
    [HttpGet]
 | 
						|
    [ProducesResponseType<SnAccount>(StatusCodes.Status200OK)]
 | 
						|
    [ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
 | 
						|
    public async Task<ActionResult<SnAccount>> GetCurrentIdentity()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        var userId = currentUser.Id;
 | 
						|
 | 
						|
        var account = await db.Accounts
 | 
						|
            .Include(e => e.Badges)
 | 
						|
            .Include(e => e.Profile)
 | 
						|
            .Where(e => e.Id == userId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        var perk = await subscriptions.GetPerkSubscriptionAsync(account!.Id);
 | 
						|
        account.PerkSubscription = perk?.ToReference();
 | 
						|
 | 
						|
        return Ok(account);
 | 
						|
    }
 | 
						|
 | 
						|
    public class BasicInfoRequest
 | 
						|
    {
 | 
						|
        [MaxLength(256)] public string? Nick { get; set; }
 | 
						|
        [MaxLength(32)] public string? Language { get; set; }
 | 
						|
        [MaxLength(32)] public string? Region { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch]
 | 
						|
    public async Task<ActionResult<SnAccount>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
 | 
						|
 | 
						|
        if (request.Nick is not null) account.Nick = request.Nick;
 | 
						|
        if (request.Language is not null) account.Language = request.Language;
 | 
						|
        if (request.Region is not null) account.Region = request.Region;
 | 
						|
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        await accounts.PurgeAccountCache(currentUser);
 | 
						|
        return currentUser;
 | 
						|
    }
 | 
						|
 | 
						|
    public class ProfileRequest
 | 
						|
    {
 | 
						|
        [MaxLength(256)] public string? FirstName { get; set; }
 | 
						|
        [MaxLength(256)] public string? MiddleName { get; set; }
 | 
						|
        [MaxLength(256)] public string? LastName { get; set; }
 | 
						|
        [MaxLength(1024)] public string? Gender { get; set; }
 | 
						|
        [MaxLength(1024)] public string? Pronouns { get; set; }
 | 
						|
        [MaxLength(1024)] public string? TimeZone { get; set; }
 | 
						|
        [MaxLength(1024)] public string? Location { get; set; }
 | 
						|
        [MaxLength(4096)] public string? Bio { get; set; }
 | 
						|
        public Shared.Models.UsernameColor? UsernameColor { get; set; }
 | 
						|
        public Instant? Birthday { get; set; }
 | 
						|
        public List<ProfileLink>? Links { get; set; }
 | 
						|
 | 
						|
        [MaxLength(32)] public string? PictureId { get; set; }
 | 
						|
        [MaxLength(32)] public string? BackgroundId { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("profile")]
 | 
						|
    public async Task<ActionResult<SnAccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        var userId = currentUser.Id;
 | 
						|
 | 
						|
        var profile = await db.AccountProfiles
 | 
						|
            .Where(p => p.Account.Id == userId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (profile is null)
 | 
						|
            return BadRequest(new ApiError
 | 
						|
            {
 | 
						|
                Code = "NOT_FOUND",
 | 
						|
                Message = "Unable to get your account.",
 | 
						|
                Status = 400,
 | 
						|
                TraceId = HttpContext.TraceIdentifier
 | 
						|
            });
 | 
						|
 | 
						|
        if (request.FirstName is not null) profile.FirstName = request.FirstName;
 | 
						|
        if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
 | 
						|
        if (request.LastName is not null) profile.LastName = request.LastName;
 | 
						|
        if (request.Bio is not null) profile.Bio = request.Bio;
 | 
						|
        if (request.Gender is not null) profile.Gender = request.Gender;
 | 
						|
        if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
 | 
						|
        if (request.Birthday is not null) profile.Birthday = request.Birthday;
 | 
						|
        if (request.Location is not null) profile.Location = request.Location;
 | 
						|
        if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
 | 
						|
        if (request.Links is not null) profile.Links = request.Links;
 | 
						|
        if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
 | 
						|
 | 
						|
        if (request.PictureId is not null)
 | 
						|
        {
 | 
						|
            var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | 
						|
            if (profile.Picture is not null)
 | 
						|
                await fileRefs.DeleteResourceReferencesAsync(
 | 
						|
                    new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
 | 
						|
                );
 | 
						|
            await fileRefs.CreateReferenceAsync(
 | 
						|
                new CreateReferenceRequest
 | 
						|
                {
 | 
						|
                    ResourceId = profile.ResourceIdentifier,
 | 
						|
                    FileId = request.PictureId,
 | 
						|
                    Usage = "profile.picture"
 | 
						|
                }
 | 
						|
            );
 | 
						|
            profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
 | 
						|
        }
 | 
						|
 | 
						|
        if (request.BackgroundId is not null)
 | 
						|
        {
 | 
						|
            var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | 
						|
            if (profile.Background is not null)
 | 
						|
                await fileRefs.DeleteResourceReferencesAsync(
 | 
						|
                    new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
 | 
						|
                );
 | 
						|
            await fileRefs.CreateReferenceAsync(
 | 
						|
                new CreateReferenceRequest
 | 
						|
                {
 | 
						|
                    ResourceId = profile.ResourceIdentifier,
 | 
						|
                    FileId = request.BackgroundId,
 | 
						|
                    Usage = "profile.background"
 | 
						|
                }
 | 
						|
            );
 | 
						|
            profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
 | 
						|
        }
 | 
						|
 | 
						|
        db.Update(profile);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
 | 
						|
        await accounts.PurgeAccountCache(currentUser);
 | 
						|
 | 
						|
        return profile;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete]
 | 
						|
    public async Task<ActionResult> RequestDeleteAccount()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.RequestAccountDeletion(currentUser);
 | 
						|
        }
 | 
						|
        catch (InvalidOperationException)
 | 
						|
        {
 | 
						|
            return BadRequest(new ApiError
 | 
						|
            {
 | 
						|
                Code = "TOO_MANY_REQUESTS",
 | 
						|
                Message = "You already requested account deletion within 24 hours.",
 | 
						|
                Status = 400,
 | 
						|
                TraceId = HttpContext.TraceIdentifier
 | 
						|
            });
 | 
						|
        }
 | 
						|
 | 
						|
        return Ok();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("statuses")]
 | 
						|
    public async Task<ActionResult<SnAccountStatus>> GetCurrentStatus()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        var status = await events.GetStatus(currentUser.Id);
 | 
						|
        return Ok(status);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("statuses")]
 | 
						|
    [RequiredPermission("global", "accounts.statuses.update")]
 | 
						|
    public async Task<ActionResult<SnAccountStatus>> UpdateStatus([FromBody] AccountController.StatusRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        if (request is { IsAutomated: true, AppIdentifier: not null })
 | 
						|
            return BadRequest("Automated status cannot be updated.");
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var status = await db.AccountStatuses
 | 
						|
            .Where(e => e.AccountId == currentUser.Id)
 | 
						|
            .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
						|
            .OrderByDescending(e => e.CreatedAt)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
 | 
						|
        if (status.IsAutomated && request.AppIdentifier is null)
 | 
						|
            return BadRequest("Automated status cannot be updated.");
 | 
						|
 | 
						|
        status.Attitude = request.Attitude;
 | 
						|
        status.IsInvisible = request.IsInvisible;
 | 
						|
        status.IsNotDisturb = request.IsNotDisturb;
 | 
						|
        status.IsAutomated = request.IsAutomated;
 | 
						|
        status.Label = request.Label;
 | 
						|
        status.AppIdentifier = request.AppIdentifier;
 | 
						|
        status.Meta = request.Meta;
 | 
						|
        status.ClearedAt = request.ClearedAt;
 | 
						|
 | 
						|
        db.Update(status);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        events.PurgeStatusCache(currentUser.Id);
 | 
						|
 | 
						|
        return status;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("statuses")]
 | 
						|
    [RequiredPermission("global", "accounts.statuses.create")]
 | 
						|
    public async Task<ActionResult<SnAccountStatus>> CreateStatus([FromBody] AccountController.StatusRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        if (request is { IsAutomated: true, AppIdentifier: not null })
 | 
						|
        {
 | 
						|
            var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
            var existingStatus = await db.AccountStatuses
 | 
						|
                .Where(s => s.AccountId == currentUser.Id)
 | 
						|
                .Where(s => s.ClearedAt == null || s.ClearedAt > now)
 | 
						|
                .OrderByDescending(s => s.CreatedAt)
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (existingStatus is not null && existingStatus.IsAutomated)
 | 
						|
                if (existingStatus.IsAutomated && request.AppIdentifier == existingStatus.AppIdentifier)
 | 
						|
                {
 | 
						|
                    existingStatus.Attitude = request.Attitude;
 | 
						|
                    existingStatus.IsInvisible = request.IsInvisible;
 | 
						|
                    existingStatus.IsNotDisturb = request.IsNotDisturb;
 | 
						|
                    existingStatus.Meta = request.Meta;
 | 
						|
                    existingStatus.Label = request.Label;
 | 
						|
                    db.Update(existingStatus);
 | 
						|
                    await db.SaveChangesAsync();
 | 
						|
                    return Ok(existingStatus);
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    existingStatus.ClearedAt = now;
 | 
						|
                    db.Update(existingStatus);
 | 
						|
                    await db.SaveChangesAsync();
 | 
						|
                }
 | 
						|
            else if (existingStatus is not null)
 | 
						|
                return Ok(existingStatus); // Do not override manually set status with automated ones
 | 
						|
        }
 | 
						|
 | 
						|
        var status = new SnAccountStatus
 | 
						|
        {
 | 
						|
            AccountId = currentUser.Id,
 | 
						|
            Attitude = request.Attitude,
 | 
						|
            IsInvisible = request.IsInvisible,
 | 
						|
            IsNotDisturb = request.IsNotDisturb,
 | 
						|
            IsAutomated = request.IsAutomated,
 | 
						|
            Label = request.Label,
 | 
						|
            Meta = request.Meta,
 | 
						|
            AppIdentifier = request.AppIdentifier,
 | 
						|
            ClearedAt = request.ClearedAt
 | 
						|
        };
 | 
						|
 | 
						|
        return await events.CreateStatus(currentUser, status);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("statuses")]
 | 
						|
    public async Task<ActionResult> DeleteStatus([FromQuery] string? app)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var queryable = db.AccountStatuses
 | 
						|
            .Where(s => s.AccountId == currentUser.Id)
 | 
						|
            .Where(s => s.ClearedAt == null || s.ClearedAt > now)
 | 
						|
            .OrderByDescending(s => s.CreatedAt)
 | 
						|
            .AsQueryable();
 | 
						|
 | 
						|
        if (!string.IsNullOrWhiteSpace(app))
 | 
						|
            queryable = queryable.Where(s => s.IsAutomated && s.AppIdentifier == app);
 | 
						|
 | 
						|
        var status = await queryable
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (status is null) return NotFound();
 | 
						|
 | 
						|
        await events.ClearStatus(currentUser, status);
 | 
						|
        return NoContent();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("check-in")]
 | 
						|
    public async Task<ActionResult<SnCheckInResult>> GetCheckInResult()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        var userId = currentUser.Id;
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var today = now.InUtc().Date;
 | 
						|
        var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
        var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
 | 
						|
        var result = await db.AccountCheckInResults
 | 
						|
            .Where(x => x.AccountId == userId)
 | 
						|
            .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
 | 
						|
            .OrderByDescending(x => x.CreatedAt)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        return result is null
 | 
						|
            ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
 | 
						|
            : Ok(result);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("check-in")]
 | 
						|
    public async Task<ActionResult<SnCheckInResult>> DoCheckIn(
 | 
						|
        [FromBody] string? captchaToken,
 | 
						|
        [FromQuery] Instant? backdated = null
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        if (backdated is null)
 | 
						|
        {
 | 
						|
            var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
 | 
						|
            if (!isAvailable)
 | 
						|
                return BadRequest(new ApiError
 | 
						|
                {
 | 
						|
                    Code = "BAD_REQUEST",
 | 
						|
                    Message = "Check-in is not available for today.",
 | 
						|
                    Status = 400,
 | 
						|
                    TraceId = HttpContext.TraceIdentifier
 | 
						|
                });
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            if (currentUser.PerkSubscription is null)
 | 
						|
                return StatusCode(403, ApiError.Unauthorized(
 | 
						|
                    message: "You need to have a subscription to check-in backdated.",
 | 
						|
                    forbidden: true,
 | 
						|
                    traceId: HttpContext.TraceIdentifier));
 | 
						|
            var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
 | 
						|
            if (!isAvailable)
 | 
						|
                return BadRequest(new ApiError
 | 
						|
                {
 | 
						|
                    Code = "BAD_REQUEST",
 | 
						|
                    Message = "Check-in is not available for this date.",
 | 
						|
                    Status = 400,
 | 
						|
                    TraceId = HttpContext.TraceIdentifier
 | 
						|
                });
 | 
						|
        }
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
 | 
						|
            return needsCaptcha switch
 | 
						|
            {
 | 
						|
                true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
 | 
						|
                    new ApiError
 | 
						|
                    {
 | 
						|
                        Code = "CAPTCHA_REQUIRED",
 | 
						|
                        Message = "Captcha is required for this check-in.",
 | 
						|
                        Status = 423,
 | 
						|
                        TraceId = HttpContext.TraceIdentifier
 | 
						|
                    }
 | 
						|
                ),
 | 
						|
                true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
 | 
						|
                    new Dictionary<string, string[]>
 | 
						|
                    {
 | 
						|
                        ["captchaToken"] = new[] { "Invalid captcha token." }
 | 
						|
                    }, traceId: HttpContext.TraceIdentifier)),
 | 
						|
                _ => await events.CheckInDaily(currentUser, backdated)
 | 
						|
            };
 | 
						|
        }
 | 
						|
        catch (InvalidOperationException ex)
 | 
						|
        {
 | 
						|
            return BadRequest(new ApiError
 | 
						|
            {
 | 
						|
                Code = "BAD_REQUEST",
 | 
						|
                Message = "Check-in failed.",
 | 
						|
                Detail = ex.Message,
 | 
						|
                Status = 400,
 | 
						|
                TraceId = HttpContext.TraceIdentifier
 | 
						|
            });
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("calendar")]
 | 
						|
    public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
 | 
						|
        [FromQuery] int? year)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
 | 
						|
        month ??= currentDate.Month;
 | 
						|
        year ??= currentDate.Year;
 | 
						|
 | 
						|
        if (month is < 1 or > 12)
 | 
						|
            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
						|
            {
 | 
						|
                [nameof(month)] = new[] { "Month must be between 1 and 12." }
 | 
						|
            }, traceId: HttpContext.TraceIdentifier));
 | 
						|
        if (year < 1)
 | 
						|
            return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
 | 
						|
            {
 | 
						|
                [nameof(year)] = new[] { "Year must be a positive integer." }
 | 
						|
            }, traceId: HttpContext.TraceIdentifier));
 | 
						|
 | 
						|
        var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
 | 
						|
        return Ok(calendar);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("actions")]
 | 
						|
    [ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
 | 
						|
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
						|
    public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
 | 
						|
        [FromQuery] int take = 20,
 | 
						|
        [FromQuery] int offset = 0
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var query = db.ActionLogs
 | 
						|
            .Where(log => log.AccountId == currentUser.Id)
 | 
						|
            .OrderByDescending(log => log.CreatedAt);
 | 
						|
 | 
						|
        var total = await query.CountAsync();
 | 
						|
        Response.Headers.Append("X-Total", total.ToString());
 | 
						|
 | 
						|
        var logs = await query
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return Ok(logs);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("factors")]
 | 
						|
    public async Task<ActionResult<List<SnAccountAuthFactor>>> GetAuthFactors()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var factors = await db.AccountAuthFactors
 | 
						|
            .Include(f => f.Account)
 | 
						|
            .Where(f => f.Account.Id == currentUser.Id)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return Ok(factors);
 | 
						|
    }
 | 
						|
 | 
						|
    public class AuthFactorRequest
 | 
						|
    {
 | 
						|
        public Shared.Models.AccountAuthFactorType Type { get; set; }
 | 
						|
        public string? Secret { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("factors")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
        if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
 | 
						|
            return BadRequest(new ApiError
 | 
						|
            {
 | 
						|
                Code = "ALREADY_EXISTS",
 | 
						|
                Message = $"Auth factor with type {request.Type} already exists.",
 | 
						|
                Status = 400,
 | 
						|
                TraceId = HttpContext.TraceIdentifier
 | 
						|
            });
 | 
						|
 | 
						|
        var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
 | 
						|
        return Ok(factor);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("factors/{id:guid}/enable")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var factor = await db.AccountAuthFactors
 | 
						|
            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            factor = await accounts.EnableAuthFactor(factor, code);
 | 
						|
            return Ok(factor);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(new ApiError
 | 
						|
            {
 | 
						|
                Code = "BAD_REQUEST",
 | 
						|
                Message = "Failed to enable auth factor.",
 | 
						|
                Detail = ex.Message,
 | 
						|
                Status = 400,
 | 
						|
                TraceId = HttpContext.TraceIdentifier
 | 
						|
            });
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("factors/{id:guid}/disable")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountAuthFactor>> DisableAuthFactor(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var factor = await db.AccountAuthFactors
 | 
						|
            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (factor is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            factor = await accounts.DisableAuthFactor(factor);
 | 
						|
            return Ok(factor);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("factors/{id:guid}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountAuthFactor>> DeleteAuthFactor(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var factor = await db.AccountAuthFactors
 | 
						|
            .Where(f => f.AccountId == currentUser.Id && f.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (factor is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.DeleteAuthFactor(factor);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("devices")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
						|
            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
						|
 | 
						|
        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
						|
 | 
						|
        var devices = await db.AuthClients
 | 
						|
            .Where(device => device.AccountId == currentUser.Id)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
 | 
						|
        var deviceIds = challengeDevices.Select(x => x.Id).ToList();
 | 
						|
 | 
						|
        var authChallenges = await db.AuthChallenges
 | 
						|
            .Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
 | 
						|
            .GroupBy(c => c.ClientId)
 | 
						|
            .ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
 | 
						|
        foreach (var challengeDevice in challengeDevices)
 | 
						|
            if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
 | 
						|
                challengeDevice.Challenges = challenge;
 | 
						|
 | 
						|
        return Ok(challengeDevices);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("sessions")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<List<SnAuthSession>>> GetSessions(
 | 
						|
        [FromQuery] int take = 20,
 | 
						|
        [FromQuery] int offset = 0
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
						|
            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
						|
 | 
						|
        var query = db.AuthSessions
 | 
						|
            .Include(session => session.Account)
 | 
						|
            .Include(session => session.Challenge)
 | 
						|
            .Where(session => session.Account.Id == currentUser.Id);
 | 
						|
 | 
						|
        var total = await query.CountAsync();
 | 
						|
        Response.Headers.Append("X-Total", total.ToString());
 | 
						|
        Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
 | 
						|
 | 
						|
        var sessions = await query
 | 
						|
            .OrderByDescending(x => x.LastGrantedAt)
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return Ok(sessions);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("sessions/{id:guid}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAuthSession>> DeleteSession(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.DeleteSession(currentUser, id);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("devices/{deviceId}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAuthSession>> DeleteDevice(string deviceId)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.DeleteDevice(currentUser, deviceId);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("sessions/current")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAuthSession>> DeleteCurrentSession()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
						|
            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.DeleteSession(currentUser, currentSession.Id);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("devices/{deviceId}/label")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.UpdateDeviceName(currentUser, deviceId, label);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("devices/current/label")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
 | 
						|
            HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
 | 
						|
 | 
						|
        var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
 | 
						|
        if (device is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("contacts")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<List<SnAccountContact>>> GetContacts()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contacts = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return Ok(contacts);
 | 
						|
    }
 | 
						|
 | 
						|
    public class AccountContactRequest
 | 
						|
    {
 | 
						|
        [Required] public Shared.Models.AccountContactType Type { get; set; }
 | 
						|
        [Required] public string Content { get; set; } = null!;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("contacts")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> CreateContact([FromBody] AccountContactRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
 | 
						|
            return Ok(contact);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("contacts/{id:guid}/verify")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> VerifyContact(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contact = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (contact is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.VerifyContactMethod(currentUser, contact);
 | 
						|
            return Ok(contact);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("contacts/{id:guid}/primary")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> SetPrimaryContact(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contact = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (contact is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            contact = await accounts.SetContactMethodPrimary(currentUser, contact);
 | 
						|
            return Ok(contact);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("contacts/{id:guid}/public")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> SetPublicContact(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contact = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (contact is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            contact = await accounts.SetContactMethodPublic(currentUser, contact, true);
 | 
						|
            return Ok(contact);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("contacts/{id:guid}/public")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> UnsetPublicContact(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contact = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (contact is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            contact = await accounts.SetContactMethodPublic(currentUser, contact, false);
 | 
						|
            return Ok(contact);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpDelete("contacts/{id:guid}")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountContact>> DeleteContact(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var contact = await db.AccountContacts
 | 
						|
            .Where(c => c.AccountId == currentUser.Id && c.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (contact is null) return NotFound();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.DeleteContactMethod(currentUser, contact);
 | 
						|
            return NoContent();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("badges")]
 | 
						|
    [ProducesResponseType<List<SnAccountBadge>>(StatusCodes.Status200OK)]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<List<SnAccountBadge>>> GetBadges()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var badges = await db.Badges
 | 
						|
            .Where(b => b.AccountId == currentUser.Id)
 | 
						|
            .ToListAsync();
 | 
						|
        return Ok(badges);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("badges/{id:guid}/active")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnAccountBadge>> ActivateBadge(Guid id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.ActiveBadge(currentUser, id);
 | 
						|
            return Ok();
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("leveling")]
 | 
						|
    [Authorize]
 | 
						|
    public async Task<ActionResult<SnExperienceRecord>> GetLevelingHistory(
 | 
						|
        [FromQuery] int take = 20,
 | 
						|
        [FromQuery] int offset = 0
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var queryable = db.ExperienceRecords
 | 
						|
            .Where(r => r.AccountId == currentUser.Id)
 | 
						|
            .OrderByDescending(r => r.CreatedAt)
 | 
						|
            .AsQueryable();
 | 
						|
 | 
						|
        var totalCount = await queryable.CountAsync();
 | 
						|
        Response.Headers["X-Total"] = totalCount.ToString();
 | 
						|
 | 
						|
        var records = await queryable
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
        return Ok(records);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("credits")]
 | 
						|
    public async Task<ActionResult<bool>> GetSocialCredit()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var credit = await creditService.GetSocialCredit(currentUser.Id);
 | 
						|
        return Ok(credit);
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("credits/history")]
 | 
						|
    public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
 | 
						|
        [FromQuery] int take = 20,
 | 
						|
        [FromQuery] int offset = 0
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
						|
 | 
						|
        var queryable = db.SocialCreditRecords
 | 
						|
            .Where(r => r.AccountId == currentUser.Id)
 | 
						|
            .OrderByDescending(r => r.CreatedAt)
 | 
						|
            .AsQueryable();
 | 
						|
 | 
						|
        var totalCount = await queryable.CountAsync();
 | 
						|
        Response.Headers["X-Total"] = totalCount.ToString();
 | 
						|
 | 
						|
        var records = await queryable
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
        return Ok(records);
 | 
						|
    }
 | 
						|
}
 |