[WIP] first draft of conference members functionality

This commit is contained in:
2026-02-09 23:27:57 -07:00
parent 966a3f4924
commit 360eb0cc74
7 changed files with 461 additions and 19 deletions
+149
View File
@@ -14,6 +14,8 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/ui"
@@ -213,6 +215,153 @@ func ConferenceAliasAdd(ctxt ui.AmContext) (string, any, error) {
return "framed_template", "conf_aliases.jet", nil
}
// CMData is the result data passed to the conference members page.
type CMData struct {
User *database.User
Level uint16
}
// fieldMap maps field names to search field indexes.
var fieldMap = map[string]int{
"name": database.SearchUserFieldName,
"descr": database.SearchUserFieldDescription,
"first": database.SearchUserFieldFirstName,
"last": database.SearchUserFieldLastName,
}
// operMap maps operator names to search operator indices.
var operMap = map[string]int{
"st": database.SearchUserOperPrefix,
"in": database.SearchUserOperSubstring,
"re": database.SearchUserOperRegex,
}
/* ConferenceMembers shows the conference members and allows their access levels to be adjusted.
* Parameters:
* ctxt - The AmContext for the request.
* Returns:
* Command string dictating what to be rendered.
* Data as a parameter for the command string.
* Standard Go error status.
*/
func ConferenceMembers(ctxt ui.AmContext) (string, any, error) {
comm := ctxt.CurrentCommunity()
conf := ctxt.GetScratch("currentConference").(*database.Conference)
myLevel := ctxt.GetScratch("levelInConference").(uint16)
if !conf.TestPermission("Conference.Change", myLevel) {
ctxt.SetRC(http.StatusForbidden)
return ui.ErrorPage(ctxt, ENOPERM)
}
// Set the first batch of page variables.
ctxt.VarMap().Set("commName", comm.Name)
ctxt.VarMap().Set("confName", conf.Name)
ctxt.VarMap().Set("backLink", fmt.Sprintf("/comm/%s/conf/%s/manage", comm.Alias, ctxt.GetScratch("currentAlias")))
ctxt.VarMap().Set("selfLink", fmt.Sprintf("/comm/%s/conf/%s/members", comm.Alias, ctxt.GetScratch("currentAlias")))
ctxt.VarMap().Set("roleList", database.AmRoleList("Conference.UserLevels"))
ctxt.VarMap().Set("amsterdam_pageTitle", fmt.Sprintf("Membership in Conference: %s", conf.Name))
// Get the search parameter values and adjust them.
mode := ctxt.Parameter("mode")
field := ctxt.Parameter("field")
oper := ctxt.Parameter("oper")
term := ctxt.Parameter("term")
offsetStr := ctxt.Parameter("ofs")
if mode == "" {
mode = "conf"
}
if field == "" {
field = "name"
}
if oper == "" {
oper = "st"
}
offset := 0
if offsetStr != "" {
var err error
offset, err = strconv.Atoi(offsetStr)
if err != nil {
offset = 0
}
}
maxPage := ctxt.Globals().MaxSearchPage
// Adjust the offset based on the page buttons.
if ctxt.HasParameter("prev") {
offset = max(0, offset-int(maxPage))
} else if ctxt.HasParameter("next") {
offset += int(maxPage)
}
// Write the search parameters back to the page variables.
ctxt.VarMap().Set("mode", mode)
ctxt.VarMap().Set("field", field)
ctxt.VarMap().Set("oper", oper)
ctxt.VarMap().Set("term", term)
ctxt.VarMap().Set("offset", offset)
ctxt.VarMap().Set("max", maxPage)
if ctxt.HasParameter("update") {
// TODO: update the levels
}
// Get the member list for the conference.
members, err := conf.Members(ctxt.Ctx())
if err != nil {
return ui.ErrorPage(ctxt, err)
}
// Generate the result list.
total := 0
var mr []CMData
switch mode {
case "conf":
total = len(members)
if offset > 0 {
members = members[offset:]
}
if len(members) > int(maxPage) {
members = members[:maxPage]
}
mr = make([]CMData, len(members))
for i := range members {
mr[i].User, _ = database.AmGetUser(ctxt.Ctx(), members[i].Uid)
mr[i].Level = members[i].Level
}
case "comm":
ulist, t, err := database.AmSearchCommunityMembers(ctxt.Ctx(), comm, fieldMap[field], operMap[oper], term, offset, int(maxPage))
if err != nil {
return ui.ErrorPage(ctxt, err)
}
total = t
mr = make([]CMData, len(ulist))
for i := range ulist {
mr[i].User = ulist[i]
mr[i].Level = 0
for j := range members {
if members[j].Uid == ulist[i].Uid {
mr[i].Level = members[j].Level
break
}
}
}
}
// Set the last few variables and return.
ctxt.VarMap().Set("resultList", mr)
ctxt.VarMap().Set("total", total)
ctxt.VarMap().Set("validUids", strings.Join(util.Map(mr, func(cd CMData) string {
return fmt.Sprintf("%d", cd.User.Uid)
}), ","))
if offset > 0 {
ctxt.VarMap().Set("showPrev", true)
}
if (offset + len(mr)) < total {
ctxt.VarMap().Set("showNext", true)
}
return "framed_template", "conf_members.jet", nil
}
/* CreateConferenceForm displays the dialog for creating a new conference.
* Parameters:
* ctxt - The AmContext for the request.
+90
View File
@@ -1056,3 +1056,93 @@ func AmSearchCommunities(ctx context.Context, field int, oper int, term string,
}
return rc, total, nil
}
/* AmSearchCommunityMembers searches for members of a community matching certain criteria.
* Parameters:
* ctx - Standard Go context value.
* c - The community within which to search.
* field - A value indicating which field to search:
* SearchUserFieldName - The user name.
* SearchUserFieldDescription - The user description.
* SearchUserFieldFirstName - The user's first name.
* SearchUserFieldLastName - The user's last name.
* oper - The operation to perform on the search field:
* SearchUserOperPrefix - The specified field has the string "term" as a prefix.
* SearchUserOperSubstring - The specified field contains the string "term".
* SearchUserOperRegex - The specified field matches the regular expression in "term".
* term - The search term, as specified above.
* offset - Number of users to skip at beginning of list.
* max - Maximum number of users to return.
* Returns:
* Array of User pointers representing the return elements.
* The total number of users matching this query (could be greater than max)
* Standard Go error status.
*/
func AmSearchCommunityMembers(ctx context.Context, c *Community, field int, oper int, term string, offset int, max int) ([]*User, int, error) {
var queryPortion strings.Builder
switch field {
case SearchUserFieldName:
queryPortion.WriteString("u.username ")
case SearchUserFieldDescription:
queryPortion.WriteString("u.description ")
case SearchUserFieldFirstName:
queryPortion.WriteString("c.given_name ")
case SearchUserFieldLastName:
queryPortion.WriteString("c.family_name ")
default:
return nil, -1, errors.New("invalid field selector")
}
switch oper {
case SearchUserOperPrefix:
queryPortion.WriteString("LIKE '")
queryPortion.WriteString(util.SqlEscape(term, true))
queryPortion.WriteString("%'")
case SearchUserOperSubstring:
queryPortion.WriteString("LIKE '%")
queryPortion.WriteString(util.SqlEscape(term, true))
queryPortion.WriteString("%'")
case SearchUserOperRegex:
queryPortion.WriteString("REGEXP '")
queryPortion.WriteString(util.SqlEscape(term, false))
queryPortion.WriteString("'")
default:
return nil, -1, errors.New("invalid operator selector")
}
q := queryPortion.String()
row := amdb.QueryRowContext(ctx, `SELECT COUNT(*) FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid
AND m.commid = ? AND u.is_anon = 0 AND `+q, c.Id)
var total int
err := row.Scan(&total)
if err != nil {
return nil, -1, err
}
if total == 0 {
return make([]*User, 0), 0, nil
}
var rs *sql.Rows
if offset > 0 {
rs, err = amdb.QueryContext(ctx, `SELECT u.uid FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid
AND m.commid = ? AND u.is_anon = 0 AND `+q+" ORDER BY u.username LIMIT ? OFFSET ?", c.Id, max, offset)
} else {
rs, err = amdb.QueryContext(ctx, `SELECT u.uid FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid
AND m.commid = ? AND u.is_anon = 0 AND `+q+" ORDER BY u.username LIMIT ?", c.Id, max)
}
if err != nil {
return nil, total, err
}
rc := make([]*User, 0, min(max, 10000))
for rs.Next() {
var uid int32
if err = rs.Scan(&uid); err == nil {
var u *User
u, err = AmGetUser(ctx, uid)
if err == nil {
rc = append(rc, u)
}
}
if err != nil {
log.Errorf("AmSearchCommunityMembers scan error: %v", err)
}
}
return rc, total, nil
}
+57 -14
View File
@@ -14,6 +14,8 @@ import (
"database/sql"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -44,6 +46,7 @@ type Conference struct {
flags *util.OptionSet
}
// ConferenceSettings represents a user's settings within the conference.
type ConferenceSettings struct {
ConfId int32 `db:"confid"` // conference ID
Uid int32 `db:"uid"` // user ID
@@ -53,6 +56,13 @@ type ConferenceSettings struct {
newflag bool
}
// ConferenceMember represents the membership entries in a conference.
type ConferenceMember struct {
ConfId int32 `db:"confid"` // conference ID
Uid int32 `db:"uid"` // user ID
Level uint16 `db:"granted_lvl"` // level granted within the conference
}
// ConferenceProperties represents a property entry for a conference.
type ConferenceProperties struct {
ConfId int32 `db:"confid"` // conference ID
@@ -162,6 +172,7 @@ func (c *Conference) AddAlias(ctx context.Context, alias string, u *User, ipaddr
return nil
}
// RemoveAlias removes an alias from the conference.
func (c *Conference) RemoveAlias(ctx context.Context, alias string, u *User, ipaddr string) error {
row := amdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM confalias WHERE confid = ?", c.ConfId)
aliasCount := 0
@@ -200,24 +211,30 @@ func (c *Conference) RemoveAlias(ctx context.Context, alias string, u *User, ipa
// Hosts returns the list of users that host this conference.
func (c *Conference) Hosts(ctx context.Context) ([]*User, error) {
rs, err := amdb.QueryContext(ctx, "SELECT uid FROM confmember WHERE confid = ? AND granted_lvl = ?",
c.ConfId, AmRole("Conference.Host").Level())
var uids []int32
err := amdb.SelectContext(ctx, &uids, "SELECT uid FROM confmember WHERE confid = ? AND granted_lvl = ?", c.ConfId, AmRole("Conference.Host").Level())
if err != nil {
return nil, err
}
rc := make([]*User, 0, 5)
for rs.Next() {
var uid int32
if err = rs.Scan(&uid); err == nil {
u, err := AmGetUser(ctx, uid)
if err == nil {
rc = append(rc, u)
}
rc := make([]*User, 0, len(uids))
for _, uid := range uids {
u, err := AmGetUser(ctx, uid)
if err == nil {
rc = append(rc, u)
}
}
slices.SortFunc(rc, func(a, b *User) int {
return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username))
})
return rc, nil
}
// Hosts returns the list of users that host this conference, quietly.
func (c *Conference) HostsQ(ctx context.Context) []*User {
rc, _ := c.Hosts(ctx)
return rc
}
// InCommunity returns true if the specified conference is in the community.
func (c *Conference) InCommunity(ctx context.Context, comm *Community) (bool, error) {
row := amdb.QueryRowContext(ctx, "SELECT commid FROM commtoconf WHERE commid = ? AND confid = ?", comm.Id, c.ConfId)
@@ -274,10 +291,11 @@ func (c *Conference) ContainedBy(ctx context.Context) ([]*Community, error) {
return rc, nil
}
// Hosts returns the list of users that host this conference, quietly.
func (c *Conference) HostsQ(ctx context.Context) []*User {
rc, _ := c.Hosts(ctx)
return rc
// Members returns all the members of this conference, with their granted user levels.
func (c *Conference) Members(ctx context.Context) ([]ConferenceMember, error) {
var rc []ConferenceMember
err := amdb.SelectContext(ctx, &rc, "SELECT * FROM confmember WHERE confid = ?", c.ConfId)
return rc, err
}
// Membership returns a membership flag and granted level for the user in this conference.
@@ -294,6 +312,31 @@ func (c *Conference) Membership(ctx context.Context, u *User) (bool, uint16, err
return false, 0, err
}
// SetMembership sets the membership level for the given user in this conference.
func (c *Conference) SetMembership(ctx context.Context, u *User, level uint16, by *User, ipaddr string) error {
if level == 0 {
_, err := amdb.ExecContext(ctx, "DELETE FROM confmember WHERE confid = ? AND uid = ?", c.ConfId, u.Uid)
return err
}
row := amdb.QueryRowContext(ctx, "SELECT granted_lvl FROM confmember WHERE confid = ? AND uid = ?", c.ConfId, u.Uid)
var oldLevel uint16
err := row.Scan(&oldLevel)
switch err {
case nil:
if oldLevel == level {
return nil
}
_, err = amdb.ExecContext(ctx, "UPDATE confmember SET granted_lvl = ? WHERE confid = ? AND uid = ?", level, c.ConfId, u.Uid)
case sql.ErrNoRows:
_, err = amdb.ExecContext(ctx, "INSERT INTO confmember (confid, uid, granted_lvl) VALUES (?, ?, ?)", c.ConfId, u.Uid, level)
}
if err != nil {
ar := AmNewAudit(AuditConferenceMembership, by.Uid, ipaddr, fmt.Sprintf("conf=%d", c.ConfId), fmt.Sprintf("uid=%d", u.Uid), fmt.Sprintf("level=%d", level))
AmStoreAudit(ar)
}
return err
}
/* TestPermission is shorthand that tests if a user has a permission with respect to the conference.
* Parameters:
* user - The user to be checked.
+2 -2
View File
@@ -1,6 +1,6 @@
#
# Amsterdam Web Communities System
# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
# Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -15,7 +15,7 @@ scopes:
index: 6
roles:
- name: "NotInList"
display: "Not In List"
display: "(not in list)"
scope: Global
value: "0"
- name: "UnrestrictedUser"
+6 -3
View File
@@ -81,9 +81,9 @@ func setupEcho() *echo.Echo {
// community group
commGroup := e.Group("/comm/:cid", ui.SetCommunity)
fn1 := ui.AmWrap(ShowCommunity)
commGroup.GET("", fn1)
commGroup.GET("/profile", fn1)
fn = ui.AmWrap(ShowCommunity)
commGroup.GET("", fn)
commGroup.GET("/profile", fn)
commGroup.GET("/join", ui.AmWrap(JoinCommunity))
commGroup.POST("/join", ui.AmWrap(JoinCommunityWithKey))
commGroup.GET("/unjoin", ui.AmWrap(UnjoinCommunity))
@@ -116,6 +116,9 @@ func setupEcho() *echo.Echo {
confGroup.POST("/edit", ui.AmWrap(EditConference))
confGroup.GET("/aliases", ui.AmWrap(ConferenceAliasForm))
confGroup.POST("/aliases", ui.AmWrap(ConferenceAliasAdd))
fn = ui.AmWrap(ConferenceMembers)
confGroup.GET("/members", fn)
confGroup.POST("/members", fn)
confGroup.GET("/hotlist", ui.AmWrap(AddToHotlist))
confGroup.GET("/invite", ui.AmWrap(InviteToConference))
confGroup.GET("/r/:topic", ui.AmWrap(ReadPosts), ui.SetTopic)
+141
View File
@@ -0,0 +1,141 @@
{*
* Amsterdam Web Communities System
* Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*}
<div class="p-4">
<!-- Page Title -->
<div class="mb-6">
<div class="flex items-baseline gap-2">
<h1 class="text-blue-800 text-4xl font-bold">Set Conference Membership:</h1>
<span class="text-blue-800 text-2xl">{{ confName }}</span>
</div>
<hr class="border-2 border-gray-400 w-4/5 mt-2 mb-6">
</div>
<!-- Upper Links -->
<div class="mb-4">
<a class="text-blue-700 hover:text-blue-900 text-sm font-medium" href="{{ selfLink }}">Redisplay Member List</a> |
<a class="text-blue-700 hover:text-blue-900 text-sm font-medium" href="{{ backLink }}">Return to Manage Conference Menu</a>
</div>
<!-- Member Find Form -->
<div class="max-w-3xl mb-8">
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="comm">
<input type="hidden" name="ofs" value="0">
<div class="bg-gray-50 p-6 rounded-lg">
<h2 class="text-xl font-bold text-black mb-4">Find Members of Community "{{ commName }}":</h2>
<div class="space-y-4">
<div class="flex items-center gap-2 text-sm">
<span class="text-black">Display all community members whose</span>
<select name="field"
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="name" {{ if field == "name" }}selected{{ end }}>user name</option>
<option value="descr"{{ if field == "descr" }}selected{{ end }}>description</option>
<option value="first"{{ if field == "first" }}selected{{ end }}>first name</option>
<option value="last"{{ if field == "last" }}selected{{ end }}>last name</option>
</select>
</div>
<!-- Search Criteria -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<select name="oper"
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="st" {{ if oper == "st" }}selected{{ end }}>starts with the string</option>
<option value="in" {{ if oper == "in" }}selected{{ end }}>contains the string</option>
<option value="re" {{ if oper == "re" }}selected{{ end }}>matches the regular expression</option>
</select>
<input type="text" name="term" size="32" maxlength="255" value="{{ term }}"
placeholder="Enter search term..."
class="flex-1 min-w-64 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<button type="submit" name="search"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">Search</button>
</div>
</div>
</div>
</form>
</div>
<hr class="border-gray-400 mb-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-black mb-4">
{{ if mode == "conf" }}
Conference Members:
{{ else if mode == "comm" }}
Search Results:
{{ end }}
</h2>
{{ if total > 0 }}
<div class="text-sm text-black font-bold">(Displaying {{ offset + 1 }}-{{ offset + len(resultList) }} of {{ total }})</div>
{{ else }}
<div class="text-sm text-black font-bold">(None found)</div>
{{ end }}
{{ if isset(showPrev) || isset(showNext) }}
<div class="flex justify-center gap-4 mt-6">
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="{{ mode }}">
<input type="hidden" name="field" value="{{ field }}">
<input type="hidden" name="oper" value="{{ oper }}">
<input type="hidden" name="term" value="{{ term }}">
<input type="hidden" name="ofs" value="{{ offset }}">
{{ if isset(showPrev) }}
<button type="submit" name="prev"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
⏪ Prev
</button>
{{ end }}
{{ if isset(showNext) }}
<button type="submit" name="next"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
Next ⏩
</button>
{{ end }}
</form>
</div>
{{ end }}
</div>
<form method="POST" action="{{ selfLink }}">
<input type="hidden" name="mode" value="{{ mode }}">
<input type="hidden" name="field" value="{{ field }}">
<input type="hidden" name="oper" value="{{ oper }}">
<input type="hidden" name="term" value="{{ term }}">
<input type="hidden" name="ofs" value="{{ offset }}">
<input type="hidden" name="validUids" value="{{ validUids }}">
<div class="bg-gray-50 p-6 rounded-lg">
<div class="space-y-4">
{{ range _, rx := resultList }}
<div class="flex items-start gap-3">
<span class="text-sm pt-0.5 flex-shrink-0">🟣</span>
<div class="flex-1">
<div class="mb-2">
<a href="/user/{{ rx.User.Username }}"
class="text-blue-700 hover:text-blue-900 font-bold text-base">{{ rx.User.Username }}</a>
</div>
</div>
<input type="hidden" name="old_{{ rx.User.Uid }}" value="{{ rx.Level }}">
<select id="new_{{ rx.User.Uid }}" name="new_{{ rx.User.Uid }}"
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{{ range _, role := roleList.Roles() }}
<option value="{{ role.Level() }}" {{ if role.Level() == rx.Level }}selected{{ end }}>{{ role.Name() }}</option>
{{ end }}
</select>
</div>
{{ end }}
<div class="flex items-start gap-3">
<button type="submit" name="update"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">Update</button>
</div>
</div>
</div>
</form>
</div>
+16
View File
@@ -139,3 +139,19 @@ func WordRunLengthAfterPrefix(s string, nrunes int) (int, bool) {
}
return WordRunLength(s[ofs:])
}
/* Map applies a transformation function on all elements of an array of one type, turning it into an
* array of another type.
* Parameters:
* in - The input array to be transformed.
* fn - The function to be executed on each element.
* Returns:
* The array of new elements.
*/
func Map[A, B any](in []A, fn func(A) B) []B {
rc := make([]B, len(in))
for i, v := range in {
rc[i] = fn(v)
}
return rc
}