feat: add bell notification system for task assignment

This commit is contained in:
Jordan Knott
2021-11-02 14:45:05 -05:00
parent 3afd860534
commit 799d7f3ad0
53 changed files with 3306 additions and 163 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import (
"os"
"reflect"
"strings"
"sync"
"time"
"github.com/99designs/gqlgen/graphql"
@ -31,6 +32,10 @@ func NewHandler(repo db.Repository, appConfig config.AppConfig) http.Handler {
Resolvers: &Resolver{
Repository: repo,
AppConfig: appConfig,
Notifications: NotificationObservers{
Mu: sync.Mutex{},
Subscribers: make(map[string]map[string]chan *Notified),
},
},
}
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
@ -223,16 +228,6 @@ func ConvertToRoleCode(r string) RoleCode {
return RoleCodeObserver
}
// GetActionType converts integer to ActionType enum
func GetActionType(actionType int32) ActionType {
switch actionType {
case 1:
return ActionTypeTaskMemberAdded
default:
panic("Not a valid entity type!")
}
}
type MemberType string
const (

View File

@ -3,8 +3,12 @@ package graph
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/db"
log "github.com/sirupsen/logrus"
)
// GetOwnedList todo: remove this
@ -12,6 +16,57 @@ func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*O
return &OwnedList{}, nil
}
type CreateNotificationParams struct {
NotifiedList []uuid.UUID
ActionType ActionType
CausedBy uuid.UUID
Data map[string]string
}
func (r *Resolver) CreateNotification(ctx context.Context, data CreateNotificationParams) error {
now := time.Now().UTC()
raw, err := json.Marshal(NotifiedData{Data: data.Data})
if err != nil {
log.WithError(err).Error("error while marshal json data for notification")
return err
}
log.WithField("ActionType", data.ActionType).Info("creating notification object")
n, err := r.Repository.CreateNotification(ctx, db.CreateNotificationParams{
CausedBy: data.CausedBy,
ActionType: data.ActionType.String(),
CreatedOn: now,
Data: json.RawMessage(raw),
})
if err != nil {
log.WithError(err).Error("error while creating notification")
return err
}
for _, nn := range data.NotifiedList {
log.WithFields(log.Fields{"UserID": nn, "NotificationID": n.NotificationID}).Info("creating notification notified object")
notified, err := r.Repository.CreateNotificationNotifed(ctx, db.CreateNotificationNotifedParams{
UserID: nn,
NotificationID: n.NotificationID,
})
if err != nil {
log.WithError(err).Error("error while creating notification notified object")
return err
}
for ouid, observers := range r.Notifications.Subscribers {
log.WithField("ouid", ouid).Info("checking user subscribers")
for oid, ochan := range observers {
log.WithField("ouid", ouid).WithField("oid", oid).Info("checking user subscriber")
ochan <- &Notified{
ID: notified.NotifiedID,
Read: notified.Read,
ReadAt: &notified.ReadAt.Time,
Notification: &n,
}
}
}
}
return nil
}
// GetMemberList returns a list of projects the user is a member of
func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) {
projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID)
@ -45,3 +100,7 @@ func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*
type ActivityData struct {
Data map[string]string
}
type NotifiedData struct {
Data map[string]string
}

View File

