feat: add bell notification system for task assignment
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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 (
|
||||
|
@ -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: ¬ified.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
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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, ¬ifiedData)
|
||||
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 = ¬ified.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 = ¬ified.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.
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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!
|
||||
}
|
||||
|
@ -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!
|
||||
}
|
||||
|
@ -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!
|
||||
|
@ -27,6 +27,7 @@ type Task {
|
||||
name: String!
|
||||
position: Float!
|
||||
description: String
|
||||
watched: Boolean!
|
||||
dueDate: Time
|
||||
hasTime: Boolean!
|
||||
complete: Boolean!
|
||||
|
@ -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!
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user