Skip to content

Commit 48c0de9

Browse files
authored
Merge pull request #1 from munkycdev/feature/world-invitations
Add world invitation system (reusable invite links)
2 parents 8dc1927 + 4bb87a1 commit 48c0de9

40 files changed

Lines changed: 3882 additions & 6 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Nornis.Api.Contracts.Requests;
2+
3+
public record CreateInviteRequest(
4+
string Role,
5+
DateTimeOffset? ExpiresAt = null,
6+
int? MaxUses = null);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Nornis.Api.Contracts.Responses;
2+
3+
public record AcceptInviteResponse(
4+
Guid WorldId,
5+
string WorldName,
6+
bool AlreadyMember);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Nornis.Api.Contracts.Responses;
2+
3+
public record InvitePreviewResponse(
4+
Guid WorldId,
5+
string WorldName,
6+
string Role,
7+
string Status);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Nornis.Api.Contracts.Responses;
2+
3+
/// <summary>
4+
/// A world invite as seen by a managing GM. <c>Code</c> is the redemption token; the Web app
5+
/// builds the shareable <c>nornis.app/invite/{Code}</c> URL from it. <c>Status</c> is the
6+
/// invite's current redeemability (Active/Revoked/Expired/Exhausted).
7+
/// </summary>
8+
public record WorldInviteResponse(
9+
Guid Id,
10+
Guid WorldId,
11+
string Code,
12+
string Role,
13+
string Status,
14+
int UseCount,
15+
int? MaxUses,
16+
DateTimeOffset? ExpiresAt,
17+
DateTimeOffset CreatedAt,
18+
DateTimeOffset? RevokedAt);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Nornis.Api.Contracts.Responses;
3+
using Nornis.Api.Extensions;
4+
using Nornis.Application.Errors;
5+
using Nornis.Application.Services;
6+
7+
namespace Nornis.Api.Controllers;
8+
9+
/// <summary>
10+
/// Invite redemption for the invitee. Deliberately NOT world-scoped and NOT behind the
11+
/// <see cref="Filters.WorldMemberActionFilter"/> — the caller is a prospective member, so a
12+
/// membership check would 403 every legitimate redemption. Still authenticated-by-default via
13+
/// the global fallback policy, so <see cref="Middleware.UserProvisioningMiddleware"/> has
14+
/// resolved (and, for brand-new users, created) the Nornis user before these actions run.
15+
/// </summary>
16+
[ApiController]
17+
[Route("api/invites")]
18+
public class InvitesController : ControllerBase
19+
{
20+
private readonly IWorldInviteService _inviteService;
21+
22+
public InvitesController(IWorldInviteService inviteService)
23+
{
24+
_inviteService = inviteService;
25+
}
26+
27+
/// <summary>Describes an invite so the landing page can greet the invitee.</summary>
28+
[HttpGet("{code}")]
29+
public async Task<IActionResult> Preview(string code, CancellationToken ct)
30+
{
31+
var result = await _inviteService.PreviewAsync(code, ct);
32+
if (!result.IsSuccess)
33+
{
34+
return MapError(result.Error!);
35+
}
36+
37+
var preview = result.Value!;
38+
return Ok(new InvitePreviewResponse(
39+
preview.WorldId,
40+
preview.WorldName,
41+
preview.Role.ToString(),
42+
preview.Status.ToString()));
43+
}
44+
45+
/// <summary>Redeems the invite for the calling user, joining them to the world.</summary>
46+
[HttpPost("{code}/accept")]
47+
public async Task<IActionResult> Accept(string code, CancellationToken ct)
48+
{
49+
var user = HttpContext.GetNornisUser();
50+
var result = await _inviteService.RedeemAsync(code, user.Id, ct);
51+
52+
if (!result.IsSuccess)
53+
{
54+
return MapError(result.Error!);
55+
}
56+
57+
var redemption = result.Value!;
58+
return Ok(new AcceptInviteResponse(
59+
redemption.WorldId,
60+
redemption.WorldName,
61+
redemption.AlreadyMember));
62+
}
63+
64+
private IActionResult MapError(AppError error)
65+
{
66+
return error.StatusCode switch
67+
{
68+
400 => BadRequest(new ErrorResponse(error.Code, error.Message)),
69+
403 => StatusCode(403, new ErrorResponse(error.Code, error.Message)),
70+
404 => NotFound(new ErrorResponse(error.Code, error.Message)),
71+
409 => Conflict(new ErrorResponse(error.Code, error.Message)),
72+
_ => StatusCode(error.StatusCode, new ErrorResponse(error.Code, error.Message))
73+
};
74+
}
75+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Nornis.Api.Contracts.Requests;
3+
using Nornis.Api.Contracts.Responses;
4+
using Nornis.Api.Extensions;
5+
using Nornis.Api.Filters;
6+
using Nornis.Application.Errors;
7+
using Nornis.Application.Models;
8+
using Nornis.Application.Services;
9+
using Nornis.Domain.Entities;
10+
using Nornis.Domain.Enums;
11+
12+
namespace Nornis.Api.Controllers;
13+
14+
/// <summary>
15+
/// GM management of a world's reusable invite links. World-scoped: the
16+
/// <see cref="WorldMemberActionFilter"/> resolves membership, and every action is GM-only.
17+
/// Redemption of an invite lives on the separate, non-world-scoped <see cref="InvitesController"/>,
18+
/// because an invitee is by definition not yet a member.
19+
/// </summary>
20+
[ApiController]
21+
[Route("api/worlds/{worldId:guid}/invites")]
22+
[ServiceFilter(typeof(WorldMemberActionFilter))]
23+
public class WorldInvitesController : ControllerBase
24+
{
25+
private readonly IWorldInviteService _inviteService;
26+
27+
public WorldInvitesController(IWorldInviteService inviteService)
28+
{
29+
_inviteService = inviteService;
30+
}
31+
32+
[HttpGet]
33+
public async Task<IActionResult> List(Guid worldId, CancellationToken ct)
34+
{
35+
if (HttpContext.GetWorldMember().Role != WorldRole.GM)
36+
{
37+
return StatusCode(403, new ErrorResponse("insufficient_role", "Only GMs can view invites."));
38+
}
39+
40+
var user = HttpContext.GetNornisUser();
41+
var result = await _inviteService.ListAsync(worldId, user.Id, ct);
42+
43+
if (!result.IsSuccess)
44+
{
45+
return MapError(result.Error!);
46+
}
47+
48+
var response = result.Value!.Select(ToResponse).ToList();
49+
return Ok(response);
50+
}
51+
52+
[HttpPost]
53+
public async Task<IActionResult> Create(
54+
Guid worldId,
55+
[FromBody] CreateInviteRequest request,
56+
CancellationToken ct)
57+
{
58+
if (HttpContext.GetWorldMember().Role != WorldRole.GM)
59+
{
60+
return StatusCode(403, new ErrorResponse("insufficient_role", "Only GMs can create invites."));
61+
}
62+
63+
if (!Enum.TryParse<WorldRole>(request.Role, ignoreCase: true, out var role))
64+
{
65+
return BadRequest(new ErrorResponse("invalid_role", $"'{request.Role}' is not a valid world role."));
66+
}
67+
68+
var user = HttpContext.GetNornisUser();
69+
var command = new CreateInviteCommand(worldId, user.Id, role, request.ExpiresAt, request.MaxUses);
70+
71+
var result = await _inviteService.CreateAsync(command, ct);
72+
if (!result.IsSuccess)
73+
{
74+
return MapError(result.Error!);
75+
}
76+
77+
var response = ToResponse(result.Value!);
78+
return CreatedAtAction(nameof(List), new { worldId }, response);
79+
}
80+
81+
[HttpDelete("{inviteId:guid}")]
82+
public async Task<IActionResult> Revoke(Guid worldId, Guid inviteId, CancellationToken ct)
83+
{
84+
if (HttpContext.GetWorldMember().Role != WorldRole.GM)
85+
{
86+
return StatusCode(403, new ErrorResponse("insufficient_role", "Only GMs can revoke invites."));
87+
}
88+
89+
var user = HttpContext.GetNornisUser();
90+
var result = await _inviteService.RevokeAsync(worldId, inviteId, user.Id, ct);
91+
92+
if (!result.IsSuccess)
93+
{
94+
return MapError(result.Error!);
95+
}
96+
97+
return NoContent();
98+
}
99+
100+
private static WorldInviteResponse ToResponse(WorldInvite invite)
101+
{
102+
return new WorldInviteResponse(
103+
Id: invite.Id,
104+
WorldId: invite.WorldId,
105+
Code: invite.Code,
106+
Role: invite.Role.ToString(),
107+
Status: invite.StatusAt(DateTimeOffset.UtcNow).ToString(),
108+
UseCount: invite.UseCount,
109+
MaxUses: invite.MaxUses,
110+
ExpiresAt: invite.ExpiresAt,
111+
CreatedAt: invite.CreatedAt,
112+
RevokedAt: invite.RevokedAt);
113+
}
114+
115+
private IActionResult MapError(AppError error)
116+
{
117+
return error.StatusCode switch
118+
{
119+
400 => BadRequest(new ErrorResponse(error.Code, error.Message)),
120+
403 => StatusCode(403, new ErrorResponse(error.Code, error.Message)),
121+
404 => NotFound(new ErrorResponse(error.Code, error.Message)),
122+
409 => Conflict(new ErrorResponse(error.Code, error.Message)),
123+
_ => StatusCode(error.StatusCode, new ErrorResponse(error.Code, error.Message))
124+
};
125+
}
126+
}

src/Nornis.Api/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
builder.Services.AddScoped<IUserRepository, UserRepository>();
6565
builder.Services.AddScoped<IWorldRepository, WorldRepository>();
6666
builder.Services.AddScoped<IWorldMemberRepository, WorldMemberRepository>();
67+
builder.Services.AddScoped<IWorldInviteRepository, WorldInviteRepository>();
6768
builder.Services.AddScoped<ICampaignRepository, CampaignRepository>();
6869
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
6970
builder.Services.AddScoped<ISourceRepository, SourceRepository>();
@@ -84,6 +85,8 @@
8485
// Application service registrations
8586
builder.Services.AddScoped<IWorldService, WorldService>();
8687
builder.Services.AddScoped<IWorldMemberService, WorldMemberService>();
88+
builder.Services.AddScoped<IWorldInviteService, WorldInviteService>();
89+
builder.Services.AddSingleton<IInviteCodeGenerator, InviteCodeGenerator>();
8790
builder.Services.AddScoped<ICampaignService, CampaignService>();
8891
builder.Services.AddScoped<ICharacterService, CharacterService>();
8992
builder.Services.AddScoped<ISourceService, SourceService>();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Nornis.Domain.Enums;
2+
3+
namespace Nornis.Application.Models;
4+
5+
/// <summary>
6+
/// A GM's request to mint a reusable invite link for a world. <paramref name="ExpiresAt"/>
7+
/// and <paramref name="MaxUses"/> are optional caps (null = never expires / unlimited).
8+
/// </summary>
9+
public record CreateInviteCommand(
10+
Guid WorldId,
11+
Guid ActingUserId,
12+
WorldRole Role,
13+
DateTimeOffset? ExpiresAt = null,
14+
int? MaxUses = null);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Nornis.Domain.Enums;
2+
3+
namespace Nornis.Application.Models;
4+
5+
/// <summary>
6+
/// What an authenticated invitee sees before deciding to join: which world the invite is
7+
/// for, the role it grants, and whether it can still be redeemed.
8+
/// </summary>
9+
public record InvitePreview(
10+
Guid WorldId,
11+
string WorldName,
12+
WorldRole Role,
13+
InviteStatus Status);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Nornis.Application.Models;
2+
3+
/// <summary>
4+
/// The outcome of redeeming an invite. <paramref name="AlreadyMember"/> is true when the
5+
/// caller was already in the world — redemption is idempotent and does not double-count.
6+
/// </summary>
7+
public record InviteRedemption(
8+
Guid WorldId,
9+
string WorldName,
10+
bool AlreadyMember);

0 commit comments

Comments
 (0)