public.gno
20.77 Kb · 801 lines
1package boards2
2
3import (
4 "chain"
5 "chain/runtime/unsafe"
6 "regexp"
7 "strconv"
8 "strings"
9 "time"
10
11 "gno.land/p/gnoland/boards/v0"
12)
13
14const (
15 // MaxBoardNameLength defines the maximum length allowed for board names.
16 MaxBoardNameLength = 50
17
18 // MaxThreadTitleLength defines the maximum length allowed for thread titles.
19 MaxThreadTitleLength = 100
20
21 // MaxThreadBodyLength defines the maximum length allowed for thread bodies.
22 // 40,000 mirrors Reddit's self-post body cap.
23 MaxThreadBodyLength = 40000
24
25 // MaxReplyLength defines the maximum length allowed for replies.
26 // 10,000 mirrors Reddit's comment cap.
27 MaxReplyLength = 10000
28)
29
30var reBoardName = regexp.MustCompile(`(?i)^[a-z]+[a-z0-9_\-]{2,50}$`)
31
32// SetHelp sets or updates boards realm help content.
33func SetHelp(cur realm, content string) {
34 content = strings.TrimSpace(content)
35 caller := cur.Previous().Address()
36 args := boards.Args{content}
37 gPerms.WithPermission(caller, PermissionRealmHelpChange, args, func() {
38 Help = content
39 })
40}
41
42// SetRequiredAccountAmount sets the required account amount to interact as a non member with open boards.
43// Amount must be given as ugnot.
44// The amount requirement is not applied to members that were invited to an open board.
45func SetRequiredAccountAmount(cur realm, amount int64) {
46 if amount < 0 {
47 panic("invalid amount")
48 }
49
50 caller := cur.Previous().Address()
51 args := boards.Args{amount}
52 gPerms.WithPermission(caller, PermissionAccountRequiredAmountChange, args, func() {
53 RequiredAccountAmount = amount
54
55 chain.Emit(
56 "RequiredAccountAmountChanged",
57 "caller", caller.String(),
58 "amount", strconv.FormatInt(amount, 10),
59 )
60 })
61}
62
63// SetPermissions sets a permissions implementation for boards2 realm or a board.
64func SetPermissions(cur realm, boardID boards.ID, p boards.Permissions) {
65 assertRealmIsNotLocked()
66 assertBoardExists(boardID)
67
68 if p == nil {
69 panic("permissions is required")
70 }
71
72 caller := cur.Previous().Address()
73 args := boards.Args{boardID}
74 gPerms.WithPermission(caller, PermissionPermissionsUpdate, args, func() {
75 assertRealmIsNotLocked()
76
77 // When board ID is zero it means that realm permissions are being updated
78 if boardID == 0 {
79 gPerms = p
80
81 chain.Emit(
82 "RealmPermissionsChanged",
83 "caller", caller.String(),
84 )
85 return
86 }
87
88 // Otherwise update the permissions of a single board
89 board := mustGetBoard(boardID)
90 board.Permissions = p
91
92 chain.Emit(
93 "BoardPermissionsChanged",
94 "caller", caller.String(),
95 "boardID", board.ID.String(),
96 )
97 })
98}
99
100// SetRealmNotice sets a notice to be displayed globally within the realm.
101// An empty message removes the realm notice.
102func SetRealmNotice(cur realm, message string) {
103 message = strings.TrimSpace(message)
104 caller := cur.Previous().Address()
105 args := boards.Args{message}
106 gPerms.WithPermission(caller, PermissionRealmNotice, args, func() {
107 Notice = message
108
109 chain.Emit(
110 "RealmNoticeChanged",
111 "caller", caller.String(),
112 "message", message,
113 )
114 })
115}
116
117// GetBoardIDFromName searches a board by name and returns its ID.
118func GetBoardIDFromName(_ realm, name string) (_ boards.ID, found bool) {
119 board, found := gBoards.GetByName(name)
120 if !found {
121 return 0, false
122 }
123 return board.ID, true
124}
125
126// BoardCount returns the total number of boards.
127func BoardCount() int {
128 return gBoards.Size()
129}
130
131// CreateBoard creates a new board.
132//
133// Listed boards are included in the realm's list of boards.
134// Open boards allow anyone to create threads and comment.
135func CreateBoard(cur realm, name string, listed, open bool) boards.ID {
136 assertRealmIsNotLocked()
137
138 name = strings.TrimSpace(name)
139 assertIsValidBoardName(name)
140 assertBoardNameNotExists(name)
141
142 caller := cur.Previous().Address()
143 id := gBoardsSequence.Next()
144 args := boards.Args{caller, name, id, listed, open}
145 gPerms.WithPermission(caller, PermissionBoardCreate, args, func() {
146 assertRealmIsNotLocked()
147 assertBoardNameNotExists(name)
148 addBoard(id, caller, name, listed, open)
149 })
150 return id
151}
152
153// addBoard creates a board and adds it to the realm storage.
154// It performs no permission or validation checks, so callers are responsible
155// for doing their own.
156func addBoard(id boards.ID, caller address, name string, listed, open bool) {
157 board := boards.New(id)
158 board.Name = name
159 board.Creator = caller
160 board.Meta = &BoardMeta{
161 HiddenThreads: boards.NewPostStorage(),
162 }
163
164 if open {
165 board.Permissions = createOpenBoardPermissions(caller)
166 } else {
167 board.Permissions = createBasicBoardPermissions(caller)
168 }
169
170 if err := gBoards.Add(board); err != nil {
171 panic(err)
172 }
173
174 // Listed boards are also indexed separately for easier iteration and pagination
175 if listed {
176 gListedBoardsByID.Set(board.ID.Key(), board)
177 }
178
179 chain.Emit(
180 "BoardCreated",
181 "caller", caller.String(),
182 "boardID", board.ID.String(),
183 "name", name,
184 )
185}
186
187// RenameBoard changes the name of an existing board.
188//
189// A history of previous board names is kept when boards are renamed.
190// Because of that boards are also accessible using previous name(s).
191func RenameBoard(cur realm, name, newName string) {
192 assertRealmIsNotLocked()
193
194 newName = strings.TrimSpace(newName)
195 assertIsValidBoardName(newName)
196 assertBoardNameNotExists(newName)
197
198 board := mustGetBoardByName(name)
199 assertBoardIsNotFrozen(board)
200
201 caller := cur.Previous().Address()
202 args := boards.Args{caller, board.ID, name, newName}
203 board.Permissions.WithPermission(caller, PermissionBoardRename, args, func() {
204 assertRealmIsNotLocked()
205 assertBoardNameNotExists(newName)
206
207 board.Aliases = append(board.Aliases, board.Name)
208 board.Name = newName
209 board.UpdatedAt = time.Now()
210
211 // Index board for the new name keeping previous indexes for older names
212 gBoards.Add(board)
213
214 chain.Emit(
215 "BoardRenamed",
216 "caller", caller.String(),
217 "boardID", board.ID.String(),
218 "name", name,
219 "newName", newName,
220 )
221 })
222}
223
224// CreateThread creates a new thread within a board.
225func CreateThread(cur realm, boardID boards.ID, title, body string) boards.ID {
226 assertRealmIsNotLocked()
227
228 title = strings.TrimSpace(title)
229 assertTitleIsValid(title)
230 assertThreadBodyIsValid(body)
231
232 caller := cur.Previous().Address()
233 assertUserIsNotBanned(boardID, caller)
234
235 board := mustGetBoard(boardID)
236 assertBoardIsNotFrozen(board)
237
238 thread := boards.MustNewThread(board, caller, title, body)
239 args := boards.Args{caller, board.ID, thread.ID, title, body}
240 board.Permissions.WithPermission(caller, PermissionThreadCreate, args, func() {
241 assertRealmIsNotLocked()
242 assertUserIsNotBanned(board.ID, caller)
243
244 thread.Meta = &ThreadMeta{
245 AllReplies: boards.NewPostStorage(),
246 }
247
248 if err := board.Threads.Add(thread); err != nil {
249 panic(err)
250 }
251
252 chain.Emit(
253 "ThreadCreated",
254 "caller", caller.String(),
255 "boardID", board.ID.String(),
256 "threadID", thread.ID.String(),
257 "title", title,
258 )
259 })
260 return thread.ID
261}
262
263// CreateReply creates a new comment or reply within a thread.
264//
265// The value of `replyID` is only required when creating a reply of another reply.
266func CreateReply(cur realm, boardID, threadID, replyID boards.ID, body string) boards.ID {
267 assertRealmIsNotLocked()
268
269 body = strings.TrimSpace(body)
270 assertReplyBodyIsValid(body)
271
272 caller := cur.Previous().Address()
273 assertUserIsNotBanned(boardID, caller)
274
275 board := mustGetBoard(boardID)
276 assertBoardIsNotFrozen(board)
277
278 thread := mustGetThread(board, threadID)
279 assertThreadIsVisible(thread)
280 assertThreadIsNotFrozen(thread)
281
282 // By default consider that reply's parent is the thread.
283 // Or when replyID is assigned use that reply as the parent.
284 parent := thread
285 if replyID > 0 {
286 parent = mustGetReply(thread, replyID)
287 if parent.Hidden || parent.Readonly {
288 panic("replying to a hidden or frozen reply is not allowed")
289 }
290 }
291
292 reply := boards.MustNewReply(parent, caller, body)
293 args := boards.Args{caller, board.ID, thread.ID, parent.ID, reply.ID, body}
294 board.Permissions.WithPermission(caller, PermissionReplyCreate, args, func() {
295 assertRealmIsNotLocked()
296
297 // Add reply to its parent
298 if err := parent.Replies.Add(reply); err != nil {
299 panic(err)
300 }
301
302 // Always add reply to the thread so it contains all comments and replies.
303 // Comment and reply only contains direct replies.
304 meta := thread.Meta.(*ThreadMeta)
305 if err := meta.AllReplies.Add(reply); err != nil {
306 panic(err)
307 }
308
309 chain.Emit(
310 "ReplyCreate",
311 "caller", caller.String(),
312 "boardID", board.ID.String(),
313 "threadID", thread.ID.String(),
314 "replyID", reply.ID.String(),
315 )
316 })
317 return reply.ID
318}
319
320// CreateRepost reposts a thread into another board.
321func CreateRepost(cur realm, boardID, threadID, destinationBoardID boards.ID, title, body string) boards.ID {
322 assertRealmIsNotLocked()
323
324 title = strings.TrimSpace(title)
325 assertTitleIsValid(title)
326 assertThreadBodyIsValid(body)
327
328 caller := cur.Previous().Address()
329 assertUserIsNotBanned(destinationBoardID, caller)
330
331 dst := mustGetBoard(destinationBoardID)
332 assertBoardIsNotFrozen(dst)
333
334 board := mustGetBoard(boardID)
335 thread := mustGetThread(board, threadID)
336 assertThreadIsVisible(thread)
337
338 repost := boards.MustNewRepost(thread, dst, caller)
339 args := boards.Args{caller, board.ID, thread.ID, dst.ID, repost.ID, title, body}
340 dst.Permissions.WithPermission(caller, PermissionThreadRepost, args, func() {
341 assertRealmIsNotLocked()
342
343 repost.Title = title
344 repost.Body = strings.TrimSpace(body)
345 repost.Meta = &ThreadMeta{
346 AllReplies: boards.NewPostStorage(),
347 }
348
349 if err := dst.Threads.Add(repost); err != nil {
350 panic(err)
351 }
352
353 if err := thread.Reposts.Add(repost); err != nil {
354 panic(err)
355 }
356
357 chain.Emit(
358 "Repost",
359 "caller", caller.String(),
360 "boardID", board.ID.String(),
361 "threadID", thread.ID.String(),
362 "destinationBoardID", dst.ID.String(),
363 "repostID", repost.ID.String(),
364 "title", title,
365 )
366 })
367 return repost.ID
368}
369
370// DeleteThread deletes a thread from a board.
371//
372// Threads can be deleted by the users who created them or otherwise by users with special permissions.
373func DeleteThread(cur realm, boardID, threadID boards.ID) {
374 assertRealmIsNotLocked()
375
376 caller := cur.Previous().Address()
377 board := mustGetBoard(boardID)
378 assertUserIsNotBanned(boardID, caller)
379
380 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteThread filetest cases for realm owners
381 if !isRealmOwner {
382 assertBoardIsNotFrozen(board)
383 }
384
385 thread := mustGetThread(board, threadID)
386 deleteThread := func() {
387 board.Threads.Remove(thread.ID)
388
389 chain.Emit(
390 "ThreadDeleted",
391 "caller", caller.String(),
392 "boardID", board.ID.String(),
393 "threadID", thread.ID.String(),
394 )
395 }
396
397 // Thread can be directly deleted by user that created it.
398 // It can also be deleted by realm owners, to be able to delete inappropriate content.
399 // TODO: Discuss and decide if realm owners should be able to delete threads.
400 if isRealmOwner || caller == thread.Creator {
401 deleteThread()
402 return
403 }
404
405 args := boards.Args{caller, board.ID, thread.ID}
406 board.Permissions.WithPermission(caller, PermissionThreadDelete, args, func() {
407 assertRealmIsNotLocked()
408 deleteThread()
409 })
410}
411
412// DeleteReply deletes a reply from a thread.
413//
414// Replies can be deleted by the users who created them or otherwise by users with special permissions.
415// Soft deletion is used when the deleted reply contains sub replies, in which case the reply content
416// is replaced by a text informing that reply has been deleted to avoid deleting sub-replies.
417func DeleteReply(cur realm, boardID, threadID, replyID boards.ID) {
418 assertRealmIsNotLocked()
419
420 caller := cur.Previous().Address()
421 board := mustGetBoard(boardID)
422 assertUserIsNotBanned(boardID, caller)
423
424 thread := mustGetThread(board, threadID)
425 reply := mustGetReply(thread, replyID)
426 isRealmOwner := gPerms.HasRole(caller, RoleOwner) // TODO: Add DeleteReply filetest cases for realm owners
427 if !isRealmOwner {
428 assertBoardIsNotFrozen(board)
429 assertThreadIsNotFrozen(thread)
430 assertReplyIsVisible(reply)
431 }
432
433 deleteReply := func() {
434 // Soft delete comment/reply by changing its body when
435 // it contains replies, otherwise hard delete it.
436 if reply.Replies.Size() > 0 {
437 reply.Body = "⚠ This comment has been deleted"
438 reply.UpdatedAt = time.Now()
439 } else {
440 // Remove reply from the flat thread index.
441 meta := thread.Meta.(*ThreadMeta)
442 reply, removed := meta.AllReplies.Remove(replyID)
443 if !removed {
444 panic("reply not found")
445 }
446
447 // Remove reply from its parent's direct-children list too. A
448 // direct thread reply's parent is the thread itself; a nested
449 // reply's parent is another reply (resolved via the flat index).
450 // Missing the thread-parent case left a ghost in thread.Replies
451 // (rendered in the threaded view, with a stale reply count).
452 if reply.ParentID == thread.ID {
453 thread.Replies.Remove(replyID)
454 } else if parent, found := meta.AllReplies.Get(reply.ParentID); found {
455 parent.Replies.Remove(replyID)
456 }
457 }
458
459 chain.Emit(
460 "ReplyDeleted",
461 "caller", caller.String(),
462 "boardID", board.ID.String(),
463 "threadID", thread.ID.String(),
464 "replyID", reply.ID.String(),
465 )
466 }
467
468 // Reply can be directly deleted by user that created it.
469 // It can also be deleted by realm owners, to be able to delete inappropriate content.
470 // TODO: Discuss and decide if realm owners should be able to delete replies.
471 if isRealmOwner || caller == reply.Creator {
472 deleteReply()
473 return
474 }
475
476 args := boards.Args{caller, board.ID, thread.ID, reply.ID}
477 board.Permissions.WithPermission(caller, PermissionReplyDelete, args, func() {
478 assertRealmIsNotLocked()
479 deleteReply()
480 })
481}
482
483// EditThread updates the title and body of a thread.
484//
485// Threads can be updated by the users who created them or otherwise by users with special permissions.
486func EditThread(cur realm, boardID, threadID boards.ID, title, body string) {
487 assertRealmIsNotLocked()
488
489 title = strings.TrimSpace(title)
490 assertTitleIsValid(title)
491 assertThreadBodyIsValid(body)
492
493 board := mustGetBoard(boardID)
494 assertBoardIsNotFrozen(board)
495
496 caller := cur.Previous().Address()
497 assertUserIsNotBanned(boardID, caller)
498
499 thread := mustGetThread(board, threadID)
500 assertThreadIsNotFrozen(thread)
501
502 body = strings.TrimSpace(body)
503 if !boards.IsRepost(thread) {
504 assertBodyIsNotEmpty(body)
505 }
506
507 editThread := func() {
508 thread.Title = title
509 thread.Body = body
510 thread.UpdatedAt = time.Now()
511
512 chain.Emit(
513 "ThreadEdited",
514 "caller", caller.String(),
515 "boardID", board.ID.String(),
516 "threadID", thread.ID.String(),
517 "title", title,
518 )
519 }
520
521 if caller == thread.Creator {
522 editThread()
523 return
524 }
525
526 args := boards.Args{caller, board.ID, thread.ID, title, body}
527 board.Permissions.WithPermission(caller, PermissionThreadEdit, args, func() {
528 assertRealmIsNotLocked()
529 editThread()
530 })
531}
532
533// EditReply updates the body of a comment or reply.
534//
535// Replies can be updated only by the users who created them.
536func EditReply(cur realm, boardID, threadID, replyID boards.ID, body string) {
537 assertRealmIsNotLocked()
538
539 body = strings.TrimSpace(body)
540 assertReplyBodyIsValid(body)
541
542 board := mustGetBoard(boardID)
543 assertBoardIsNotFrozen(board)
544
545 caller := cur.Previous().Address()
546 assertUserIsNotBanned(boardID, caller)
547
548 thread := mustGetThread(board, threadID)
549 assertThreadIsNotFrozen(thread)
550
551 reply := mustGetReply(thread, replyID)
552 assertReplyIsVisible(reply)
553
554 if caller != reply.Creator {
555 panic("only the reply creator is allowed to edit it")
556 }
557
558 reply.Body = body
559 reply.UpdatedAt = time.Now()
560
561 chain.Emit(
562 "ReplyEdited",
563 "caller", caller.String(),
564 "boardID", board.ID.String(),
565 "threadID", thread.ID.String(),
566 "replyID", reply.ID.String(),
567 "body", body,
568 )
569}
570
571// RemoveMember removes a member from the realm or a board.
572//
573// Board ID is only required when removing a member from board.
574func RemoveMember(cur realm, boardID boards.ID, member address) {
575 assertMembersUpdateIsEnabled(boardID)
576 assertMemberAddressIsValid(member)
577
578 perms := mustGetPermissions(boardID)
579 origin := unsafe.OriginCaller()
580 caller := cur.Previous().Address()
581 removeMember := func() {
582 if !perms.RemoveUser(member) {
583 panic("member not found")
584 }
585
586 chain.Emit(
587 "MemberRemoved",
588 "caller", caller.String(),
589 "origin", origin.String(), // When origin and caller match it means self removal
590 "boardID", boardID.String(),
591 "member", member.String(),
592 )
593 }
594
595 // Members can remove themselves without permission
596 if origin == member {
597 removeMember()
598 return
599 }
600
601 args := boards.Args{boardID, member}
602 perms.WithPermission(caller, PermissionMemberRemove, args, func() {
603 assertMembersUpdateIsEnabled(boardID)
604 removeMember()
605 })
606}
607
608// IsMember checks if a user is a member of the realm or a board.
609//
610// Board ID is only required when checking if a user is a member of a board.
611func IsMember(boardID boards.ID, user address) bool {
612 assertUserAddressIsValid(user)
613
614 if boardID != 0 {
615 board := mustGetBoard(boardID)
616 assertBoardIsNotFrozen(board)
617 }
618
619 perms := mustGetPermissions(boardID)
620 return perms.HasUser(user)
621}
622
623// HasMemberRole checks if a realm or board member has a specific role assigned.
624//
625// Board ID is only required when checking a member of a board.
626func HasMemberRole(boardID boards.ID, member address, role boards.Role) bool {
627 assertMemberAddressIsValid(member)
628
629 if boardID != 0 {
630 board := mustGetBoard(boardID)
631 assertBoardIsNotFrozen(board)
632 }
633
634 perms := mustGetPermissions(boardID)
635 return perms.HasRole(member, role)
636}
637
638// ChangeMemberRole changes the role of a realm or board member.
639//
640// Board ID is only required when changing the role for a member of a board.
641func ChangeMemberRole(cur realm, boardID boards.ID, member address, role boards.Role) {
642 assertMemberAddressIsValid(member)
643 assertMembersUpdateIsEnabled(boardID)
644
645 if role == "" {
646 role = RoleGuest
647 }
648
649 perms := mustGetPermissions(boardID)
650 caller := cur.Previous().Address()
651 args := boards.Args{caller, boardID, member, role}
652 perms.WithPermission(caller, PermissionRoleChange, args, func() {
653 assertMembersUpdateIsEnabled(boardID)
654
655 perms.SetUserRoles(member, role)
656
657 chain.Emit(
658 "RoleChanged",
659 "caller", caller.String(),
660 "boardID", boardID.String(),
661 "member", member.String(),
662 "newRole", string(role),
663 )
664 })
665}
666
667func assertMemberAddressIsValid(member address) {
668 if !member.IsValid() {
669 panic("invalid member address: " + member.String())
670 }
671}
672
673func assertUserAddressIsValid(user address) {
674 if !user.IsValid() {
675 panic("invalid user address: " + user.String())
676 }
677}
678
679func assertBoardExists(id boards.ID) {
680 if id == 0 { // ID zero is used to refer to the realm
681 return
682 }
683
684 if _, found := gBoards.Get(id); !found {
685 panic("board not found: " + id.String())
686 }
687}
688
689func assertBoardIsNotFrozen(b *boards.Board) {
690 if b.Readonly {
691 panic("board is frozen")
692 }
693}
694
695func assertIsValidBoardName(name string) {
696 size := len(name)
697 if size == 0 {
698 panic("board name is empty")
699 }
700
701 if size < 3 {
702 panic("board name is too short, minimum length is 3 characters")
703 }
704
705 if size > MaxBoardNameLength {
706 n := strconv.Itoa(MaxBoardNameLength)
707 panic("board name is too long, maximum allowed is " + n + " characters")
708 }
709
710 if !reBoardName.MatchString(name) {
711 panic("board name must start with a letter and have letters, numbers, \"-\" and \"_\"")
712 }
713}
714
715func assertThreadIsNotFrozen(t *boards.Post) {
716 if t.Readonly {
717 panic("thread is frozen")
718 }
719}
720
721func assertNameIsNotEmpty(name string) {
722 if name == "" {
723 panic("name is empty")
724 }
725}
726
727func assertTitleIsValid(title string) {
728 if title == "" {
729 panic("title is empty")
730 }
731
732 if len(title) > MaxThreadTitleLength {
733 n := strconv.Itoa(MaxThreadTitleLength)
734 panic("title is too long, maximum allowed is " + n + " characters")
735 }
736}
737
738func assertBodyIsNotEmpty(body string) {
739 if body == "" {
740 panic("body is empty")
741 }
742}
743
744func assertBoardNameNotExists(name string) {
745 name = strings.ToLower(name)
746 if _, found := gBoards.GetByName(name); found {
747 panic("board already exists")
748 }
749}
750
751func assertThreadExists(b *boards.Board, threadID boards.ID) {
752 if _, found := getThread(b, threadID); !found {
753 panic("thread not found: " + threadID.String())
754 }
755}
756
757func assertReplyExists(thread *boards.Post, replyID boards.ID) {
758 if _, found := getReply(thread, replyID); !found {
759 panic("reply not found: " + replyID.String())
760 }
761}
762
763func assertThreadIsVisible(thread *boards.Post) {
764 if thread.Hidden {
765 panic("thread is hidden")
766 }
767}
768
769func assertReplyIsVisible(thread *boards.Post) {
770 if thread.Hidden {
771 panic("reply is hidden")
772 }
773}
774
775func assertThreadBodyIsValid(body string) {
776 if len(body) > MaxThreadBodyLength {
777 n := strconv.Itoa(MaxThreadBodyLength)
778 panic("thread body is too long, maximum allowed is " + n + " characters")
779 }
780}
781
782func assertReplyBodyIsValid(body string) {
783 assertBodyIsNotEmpty(body)
784
785 if len(body) > MaxReplyLength {
786 n := strconv.Itoa(MaxReplyLength)
787 panic("reply is too long, maximum allowed is " + n + " characters")
788 }
789
790 // No markdown-structure or gno-form blacklist here: reply bodies
791 // render inside a <gno-foreign> sandbox (see indentForeignBody),
792 // which contains block structure and omits forms at render time.
793}
794
795func assertMembersUpdateIsEnabled(boardID boards.ID) {
796 if boardID != 0 {
797 assertRealmIsNotLocked()
798 } else {
799 assertRealmMembersAreNotLocked()
800 }
801}