feat(reactions): add reactions struct and routes

This commit is contained in:
kolaente 2024-03-11 21:43:43 +01:00
parent 85fb8e3443
commit 389ea6258d
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
9 changed files with 303 additions and 8 deletions

View File

@ -97,6 +97,7 @@ This document describes the different errors Vikunja can return.
| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
| 4023 | 409 | Tried to create a task relation which would create a cycle. |
| 4024 | 400 | The provided filter expression is invalid. |
| 4025 | 400 | The reaction kind is invalid. |
## Team

View File

@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"time"
"xorm.io/builder"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
@ -227,3 +228,12 @@ func NewSession() *xorm.Session {
func Type() schemas.DBType {
return x.Dialect().URI().DBType
}
func GetDialect() string {
dialect := config.DatabaseType.GetString()
if dialect == "sqlite" {
dialect = builder.SQLITE
}
return dialect
}

View File

@ -0,0 +1,49 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"time"
"xorm.io/xorm"
)
type reactions20240311173251 struct {
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"reaction"`
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
EntityID int64 `xorm:"bigint not null INDEX" json:"entity_id"`
EntityKind int `xorm:"bigint not null INDEX" json:"entity_kind"`
Value string `xorm:"varchar(20) not null INDEX" json:"value"`
Created time.Time `xorm:"created not null" json:"created"`
}
func (reactions20240311173251) TableName() string {
return "reactions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20240311173251",
Description: "Create reactions table",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(reactions20240311173251{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -1060,6 +1060,33 @@ func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError {
}
}
// ErrInvalidReactionEntityKind represents an error where the reaction kind is invalid
type ErrInvalidReactionEntityKind struct {
Kind string
}
// IsErrInvalidReactionEntityKind checks if an error is ErrInvalidReactionEntityKind.
func IsErrInvalidReactionEntityKind(err error) bool {
_, ok := err.(ErrInvalidReactionEntityKind)
return ok
}
func (err ErrInvalidReactionEntityKind) Error() string {
return fmt.Sprintf("Reaction kind %s is invalid", err.Kind)
}
// ErrCodeInvalidReactionEntityKind holds the unique world-error code of this error
const ErrCodeInvalidReactionEntityKind = 4025
// HTTPError holds the http error description
func (err ErrInvalidReactionEntityKind) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusBadRequest,
Code: ErrCodeInvalidReactionEntityKind,
Message: fmt.Sprintf("The reaction kind '%s' is invalid.", err.Kind),
}
}
// ============
// Team errors
// ============

View File

@ -23,7 +23,6 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
@ -370,10 +369,7 @@ type projectOptions struct {
}
func getUserProjectsStatement(parentProjectIDs []int64, userID int64, search string, getArchived bool) *builder.Builder {
dialect := config.DatabaseType.GetString()
if dialect == "sqlite" {
dialect = builder.SQLITE
}
dialect := db.GetDialect()
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
var getArchivedCond builder.Cond = builder.Eq{"1": 1}

117
pkg/models/reaction.go Normal file
View File

@ -0,0 +1,117 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"time"
"xorm.io/builder"
"xorm.io/xorm"
)
type ReactionKind int
const (
ReactionKindTask = iota
ReactionKindComment
)
type Reaction struct {
// The unique numeric id of this reaction
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"reaction"`
// The user who reacted
User *user.User `xorm:"-" json:"user" valid:"-"`
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
// The id of the entity you're reacting to
EntityID int64 `xorm:"bigint not null INDEX" json:"entity_id" param:"entityid"`
// The entity kind which you're reacting to. Can be 0 for task, 1 for comment.
EntityKind ReactionKind `xorm:"bigint not null INDEX" json:"entity_kind"`
EntityKindString string `xorm:"-" json:"-" param:"entitykind"`
// The actual reaction. This can be any valid utf character or text, up to a length of 20.
Value string `xorm:"varchar(20) not null INDEX" json:"value"`
// A timestamp when this reaction was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
func (*Reaction) TableName() string {
return "reactions"
}
func (r *Reaction) ReadAll(s *xorm.Session, _ web.Auth, _ string, _ int, _ int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
reactions := []*Reaction{}
err = s.Where("entity_id = ? AND entity_kind = ?", r.EntityID, r.EntityKind).Find(&reactions)
if err != nil {
return
}
cond := builder.
Select("user_id").
From("reactions").
And(builder.Eq{"entity_id": r.EntityID}).
And(builder.Eq{"entity_kind": r.EntityKind})
users, err := user.GetUsersByCond(s, cond)
if err != nil {
return
}
reactionMap := make(map[string][]*user.User)
for _, reaction := range reactions {
if _, has := reactionMap[reaction.Value]; !has {
reactionMap[reaction.Value] = []*user.User{}
}
reactionMap[reaction.Value] = append(reactionMap[reaction.Value], users[reaction.UserID])
}
return reactionMap, len(reactionMap), int64(len(reactions)), nil
}
func (r *Reaction) Delete(s *xorm.Session, a web.Auth) (err error) {
r.UserID = a.GetID()
_, err = s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind).
Delete(&Reaction{})
return
}
func (r *Reaction) Create(s *xorm.Session, a web.Auth) (err error) {
r.UserID = a.GetID()
exists, err := s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind).
Exist(&Reaction{})
if err != nil {
return err
}
if exists {
_, err = s.Where("user_id = ? AND entity_id = ? AND entity_kind = ?", r.UserID, r.EntityID, r.EntityKind).
Delete(&Reaction{})
return
}
_, err = s.Insert(r)
return
}

