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

Content liking

New per space option that allows users to like/dislike content.

The prompt is configurable per space, e.g. "Was this useful?".

Enterprise edition gets new Votes report providing insights into most/least liked content.
This commit is contained in:
Harvey Kandola 2018-04-13 11:01:36 +01:00
parent 6c8f23792c
commit 22b6674edb
23 changed files with 1031 additions and 694 deletions

View file

@ -52,9 +52,9 @@ Space view.
## Latest version
[Community edition: v1.61.0](https://github.com/documize/community/releases)
[Community edition: v1.62.0](https://github.com/documize/community/releases)
[Enterprise edition: v1.63.0](https://documize.com/downloads)
[Enterprise edition: v1.64.0](https://documize.com/downloads)
## OS support

View file

@ -0,0 +1,27 @@
/* community edition */
-- content likes/feedback
DROP TABLE IF EXISTS `vote`;
CREATE TABLE IF NOT EXISTS `vote` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`refid` CHAR(16) NOT NULL COLLATE utf8_bin,
`orgid` CHAR(16) NOT NULL COLLATE utf8_bin,
`documentid` CHAR(16) NOT NULL COLLATE utf8_bin,
`voter` CHAR(16) NOT NULL DEFAULT '' COLLATE utf8_bin,
`vote` INT NOT NULL DEFAULT 0,
`created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`revised` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX `idx_vote_id` (`id` ASC),
INDEX `idx_vote_refid` (`refid` ASC),
INDEX `idx_vote_documentid` (`documentid` ASC),
INDEX `idx_vote_orgid` (`orgid` ASC))
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
ENGINE = InnoDB;
CREATE INDEX idx_vote_1 ON vote(orgid,documentid);
-- liking
ALTER TABLE label ADD COLUMN `likes` VARCHAR(1000) NOT NULL DEFAULT '' AFTER `type`;
-- deprecations

View file

@ -24,7 +24,9 @@ import (
"github.com/documize/community/core/response"
"github.com/documize/community/core/streamutil"
"github.com/documize/community/core/stringutil"
"github.com/documize/community/core/uniqueid"
"github.com/documize/community/domain"
"github.com/documize/community/domain/organization"
"github.com/documize/community/domain/permission"
indexer "github.com/documize/community/domain/search"
"github.com/documize/community/model/activity"
@ -626,3 +628,72 @@ type BulkDocumentData struct {
Links []link.Link `json:"links"`
Versions []doc.Version `json:"versions"`
}
// Vote records document content vote, Yes, No.
// Anonymous users should be assigned a temporary ID
func (h *Handler) Vote(w http.ResponseWriter, r *http.Request) {
method := "document.Vote"
ctx := domain.GetRequestContext(r)
// Deduce ORG because public API call.
ctx.Subdomain = organization.GetSubdomainFromHost(r)
org, err := h.Store.Organization.GetOrganizationByDomain(ctx.Subdomain)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.OrgID = org.RefID
documentID := request.Param(r, "documentID")
if len(documentID) == 0 {
response.WriteMissingDataError(w, method, "documentID")
return
}
defer streamutil.Close(r.Body)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, err.Error())
h.Runtime.Log.Error(method, err)
return
}
var payload struct {
UserID string `json:"userId"`
Vote int `json:"vote"`
}
err = json.Unmarshal(body, &payload)
if err != nil {
response.WriteBadRequestError(w, method, err.Error())
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
doc, err := h.Store.Document.Get(ctx, documentID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
err = h.Store.Document.Vote(ctx, uniqueid.Generate(), doc.OrgID, documentID, payload.UserID, payload.Vote)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
response.WriteEmpty(w)
}

View file

@ -41,7 +41,7 @@ func (s Scope) Add(ctx domain.RequestContext, d doc.Document) (err error) {
d.Template, d.Protection, d.Approval, d.Lifecycle, d.Versioned, d.VersionID, d.VersionOrder, d.GroupID, d.Created, d.Revised)
if err != nil {
err = errors.Wrap(err, "execuet insert document")
err = errors.Wrap(err, "execute insert document")
}
return
@ -264,23 +264,28 @@ func (s Scope) MoveActivity(ctx domain.RequestContext, documentID, oldSpaceID, n
// Remove document pages, revisions, attachments, updates the search subsystem.
func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64, err error) {
b := mysql.BaseQuery{}
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from page WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM page WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from revision WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM revision WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from attachment WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM attachment WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from categorymember WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM categorymember WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM vote WHERE documentid=\"%s\" AND orgid=\"%s\"", documentID, ctx.OrgID))
if err != nil {
return
}
@ -292,18 +297,23 @@ func (s Scope) Delete(ctx domain.RequestContext, documentID string) (rows int64,
// Remove document pages, revisions, attachments, updates the search subsystem.
func (s Scope) DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows int64, err error) {
b := mysql.BaseQuery{}
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from page WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
rows, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM page WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from revision WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM revision WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE from attachment WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM attachment WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
if err != nil {
return
}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM vote WHERE documentid IN (SELECT refid FROM document WHERE labelid=\"%s\" AND orgid=\"%s\")", spaceID, ctx.OrgID))
if err != nil {
return
}
@ -336,3 +346,24 @@ func (s Scope) GetVersions(ctx domain.RequestContext, groupID string) (v []doc.V
return
}
// Vote records document content vote.
// Any existing vote by the user is replaced.
func (s Scope) Vote(ctx domain.RequestContext, refID, orgID, documentID, userID string, vote int) (err error) {
b := mysql.BaseQuery{}
_, err = b.DeleteWhere(ctx.Transaction,
fmt.Sprintf("DELETE FROM vote WHERE orgid=\"%s\" AND documentid=\"%s\" AND voter=\"%s\"",
orgID, documentID, userID))
if err != nil {
s.Runtime.Log.Error("store.Vote", err)
}
_, err = ctx.Transaction.Exec(`INSERT INTO vote (refid, orgid, documentid, voter, vote) VALUES (?, ?, ?, ?, ?)`,
refID, orgID, documentID, userID, vote)
if err != nil {
err = errors.Wrap(err, "execute insert vote")
}
return
}

View file

@ -35,8 +35,8 @@ func (s Scope) Add(ctx domain.RequestContext, sp space.Space) (err error) {
sp.Created = time.Now().UTC()
sp.Revised = time.Now().UTC()
_, err = ctx.Transaction.Exec("INSERT INTO label (refid, label, orgid, userid, type, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?)",
sp.RefID, sp.Name, sp.OrgID, sp.UserID, sp.Type, sp.Created, sp.Revised)
_, err = ctx.Transaction.Exec("INSERT INTO label (refid, label, orgid, userid, type, likes, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
sp.RefID, sp.Name, sp.OrgID, sp.UserID, sp.Type, sp.Likes, sp.Created, sp.Revised)
if err != nil {
err = errors.Wrap(err, "unable to execute insert for label")
@ -47,7 +47,7 @@ func (s Scope) Add(ctx domain.RequestContext, sp space.Space) (err error) {
// Get returns a space from the store.
func (s Scope) Get(ctx domain.RequestContext, id string) (sp space.Space, err error) {
err = s.Runtime.Db.Get(&sp, "SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label WHERE orgid=? and refid=?",
err = s.Runtime.Db.Get(&sp, "SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label WHERE orgid=? and refid=?",
ctx.OrgID, id)
if err != nil {
@ -59,7 +59,7 @@ func (s Scope) Get(ctx domain.RequestContext, id string) (sp space.Space, err er
// PublicSpaces returns spaces that anyone can see.
func (s Scope) PublicSpaces(ctx domain.RequestContext, orgID string) (sp []space.Space, err error) {
qry := "SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label a where orgid=? AND type=1"
qry := "SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label a where orgid=? AND type=1"
err = s.Runtime.Db.Select(&sp, qry, orgID)
@ -78,7 +78,7 @@ func (s Scope) PublicSpaces(ctx domain.RequestContext, orgID string) (sp []space
// Also handles which spaces can be seen by anonymous users.
func (s Scope) GetViewable(ctx domain.RequestContext) (sp []space.Space, err error) {
q := `
SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label
SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label
WHERE orgid=?
AND refid IN (SELECT refid FROM permission WHERE orgid=? AND location='space' AND refid IN (
SELECT refid from permission WHERE orgid=? AND who='user' AND (whoid=? OR whoid='0') AND location='space' AND action='view' UNION ALL
@ -109,7 +109,7 @@ func (s Scope) GetViewable(ctx domain.RequestContext) (sp []space.Space, err err
// GetAll for admin users!
func (s Scope) GetAll(ctx domain.RequestContext) (sp []space.Space, err error) {
qry := `
SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label
SELECT id,refid,label as name,orgid,userid,type,likes,created,revised FROM label
WHERE orgid=?
ORDER BY name`
@ -130,7 +130,7 @@ func (s Scope) GetAll(ctx domain.RequestContext) (sp []space.Space, err error) {
func (s Scope) Update(ctx domain.RequestContext, sp space.Space) (err error) {
sp.Revised = time.Now().UTC()
_, err = ctx.Transaction.NamedExec("UPDATE label SET label=:name, type=:type, userid=:userid, revised=:revised WHERE orgid=:orgid AND refid=:refid", &sp)
_, err = ctx.Transaction.NamedExec("UPDATE label SET label=:name, type=:type, userid=:userid, likes=:likes, revised=:revised WHERE orgid=:orgid AND refid=:refid", &sp)
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute update for label %s", sp.RefID))

View file

@ -181,6 +181,7 @@ type DocumentStorer interface {
DeleteBySpace(ctx RequestContext, spaceID string) (rows int64, err error)
GetVersions(ctx RequestContext, groupID string) (v []doc.Version, err error)
MoveActivity(ctx RequestContext, documentID, oldSpaceID, newSpaceID string) (err error)
Vote(ctx RequestContext, refID, orgID, documentID, userID string, vote int) (err error)
}
// SettingStorer defines required methods for persisting global and user level settings

View file

@ -41,7 +41,7 @@ func main() {
// product details
rt.Product = env.ProdInfo{}
rt.Product.Major = "1"
rt.Product.Minor = "61"
rt.Product.Minor = "62"
rt.Product.Patch = "0"
rt.Product.Version = fmt.Sprintf("%s.%s.%s", rt.Product.Major, rt.Product.Minor, rt.Product.Patch)
rt.Product.Edition = "Community"

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,7 @@ export default Component.extend(TooltipMixin, {
return this.get('blocks.length') > 0;
}),
mousetrap: null,
voteThanks: false,
didRender() {
this._super(...arguments);
@ -351,6 +352,11 @@ export default Component.extend(TooltipMixin, {
});
return true;
},
onVote(vote) {
this.get('documentService').vote(this.get('document.id'), vote);
this.set('voteThanks', true);
}
}
});

View file

@ -12,7 +12,9 @@
import $ from 'jquery';
import { computed } from '@ember/object';
import { schedule } from '@ember/runloop';
import { A } from '@ember/array';
import { inject as service } from '@ember/service';
import constants from '../../utils/constants';
import TooltipMixin from '../../mixins/tooltip';
import ModalMixin from '../../mixins/modal';
import AuthMixin from '../../mixins/auth';
@ -48,6 +50,11 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
dropzone: null,
spaceTypeOptions: A([]),
spaceType: constants.FolderType.Private,
likes: '',
allowLikes: false,
init() {
this._super(...arguments);
this.importedDocuments = [];
@ -77,6 +84,16 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
if (this.get('inviteMessage').length === 0) {
this.set('inviteMessage', this.getDefaultInvitationMessage());
}
let spaceTypeOptions = A([]);
spaceTypeOptions.pushObject({id: constants.FolderType.Private, label: 'Private - viewable only by me'});
spaceTypeOptions.pushObject({id: constants.FolderType.Protected, label: 'Protected - access is restricted to selected users'});
spaceTypeOptions.pushObject({id: constants.FolderType.Public, label: 'Public - can be seen by everyone'});
this.set('spaceTypeOptions', spaceTypeOptions);
this.set('spaceType', spaceTypeOptions.findBy('id', folder.get('folderType')));
this.set('likes', folder.get('likes'));
this.set('allowLikes', folder.get('allowLikes'));
},
didInsertElement() {
@ -357,6 +374,31 @@ export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
let slug = stringUtil.makeSlug(template.get('title'));
this.get('router').transitionTo('document', this.get('space.id'), this.get('space.slug'), id, slug);
},
onSetSpaceType(t) {
this.set('spaceType', t);
},
onSetLikes(l) {
this.set('allowLikes', l);
schedule('afterRender', () => {
if (l) $('#space-likes-prompt').focus();
});
},
onSpaceSettings() {
let space = this.get('space');
space.set('folderType', this.get('spaceType.id'));
let allowLikes = this.get('allowLikes');
space.set('likes', allowLikes ? this.get('likes') : '');
this.get('spaceService').save(space).then(() => {
});
this.modalClose("#space-settings-modal");
}
}
});

View file

@ -12,5 +12,5 @@
import Component from '@ember/component';
export default Component.extend({
classNames: ['col', 'col-sm-4']
classNames: ['col', 'col-sm-5']
});

View file

@ -14,6 +14,6 @@ import Component from '@ember/component';
export default Component.extend({
appMeta: service(),
classNames: ['col', 'col-sm-8'],
classNames: ['col', 'col-sm-7'],
selectItem: '',
});

View file

@ -20,6 +20,11 @@ export default Model.extend({
orgId: attr('string'),
userId: attr('string'),
folderType: attr('number', { defaultValue: 2 }),
likes: attr('string'),
allowLikes: computed('likes', function () {
return is.not.empty(this.get('likes')) && is.not.undefined(this.get('likes'));
}),
slug: computed('name', function () {
return stringUtil.makeSlug(this.get('name'));

View file

@ -11,11 +11,13 @@
import { set } from '@ember/object';
import { A } from '@ember/array';
import stringUtil from '../utils/string';
import ArrayProxy from '@ember/array/proxy';
import Service, { inject as service } from '@ember/service';
export default Service.extend({
sessionService: service('session'),
storageSvc: service('localStorage'),
folderService: service('folder'),
ajax: service(),
store: service(),
@ -318,6 +320,41 @@ export default Service.extend({
});
},
//**************************************************
// Voting / Liking
//**************************************************
// Vote records content vote from user.
// Anonymous users can vote to and are assigned temp id that is stored
// client-side in browser local storage.
vote(documentId, vote) {
let userId = '';
if (this.get('sessionService.authenticated')) {
userId = this.get('sessionService.user.id');
} else {
let id = this.get('storageSvc').getSessionItem('anonId');
if (is.not.null(id) && is.not.undefined(id) && id.length === 16) {
userId = id;
} else {
userId = stringUtil.anonUserId();
}
this.get('storageSvc').storeSessionItem('anonId', userId);
}
let payload = {
userId: userId,
vote: vote
};
return this.get('ajax').post(`public/document/${documentId}/vote`, {
data: JSON.stringify(payload),
contentType: 'json'
});
},
//**************************************************
// Fetch bulk data
//**************************************************

View file

@ -7,4 +7,5 @@
@import "view-attachment.scss";
@import "view-activity.scss";
@import "view-revision.scss";
@import "vote-likes.scss";
@import "wysiwyg.scss";

View file

@ -0,0 +1,25 @@
.vote-box {
margin: 50px 0;
padding: 30px 50px;
text-align: center;
max-width: 400px;
border: 1px dotted $color-border;
background: $color-off-white;
@include border-radius(3px);
> .prompt {
font-size: 1.5rem;
font-weight: 600;
color: $color-dark;
}
> .buttons {
margin: 30px 0 0 0;
}
> .ack {
font-size: 1.2rem;
font-weight: 600;
color: $color-green;
}
}

View file

@ -23,6 +23,24 @@
</div>
</div>
{{/if}}
{{#if folder.allowLikes}}
<div class=" d-flex justify-content-center">
<div class="vote-box">
{{#unless voteThanks}}
<div class="prompt">
{{folder.likes}}
</div>
<div class="buttons">
<button type="button" class="btn btn-outline-success font-weight-bold" {{action 'onVote' 1}}>Yes, thanks!</button>&nbsp;&nbsp;
<button type="button" class="btn btn-outline-secondary font-weight-bold" {{action 'onVote' 2}}>Not really</button>
</div>
{{else}}
<div class="ack">Thanks for the feedback!</div>
{{/unless}}
</div>
</div>
{{/if}}
{{/if}}
{{#unless hasPages}}

View file

@ -105,6 +105,47 @@
</div>
{{/if}}
{{#if (or permissions.spaceOwner permissions.spaceManage)}}
<div id="space-settings-button" class="button-icon-gray align-middle" data-toggle="tooltip" data-placement="top" title="Settings">
<i class="material-icons" data-toggle="modal" data-target="#space-settings-modal" data-backdrop="static">settings</i>
</div>
<div class="button-icon-gap" />
<div id="space-settings-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Space Settings</div>
<div class="modal-body">
<form>
<div class="form-group">
<label>Space Type</label>
{{ui-select id="group-dropdown" content=spaceTypeOptions optionValuePath="id" optionLabelPath="label" selection=spaceType action=(action 'onSetSpaceType')}}
</div>
<div class="form-group">
<label>Content Liking</label>
{{#if allowLikes}}
{{input type='text' id="space-likes-prompt" class="form-control" placeholder="Did this help you?" value=likes}}
<small class="form-text text-muted">Specify the prompt, e.g. Did this help you? Was this helpful? Did you find what you needed?</small>
<div class="mt-4">
<button type="button" class="btn btn-secondary" onclick={{action 'onSetLikes' false}}>Do not allow users to like content</button>
</div>
{{else}}
<div>
<button type="button" class="btn btn-outline-success" onclick={{action 'onSetLikes' true}}>Allow users to like content</button>
</div>
{{/if}}
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick={{action 'onSpaceSettings'}}>Save</button>
</div>
</div>
</div>
</div>
{{/if}}
{{#if pinState.isPinned}}
<div id="space-pin-button" class="button-icon-gold align-middle" data-toggle="tooltip" data-placement="top" title="Remove favorite" {{action 'onUnpin'}}>
<i class="material-icons">star</i>

View file

@ -36,9 +36,14 @@ function endsWith(str, suffix) {
return str.indexOf(suffix, str.length - suffix.length) !== -1;
}
function anonUserId() {
return 'anon_' + makeId(11);
}
export default {
makeSlug,
makeId,
endsWith
endsWith,
anonUserId
};

View file

@ -1,6 +1,6 @@
{
"name": "documize",
"version": "1.61.0",
"version": "1.62.0",
"description": "The Document IDE",
"private": true,
"repository": "",

View file

@ -22,6 +22,9 @@ type Space struct {
OrgID string `json:"orgId"`
UserID string `json:"userId"`
Type Scope `json:"folderType"`
// Likes stores the question to ask the user such as 'Did this help you?'.
// An empty value tells us liking is not allowed.
Likes string `json:"likes"`
}
// Scope determines folder visibility.

View file

@ -86,6 +86,7 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) {
AddPublic(rt, "reset/{token}", []string{"POST", "OPTIONS"}, nil, user.ResetPassword)
AddPublic(rt, "share/{spaceID}", []string{"POST", "OPTIONS"}, nil, space.AcceptInvitation)
AddPublic(rt, "attachments/{orgID}/{attachmentID}", []string{"GET", "OPTIONS"}, nil, attachment.Download)
AddPublic(rt, "document/{documentID}/vote", []string{"POST", "OPTIONS"}, nil, document.Vote)
//**************************************************
// Secured private routes (require authentication)