@ -230,6 +230,10 @@ type FindUser struct {
UserID uuid.UUID `json:"userID"`
}
type HasUnreadNotificationsResult struct {
Unread bool `json:"unread"`
}
type InviteProjectMembers struct {
ProjectID uuid.UUID `json:"projectID"`
Members []MemberInvite `json:"members"`
@ -367,6 +371,10 @@ type NotificationData struct {
Value string `json:"value"`
}
type NotificationToggleReadInput struct {
NotifiedID uuid.UUID `json:"notifiedID"`
}
type Notified struct {
ID uuid.UUID `json:"id"`
Notification *db.Notification `json:"notification"`
@ -374,6 +382,18 @@ type Notified struct {
ReadAt *time.Time `json:"readAt"`
}
type NotifiedInput struct {
Limit int `json:"limit"`
Cursor *string `json:"cursor"`
Filter NotificationFilter `json:"filter"`
}
type NotifiedResult struct {
TotalCount int `json:"totalCount"`
Notified []Notified `json:"notified"`
PageInfo *PageInfo `json:"pageInfo"`
}
type OwnedList struct {
Teams []db.Team `json:"teams"`
Projects []db.Project `json:"projects"`
@ -384,6 +404,11 @@ type OwnersList struct {
Teams []uuid.UUID `json:"teams"`
}
type PageInfo struct {
EndCursor string `json:"endCursor"`
HasNextPage bool `json:"hasNextPage"`
}
type ProfileIcon struct {
URL *string `json:"url"`
Initials *string `json:"initials"`
@ -479,6 +504,10 @@ type ToggleTaskLabelPayload struct {
Task *db.Task `json:"task"`
}
type ToggleTaskWatch struct {
TaskID uuid.UUID `json:"taskID"`
}
type UnassignTaskInput struct {
TaskID uuid.UUID `json:"taskID"`
UserID uuid.UUID `json:"userID"`
@ -671,16 +700,42 @@ func (e ActionLevel) MarshalGQL(w io.Writer) {
type ActionType string
const (
ActionTypeTaskMemberAdded ActionType = "TASK_MEMBER_ADDED"
ActionTypeTeamAdded ActionType = "TEAM_ADDED"
ActionTypeTeamRemoved ActionType = "TEAM_REMOVED"
ActionTypeProjectAdded ActionType = "PROJECT_ADDED"
ActionTypeProjectRemoved ActionType = "PROJECT_REMOVED"
ActionTypeProjectArchived ActionType = "PROJECT_ARCHIVED"
ActionTypeDueDateAdded ActionType = "DUE_DATE_ADDED"
ActionTypeDueDateRemoved ActionType = "DUE_DATE_REMOVED"
ActionTypeDueDateChanged ActionType = "DUE_DATE_CHANGED"
ActionTypeTaskAssigned ActionType = "TASK_ASSIGNED"
ActionTypeTaskMoved ActionType = "TASK_MOVED"
ActionTypeTaskArchived ActionType = "TASK_ARCHIVED"
ActionTypeTaskAttachmentUploaded ActionType = "TASK_ATTACHMENT_UPLOADED"
ActionTypeCommentMentioned ActionType = "COMMENT_MENTIONED"
ActionTypeCommentOther ActionType = "COMMENT_OTHER"
)
var AllActionType = []ActionType{
ActionTypeTaskMemberAdded,
ActionTypeTeamAdded,
ActionTypeTeamRemoved,
ActionTypeProjectAdded,
ActionTypeProjectRemoved,
ActionTypeProjectArchived,
ActionTypeDueDateAdded,
ActionTypeDueDateRemoved,
ActionTypeDueDateChanged,
ActionTypeTaskAssigned,
ActionTypeTaskMoved,
ActionTypeTaskArchived,
ActionTypeTaskAttachmentUploaded,
ActionTypeCommentMentioned,
ActionTypeCommentOther,
}
func (e ActionType) IsValid() bool {
switch e {
case ActionTypeTaskMemberAdded:
case ActionTypeTeamAdded, ActionTypeTeamRemoved, ActionTypeProjectAdded, ActionTypeProjectRemoved, ActionTypeProjectArchived, ActionTypeDueDateAdded, ActionTypeDueDateRemoved, ActionTypeDueDateChanged, ActionTypeTaskAssigned, ActionTypeTaskMoved, ActionTypeTaskArchived, ActionTypeTaskAttachmentUploaded, ActionTypeCommentMentioned, ActionTypeCommentOther:
return true
}
return false
@ -860,6 +915,51 @@ func (e MyTasksStatus) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type NotificationFilter string
const (
NotificationFilterAll NotificationFilter = "ALL"
NotificationFilterUnread NotificationFilter = "UNREAD"
NotificationFilterAssigned NotificationFilter = "ASSIGNED"
NotificationFilterMentioned NotificationFilter = "MENTIONED"
)
var AllNotificationFilter = []NotificationFilter{
NotificationFilterAll,
NotificationFilterUnread,
NotificationFilterAssigned,
NotificationFilterMentioned,
}
func (e NotificationFilter) IsValid() bool {
switch e {
case NotificationFilterAll, NotificationFilterUnread, NotificationFilterAssigned, NotificationFilterMentioned:
return true
}
return false
}
func (e NotificationFilter) String() string {
return string(e)
}
func (e *NotificationFilter) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = NotificationFilter(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid NotificationFilter", str)
}
return nil
}
func (e NotificationFilter) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ObjectType string
const (

View File

@ -6,32 +6,88 @@ package graph
import (
"context"
"database/sql"
"fmt"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus"
)
func (r *mutationResolver) NotificationToggleRead(ctx context.Context, input NotificationToggleReadInput) (*Notified, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &Notified{}, errors.New("unknown user ID")
}
notified, err := r.Repository.GetNotifiedByID(ctx, input.NotifiedID)
if err != nil {
log.WithError(err).Error("error while getting notified by ID")
return &Notified{}, err
}
readAt := time.Now().UTC()
read := true
if notified.Read {
read = false
err = r.Repository.MarkNotificationAsRead(ctx, db.MarkNotificationAsReadParams{
UserID: userID,
NotifiedID: input.NotifiedID,
Read: false,
ReadAt: sql.NullTime{
Valid: false,
Time: time.Time{},
},
})
} else {
err = r.Repository.MarkNotificationAsRead(ctx, db.MarkNotificationAsReadParams{
UserID: userID,
Read: true,
NotifiedID: input.NotifiedID,
ReadAt: sql.NullTime{
Valid: true,
Time: readAt,
},
})
}
if err != nil {
log.WithError(err).Error("error while marking notification as read")
return &Notified{}, err
}
return &Notified{
ID: notified.NotifiedID,
Read: read,
ReadAt: &readAt,
Notification: &db.Notification{
NotificationID: notified.NotificationID,
CausedBy: notified.CausedBy,
ActionType: notified.ActionType,
Data: notified.Data,
CreatedOn: notified.CreatedOn,
},
}, nil
}
func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) {
return obj.NotificationID, nil
}
func (r *notificationResolver) ActionType(ctx context.Context, obj *db.Notification) (ActionType, error) {
return ActionTypeTaskMemberAdded, nil // TODO
actionType := ActionType(obj.ActionType)
if !actionType.IsValid() {
log.WithField("ActionType", obj.ActionType).Error("ActionType is invalid")
return actionType, errors.New("ActionType is invalid")
}
return ActionType(obj.ActionType), nil // TODO
}
func (r *notificationResolver) CausedBy(ctx context.Context, obj *db.Notification) (*NotificationCausedBy, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.CausedBy)
if err != nil {
if err == sql.ErrNoRows {
return &NotificationCausedBy{
Fullname: "Unknown user",
Username: "unknown",
ID: obj.CausedBy,
}, nil
return nil, nil
}
log.WithError(err).Error("error while resolving Notification.CausedBy")
return &NotificationCausedBy{}, err
@ -44,7 +100,16 @@ func (r *notificationResolver) CausedBy(ctx context.Context, obj *db.Notificatio
}
func (r *notificationResolver) Data(ctx context.Context, obj *db.Notification) ([]NotificationData, error) {
panic(fmt.Errorf("not implemented"))
notifiedData := NotifiedData{}
err := json.Unmarshal(obj.Data, &notifiedData)
if err != nil {
return []NotificationData{}, err
}
data := []NotificationData{}
for key, value := range notifiedData.Data {
data = append(data, NotificationData{Key: key, Value: value})
}
return data, nil
}
func (r *notificationResolver) CreatedAt(ctx context.Context, obj *db.Notification) (*time.Time, error) {
@ -86,8 +151,183 @@ func (r *queryResolver) Notifications(ctx context.Context) ([]Notified, error) {
return userNotifications, nil
}
func (r *queryResolver) Notified(ctx context.Context, input NotifiedInput) (*NotifiedResult, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &NotifiedResult{}, errors.New("userID is not found")
}
log.WithField("userID", userID).Info("fetching notified")
if input.Cursor != nil {
t, id, err := utils.DecodeCursor(*input.Cursor)
if err != nil {
log.WithError(err).Error("error decoding cursor")
return &NotifiedResult{}, err
}
n, err := r.Repository.GetNotificationsForUserIDCursor(ctx, db.GetNotificationsForUserIDCursorParams{
CreatedOn: t,
NotificationID: id,
LimitRows: int32(input.Limit + 1),
UserID: userID,
})
if err != nil {
log.WithError(err).Error("error decoding fetching notifications")
return &NotifiedResult{}, err
}
hasNextPage := false
log.WithFields(log.Fields{
"nLen": len(n),
"cursorTime": t,
"cursorId": id,
"limit": input.Limit,
}).Info("fetched notified")
endCursor := n[len(n)-1]
if len(n) == input.Limit+1 {
hasNextPage = true
n = n[:len(n)-1]
endCursor = n[len(n)-1]
}
userNotifications := []Notified{}
for _, notified := range n {
var readAt *time.Time
if notified.ReadAt.Valid {
readAt = &notified.ReadAt.Time
}
n := Notified{
ID: notified.NotifiedID,
Read: notified.Read,
ReadAt: readAt,
Notification: &db.Notification{
NotificationID: notified.NotificationID,
CausedBy: notified.CausedBy,
ActionType: notified.ActionType,
Data: notified.Data,
CreatedOn: notified.CreatedOn,
},
}
userNotifications = append(userNotifications, n)
}
pageInfo := &PageInfo{
HasNextPage: hasNextPage,
EndCursor: utils.EncodeCursor(endCursor.CreatedOn, endCursor.NotificationID),
}
log.WithField("pageInfo", pageInfo).Info("created page info")
return &NotifiedResult{
TotalCount: len(n) - 1,
PageInfo: pageInfo,
Notified: userNotifications,
}, nil
}
enableRead := false
enableActionType := false
actionTypes := []string{}
switch input.Filter {
case NotificationFilterUnread:
enableRead = true
break
case NotificationFilterMentioned:
enableActionType = true
actionTypes = []string{"COMMENT_MENTIONED"}
break
case NotificationFilterAssigned:
enableActionType = true
actionTypes = []string{"TASK_ASSIGNED"}
break
}
n, err := r.Repository.GetNotificationsForUserIDPaged(ctx, db.GetNotificationsForUserIDPagedParams{
LimitRows: int32(input.Limit + 1),
EnableUnread: enableRead,
EnableActionType: enableActionType,
ActionType: actionTypes,
UserID: userID,
})
if err != nil {
log.WithError(err).Error("error decoding fetching notifications")
return &NotifiedResult{}, err
}
hasNextPage := false
log.WithFields(log.Fields{
"nLen": len(n),
"limit": input.Limit,
}).Info("fetched notified")
endCursor := n[len(n)-1]
if len(n) == input.Limit+1 {
hasNextPage = true
n = n[:len(n)-1]
endCursor = n[len(n)-1]
}
userNotifications := []Notified{}
for _, notified := range n {
var readAt *time.Time
if notified.ReadAt.Valid {
readAt = &notified.ReadAt.Time
}
n := Notified{
ID: notified.NotifiedID,
Read: notified.Read,
ReadAt: readAt,
Notification: &db.Notification{
NotificationID: notified.NotificationID,
CausedBy: notified.CausedBy,
ActionType: notified.ActionType,
Data: notified.Data,
CreatedOn: notified.CreatedOn,
},
}
userNotifications = append(userNotifications, n)
}
pageInfo := &PageInfo{
HasNextPage: hasNextPage,
EndCursor: utils.EncodeCursor(endCursor.CreatedOn, endCursor.NotificationID),
}
log.WithField("pageInfo", pageInfo).Info("created page info")
return &NotifiedResult{
TotalCount: len(n),
PageInfo: pageInfo,
Notified: userNotifications,
}, nil
}
func (r *queryResolver) HasUnreadNotifications(ctx context.Context) (*HasUnreadNotificationsResult, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &HasUnreadNotificationsResult{}, errors.New("userID is missing")
}
unread, err := r.Repository.HasUnreadNotification(ctx, userID)
if err != nil {
log.WithError(err).Error("error while fetching unread notifications")
return &HasUnreadNotificationsResult{}, err
}
return &HasUnreadNotificationsResult{
Unread: unread,
}, nil
}
func (r *subscriptionResolver) NotificationAdded(ctx context.Context) (<-chan *Notified, error) {
panic(fmt.Errorf("not implemented"))
notified := make(chan *Notified, 1)
userID, ok := GetUserID(ctx)
if !ok {
return notified, errors.New("userID is not found")
}
id := uuid.New().String()
go func() {
<-ctx.Done()
r.Notifications.Mu.Lock()
if _, ok := r.Notifications.Subscribers[userID.String()]; ok {
delete(r.Notifications.Subscribers[userID.String()], id)
}
r.Notifications.Mu.Unlock()
}()
r.Notifications.Mu.Lock()
if _, ok := r.Notifications.Subscribers[userID.String()]; !ok {
r.Notifications.Subscribers[userID.String()] = make(map[string]chan *Notified)
}
log.WithField("userID", userID).WithField("id", id).Info("adding new channel")
r.Notifications.Subscribers[userID.String()][id] = notified
r.Notifications.Mu.Unlock()
return notified, nil
}
// Notification returns NotificationResolver implementation.

View File

@ -10,9 +10,14 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
)
type NotificationObservers struct {
Subscribers map[string]map[string]chan *Notified
Mu sync.Mutex
}
// Resolver handles resolving GraphQL queries & mutations
type Resolver struct {
Repository db.Repository
AppConfig config.AppConfig
mu sync.Mutex
Repository db.Repository
AppConfig config.AppConfig
Notifications NotificationObservers
}

View File

@ -62,7 +62,6 @@ func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserA
}
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) {
logger.New(ctx).Info("finding project user")
_, isLoggedIn := GetUser(ctx)
if !isLoggedIn {
isPublic, _ := IsProjectPublic(ctx, r.Repository, input.ProjectID)

View File

@ -4,10 +4,60 @@ extend type Subscription {
extend type Query {
notifications: [Notified!]!
notified(input: NotifiedInput!): NotifiedResult!
hasUnreadNotifications: HasUnreadNotificationsResult!
}
extend type Mutation {
notificationToggleRead(input: NotificationToggleReadInput!): Notified!
}
type HasUnreadNotificationsResult {
unread: Boolean!
}
input NotificationToggleReadInput {
notifiedID: UUID!
}
input NotifiedInput {
limit: Int!
cursor: String
filter: NotificationFilter!
}
type PageInfo {
endCursor: String!
hasNextPage: Boolean!
}
type NotifiedResult {
totalCount: Int!
notified: [Notified!]!
pageInfo: PageInfo!
}
enum ActionType {
TASK_MEMBER_ADDED
TEAM_ADDED
TEAM_REMOVED
PROJECT_ADDED
PROJECT_REMOVED
PROJECT_ARCHIVED
DUE_DATE_ADDED
DUE_DATE_REMOVED
DUE_DATE_CHANGED
TASK_ASSIGNED
TASK_MOVED
TASK_ARCHIVED
TASK_ATTACHMENT_UPLOADED
COMMENT_MENTIONED
COMMENT_OTHER
}
enum NotificationFilter {
ALL
UNREAD
ASSIGNED
MENTIONED
}
type NotificationData {
@ -24,7 +74,7 @@ type NotificationCausedBy {
type Notification {
id: ID!
actionType: ActionType!
causedBy: NotificationCausedBy!
causedBy: NotificationCausedBy
data: [NotificationData!]!
createdAt: Time!
}

View File

@ -4,10 +4,60 @@ extend type Subscription {
extend type Query {
notifications: [Notified!]!
notified(input: NotifiedInput!): NotifiedResult!
hasUnreadNotifications: HasUnreadNotificationsResult!
}
extend type Mutation {
notificationToggleRead(input: NotificationToggleReadInput!): Notified!
}
type HasUnreadNotificationsResult {
unread: Boolean!
}
input NotificationToggleReadInput {
notifiedID: UUID!
}
input NotifiedInput {
limit: Int!
cursor: String
filter: NotificationFilter!
}
type PageInfo {
endCursor: String!
hasNextPage: Boolean!
}
type NotifiedResult {
totalCount: Int!
notified: [Notified!]!
pageInfo: PageInfo!
}
enum ActionType {
TASK_MEMBER_ADDED
TEAM_ADDED
TEAM_REMOVED
PROJECT_ADDED
PROJECT_REMOVED
PROJECT_ARCHIVED
DUE_DATE_ADDED
DUE_DATE_REMOVED
DUE_DATE_CHANGED
TASK_ASSIGNED
TASK_MOVED
TASK_ARCHIVED
TASK_ATTACHMENT_UPLOADED
COMMENT_MENTIONED
COMMENT_OTHER
}
enum NotificationFilter {
ALL
UNREAD
ASSIGNED
MENTIONED
}
type NotificationData {
@ -24,7 +74,7 @@ type NotificationCausedBy {
type Notification {
id: ID!
actionType: ActionType!
causedBy: NotificationCausedBy!
causedBy: NotificationCausedBy
data: [NotificationData!]!
createdAt: Time!
}

View File

@ -27,6 +27,7 @@ type Task {
name: String!
position: Float!
description: String
watched: Boolean!
dueDate: Time
hasTime: Boolean!
complete: Boolean!
@ -352,6 +353,8 @@ extend type Mutation {
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskDueDate(input: UpdateTaskDueDate!):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
toggleTaskWatch(input: ToggleTaskWatch!):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
assignTask(input: AssignTaskInput):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
@ -359,6 +362,10 @@ extend type Mutation {
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input ToggleTaskWatch {
taskID: UUID!
}
input NewTask {
taskGroupID: UUID!
name: String!

View File

@ -27,6 +27,7 @@ type Task {
name: String!
position: Float!
description: String
watched: Boolean!
dueDate: Time
hasTime: Boolean!
complete: Boolean!

View File

@ -14,6 +14,8 @@ extend type Mutation {
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskDueDate(input: UpdateTaskDueDate!):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
toggleTaskWatch(input: ToggleTaskWatch!):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
assignTask(input: AssignTaskInput):
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
@ -21,6 +23,10 @@ extend type Mutation {
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input ToggleTaskWatch {
taskID: UUID!
}
input NewTask {
taskGroupID: UUID!
name: String!

View File

@ -543,6 +543,45 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
return &task, err
}
func (r *mutationResolver) ToggleTaskWatch(ctx context.Context, input ToggleTaskWatch) (*db.Task, error) {
userID, ok := GetUserID(ctx)
if !ok {
log.Error("user ID is missing")
return &db.Task{}, errors.New("user ID is unknown")
}
_, err := r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: userID, TaskID: input.TaskID})
isWatching := true
if err != nil {
if err != sql.ErrNoRows {
log.WithError(err).Error("error while getting task watcher")
return &db.Task{}, err
}
isWatching = false
}
if isWatching {
err := r.Repository.DeleteTaskWatcher(ctx, db.DeleteTaskWatcherParams{UserID: userID, TaskID: input.TaskID})
if err != nil {
log.WithError(err).Error("error while getting deleteing task watcher")
return &db.Task{}, err
}
} else {
now := time.Now().UTC()
_, err := r.Repository.CreateTaskWatcher(ctx, db.CreateTaskWatcherParams{UserID: userID, TaskID: input.TaskID, WatchedAt: now})
if err != nil {
log.WithError(err).Error("error while creating task watcher")
return &db.Task{}, err
}
}
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err != nil {
log.WithError(err).Error("error while getting task by id")
return &db.Task{}, err
}
return &task, nil
}
func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInput) (*db.Task, error) {
assignedDate := time.Now().UTC()
assignedTask, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{input.TaskID, input.UserID, assignedDate})
@ -552,20 +591,80 @@ func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInpu
"assignedTaskID": assignedTask.TaskAssignedID,
}).Info("assigned task")
if err != nil {
log.WithError(err).Error("error while creating task assigned")
return &db.Task{}, err
}
// r.NotificationQueue.TaskMemberWasAdded(assignedTask.TaskID, userID, assignedTask.UserID)
_, err = r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: assignedTask.UserID, TaskID: assignedTask.TaskID})
if err != nil {
if err != sql.ErrNoRows {
log.WithError(err).Error("error while fetching task watcher")
return &db.Task{}, err
}
_, err = r.Repository.CreateTaskWatcher(ctx, db.CreateTaskWatcherParams{UserID: assignedTask.UserID, TaskID: assignedTask.TaskID, WatchedAt: assignedDate})
if err != nil {
log.WithError(err).Error("error while creating task assigned task watcher")
return &db.Task{}, err
}
}
userID, ok := GetUserID(ctx)
if !ok {
log.Error("error getting user ID")
return &db.Task{}, errors.New("UserID is missing")
}
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
return &task, err
if err != nil {
log.WithError(err).Error("error while getting task by ID")
return &db.Task{}, err
}
if userID != assignedTask.UserID {
causedBy, err := r.Repository.GetUserAccountByID(ctx, userID)
if err != nil {
log.WithError(err).Error("error while getting user account in assign task")
return &db.Task{}, err
}
project, err := r.Repository.GetProjectInfoForTask(ctx, input.TaskID)
if err != nil {
log.WithError(err).Error("error while getting project in assign task")
return &db.Task{}, err
}
err = r.CreateNotification(ctx, CreateNotificationParams{
ActionType: ActionTypeTaskAssigned,
CausedBy: userID,
NotifiedList: []uuid.UUID{assignedTask.UserID},
Data: map[string]string{
"CausedByUsername": causedBy.Username,
"CausedByFullName": causedBy.FullName,
"TaskID": assignedTask.TaskID.String(),
"TaskName": task.Name,
"ProjectID": project.ProjectID.String(),
"ProjectName": project.Name,
},
})
}
if err != nil {
return &task, err
}
// r.NotificationQueue.TaskMemberWasAdded(assignedTask.TaskID, userID, assignedTask.UserID)
return &task, nil
}
func (r *mutationResolver) UnassignTask(ctx context.Context, input *UnassignTaskInput) (*db.Task, error) {
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err != nil {
log.WithError(err).Error("error while getting task by ID")
return &db.Task{}, err
}
_, err = r.Repository.DeleteTaskAssignedByID(ctx, db.DeleteTaskAssignedByIDParams{input.TaskID, input.UserID})
log.WithFields(log.Fields{"UserID": input.UserID, "TaskID": input.TaskID}).Info("deleting task assignment")
_, err = r.Repository.DeleteTaskAssignedByID(ctx, db.DeleteTaskAssignedByIDParams{TaskID: input.TaskID, UserID: input.UserID})
if err != nil && err != sql.ErrNoRows {
log.WithError(err).Error("error while deleting task by ID")
return &db.Task{}, err
}
err = r.Repository.DeleteTaskWatcher(ctx, db.DeleteTaskWatcherParams{UserID: input.UserID, TaskID: input.TaskID})
if err != nil {
log.WithError(err).Error("error while creating task assigned task watcher")
return &db.Task{}, err
}
return &task, nil
@ -591,6 +690,23 @@ func (r *taskResolver) Description(ctx context.Context, obj *db.Task) (*string,
return &task.Description.String, nil
}
func (r *taskResolver) Watched(ctx context.Context, obj *db.Task) (bool, error) {
userID, ok := GetUserID(ctx)
if !ok {
log.Error("user ID is missing")
return false, errors.New("user ID is unknown")
}
_, err := r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: userID, TaskID: obj.TaskID})
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
log.WithError(err).Error("error while getting task watcher")
return false, err
}
return true, nil
}
func (r *taskResolver) DueDate(ctx context.Context, obj *db.Task) (*time.Time, error) {
if obj.DueDate.Valid {
return &obj.DueDate.Time, nil