public_invite.gno

package boards2

import (
	"chain"
	"chain/runtime"
	"strings"
	"time"

	"gno.land/p/gnoland/boards"
	"gno.land/p/nt/avl/v0"
)

// Invite contains a user invitation.
type Invite struct {
	// User is the user to invite.
	User address

	// Role is the optional role to assign to the user.
	Role boards.Role
}

// InviteMember adds a member to the realm or to a board.
//
// A role can optionally be specified to be assigned to the new member.
func InviteMember(_ realm, boardID boards.ID, user address, role boards.Role) {
	inviteMembers(boardID, Invite{
		User: user,
		Role: role,
	})
}

// InviteMembers adds one or more members to the realm or to a board.
//
// Board ID is only required when inviting a member to a specific board.
func InviteMembers(_ realm, boardID boards.ID, invites ...Invite) {
	inviteMembers(boardID, invites...)
}

// RequestInvite request to be invited to a board.
func RequestInvite(_ realm, boardID boards.ID) {
	assertMembersUpdateIsEnabled(boardID)

	if !runtime.PreviousRealm().IsUser() {
		panic("caller must be user")
	}

	// TODO: Request a fee (returned on accept) or registered user to avoid spam?
	// TODO: Make open invite requests optional (per board)

	board := mustGetBoard(boardID)
	user := runtime.PreviousRealm().Address()
	if board.Permissions.HasUser(user) {
		panic("caller is already a member")
	}

	invitee := user.String()
	requests, found := getInviteRequests(boardID)
	if !found {
		requests = avl.NewTree()
		requests.Set(invitee, time.Now())
		gInviteRequests.Set(boardID.Key(), requests)
		return
	}

	if requests.Has(invitee) {
		panic("invite request already exists")
	}

	requests.Set(invitee, time.Now())
}

// AcceptInvite accepts a board invite request.
func AcceptInvite(_ realm, boardID boards.ID, user address) {
	assertMembersUpdateIsEnabled(boardID)
	assertInviteRequestExists(boardID, user)

	board := mustGetBoard(boardID)
	if board.Permissions.HasUser(user) {
		panic("user is already a member")
	}

	caller := runtime.PreviousRealm().Address()
	invite := Invite{
		User: user,
		Role: RoleGuest,
	}
	args := boards.Args{caller, boardID, []Invite{invite}}
	board.Permissions.WithPermission(caller, PermissionMemberInvite, args, crossingFn(func() {
		assertMembersUpdateIsEnabled(boardID)

		invitee := user.String()
		requests, found := getInviteRequests(boardID)
		if !found || !requests.Has(invitee) {
			panic("invite request not found")
		}

		if board.Permissions.HasUser(user) {
			panic("user is already a member")
		}

		board.Permissions.SetUserRoles(user)
		requests.Remove(invitee)

		chain.Emit(
			"MembersInvited",
			"invitedBy", caller.String(),
			"boardID", board.ID.String(),
			"members", user.String()+":"+string(RoleGuest), // TODO: Support optional role assign
		)
	}))
}

// RevokeInvite revokes a board invite request.
func RevokeInvite(_ realm, boardID boards.ID, user address) {
	assertInviteRequestExists(boardID, user)

	board := mustGetBoard(boardID)
	caller := runtime.PreviousRealm().Address()
	args := boards.Args{boardID, user, RoleGuest}
	board.Permissions.WithPermission(caller, PermissionMemberInviteRevoke, args, crossingFn(func() {
		invitee := user.String()
		requests, found := getInviteRequests(boardID)
		if !found || !requests.Has(invitee) {
			panic("invite request not found")
		}

		requests.Remove(invitee)

		chain.Emit(
			"InviteRevoked",
			"revokedBy", caller.String(),
			"boardID", board.ID.String(),
			"user", user.String(),
		)
	}))
}

func inviteMembers(boardID boards.ID, invites ...Invite) {
	if len(invites) == 0 {
		panic("one or more user invites are required")
	}

	assertMembersUpdateIsEnabled(boardID)
	assertNoDuplicatedInvites(invites)

	perms := mustGetPermissions(boardID)
	caller := runtime.PreviousRealm().Address()
	args := boards.Args{caller, boardID, invites}
	perms.WithPermission(caller, PermissionMemberInvite, args, crossingFn(func() {
		assertMembersUpdateIsEnabled(boardID)

		users := make([]string, len(invites))
		for _, v := range invites {
			assertMemberAddressIsValid(v.User)

			if perms.HasUser(v.User) {
				panic("user is already a member: " + v.User.String())
			}

			// NOTE: Permissions implementation should check that role is valid
			perms.SetUserRoles(v.User, v.Role)
			users = append(users, v.User.String()+":"+string(v.Role))
		}

		chain.Emit(
			"MembersInvited",
			"invitedBy", caller.String(),
			"boardID", boardID.String(),
			"members", strings.Join(users, ","),
		)
	}))
}

func assertInviteRequestExists(boardID boards.ID, user address) {
	invitee := user.String()
	requests, found := getInviteRequests(boardID)
	if !found || !requests.Has(invitee) {
		panic("invite request not found")
	}
}

func assertNoDuplicatedInvites(invites []Invite) {
	if len(invites) == 1 {
		return
	}

	seen := make(map[address]struct{}, len(invites))
	for _, v := range invites {
		if _, found := seen[v.User]; found {
			panic("duplicated invite: " + v.User.String())
		}

		seen[v.User] = struct{}{}
	}
}