View File

@ -0,0 +1,81 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/web"
"xorm.io/xorm"
)
func (r *Reaction) setEntityKindFromString() (err error) {
switch r.EntityKindString {
case "tasks":
r.EntityKind = ReactionKindTask
return
case "comments":
r.EntityKind = ReactionKindComment
return
}
return ErrInvalidReactionEntityKind{
Kind: r.EntityKindString,
}
}
func (r *Reaction) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
t, err := r.getTask(s)
if err != nil {
return false, 0, err
}
return t.CanRead(s, a)
}
func (r *Reaction) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
t, err := r.getTask(s)
if err != nil {
return false, err
}
return t.CanUpdate(s, a)
}
func (r *Reaction) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
t, err := r.getTask(s)
if err != nil {
return false, err
}
return t.CanUpdate(s, a)
}
func (r *Reaction) getTask(s *xorm.Session) (t *Task, err error) {
err = r.setEntityKindFromString()
if err != nil {
return
}
t = &Task{ID: r.EntityID}
if r.EntityKind == ReactionKindComment {
tc := &TaskComment{TaskID: r.EntityID}
err = getTaskCommentSimple(s, tc)
if err != nil {
return
}
t.ID = tc.TaskID
}
return
}

View File

@ -589,6 +589,15 @@ func registerAPIRoutes(a *echo.Group) {
a.POST("/projects/:project/webhooks/:webhook", webhookProvider.UpdateWeb)
a.GET("/webhooks/events", apiv1.GetAvailableWebhookEvents)
}
reactionProvider := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.Reaction{}
},
}
a.GET("/:entitykind/:entityid/reactions", reactionProvider.ReadAllWeb)
a.DELETE("/:entitykind/:entityid/reactions", reactionProvider.DeleteWeb)
a.PUT("/:entitykind/:entityid/reactions", reactionProvider.CreateWeb)
}
func registerMigrations(m *echo.Group) {

View File

@ -23,6 +23,7 @@ import (
"reflect"
"strconv"
"time"
"xorm.io/builder"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
@ -259,13 +260,17 @@ func GetUserWithEmail(s *xorm.Session, user *User) (userOut *User, err error) {
// GetUsersByIDs returns a map of users from a slice of user ids
func GetUsersByIDs(s *xorm.Session, userIDs []int64) (users map[int64]*User, err error) {
users = make(map[int64]*User)
if len(userIDs) == 0 {
return users, nil
}
err = s.In("id", userIDs).Find(&users)
return GetUsersByCond(s, builder.Dialect(db.GetDialect()).Where(builder.In("id", userIDs)))
}
func GetUsersByCond(s *xorm.Session, cond *builder.Builder) (users map[int64]*User, err error) {
users = make(map[int64]*User)
err = s.Where(cond).Find(&users)
if err != nil {
return
}