1
0
Fork 0
mirror of https://github.com/documize/community.git synced 2025-07-20 05:39:42 +02:00

list group members & non-members

This commit is contained in:
sauls8t 2018-02-28 14:55:36 +00:00
parent 19b4a3de49
commit 0680a72ee2
15 changed files with 360 additions and 60 deletions

View file

@ -216,3 +216,30 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
response.WriteEmpty(w) response.WriteEmpty(w)
} }
// GetGroupMembers returns all users associated with given group.
func (h *Handler) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
method := "group.GetGroupMembers"
ctx := domain.GetRequestContext(r)
// Should be no reason for non-admin to see members
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
m, err := h.Store.Group.GetGroupMembers(ctx, groupID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, m)
}

View file

@ -59,12 +59,17 @@ func (s Scope) Get(ctx domain.RequestContext, refID string) (g group.Group, err
// GetAll returns all user groups for current orgID. // GetAll returns all user groups for current orgID.
func (s Scope) GetAll(ctx domain.RequestContext) (groups []group.Group, err error) { func (s Scope) GetAll(ctx domain.RequestContext) (groups []group.Group, err error) {
err = s.Runtime.Db.Select(&groups, err = s.Runtime.Db.Select(&groups,
`select id, refid, orgid, role as name, purpose, created, revised FROM role WHERE orgid=? ORDER BY role`, `SELECT a.id, a.refid, a.orgid, a.role as name, a.purpose, a.created, a.revised, COUNT(b.roleid) AS members
FROM role a
LEFT JOIN rolemember b ON a.refid=b.roleid
WHERE a.orgid=?
GROUP BY a.id, a.refid, a.orgid, a.role, a.purpose, a.created, a.revised
ORDER BY a.role`,
ctx.OrgID) ctx.OrgID)
if err == sql.ErrNoRows || len(groups) == 0 { if err == sql.ErrNoRows || len(groups) == 0 {
groups = []group.Group{}
err = nil err = nil
groups = []group.Group{}
} }
if err != nil { if err != nil {
err = errors.Wrap(err, "select groups") err = errors.Wrap(err, "select groups")
@ -93,3 +98,25 @@ func (s Scope) Delete(ctx domain.RequestContext, refID string) (rows int64, err
b.DeleteConstrained(ctx.Transaction, "role", ctx.OrgID, refID) b.DeleteConstrained(ctx.Transaction, "role", ctx.OrgID, refID)
return b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM rolemember WHERE orgid=\"%s\" AND roleid=\"%s\"", ctx.OrgID, refID)) return b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM rolemember WHERE orgid=\"%s\" AND roleid=\"%s\"", ctx.OrgID, refID))
} }
// GetGroupMembers returns all user associated with given group.
func (s Scope) GetGroupMembers(ctx domain.RequestContext, groupID string) (members []group.Member, err error) {
err = s.Runtime.Db.Select(&members,
`SELECT a.id, a.orgid, a.roleid, a.userid,
IFNULL(b.firstname, '') as firstname, IFNULL(b.lastname, '') as lastname
FROM rolemember a
LEFT JOIN user b ON b.refid=a.userid
WHERE a.orgid=? AND a.roleid=?
ORDER BY b.firstname, b.lastname`,
ctx.OrgID, groupID)
if err == sql.ErrNoRows || len(members) == 0 {
err = nil
members = []group.Member{}
}
if err != nil {
err = errors.Wrap(err, "select members")
}
return
}

View file

