using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Yavsc.Models; using Yavsc.Models.Relationship; using Yavsc.Server.Helpers; using static Yavsc.Blogs.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] [Route(APIPrefix +"/circle")] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; public CircleApiController(ApplicationDbContext context) { _context = context; } /// /// Returns the caller's own circles. Circles are personal — /// the API never exposes another user's circles, even by id. /// // GET: api/circle [HttpGet] public IEnumerable GetCircle() { var uid = User.GetUserId(); return _context.Circle.Where(c => c.OwnerId == uid); } /// /// Returns a single circle only when it belongs to the caller. /// // GET: api/circle/5 [HttpGet("{id}", Name = "GetCircle")] public async Task GetCircle([FromRoute] long id) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); Circle circle = await _context.Circle.SingleOrDefaultAsync( m => m.Id == id && m.OwnerId == uid); if (circle == null) { return NotFound(); } return Ok(circle); } /// /// Replaces a circle. The caller must own it; the server /// reasserts ownership regardless of any OwnerId /// the client tries to put in the body. /// /// The body shape is a — a /// flat, navigation-free projection — not the EF entity. /// The EF entity carries [JsonIgnore]-decorated /// navigation properties (Owner, Members) /// that bind to server-only types (ApplicationUser, /// CircleMember); keeping the wire shape as a /// DTO avoids any future regression where the entity /// grows a navigable property that System.Text.Json /// refuses to materialise. The client-side mirror lives /// in Yavsc.Api.Client.Dtos.CircleDto. /// // PUT: api/circle/5 [HttpPut("{id}")] public async Task PutCircle( [FromRoute] long id, [FromBody] CircleDto circle) { if (!ModelState.IsValid) { return BadRequest(ModelState); } if (id != circle.Id) { return BadRequest(); } var uid = User.GetUserId(); var existing = await _context.Circle.SingleOrDefaultAsync( c => c.Id == id && c.OwnerId == uid); if (existing is null) { return new ChallengeResult(); } // Map the wire shape onto the entity. OwnerId is // forced to the caller regardless of what the body // says; Name and Public come from the body. existing.Name = circle.Name; existing.Public = circle.Public; existing.OwnerId = uid; _context.Entry(existing).State = EntityState.Modified; try { await _context.SaveChangesAsync(User.GetUserId()); } catch (DbUpdateConcurrencyException) { if (!CircleExists(id)) { return NotFound(); } else { throw; } } return new StatusCodeResult(StatusCodes.Status204NoContent); } /// /// Creates a circle owned by the caller. The server overwrites /// any OwnerId the client sends in the body. /// // POST: api/circle [HttpPost] public async Task PostCircle([FromBody] CircleDto circle) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); circle.OwnerId = uid; Circle newCircle = new Circle { OwnerId = User.GetUserId(), Name = circle.Name, Public = circle.Public }; _context.Circle.Add(newCircle); try { await _context.SaveChangesAsync(User.GetUserId()); } catch (DbUpdateException) { if (CircleExists(circle.Id)) { return new StatusCodeResult(StatusCodes.Status409Conflict); } else { throw; } } return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle); } /// /// Deletes a circle only if the caller owns it. Returns 404 /// (not 403) when the circle does not exist or is not owned /// by the caller, to avoid leaking the existence of someone /// else's circle. /// // DELETE: api/circle/5 [HttpDelete("{id}")] public async Task DeleteCircle([FromRoute] long id) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); Circle circle = await _context.Circle.SingleOrDefaultAsync( m => m.Id == id && m.OwnerId == uid); if (circle == null) { return NotFound(); } _context.Circle.Remove(circle); await _context.SaveChangesAsync(User.GetUserId()); return Ok(circle); } /// /// Returns the members of one of the caller's circles. /// Returns 404 (not 403) when the circle does not exist /// or is not owned by the caller, mirroring the scoping /// of the rest of this controller. /// // GET: api/circle/5/members [HttpGet("{id}/members")] public async Task GetMembers([FromRoute] long id) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid); if (!ownsIt) { return NotFound(); } var members = await _context.CircleMembers .Where(m => m.CircleId == id) .Select(m => new CircleMemberDto { Id = m.MemberId, UserName = m.Member.UserName ?? string.Empty, FullName = m.Member.FullName, Avatar = m.Member.Avatar, }) .ToListAsync(); return Ok(members); } /// /// Adds a Yavsc user to one of the caller's circles. The /// body carries the user id (resolved client-side via the /// central /api/user-search endpoint). Returns /// 404 (not 403) when the circle does not exist or is not /// owned by the caller, and 404 when the target user does /// not exist, so the caller can't probe whether an email /// belongs to a real account. /// /// Returns 409 Conflict if the user is already a /// member of the circle; the client treats this as a /// no-op success. /// // POST: api/circle/5/members // body: { "userId": "..." } [HttpPost("{id}/members")] public async Task AddMember( [FromRoute] long id, [FromBody] AddCircleMemberDto body) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid); if (!ownsIt) { return NotFound(); } // Reject unknown user ids the same way as an unknown // circle: 404. Probing the user table by id should not // be possible through this endpoint. var userExists = await _context.Users.AnyAsync(u => u.Id == body.UserId); if (!userExists) { return NotFound(); } // Idempotency: re-adding an existing member is a // 409, not a silent success. Clients that don't // dedupe beforehand will at least get an actionable // status code rather than a misleading "created". var alreadyMember = await _context.CircleMembers.AnyAsync( m => m.CircleId == id && m.MemberId == body.UserId); if (alreadyMember) { return new StatusCodeResult(StatusCodes.Status409Conflict); } _context.CircleMembers.Add(new CircleMember { CircleId = id, MemberId = body.UserId, }); await _context.SaveChangesAsync(User.GetUserId()); return CreatedAtRoute("GetCircle", new { id }, body); } /// /// Removes a user from one of the caller's circles. /// Returns 404 when the circle does not exist or is not /// owned by the caller, mirroring the rest of this /// controller's scoping. Returns 404 when the user is /// not a member of the circle (idempotent: removing a /// non-member is the same as having nothing to remove). /// // DELETE: api/circle/5/members/tester [HttpDelete("{id}/members/{userId}")] public async Task RemoveMember( [FromRoute] long id, [FromRoute] string userId) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var uid = User.GetUserId(); var ownsIt = await _context.Circle.AnyAsync(c => c.Id == id && c.OwnerId == uid); if (!ownsIt) { return NotFound(); } var membership = await _context.CircleMembers.SingleOrDefaultAsync( m => m.CircleId == id && m.MemberId == userId); if (membership is null) { return NotFound(); } _context.CircleMembers.Remove(membership); await _context.SaveChangesAsync(User.GetUserId()); return Ok(); } protected override void Dispose(bool disposing) { if (disposing) { _context.Dispose(); } base.Dispose(disposing); } private bool CircleExists(long id) { return _context.Circle.Count(e => e.Id == id) > 0; } } /// /// Wire shape for PUT /api/circle/{id}. Flat by /// design — navigation properties (Owner, /// Members) live on the EF entity only and never /// cross the wire. /// /// Field names match the JSON the server emits /// (camelCase via ASP.NET Core's Web defaults), so no /// [JsonPropertyName] attributes are required. /// Mirrors the client-side Yavsc.Api.Client.Dtos.CircleDto /// — keep them in sync. /// public sealed class CircleDto { public long Id { get; set; } public string Name { get; set; } = string.Empty; public string OwnerId { get; set; } = string.Empty; public bool Public { get; set; } } /// /// Wire shape for GET /api/circle/{id}/members. /// Mirrors but stops /// short of the Email field — circle membership UI only /// needs to render a name and an avatar, not contact /// details. /// public sealed class CircleMemberDto { public string Id { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; public string? FullName { get; set; } public string? Avatar { get; set; } } /// /// Wire shape for POST /api/circle/{id}/members. /// The body is intentionally tiny: the client resolves /// the user id via /api/user-search before /// posting, so all we need is the resolved id. /// public sealed class AddCircleMemberDto { public string UserId { get; set; } = string.Empty; } }