namespace DodoSSH.Domain; /// /// A group of users who can share vaults. /// /// /// The tables exist from the first migration although team features ship in M3. Adding them /// later would mean altering to introduce a foreign key on a live table, and /// the cost of carrying two unused tables is far lower than that. /// public sealed class Team { /// Primary key. public Guid Id { get; set; } /// Display name. public string Name { get; set; } = string.Empty; /// URL-safe unique identifier. public string Slug { get; set; } = string.Empty; /// Optional description. public string? Description { get; set; } /// Who created it. public Guid CreatedByUserId { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Soft-delete marker. public DateTimeOffset? DeletedAtUtc { get; set; } /// Members. public ICollection Memberships { get; } = []; /// Invitations to addresses that have no account here yet. public ICollection Invitations { get; } = []; } /// /// A user's membership of a team. /// /// /// Revoked memberships are retained rather than deleted, so historic audit entries remain /// resolvable to a person. /// public sealed class TeamMembership { /// Primary key. public Guid Id { get; set; } /// The team. public Guid TeamId { get; set; } /// The team. public Team? Team { get; set; } /// The member. public Guid UserId { get; set; } /// The member. public UserAccount? User { get; set; } /// Role within the team. public TeamRole Role { get; set; } /// Membership state. public MembershipStatus Status { get; set; } /// Who invited them. public Guid? InvitedByUserId { get; set; } /// When the invitation was accepted. public DateTimeOffset? JoinedAtUtc { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// Soft-delete marker. public DateTimeOffset? DeletedAtUtc { get; set; } } /// /// A standing offer of membership to an email address that has no account here yet. /// /// /// /// Its own table rather than a with /// . A membership names an account — /// team_membership.user_id is not nullable and carries a foreign key to /// — so an invitee who has never signed in has nothing for that row to /// point at. Widening that column would make the unique index on (team, user) meaningless, because /// PostgreSQL counts every NULL as distinct, and would silently change what every /// m.UserId == user.Id query in the server means. /// /// /// There is no token. Nothing is sent, because this server has no outbound mail path; the row /// is an instruction to the sign-in path rather than a secret somebody presents. That is why it is /// keyed on the address and why the address has to be one the identity provider marks verified before /// the claim is honoured — an unclaimable invitation is an inconvenience, but one claimable by /// anybody who can assert an address is a way into the team. /// /// /// Revoked and accepted rows are retained rather than deleted, as is and /// for the same reason: the uniqueness that matters is among pending invitations, and the /// history of who invited whom stays resolvable. /// /// public sealed class TeamInvitation { /// Primary key. public Guid Id { get; set; } /// The team. public Guid TeamId { get; set; } /// The team. public Team? Team { get; set; } /// The address invited. Case-insensitive. public string Email { get; set; } = string.Empty; /// Role the membership will carry when it is claimed. public TeamRole Role { get; set; } /// Who issued it. public Guid InvitedByUserId { get; set; } /// Creation timestamp. public DateTimeOffset CreatedAtUtc { get; set; } /// When it stops being claimable. public DateTimeOffset ExpiresAtUtc { get; set; } /// When an account with this address signed in and took it up. public DateTimeOffset? AcceptedAtUtc { get; set; } /// Which account took it up. public Guid? AcceptedByUserId { get; set; } /// Revocation timestamp. public DateTimeOffset? RevokedAtUtc { get; set; } }