@ -118,6 +118,7 @@ type UserStorer interface {
DeactiveUser(ctx RequestContext, userID string) (err error) DeactiveUser(ctx RequestContext, userID string) (err error)
ForgotUserPassword(ctx RequestContext, email, token string) (err error) ForgotUserPassword(ctx RequestContext, email, token string) (err error)
CountActiveUsers() (c int) CountActiveUsers() (c int)
MatchUsers(ctx RequestContext, text string, maxMatches int) (u []user.User, err error)
} }
// AccountStorer defines required methods for account management // AccountStorer defines required methods for account management
@ -275,4 +276,5 @@ type GroupStorer interface {
GetAll(ctx RequestContext) (g []group.Group, err error) GetAll(ctx RequestContext) (g []group.Group, err error)
Update(ctx RequestContext, g group.Group) (err error) Update(ctx RequestContext, g group.Group) (err error)
Delete(ctx RequestContext, refID string) (rows int64, err error) Delete(ctx RequestContext, refID string) (rows int64, err error)
GetGroupMembers(ctx RequestContext, groupID string) (m []group.Member, err error)
} }

View file

@ -644,3 +644,28 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
response.WriteEmpty(w) response.WriteEmpty(w)
} }
// MatchUsers returns users where provided text
// matches firstname, lastname, email
func (h *Handler) MatchUsers(w http.ResponseWriter, r *http.Request) {
method := "user.MatchUsers"
ctx := domain.GetRequestContext(r)
defer streamutil.Close(r.Body)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, "text")
h.Runtime.Log.Error(method, err)
return
}
searchText := string(body)
u, err := h.Store.User.MatchUsers(ctx, searchText, 100)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, u)
}

View file

@ -14,6 +14,7 @@ package mysql
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -255,3 +256,31 @@ func (s Scope) CountActiveUsers() (c int) {
return return
} }
// MatchUsers returns users that have match to either firstname, lastname or email.
func (s Scope) MatchUsers(ctx domain.RequestContext, text string, maxMatches int) (u []user.User, err error) {
text = strings.TrimSpace(strings.ToLower(text))
likeQuery := ""
if len(text) > 0 {
likeQuery = " AND (LOWER(firstname) LIKE '%" + text + "%' OR LOWER(lastname) LIKE '%" + text + "%' OR LOWER(email) LIKE '%" + text + "%') "
}
err = s.Runtime.Db.Select(&u,
`SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised,
u.global, a.active, a.editor, a.admin, a.users as viewusers
FROM user u, account a
WHERE a.orgid=? AND u.refid=a.userid AND a.active=1 `+likeQuery+
`ORDER BY u.firstname,u.lastname LIMIT `+strconv.Itoa(maxMatches),
ctx.OrgID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("matching users for org %s", ctx.OrgID))
}
return
}

View file

