public_invite.gno
5.22 Kb · 202 lines
1package boards2
2
3import (
4 "chain"
5 "strings"
6 "time"
7
8 "gno.land/p/gnoland/boards"
9 "gno.land/p/nt/bptree/v0"
10)
11
12// Invite contains a user invitation.
13type Invite struct {
14 // User is the user to invite.
15 User address
16
17 // Role is the optional role to assign to the user.
18 Role boards.Role
19}
20
21// InviteMember adds a member to the realm or to a board.
22//
23// A role can optionally be specified to be assigned to the new member.
24func InviteMember(cur realm, boardID boards.ID, user address, role boards.Role) {
25 inviteMembers(0, cur, boardID, Invite{
26 User: user,
27 Role: role,
28 })
29}
30
31// InviteMembers adds one or more members to the realm or to a board.
32//
33// Board ID is only required when inviting a member to a specific board.
34func InviteMembers(cur realm, boardID boards.ID, invites ...Invite) {
35 inviteMembers(0, cur, boardID, invites...)
36}
37
38// RequestInvite request to be invited to a board.
39func RequestInvite(cur realm, boardID boards.ID) {
40 assertMembersUpdateIsEnabled(boardID)
41
42 if !cur.Previous().IsUser() {
43 panic("caller must be user")
44 }
45
46 // TODO: Request a fee (returned on accept) or registered user to avoid spam?
47 // WARNING: if a fee is added via unsafe.OriginSend(), the guard above
48 // must be tightened to IsUserCall() — IsUser() accepts maketx-run
49 // ephemeral realms which can consume the origin-send envelope before
50 // calling us, bypassing the fee. See
51 // docs/resources/effective-gno.md#verifying-inbound-coin-payments.
52 // TODO: Make open invite requests optional (per board)
53
54 board := mustGetBoard(boardID)
55 user := cur.Previous().Address()
56 if board.Permissions.HasUser(user) {
57 panic("caller is already a member")
58 }
59
60 invitee := user.String()
61 requests, found := getInviteRequests(boardID)
62 if !found {
63 requests = bptree.NewBPTree32()
64 requests.Set(invitee, time.Now())
65 gInviteRequests.Set(boardID.Key(), requests)
66 return
67 }
68
69 if requests.Has(invitee) {
70 panic("invite request already exists")
71 }
72
73 requests.Set(invitee, time.Now())
74}
75
76// AcceptInvite accepts a board invite request.
77func AcceptInvite(cur realm, boardID boards.ID, user address) {
78 assertMembersUpdateIsEnabled(boardID)
79 assertInviteRequestExists(boardID, user)
80
81 board := mustGetBoard(boardID)
82 if board.Permissions.HasUser(user) {
83 panic("user is already a member")
84 }
85
86 caller := cur.Previous().Address()
87 invite := Invite{
88 User: user,
89 Role: RoleGuest,
90 }
91 args := boards.Args{caller, boardID, []Invite{invite}}
92 board.Permissions.WithPermission(caller, PermissionMemberInvite, args, func() {
93 assertMembersUpdateIsEnabled(boardID)
94
95 invitee := user.String()
96 requests, found := getInviteRequests(boardID)
97 if !found || !requests.Has(invitee) {
98 panic("invite request not found")
99 }
100
101 if board.Permissions.HasUser(user) {
102 panic("user is already a member")
103 }
104
105 board.Permissions.SetUserRoles(user)
106 requests.Remove(invitee)
107
108 chain.Emit(
109 "MembersInvited",
110 "invitedBy", caller.String(),
111 "boardID", board.ID.String(),
112 "members", user.String()+":"+string(RoleGuest), // TODO: Support optional role assign
113 )
114 })
115}
116
117// RevokeInvite revokes a board invite request.
118func RevokeInvite(cur realm, boardID boards.ID, user address) {
119 assertInviteRequestExists(boardID, user)
120
121 board := mustGetBoard(boardID)
122 caller := cur.Previous().Address()
123 args := boards.Args{boardID, user, RoleGuest}
124 board.Permissions.WithPermission(caller, PermissionMemberInviteRevoke, args, func() {
125 invitee := user.String()
126 requests, found := getInviteRequests(boardID)
127 if !found || !requests.Has(invitee) {
128 panic("invite request not found")
129 }
130
131 requests.Remove(invitee)
132
133 chain.Emit(
134 "InviteRevoked",
135 "revokedBy", caller.String(),
136 "boardID", board.ID.String(),
137 "user", user.String(),
138 )
139 })
140}
141
142func inviteMembers(_ int, rlm realm, boardID boards.ID, invites ...Invite) {
143 if !rlm.IsCurrent() {
144 panic("unauthorized: rlm is not the caller's live cur")
145 }
146 if len(invites) == 0 {
147 panic("one or more user invites are required")
148 }
149
150 assertMembersUpdateIsEnabled(boardID)
151 assertNoDuplicatedInvites(invites)
152
153 perms := mustGetPermissions(boardID)
154 caller := rlm.Previous().Address()
155 args := boards.Args{caller, boardID, invites}
156 perms.WithPermission(caller, PermissionMemberInvite, args, func() {
157 assertMembersUpdateIsEnabled(boardID)
158
159 users := make([]string, len(invites))
160 for _, v := range invites {
161 assertMemberAddressIsValid(v.User)
162
163 if perms.HasUser(v.User) {
164 panic("user is already a member: " + v.User.String())
165 }
166
167 // NOTE: Permissions implementation should check that role is valid
168 perms.SetUserRoles(v.User, v.Role)
169 users = append(users, v.User.String()+":"+string(v.Role))
170 }
171
172 chain.Emit(
173 "MembersInvited",
174 "invitedBy", caller.String(),
175 "boardID", boardID.String(),
176 "members", strings.Join(users, ","),
177 )
178 })
179}
180
181func assertInviteRequestExists(boardID boards.ID, user address) {
182 invitee := user.String()
183 requests, found := getInviteRequests(boardID)
184 if !found || !requests.Has(invitee) {
185 panic("invite request not found")
186 }
187}
188
189func assertNoDuplicatedInvites(invites []Invite) {
190 if len(invites) == 1 {
191 return
192 }
193
194 seen := make(map[address]struct{}, len(invites))
195 for _, v := range invites {
196 if _, found := seen[v.User]; found {
197 panic("duplicated invite: " + v.User.String())
198 }
199
200 seen[v.User] = struct{}{}
201 }
202}