diff --git a/community.go b/community.go index a7a0518..2575673 100644 --- a/community.go +++ b/community.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "git.erbosoft.com/amy/amsterdam/database" @@ -238,6 +239,14 @@ func JoinCommunityWithKey(ctxt ui.AmContext) (string, any, error) { return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias), nil } +/* UnjoinCommunity starts the process of unjoining a community. + * 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 UnjoinCommunity(ctxt ui.AmContext) (string, any, error) { me := ctxt.CurrentUser() err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) @@ -264,6 +273,14 @@ func UnjoinCommunity(ctxt ui.AmContext) (string, any, error) { return "framed_template", "unjoin.jet", nil } +/* UnjoinCommunityConfirm finishes the process of unjoining a community. + * 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 UnjoinCommunityConfirm(ctxt ui.AmContext) (string, any, error) { me := ctxt.CurrentUser() err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) @@ -298,3 +315,134 @@ func UnjoinCommunityConfirm(ctxt ui.AmContext) (string, any, error) { } return ui.ErrorPage(ctxt, errors.New("unknown button pressed to confirm unjoin")) } + +/* MemberList lists the members of the community. + * 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 MemberList(ctxt ui.AmContext) (string, any, error) { + err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, err) + } + comm := ctxt.CurrentCommunity() + ofs := 0 + p := ctxt.Parameter("ofs") + if p != "" { + v, err := strconv.Atoi(p) + if err == nil { + ofs = v + } + } + ctxt.SetLeftMenu("community") + ctxt.VarMap().Set("comm", comm) + showHidden := ctxt.TestPermission("Community.ShowHiddenMembers") + ctxt.VarMap().Set("canExport", showHidden) + ctxt.VarMap().Set("field", "name") + ctxt.VarMap().Set("oper", "st") + ctxt.VarMap().Set("term", "") + ctxt.VarMap().Set("ofs", ofs) + ctxt.VarMap().Set("amsterdam_pageTitle", "List Members") + listMax := int(ctxt.Globals().MaxCommunityMemberPage) + results, total, err := comm.ListMembers(database.ListMembersFieldNone, database.ListMembersOperNone, "", ofs*listMax, listMax, showHidden) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + if total == 0 { + ctxt.VarMap().Set("headerLine", "Community Members: (None)") + } else { + ctxt.VarMap().Set("headerLine", fmt.Sprintf("Community Members: (Displaying %d-%d of %d)", + ofs*listMax+1, ofs*listMax+len(results), total)) + } + if len(results) > 0 { + ctxt.VarMap().Set("resultList", results) + if ofs > 0 { + ctxt.VarMap().Set("resultShowPrev", true) + } + if ofs*listMax+len(results) < total { + ctxt.VarMap().Set("resultShowNext", true) + } + } + return "framed_template", "memberlist.jet", nil +} + +/* MemberSearch searches for members of the community. + * 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 MemberSearch(ctxt ui.AmContext) (string, any, error) { + err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, err) + } + comm := ctxt.CurrentCommunity() + ofs, _ := ctxt.FormFieldInt("ofs") + field := ctxt.FormField("field") + oper := ctxt.FormField("oper") + term := ctxt.FormField("term") + ctxt.SetLeftMenu("community") + ctxt.VarMap().Set("comm", comm) + showHidden := ctxt.TestPermission("Community.ShowHiddenMembers") + ctxt.VarMap().Set("canExport", showHidden) + ctxt.VarMap().Set("field", field) + ctxt.VarMap().Set("oper", oper) + ctxt.VarMap().Set("term", term) + ctxt.VarMap().Set("ofs", ofs) + ctxt.VarMap().Set("amsterdam_pageTitle", "Search for Members") + var iField, iOper int + switch field { + case "name": + iField = database.ListMembersFieldName + case "descr": + iField = database.ListMembersFieldDescription + case "first": + iField = database.ListMembersFieldFirstName + case "last": + iField = database.ListMembersFieldLastName + default: + ctxt.VarMap().Set("errorMessage", "invalid parameter to find") + return "framed_template", "memberlist.jet", nil + } + switch oper { + case "st": + iOper = database.ListMembersOperPrefix + case "in": + iOper = database.ListMembersOperSubstring + case "re": + iOper = database.ListMembersOperRegex + default: + ctxt.VarMap().Set("errorMessage", "invalid parameter to find") + return "framed_template", "memberlist.jet", nil + } + listMax := int(ctxt.Globals().MaxCommunityMemberPage) + results, total, err := comm.ListMembers(iField, iOper, term, ofs*listMax, listMax, showHidden) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + if total == 0 { + ctxt.VarMap().Set("headerLine", "Search Results: (None)") + } else { + ctxt.VarMap().Set("headerLine", fmt.Sprintf("Search Results: (Displaying %d-%d of %d)", + ofs*listMax+1, ofs*listMax+len(results), total)) + } + if len(results) > 0 { + ctxt.VarMap().Set("resultList", results) + if ofs > 0 { + ctxt.VarMap().Set("resultShowPrev", true) + } + if ofs*listMax+len(results) < total { + ctxt.VarMap().Set("resultShowNext", true) + } + } + return "framed_template", "memberlist.jet", nil +} diff --git a/database/community.go b/database/community.go index 5b68ff0..86f6220 100644 --- a/database/community.go +++ b/database/community.go @@ -80,6 +80,20 @@ const ( SearchCommOperRegex = 2 ) +// Field and operator selectors for ListMembers. +const ( + ListMembersFieldNone = -1 + ListMembersFieldName = 0 + ListMembersFieldDescription = 1 + ListMembersFieldFirstName = 2 + ListMembersFieldLastName = 3 + + ListMembersOperNone = -1 + ListMembersOperPrefix = 0 + ListMembersOperSubstring = 1 + ListMembersOperRegex = 2 +) + // communityCache is the cache for Community objects. var communityCache *lru.TwoQueueCache = nil @@ -231,6 +245,96 @@ func (c *Community) MemberCount(hidden bool) (int, error) { return -1, errors.New("internal error reading member count") } +/* ListMembers lists or searches for community members matching certain criteria. + * Parameters: + * field - A value indicating which field to search: + * ListMembersFieldNone - Do not search, just return all community members. + * ListMembersFieldName - The user name. + * ListMembersFieldDescription - The user description. + * ListMembersFieldFirstName - The user's first name. + * ListMembersFieldLastName - The user's last name. + * oper - The operation to perform on the search field: + * ListMembersOperNone - Do not search, just return all community members. + * ListMembersOperPrefix - The specified field has the string "term" as a prefix. + * ListMembersOperSubstring - The specified field contains the string "term". + * ListMembersOperRegex - The specified field matches the regular expression in "term". + * term - The search term, as specified above. + * offset - Number of members to skip at beginning of list. + * max - Maximum number of members to return. + * Returns: + * Array of User pointers representing the return elements. + * The total number of members matching this query (could be greater than max) + * Standard Go error status. + */ +func (c *Community) ListMembers(field int, oper int, term string, offset int, max int, showHidden bool) ([]*User, int, error) { + var query strings.Builder + if field != ListMembersFieldNone && oper != ListMembersOperNone { + query.WriteString(" AND ") + switch field { + case ListMembersFieldName: + query.WriteString("u.username ") + case ListMembersFieldDescription: + query.WriteString("u.description ") + case ListMembersFieldFirstName: + query.WriteString("c.given_name ") + case ListMembersFieldLastName: + query.WriteString("c.family_name ") + default: + return nil, -1, errors.New("invalid field selector") + } + switch oper { + case ListMembersOperPrefix: + query.WriteString("LIKE '") + query.WriteString(util.SqlEscape(term, true)) + query.WriteString("%'") + case ListMembersOperSubstring: + query.WriteString("LIKE '%") + query.WriteString(util.SqlEscape(term, true)) + query.WriteString("%'") + case ListMembersOperRegex: + query.WriteString("REGEXP '") + query.WriteString(util.SqlEscape(term, false)) + query.WriteString("'") + default: + return nil, -1, errors.New("invalid operator selector") + } + } + if !showHidden { + query.WriteString(" AND m.hidden = 0") + } + q := query.String() + rs, err := amdb.Query(`SELECT COUNT(*) FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid + AND u.contactid = c.contactid`+q, c.Id) + if err != nil { + return nil, -1, err + } + if !rs.Next() { + return nil, -1, errors.New("internal error getting member count") + } + var total int + rs.Scan(&total) + if offset > 0 { + rs, err = amdb.Query(`SELECT m.uid FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid + AND u.contactid = c.contactid`+q+" ORDER BY u.username LIMIT ? OFFSET ?", c.Id, max, offset) + } else { + rs, err = amdb.Query(`SELECT m.uid FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid + AND u.contactid = c.contactid`+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 + rs.Scan(&uid) + u, err := AmGetUser(uid) + if err == nil { + rc = append(rc, u) + } + } + return rc, total, nil +} + /* SetMembership sets a user's membership status within the community. * Parameters: * u - The user to change the membership status of. diff --git a/database/servicedefs.yaml b/database/servicedefs.yaml index 7a510da..1acc79c 100644 --- a/database/servicedefs.yaml +++ b/database/servicedefs.yaml @@ -52,5 +52,5 @@ domains: requirePermission: "Community.Read" requireRole: "Community.Member" linkSequence: 4800 - link: "/TODO/comm/[CID]/members" + link: "/comm/[CID]/members" title: "Members" diff --git a/main.go b/main.go index fb77601..a6f4d00 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,8 @@ func setupEcho() *echo.Echo { e.POST("/comm/:cid/join", ui.AmWrap(JoinCommunityWithKey)) e.GET("/comm/:cid/unjoin", ui.AmWrap(UnjoinCommunity)) e.POST("/comm/:cid/unjoin", ui.AmWrap(UnjoinCommunityConfirm)) + e.GET("/comm/:cid/members", ui.AmWrap(MemberList)) + e.POST("/comm/:cid/members", ui.AmWrap(MemberSearch)) e.GET("/comm/:cid/admin", ui.AmWrap(CommunityAdminMenu)) e.GET("/comm/:cid/admin/profile", ui.AmWrap(CommunityProfileForm)) e.POST("/comm/:cid/admin/profile", ui.AmWrap(EditCommunityProfile)) diff --git a/ui/views/memberlist.jet b/ui/views/memberlist.jet new file mode 100644 index 0000000..c243cf5 --- /dev/null +++ b/ui/views/memberlist.jet @@ -0,0 +1,152 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025 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/. + *} +
+ +
+
+

Find:

+ {{ comm.Name }} +
+
+
+ {{ if canExport }} +
+ [ + Export Member List + ] +
+ {{ end }} + + {{ if isset(errorMessage) }} + +
+
+
+ ⚠️ +
+
+

{{ CapitalizeString(errorMessage) }}.

+
+
+
+ {{ end }} + + +
+
+ +
+

Find Members of Community "{{ comm.Name }}":

+
+ +
+ Display all community members whose + +
+ + +
+ + +
+ + +
+ +
+
+
+
+
+ + +
+
+
{{ headerLine }}
+
+ {{ if isset(resultList) }} + +
+
+ {{ range _, rx := resultList }} + +
+ 🟣 +
+ +
+
+ {{ ci := rx.ContactInfoQ() }} + {{ DisplayFullName(ci) }}, from {{ ci.Locality }}, {{ ci.Region }} {{ ci.Country }} + {{ if rx.Uid == comm.HostUid }}👑{{ end }} +
+
+
+
+ {{ end }} +
+ {{ if isset(resultShowPrev) || isset(resultShowNext) }} +
+ {{ if isset(fromSearch) }} +
+ + + + + {{ if isset(resultShowPrev) }} + + {{ end }} + {{ if isset(resultShowNext) }} + + {{ end }} +
+ {{ else }} + {{ if isset(resultShowPrev) }} + + ⏪ Prev + + {{ end }} + {{ if isset(resultShowNext) }} + + Next ⏩ + + {{ end }} + {{ end }} +
+ {{ end }} +
+ {{ end }} +