@ -11,13 +11,20 @@
import $ from 'jquery'; import $ from 'jquery';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { debounce } from '@ember/runloop';
import Component from '@ember/component'; import Component from '@ember/component';
import AuthProvider from '../../mixins/auth'; import AuthProvider from '../../mixins/auth';
import ModalMixin from '../../mixins/modal'; import ModalMixin from '../../mixins/modal';
export default Component.extend(AuthProvider, ModalMixin, { export default Component.extend(AuthProvider, ModalMixin, {
groupSvc: service('group'), groupSvc: service('group'),
userSvc: service('user'),
newGroup: null, newGroup: null,
searchText: '',
showUsers: false,
showMembers: true,
users: null,
members: null,
didReceiveAttrs() { didReceiveAttrs() {
this._super(...arguments); this._super(...arguments);
@ -35,6 +42,37 @@ export default Component.extend(AuthProvider, ModalMixin, {
this.set('newGroup', { name: '', purpose: '' }); this.set('newGroup', { name: '', purpose: '' });
}, },
loadUsers(searchText) {
this.get('userSvc').matchUsers(searchText).then((users) => {
let members = this.get('members');
if (members.length > 0) {
users.forEach((user) => {
let m = members.findBy('userId', user.get('id'));
user.set('isMember', is.not.undefined(m));
})
}
this.set('users', users);
});
},
loadMembers(groupId) {
this.get('groupSvc').getGroupMembers(groupId).then((members) => {
this.set('members', members);
// if we have no members, then prefetch users (server should limit to top 100 users)
if (members.length === 0) {
this.loadUsers('');
this.set('showMembers', false);
this.set('showUsers', true);
} else {
this.set('showMembers', true);
this.set('showUsers', false);
}
});
},
actions: { actions: {
onOpenGroupModal() { onOpenGroupModal() {
this.modalOpen("#add-group-modal", {"show": true}, '#new-group-name'); this.modalOpen("#add-group-modal", {"show": true}, '#new-group-name');
@ -44,6 +82,7 @@ export default Component.extend(AuthProvider, ModalMixin, {
e.preventDefault(); e.preventDefault();
let newGroup = this.get('newGroup'); let newGroup = this.get('newGroup');
if (is.empty(newGroup.name)) { if (is.empty(newGroup.name)) {
$("#new-group-name").addClass("is-invalid").focus(); $("#new-group-name").addClass("is-invalid").focus();
return; return;
@ -82,8 +121,7 @@ export default Component.extend(AuthProvider, ModalMixin, {
}, },
onShowEditModal(groupId) { onShowEditModal(groupId) {
let group = this.get('groups').findBy('id', groupId); this.set('editGroup', this.get('groups').findBy('id', groupId));
this.set('editGroup', group);
this.modalOpen("#edit-group-modal", {"show": true}, '#edit-group-name'); this.modalOpen("#edit-group-modal", {"show": true}, '#edit-group-name');
}, },
@ -103,6 +141,43 @@ export default Component.extend(AuthProvider, ModalMixin, {
this.modalClose("#edit-group-modal"); this.modalClose("#edit-group-modal");
this.set('editGroup', null); this.set('editGroup', null);
},
onShowMembersModal(groupId) {
this.set('membersGroup', this.get('groups').findBy('id', groupId));
this.modalOpen("#group-members-modal", {"show": true}, '#group-members-search');
this.set('members', null);
this.set('users', null);
this.loadMembers(groupId);
},
onSearch() {
debounce(this, function() {
let searchText = this.get('searchText');
let groupId = this.get('membersGroup.id');
if (is.not.empty(searchText)) {
this.loadUsers(searchText);
this.set('showMembers', false);
this.set('showUsers', true);
} else {
this.loadMembers(groupId);
this.set('showMembers', true);
this.set('showUsers', false);
}
}, 250);
},
onLeaveGroup(userId) {
this.get('groupSvc').leave(this.get('membersGroup.id'), userId).then(() => {
this.load();
});
},
onJoinGroup(userId) {
this.get('groupSvc').join(this.get('membersGroup.id'), userId).then(() => {
this.load();
});
} }
} }
}); });

View file

@ -0,0 +1,27 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed } from '@ember/object';
export default Model.extend({
orgId: attr('string'),
roleId: attr('string'),
userId: attr('string'),
// for UI only
firstname: attr('string'),
lastname: attr('string'),
fullname: computed('firstname', 'lastname', function () {
return `${this.get('firstname')} ${this.get('lastname')}`;
})
});

View file

@ -9,17 +9,9 @@
// //
// https://documize.com // https://documize.com
import { inject as service } from '@ember/service';
import Controller from '@ember/controller'; import Controller from '@ember/controller';
export default Controller.extend({ export default Controller.extend({
userService: service('user'),
init() {
this._super(...arguments);
// this.newUser = { firstname: "", lastname: "", email: "", active: true };
},
actions: { actions: {
} }
}); });

View file

@ -64,5 +64,35 @@ export default BaseService.extend({
return this.get('ajax').request(`group/${groupId}`, { return this.get('ajax').request(`group/${groupId}`, {
method: 'DELETE' method: 'DELETE'
}); });
} },
// Returns users associated with given group
getGroupMembers(groupId) {
return this.get('ajax').request(`group/${groupId}/members`, {
method: 'GET'
}).then((response) => {
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('group-member', obj);
return this.get('store').push(data);
});
return data;
});
},
// join adds user to group.
join(groupId, userId) {
return this.get('ajax').request(`group/${groupId}/join/${userId}`, {
method: 'POST'
});
},
// leave removes user from group.
leave(groupId, userId) {
return this.get('ajax').request(`group/${groupId}/leave/${userId}`, {
method: 'DELETE'
});
},
}); });

View file

@ -145,5 +145,24 @@ export default Service.extend({
method: "POST", method: "POST",
data: password data: password
}); });
},
// matchUsers on firstname, lastname, email
matchUsers(text) {
return this.get('ajax').request('users/match', {
method: 'POST',
dataType: 'json',
contentType: 'text',
data: text
}).then((response) => {
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('user', obj);
return this.get('store').push(data);
});
return data;
});
} }
}); });

