GuestOps/src/GuestOps.Api/TeamEndpoints.cs
2026-09-30 10:24:27 +01:00

68 lines
6.1 KiB
C#

using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
namespace GuestOps.Web;
public static class TeamEndpoints
{
static object Delivery(AccountLinkResult result,bool preview)=>preview?new{result.UserId,result.DeliveryState,result.ExpiresAt,result.Link}:(object)new{result.UserId,result.DeliveryState,result.ExpiresAt};
public static void Map(WebApplication app,RouteGroupBuilder api,bool preview)
{
app.MapPost("/api/account-links/inspect",async(AccountTokenInput input,TeamAccounts accounts,IStore store)=>
{
var user=await accounts.Inspect(input.Token);if(user==null)return Results.BadRequest(new {error="This link is invalid or has expired. Ask for a new link."});
var hotel=await store.Get<Hotel>(user.HotelId,user.HotelId);
return Results.Ok(new {user.Name,user.Email,purpose=user.AccountLinkPurpose,expiresAt=user.AccountLinkExpiresAt,hotelName=hotel?.Name});
}).RequireRateLimiting("accounts");
app.MapPost("/api/account-links/accept",async(AccountAcceptInput input,TeamAccounts accounts,IStore store,HttpContext c)=>
{
var user=await accounts.Inspect(input.Token);
if(user==null||!await accounts.Accept(input))return Results.BadRequest(new {error="This link is invalid or has expired. Ask for a new link."});
await store.Insert(new Activity{HotelId=user.HotelId,UserName=user.Name,Action=user.AccountLinkPurpose=="Invite"?"Accepted staff invitation":"Changed account password"});
await c.SignOutAsync();return Results.Ok();
}).RequireRateLimiting("accounts");
var team=api.MapGroup("/team").RequireAuthorization(Access.Team);
team.MapGet("/",async(HttpContext c,IStore store)=>Results.Ok((await store.List<StaffUser>(Session.Hotel(c))).Select(TeamAccounts.View)));
team.MapPost("/invite",async(InviteInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
if(!Access.CanManage(actor,role))return Results.Forbid();
var result=await accounts.Invite(Session.Hotel(c),input with{Role=role});await Session.Audit(store,c,"Issued a team invitation");return Results.Ok(Delivery(result,preview));
}).RequireRateLimiting("accounts");
team.MapPost("/{id}/{action}",async(string id,string action,VersionInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";if(!Access.CanManage(actor,user.Role))return Results.Forbid();
if(action is "reset" or "restore")
{
var result=action=="reset"?await accounts.ResetStaff(user,input.Version):await accounts.Restore(user,input.Version);
await Session.Audit(store,c,action=="reset"?"Issued a team password recovery":"Issued a team restoration");return Results.Ok(Delivery(result,preview));
}
if(action=="mfa-reset")
{
if(Access.NormalizeRole(actor)!=Access.Owner)return Results.Forbid();if(!await accounts.ResetMfa(user,input.Version))return Input.Conflict();await Session.Audit(store,c,"Reset team MFA after identity verification");return Results.Ok();
}
if(action is not ("disable" or "revoke"))return Results.NotFound();
if(!(action=="disable"?await accounts.Disable(user,input.Version):await accounts.Revoke(user,input.Version)))return Input.Conflict();
await Session.Audit(store,c,action=="disable"?"Disabled a team account":"Revoked a team account link");return Results.Ok();
}).RequireRateLimiting("accounts");
team.MapPut("/{id}/role",async(string id,RoleInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
if(!Access.CanManage(actor,user.Role)||!Access.CanManage(actor,role))return Results.Forbid();if(!await accounts.ChangeRole(user,input.Version,role))return Input.Conflict();await Session.Audit(store,c,"Changed a team role to "+role);return Results.Ok(TeamAccounts.View(user));
});
api.MapGet("/onboarding",async(HttpContext c,IStore store)=>
{
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);
var knowledge=await store.List<KnowledgeEntry>(id);var mailboxes=await store.List<Mailbox>(id);var users=await store.List<StaffUser>(id);
return Results.Ok(new {preview,steps=new[]{
new {title="Check your hotel details",detail="Review the hotel name, timezone and email signature.",path="/settings",complete=hotel!=null&&hotel.Name.Length>=2&&hotel.Signature.Length>0,optional=false},
new {title="Approve your guest answers",detail="Add current check-in, parking and breakfast information.",path="/knowledge",complete=knowledge.Any(x=>x.Approved),optional=false},
new {title="Connect the hotel mailbox",detail="Connect Google and check that guest messages appear in the inbox.",path="/settings",complete=mailboxes.Any(x=>x.Status=="Connected"&&x.LastSyncAt!=null),optional=false},
new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>Access.NormalizeRole(x.Role)!=Access.Owner&&x.Active),optional=true},
new {title="Test FAQ automation",detail="Review test results before enabling live replies. This checklist does not enable sending.",path="/automation",complete=hotel?.AutoReplyMode=="Test"||hotel?.AutoReplyMode=="Live",optional=true}
}});
}).RequireAuthorization(Access.HotelSettings);
}
}
public sealed record RoleInput(string Role,long Version);