From 963e5112872be77ac35b520250f66c54eb56cc74 Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 25 Apr 2026 07:55:07 -0500 Subject: [PATCH] Phase 3: Budget and sharing API - Add BudgetsController: list (owner + shared), create, get, rename, delete - Add BudgetAuthorizationService: Owner / Edit / View / None access levels - Add SharesController: list, add (resolves KnownUser immediately), update permission, revoke - Register BudgetAuthorizationService as scoped service - Add BudgetDto, ShareDto, and associated request DTOs Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/BudgetsController.cs | 79 +++++++++++++++++++ .../Controllers/SharesController.cs | 75 ++++++++++++++++++ src/Budget.Api/DTOs/BudgetDtos.cs | 7 ++ src/Budget.Api/DTOs/ShareDtos.cs | 9 +++ src/Budget.Api/Program.cs | 1 + .../Services/BudgetAuthorizationService.cs | 49 ++++++++++++ 6 files changed, 220 insertions(+) create mode 100644 src/Budget.Api/Controllers/BudgetsController.cs create mode 100644 src/Budget.Api/Controllers/SharesController.cs create mode 100644 src/Budget.Api/DTOs/BudgetDtos.cs create mode 100644 src/Budget.Api/DTOs/ShareDtos.cs create mode 100644 src/Budget.Api/Services/BudgetAuthorizationService.cs diff --git a/src/Budget.Api/Controllers/BudgetsController.cs b/src/Budget.Api/Controllers/BudgetsController.cs new file mode 100644 index 0000000..77c8b3f --- /dev/null +++ b/src/Budget.Api/Controllers/BudgetsController.cs @@ -0,0 +1,79 @@ +using Budget.Api.Data; +using Budget.Api.DTOs; +using Budget.Api.Models; +using Budget.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Budget.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class BudgetsController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase +{ + private string UserId => User.FindFirst("sub")!.Value; + + [HttpGet] + public async Task List() + { + var userId = UserId; + var budgets = await db.Budgets + .Where(b => b.OwnerUserId == userId || + b.Shares.Any(s => s.SharedWithUserId == userId && !s.IsPending)) + .Select(b => new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)) + .ToListAsync(); + + return Ok(budgets); + } + + [HttpPost] + public async Task Create([FromBody] CreateBudgetRequest req) + { + var budget = new Models.Budget + { + Id = Guid.NewGuid(), + Name = req.Name, + OwnerUserId = UserId, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + db.Budgets.Add(budget); + await db.SaveChangesAsync(); + return CreatedAtAction(nameof(Get), new { id = budget.Id }, + new BudgetDto(budget.Id, budget.Name, budget.OwnerUserId, budget.EffectiveTaxRate, budget.CreatedAt, budget.UpdatedAt)); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + if (!await authz.CanReadAsync(id, UserId)) return Forbid(); + var b = await db.Budgets.FindAsync(id); + if (b is null) return NotFound(); + return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpdateBudgetRequest req) + { + if (!await authz.CanWriteAsync(id, UserId)) return Forbid(); + var b = await db.Budgets.FindAsync(id); + if (b is null) return NotFound(); + b.Name = req.Name; + b.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + return Ok(new BudgetDto(b.Id, b.Name, b.OwnerUserId, b.EffectiveTaxRate, b.CreatedAt, b.UpdatedAt)); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + if (!await authz.IsOwnerAsync(id, UserId)) return Forbid(); + var b = await db.Budgets.FindAsync(id); + if (b is null) return NotFound(); + db.Budgets.Remove(b); + await db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/src/Budget.Api/Controllers/SharesController.cs b/src/Budget.Api/Controllers/SharesController.cs new file mode 100644 index 0000000..e15f8cb --- /dev/null +++ b/src/Budget.Api/Controllers/SharesController.cs @@ -0,0 +1,75 @@ +using Budget.Api.Data; +using Budget.Api.DTOs; +using Budget.Api.Models; +using Budget.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Budget.Api.Controllers; + +[ApiController] +[Route("api/budgets/{budgetId:guid}/shares")] +[Authorize] +public class SharesController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase +{ + private string UserId => User.FindFirst("sub")!.Value; + + [HttpGet] + public async Task List(Guid budgetId) + { + if (!await authz.CanReadAsync(budgetId, UserId)) return Forbid(); + var shares = await db.BudgetShares + .Where(s => s.BudgetId == budgetId) + .Select(s => new ShareDto(s.Id, s.SharedWithUserId, s.SharedWithEmail, s.Permission, s.IsPending, s.CreatedAt)) + .ToListAsync(); + return Ok(shares); + } + + [HttpPost] + public async Task Add(Guid budgetId, [FromBody] CreateShareRequest req) + { + if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + + var existing = await db.BudgetShares + .FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithEmail == req.Email); + if (existing is not null) return Conflict("A share for that email already exists."); + + var knownUser = await db.KnownUsers.FirstOrDefaultAsync(u => u.Email == req.Email); + var share = new BudgetShare + { + Id = Guid.NewGuid(), + BudgetId = budgetId, + SharedWithEmail = req.Email, + SharedWithUserId = knownUser?.Id, + IsPending = knownUser is null, + Permission = req.Permission, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.BudgetShares.Add(share); + await db.SaveChangesAsync(); + return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); + } + + [HttpPut("{shareId:guid}")] + public async Task Update(Guid budgetId, Guid shareId, [FromBody] UpdateShareRequest req) + { + if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); + if (share is null) return NotFound(); + share.Permission = req.Permission; + await db.SaveChangesAsync(); + return Ok(new ShareDto(share.Id, share.SharedWithUserId, share.SharedWithEmail, share.Permission, share.IsPending, share.CreatedAt)); + } + + [HttpDelete("{shareId:guid}")] + public async Task Revoke(Guid budgetId, Guid shareId) + { + if (!await authz.IsOwnerAsync(budgetId, UserId)) return Forbid(); + var share = await db.BudgetShares.FirstOrDefaultAsync(s => s.Id == shareId && s.BudgetId == budgetId); + if (share is null) return NotFound(); + db.BudgetShares.Remove(share); + await db.SaveChangesAsync(); + return NoContent(); + } +} diff --git a/src/Budget.Api/DTOs/BudgetDtos.cs b/src/Budget.Api/DTOs/BudgetDtos.cs new file mode 100644 index 0000000..f9ac5b7 --- /dev/null +++ b/src/Budget.Api/DTOs/BudgetDtos.cs @@ -0,0 +1,7 @@ +namespace Budget.Api.DTOs; + +public record BudgetDto(Guid Id, string Name, string OwnerUserId, decimal EffectiveTaxRate, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); + +public record CreateBudgetRequest(string Name); + +public record UpdateBudgetRequest(string Name); diff --git a/src/Budget.Api/DTOs/ShareDtos.cs b/src/Budget.Api/DTOs/ShareDtos.cs new file mode 100644 index 0000000..2d79461 --- /dev/null +++ b/src/Budget.Api/DTOs/ShareDtos.cs @@ -0,0 +1,9 @@ +using Budget.Api.Models; + +namespace Budget.Api.DTOs; + +public record ShareDto(Guid Id, string? SharedWithUserId, string SharedWithEmail, SharePermission Permission, bool IsPending, DateTimeOffset CreatedAt); + +public record CreateShareRequest(string Email, SharePermission Permission); + +public record UpdateShareRequest(SharePermission Permission); diff --git a/src/Budget.Api/Program.cs b/src/Budget.Api/Program.cs index 45c15ab..bcd22f6 100644 --- a/src/Budget.Api/Program.cs +++ b/src/Budget.Api/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddAuthorization(); +builder.Services.AddScoped(); builder.Services.AddControllers(); var app = builder.Build(); diff --git a/src/Budget.Api/Services/BudgetAuthorizationService.cs b/src/Budget.Api/Services/BudgetAuthorizationService.cs new file mode 100644 index 0000000..7ecd684 --- /dev/null +++ b/src/Budget.Api/Services/BudgetAuthorizationService.cs @@ -0,0 +1,49 @@ +using Budget.Api.Data; +using Budget.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace Budget.Api.Services; + +public enum BudgetAccess { None, View, Edit, Owner } + +public class BudgetAuthorizationService(AppDbContext db) +{ + public async Task GetAccessAsync(Guid budgetId, string userId) + { + var budget = await db.Budgets + .AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == budgetId); + + if (budget is null) return BudgetAccess.None; + if (budget.OwnerUserId == userId) return BudgetAccess.Owner; + + var share = await db.BudgetShares + .AsNoTracking() + .FirstOrDefaultAsync(s => s.BudgetId == budgetId && s.SharedWithUserId == userId && !s.IsPending); + + return share?.Permission switch + { + SharePermission.Edit => BudgetAccess.Edit, + SharePermission.View => BudgetAccess.View, + _ => BudgetAccess.None, + }; + } + + public async Task CanReadAsync(Guid budgetId, string userId) + { + var access = await GetAccessAsync(budgetId, userId); + return access != BudgetAccess.None; + } + + public async Task CanWriteAsync(Guid budgetId, string userId) + { + var access = await GetAccessAsync(budgetId, userId); + return access is BudgetAccess.Edit or BudgetAccess.Owner; + } + + public async Task IsOwnerAsync(Guid budgetId, string userId) + { + var access = await GetAccessAsync(budgetId, userId); + return access == BudgetAccess.Owner; + } +}