View file

@ -106,3 +106,7 @@ $link-hover-decoration: none;
.modal-80 { .modal-80 {
max-width: 80% !important; max-width: 80% !important;
} }
body.modal-open {
padding-right: 0 !important;
}

View file

@ -60,38 +60,29 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
> .item { > .group {
margin: 15px 0; margin: 15px 0;
padding: 15px;
@include card();
@include ease-in();
> .group { .name {
display: inline-block; font-size: 1.2rem;
color: $color-off-black;
> .name {
font-size: 1.2rem;
color: $color-primary;
}
> .purpose { > .purpose {
font-size: 1rem; font-size: 1rem;
color: $color-off-black;
}
> .info {
font-size: 0.9rem;
margin-top: 8px;
color: $color-gray; color: $color-gray;
display: inline-block;
} }
} }
}
}
> .buttons { > .group-users-members {
margin-top: 5px; > .item {
} margin: 10px 0;
> .action { > .fullname {
display: inline-block; color: $color-primary;
font-size: 1.2rem;
} }
} }
} }

View file

@ -5,6 +5,7 @@
<h1 class="admin-heading">Groups</h1> <h1 class="admin-heading">Groups</h1>
<h2 class="sub-heading">Create groups for easier user management &mdash; assign users to groups</h2> <h2 class="sub-heading">Create groups for easier user management &mdash; assign users to groups</h2>
<div class="btn btn-success mt-3 mb-3" {{action 'onOpenGroupModal'}}>Add group</div> <div class="btn btn-success mt-3 mb-3" {{action 'onOpenGroupModal'}}>Add group</div>
<div id="add-group-modal" class="modal" tabindex="-1" role="dialog"> <div id="add-group-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
@ -17,7 +18,7 @@
<small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small> <small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="space-invite-msg">Description (optional)</label> <label for="new-group-desc">Description (optional)</label>
{{textarea id="new-group-desc" value=newGroup.purpose class="form-control" rows="3"}} {{textarea id="new-group-desc" value=newGroup.purpose class="form-control" rows="3"}}
</div> </div>
</form> </form>
@ -32,22 +33,20 @@
<div class="groups-list"> <div class="groups-list">
{{#each groups as |group|}} {{#each groups as |group|}}
<div class="item row"> <div class="row group">
<div class="group col-8"> <div class="col-8">
<div class="name">{{group.name}}</div> <div class="name">
<div class="purpose">{{group.purpose}}&nbsp;</div> {{group.name}}
<div class="info"> {{#if group.purpose}}
{{#if (eq group.members 0)}} <div class="purpose">&nbsp;&nbsp;&mdash;&nbsp;{{group.purpose}}</div>
no members assigned yet
{{else if (eq group.members 1)}}
1 member
{{else}}
{{group.members}} members
{{/if}} {{/if}}
</div> </div>
</div> </div>
<div class="col-4 buttons text-right"> <div class="col-4 buttons text-right">
<div class="button-icon-gray align-middle" data-toggle="tooltip" data-placement="top" title="Rename" {{action 'onShowEditModal' group.id}} > <button class="btn btn-sm btn-secondary" {{action 'onShowMembersModal' group.id}}>{{group.members}} members</button>
<div class="button-icon-gap" />
<div class="button-icon-gray align-middle" data-toggle="tooltip" data-placement="top" title="Rename" {{action 'onShowEditModal' group.id}}>
<i class="material-icons">edit</i> <i class="material-icons">edit</i>
</div> </div>
<div class="button-icon-gap" /> <div class="button-icon-gap" />
@ -69,7 +68,7 @@
<form onsubmit={{action 'onDeleteGroup'}}> <form onsubmit={{action 'onDeleteGroup'}}>
<p>Are you sure you want to delete this group?</p> <p>Are you sure you want to delete this group?</p>
<div class="form-group"> <div class="form-group">
<label for="delete-space-name">Please type group name to confirm</label> <label for="delete-group-name">Please type group name to confirm</label>
{{input id="delete-group-name" type="text" class="form-control mousetrap" placeholder="Group name" value=deleteGroup.name}} {{input id="delete-group-name" type="text" class="form-control mousetrap" placeholder="Group name" value=deleteGroup.name}}
<small class="form-text text-muted">This will remove group membership information and associated permissions!</small> <small class="form-text text-muted">This will remove group membership information and associated permissions!</small>
</div> </div>
@ -95,7 +94,7 @@
<small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small> <small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="space-invite-msg">Description (optional)</label> <label for="edit-group-desc">Description (optional)</label>
{{textarea id="edit-group-desc" value=editGroup.purpose class="form-control" rows="3"}} {{textarea id="edit-group-desc" value=editGroup.purpose class="form-control" rows="3"}}
</div> </div>
</form> </form>
@ -108,6 +107,52 @@
</div> </div>
</div> </div>
<div id="group-members-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">{{membersGroup.name}}</div>
<div class="modal-body">
<div class="form-group">
<label for="group-members-search">Search for group members, non-members</label>
{{input id="group-members-search" type="text" class="form-control mousetrap" placeholder="Search members and users..." value=searchText key-up=(action 'onSearch')}}
<small class="form-text text-muted">matches firstname, lastname, email</small>
</div>
<div class="view-customize">
<div class="group-users-members my-5">
{{#if showMembers}}
{{#each members as |member|}}
<div class="row item">
<div class="col-10 fullname">{{member.fullname}}</div>
<div class="col-2 text-right">
<button class="btn btn-danger" {{action 'onLeaveGroup' member.userId}}>Leave</button>
</div>
</div>
{{/each}}
{{/if}}
{{#if showUsers}}
{{#each users as |user|}}
<div class="row item">
<div class="col-10 fullname">{{user.firstname}} {{user.lastname}}</div>
<div class="col-2 text-right">
{{#if user.isMember}}
<button class="btn btn-danger" {{action 'onLeaveGroup' user.id}}>Leave</button>
{{else}}
<button class="btn btn-success" {{action 'onJoinGroup' user.id}}>Join</button>
{{/if}}
</div>
</div>
{{/each}}
{{/if}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -19,12 +19,15 @@ type Group struct {
OrgID string `json:"orgId"` OrgID string `json:"orgId"`
Name string `json:"name"` Name string `json:"name"`
Purpose string `json:"purpose"` Purpose string `json:"purpose"`
Members int `json:"members"` Members int `json:"members"` // read-only info
} }
// Member defines user membership of a user group. // Member defines user membership of a user group.
type Member struct { type Member struct {
OrgID string `json:"orgId"` ID uint64 `json:"id"`
RoleID string `json:"roleId"` OrgID string `json:"orgId"`
UserID string `json:"userId"` RoleID string `json:"roleId"`
UserID string `json:"userId"`
Firstname string `json:"firstname"` //read-only info
Lastname string `json:"lastname"` //read-only info
} }

View file

@ -150,6 +150,7 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) {
Add(rt, RoutePrefixPrivate, "users/{userID}", []string{"PUT", "OPTIONS"}, nil, user.Update) Add(rt, RoutePrefixPrivate, "users/{userID}", []string{"PUT", "OPTIONS"}, nil, user.Update)
Add(rt, RoutePrefixPrivate, "users/{userID}", []string{"DELETE", "OPTIONS"}, nil, user.Delete) Add(rt, RoutePrefixPrivate, "users/{userID}", []string{"DELETE", "OPTIONS"}, nil, user.Delete)
Add(rt, RoutePrefixPrivate, "users/sync", []string{"GET", "OPTIONS"}, nil, keycloak.Sync) Add(rt, RoutePrefixPrivate, "users/sync", []string{"GET", "OPTIONS"}, nil, keycloak.Sync)
Add(rt, RoutePrefixPrivate, "users/match", []string{"POST", "OPTIONS"}, nil, user.MatchUsers)
Add(rt, RoutePrefixPrivate, "search", []string{"POST", "OPTIONS"}, nil, document.SearchDocuments) Add(rt, RoutePrefixPrivate, "search", []string{"POST", "OPTIONS"}, nil, document.SearchDocuments)
@ -182,10 +183,13 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) {
Add(rt, RoutePrefixPrivate, "pin/{userID}/sequence", []string{"POST", "OPTIONS"}, nil, pin.UpdatePinSequence) Add(rt, RoutePrefixPrivate, "pin/{userID}/sequence", []string{"POST", "OPTIONS"}, nil, pin.UpdatePinSequence)
Add(rt, RoutePrefixPrivate, "pin/{userID}/{pinID}", []string{"DELETE", "OPTIONS"}, nil, pin.DeleteUserPin) Add(rt, RoutePrefixPrivate, "pin/{userID}/{pinID}", []string{"DELETE", "OPTIONS"}, nil, pin.DeleteUserPin)
Add(rt, RoutePrefixPrivate, "group/{groupID}/members", []string{"GET", "OPTIONS"}, nil, group.GetGroupMembers)
Add(rt, RoutePrefixPrivate, "group", []string{"POST", "OPTIONS"}, nil, group.Add) Add(rt, RoutePrefixPrivate, "group", []string{"POST", "OPTIONS"}, nil, group.Add)
Add(rt, RoutePrefixPrivate, "group", []string{"GET", "OPTIONS"}, nil, group.Groups) Add(rt, RoutePrefixPrivate, "group", []string{"GET", "OPTIONS"}, nil, group.Groups)
Add(rt, RoutePrefixPrivate, "group/{groupID}", []string{"PUT", "OPTIONS"}, nil, group.Update) Add(rt, RoutePrefixPrivate, "group/{groupID}", []string{"PUT", "OPTIONS"}, nil, group.Update)
Add(rt, RoutePrefixPrivate, "group/{groupID}", []string{"DELETE", "OPTIONS"}, nil, group.Delete) Add(rt, RoutePrefixPrivate, "group/{groupID}", []string{"DELETE", "OPTIONS"}, nil, group.Delete)
Add(rt, RoutePrefixPrivate, "group/{groupID}/join/{userID}", []string{"POST", "OPTIONS"}, nil, group.JoinGroup)
Add(rt, RoutePrefixPrivate, "group/{groupID}/leave/{userID}", []string{"DELETE", "OPTIONS"}, nil, group.LeaveGroup)
// fetch methods exist to speed up UI rendering by returning data in bulk // fetch methods exist to speed up UI rendering by returning data in bulk
Add(rt, RoutePrefixPrivate, "fetch/category/space/{spaceID}", []string{"GET", "OPTIONS"}, nil, category.FetchSpaceData) Add(rt, RoutePrefixPrivate, "fetch/category/space/{spaceID}", []string{"GET", "OPTIONS"}, nil, category.FetchSpaceData)