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

View File

@ -68,6 +68,6 @@ func initConfig() {
// Execute the root cobra command
func Execute() {
rootCmd.SetVersionTemplate(VersionTemplate())
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.AddCommand(newTokenCmd(), newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.Execute()
}

View File

@ -0,0 +1,93 @@
package commands
import (
"context"
"fmt"
"time"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
)
func newTokenCmd() *cobra.Command {
cc := &cobra.Command{
Use: "token [username]",
Short: "Creates an access token for a user",
Long: "Creates an access token for a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Formatter := new(log.TextFormatter)
Formatter.TimestampFormat = "02-01-2006 15:04:05"
Formatter.FullTimestamp = true
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
appConfig, err := config.GetAppConfig()
if err != nil {
return err
}
var dbConnection *sqlx.DB
var retryDuration time.Duration
maxRetryNumber := 4
for i := 0; i < maxRetryNumber; i++ {
dbConnection, err = sqlx.Connect("postgres", appConfig.Database.GetDatabaseConnectionUri())
if err == nil {
break
}
retryDuration = time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
if i != maxRetryNumber-1 {
time.Sleep(retryDuration)
}
}
if err != nil {
return err
}
dbConnection.SetMaxOpenConns(25)
dbConnection.SetMaxIdleConns(25)
dbConnection.SetConnMaxLifetime(5 * time.Minute)
defer dbConnection.Close()
if viper.GetBool("migrate") {
log.Info("running auto schema migrations")
if err = runMigration(dbConnection); err != nil {
return err
}
}
ctx := context.Background()
repository := db.NewRepository(dbConnection)
user, err := repository.GetUserAccountByUsername(ctx, args[0])
if err != nil {
return err
}
token, err := repository.CreateAuthToken(ctx, db.CreateAuthTokenParams{
UserID: user.UserID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24 * 7),
})
if err != nil {
return err
}
fmt.Printf("Created token: %s\n", token.TokenID.String())
return nil
},
}
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
cc.Flags().IntVar(&teams, "teams", 5, "number of teams to generate")
cc.Flags().IntVar(&projects, "projects", 10, "number of projects to create per team (personal projects are included)")
cc.Flags().IntVar(&taskGroups, "task_groups", 5, "number of task groups to generate per project")
cc.Flags().IntVar(&tasks, "tasks", 25, "number of tasks to generate per task group")
viper.SetDefault("migrate", false)
return cc
}

View File

@ -10,6 +10,34 @@ import (
"github.com/google/uuid"
)
type AccountSetting struct {
AccountSettingID string `json:"account_setting_id"`
Constrained bool `json:"constrained"`
DataType string `json:"data_type"`
ConstrainedDefaultValue sql.NullString `json:"constrained_default_value"`
UnconstrainedDefaultValue sql.NullString `json:"unconstrained_default_value"`
}
type AccountSettingAllowedValue struct {
AllowedValueID uuid.UUID `json:"allowed_value_id"`
SettingID int32 `json:"setting_id"`
ItemValue string `json:"item_value"`
}
type AccountSettingDataType struct {
DataTypeID string `json:"data_type_id"`
}
type AccountSettingValue struct {
AccountSettingID uuid.UUID `json:"account_setting_id"`
UserID uuid.UUID `json:"user_id"`
SettingID int32 `json:"setting_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AllowedValueID uuid.UUID `json:"allowed_value_id"`
UnconstrainedValue sql.NullString `json:"unconstrained_value"`
}
type AuthToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
@ -172,6 +200,13 @@ type TaskLabel struct {
AssignedDate time.Time `json:"assigned_date"`
}
type TaskWatcher struct {
TaskWatcherID uuid.UUID `json:"task_watcher_id"`
TaskID uuid.UUID `json:"task_id"`
UserID uuid.UUID `json:"user_id"`
WatchedAt time.Time `json:"watched_at"`
}
type Team struct {
TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"`

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
const createNotification = `-- name: CreateNotification :one
@ -142,16 +143,285 @@ func (q *Queries) GetAllNotificationsForUserID(ctx context.Context, userID uuid.
return items, nil
}
const getNotificationsForUserIDCursor = `-- name: GetNotificationsForUserIDCursor :many
SELECT notified_id, nn.notification_id, nn.user_id, read, read_at, n.notification_id, caused_by, action_type, data, created_on, user_account.user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE (n.created_on, n.notification_id) < ($1::timestamptz, $2::uuid)
AND nn.user_id = $3::uuid
ORDER BY n.created_on DESC
LIMIT $4::int
`
type GetNotificationsForUserIDCursorParams struct {
CreatedOn time.Time `json:"created_on"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
LimitRows int32 `json:"limit_rows"`
}
type GetNotificationsForUserIDCursorRow struct {
NotifiedID uuid.UUID `json:"notified_id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
Read bool `json:"read"`
ReadAt sql.NullTime `json:"read_at"`
NotificationID_2 uuid.UUID `json:"notification_id_2"`
CausedBy uuid.UUID `json:"caused_by"`
ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"`
UserID_2 uuid.UUID `json:"user_id_2"`
CreatedAt time.Time `json:"created_at"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
ProfileBgColor string `json:"profile_bg_color"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
Active bool `json:"active"`
}
func (q *Queries) GetNotificationsForUserIDCursor(ctx context.Context, arg GetNotificationsForUserIDCursorParams) ([]GetNotificationsForUserIDCursorRow, error) {
rows, err := q.db.QueryContext(ctx, getNotificationsForUserIDCursor,
arg.CreatedOn,
arg.NotificationID,
arg.UserID,
arg.LimitRows,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetNotificationsForUserIDCursorRow
for rows.Next() {
var i GetNotificationsForUserIDCursorRow
if err := rows.Scan(
&i.NotifiedID,
&i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
&i.NotificationID_2,
&i.CausedBy,
&i.ActionType,
&i.Data,
&i.CreatedOn,
&i.UserID_2,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getNotificationsForUserIDPaged = `-- name: GetNotificationsForUserIDPaged :many
SELECT notified_id, nn.notification_id, nn.user_id, read, read_at, n.notification_id, caused_by, action_type, data, created_on, user_account.user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE nn.user_id = $1::uuid
AND ($2::boolean = false OR nn.read = false)
AND ($3::boolean = false OR n.action_type = ANY($4::text[]))
ORDER BY n.created_on DESC
LIMIT $5::int
`
type GetNotificationsForUserIDPagedParams struct {
UserID uuid.UUID `json:"user_id"`
EnableUnread bool `json:"enable_unread"`
EnableActionType bool `json:"enable_action_type"`
ActionType []string `json:"action_type"`
LimitRows int32 `json:"limit_rows"`
}
type GetNotificationsForUserIDPagedRow struct {
NotifiedID uuid.UUID `json:"notified_id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
Read bool `json:"read"`
ReadAt sql.NullTime `json:"read_at"`
NotificationID_2 uuid.UUID `json:"notification_id_2"`
CausedBy uuid.UUID `json:"caused_by"`
ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"`
UserID_2 uuid.UUID `json:"user_id_2"`
CreatedAt time.Time `json:"created_at"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
ProfileBgColor string `json:"profile_bg_color"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
Active bool `json:"active"`
}
func (q *Queries) GetNotificationsForUserIDPaged(ctx context.Context, arg GetNotificationsForUserIDPagedParams) ([]GetNotificationsForUserIDPagedRow, error) {
rows, err := q.db.QueryContext(ctx, getNotificationsForUserIDPaged,
arg.UserID,
arg.EnableUnread,
arg.EnableActionType,
pq.Array(arg.ActionType),
arg.LimitRows,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetNotificationsForUserIDPagedRow
for rows.Next() {
var i GetNotificationsForUserIDPagedRow
if err := rows.Scan(
&i.NotifiedID,
&i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
&i.NotificationID_2,
&i.CausedBy,
&i.ActionType,
&i.Data,
&i.CreatedOn,
&i.UserID_2,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getNotifiedByID = `-- name: GetNotifiedByID :one
SELECT notified_id, nn.notification_id, nn.user_id, read, read_at, n.notification_id, caused_by, action_type, data, created_on, user_account.user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM notification_notified as nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE notified_id = $1
`
type GetNotifiedByIDRow struct {
NotifiedID uuid.UUID `json:"notified_id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
Read bool `json:"read"`
ReadAt sql.NullTime `json:"read_at"`
NotificationID_2 uuid.UUID `json:"notification_id_2"`
CausedBy uuid.UUID `json:"caused_by"`
ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"`
UserID_2 uuid.UUID `json:"user_id_2"`
CreatedAt time.Time `json:"created_at"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
ProfileBgColor string `json:"profile_bg_color"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
Active bool `json:"active"`
}
func (q *Queries) GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (GetNotifiedByIDRow, error) {
row := q.db.QueryRowContext(ctx, getNotifiedByID, notifiedID)
var i GetNotifiedByIDRow
err := row.Scan(
&i.NotifiedID,
&i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
&i.NotificationID_2,
&i.CausedBy,
&i.ActionType,
&i.Data,
&i.CreatedOn,
&i.UserID_2,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const hasUnreadNotification = `-- name: HasUnreadNotification :one
SELECT EXISTS (SELECT 1 FROM notification_notified WHERE read = false AND user_id = $1)
`
func (q *Queries) HasUnreadNotification(ctx context.Context, userID uuid.UUID) (bool, error) {
row := q.db.QueryRowContext(ctx, hasUnreadNotification, userID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const markNotificationAsRead = `-- name: MarkNotificationAsRead :exec
UPDATE notification_notified SET read = true, read_at = $2 WHERE user_id = $1
UPDATE notification_notified SET read = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4
`
type MarkNotificationAsReadParams struct {
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
Read bool `json:"read"`
NotifiedID uuid.UUID `json:"notified_id"`
}
func (q *Queries) MarkNotificationAsRead(ctx context.Context, arg MarkNotificationAsReadParams) error {
_, err := q.db.ExecContext(ctx, markNotificationAsRead, arg.UserID, arg.ReadAt)
_, err := q.db.ExecContext(ctx, markNotificationAsRead,
arg.UserID,
arg.ReadAt,
arg.Read,
arg.NotifiedID,
)
return err
}

View File

@ -32,6 +32,7 @@ type Querier interface {
CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error)
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
CreateTaskWatcher(ctx context.Context, arg CreateTaskWatcherParams) (TaskWatcher, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error)
@ -54,6 +55,7 @@ type Querier interface {
DeleteTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
DeleteTaskWatcher(ctx context.Context, arg DeleteTaskWatcherParams) error
DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
@ -87,6 +89,9 @@ type Querier interface {
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetNotificationsForUserIDCursor(ctx context.Context, arg GetNotificationsForUserIDCursorParams) ([]GetNotificationsForUserIDCursorRow, error)
GetNotificationsForUserIDPaged(ctx context.Context, arg GetNotificationsForUserIDPagedParams) ([]GetNotificationsForUserIDPagedRow, error)
GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (GetNotifiedByIDRow, error)
GetPersonalProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error)
@ -94,6 +99,7 @@ type Querier interface {
GetProjectIDForTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) (uuid.UUID, error)
GetProjectIDForTaskGroup(ctx context.Context, taskGroupID uuid.UUID) (uuid.UUID, error)
GetProjectIdMappings(ctx context.Context, dollar_1 []uuid.UUID) ([]GetProjectIdMappingsRow, error)
GetProjectInfoForTask(ctx context.Context, taskID uuid.UUID) (GetProjectInfoForTaskRow, error)
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error)
@ -116,6 +122,7 @@ type Querier interface {
GetTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) (TaskLabel, error)
GetTaskLabelForTaskByProjectLabelID(ctx context.Context, arg GetTaskLabelForTaskByProjectLabelIDParams) (TaskLabel, error)
GetTaskLabelsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskLabel, error)
GetTaskWatcher(ctx context.Context, arg GetTaskWatcherParams) (TaskWatcher, error)
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetTeamMemberByID(ctx context.Context, arg GetTeamMemberByIDParams) (TeamMember, error)
@ -131,6 +138,7 @@ type Querier interface {
GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error)
HasActiveUser(ctx context.Context) (bool, error)
HasAnyUser(ctx context.Context) (bool, error)
HasUnreadNotification(ctx context.Context, userID uuid.UUID) (bool, error)
MarkNotificationAsRead(ctx context.Context, arg MarkNotificationAsReadParams) error
SetFirstUserActive(ctx context.Context) (UserAccount, error)
SetInactiveLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) error

View File

@ -4,8 +4,17 @@ SELECT * FROM notification_notified AS nn
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE nn.user_id = $1;
-- name: GetNotifiedByID :one
SELECT * FROM notification_notified as nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE notified_id = $1;
-- name: HasUnreadNotification :one
SELECT EXISTS (SELECT 1 FROM notification_notified WHERE read = false AND user_id = $1);
-- name: MarkNotificationAsRead :exec
UPDATE notification_notified SET read = true, read_at = $2 WHERE user_id = $1;
UPDATE notification_notified SET read = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4;
-- name: CreateNotification :one
INSERT INTO notification (caused_by, data, action_type, created_on)
@ -13,3 +22,22 @@ INSERT INTO notification (caused_by, data, action_type, created_on)
-- name: CreateNotificationNotifed :one
INSERT INTO notification_notified (notification_id, user_id) VALUES ($1, $2) RETURNING *;
-- name: GetNotificationsForUserIDPaged :many
SELECT * FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE nn.user_id = @user_id::uuid
AND (@enable_unread::boolean = false OR nn.read = false)
AND (@enable_action_type::boolean = false OR n.action_type = ANY(@action_type::text[]))
ORDER BY n.created_on DESC
LIMIT @limit_rows::int;
-- name: GetNotificationsForUserIDCursor :many
SELECT * FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id
LEFT JOIN user_account ON user_account.user_id = n.caused_by
WHERE (n.created_on, n.notification_id) < (@created_on::timestamptz, @notification_id::uuid)
AND nn.user_id = @user_id::uuid
ORDER BY n.created_on DESC
LIMIT @limit_rows::int;

View File

@ -1,3 +1,12 @@
-- name: GetTaskWatcher :one
SELECT * FROM task_watcher WHERE user_id = $1 AND task_id = $2;
-- name: CreateTaskWatcher :one
INSERT INTO task_watcher (user_id, task_id, watched_at) VALUES ($1, $2, $3) RETURNING *;
-- name: DeleteTaskWatcher :exec
DELETE FROM task_watcher WHERE user_id = $1 AND task_id = $2;
-- name: CreateTask :one
INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING *;
@ -44,6 +53,12 @@ SELECT project_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE task_id = $1;
-- name: GetProjectInfoForTask :one
SELECT project.project_id, project.name FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
INNER JOIN project ON task_group.project_id = project.project_id
WHERE task_id = $1;
-- name: CreateTaskComment :one
INSERT INTO task_comment (task_id, message, created_at, created_by)
VALUES ($1, $2, $3, $4) RETURNING *;

View File

@ -120,6 +120,28 @@ func (q *Queries) CreateTaskComment(ctx context.Context, arg CreateTaskCommentPa
return i, err
}
const createTaskWatcher = `-- name: CreateTaskWatcher :one
INSERT INTO task_watcher (user_id, task_id, watched_at) VALUES ($1, $2, $3) RETURNING task_watcher_id, task_id, user_id, watched_at
`
type CreateTaskWatcherParams struct {
UserID uuid.UUID `json:"user_id"`
TaskID uuid.UUID `json:"task_id"`
WatchedAt time.Time `json:"watched_at"`
}
func (q *Queries) CreateTaskWatcher(ctx context.Context, arg CreateTaskWatcherParams) (TaskWatcher, error) {
row := q.db.QueryRowContext(ctx, createTaskWatcher, arg.UserID, arg.TaskID, arg.WatchedAt)
var i TaskWatcher
err := row.Scan(
&i.TaskWatcherID,
&i.TaskID,
&i.UserID,
&i.WatchedAt,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1
`
@ -148,6 +170,20 @@ func (q *Queries) DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.
return i, err
}
const deleteTaskWatcher = `-- name: DeleteTaskWatcher :exec
DELETE FROM task_watcher WHERE user_id = $1 AND task_id = $2
`
type DeleteTaskWatcherParams struct {
UserID uuid.UUID `json:"user_id"`
TaskID uuid.UUID `json:"task_id"`
}
func (q *Queries) DeleteTaskWatcher(ctx context.Context, arg DeleteTaskWatcherParams) error {
_, err := q.db.ExecContext(ctx, deleteTaskWatcher, arg.UserID, arg.TaskID)
return err
}
const deleteTasksByTaskGroupID = `-- name: DeleteTasksByTaskGroupID :execrows
DELETE FROM task where task_group_id = $1
`
@ -409,6 +445,25 @@ func (q *Queries) GetProjectIdMappings(ctx context.Context, dollar_1 []uuid.UUID
return items, nil
}
const getProjectInfoForTask = `-- name: GetProjectInfoForTask :one
SELECT project.project_id, project.name FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
INNER JOIN project ON task_group.project_id = project.project_id
WHERE task_id = $1
`
type GetProjectInfoForTaskRow struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectInfoForTask(ctx context.Context, taskID uuid.UUID) (GetProjectInfoForTaskRow, error) {
row := q.db.QueryRowContext(ctx, getProjectInfoForTask, taskID)
var i GetProjectInfoForTaskRow
err := row.Scan(&i.ProjectID, &i.Name)
return i, err
}
const getRecentlyAssignedTaskForUserID = `-- name: GetRecentlyAssignedTaskForUserID :many
SELECT task.task_id, task.task_group_id, task.created_at, task.name, task.position, task.description, task.due_date, task.complete, task.completed_at, task.has_time FROM task_assigned INNER JOIN
task ON task.task_id = task_assigned.task_id WHERE user_id = $1
@ -488,6 +543,27 @@ func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, erro
return i, err
}
const getTaskWatcher = `-- name: GetTaskWatcher :one
SELECT task_watcher_id, task_id, user_id, watched_at FROM task_watcher WHERE user_id = $1 AND task_id = $2
`
type GetTaskWatcherParams struct {
UserID uuid.UUID `json:"user_id"`
TaskID uuid.UUID `json:"task_id"`
}
func (q *Queries) GetTaskWatcher(ctx context.Context, arg GetTaskWatcherParams) (TaskWatcher, error) {
row := q.db.QueryRowContext(ctx, getTaskWatcher, arg.UserID, arg.TaskID)
var i TaskWatcher
err := row.Scan(
&i.TaskWatcherID,
&i.TaskID,
&i.UserID,
&i.WatchedAt,
)
return i, err
}
const getTasksForTaskGroupID = `-- name: GetTasksForTaskGroupID :many
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time FROM task WHERE task_group_id = $1
`

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

View File

@ -18,30 +18,36 @@ type AuthenticationMiddleware struct {
// Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Info("middleware")
requestID := uuid.New()
foundToken := true
tokenRaw := ""
c, err := r.Cookie("authToken")
if err != nil {
if err == http.ErrNoCookie {
foundToken = false
}
}
if !foundToken {
token := r.Header.Get("Authorization")
if token != "" {
tokenRaw = token
}
token := r.Header.Get("Authorization")
if token != "" {
tokenRaw = token
} else {
foundToken = false
}
if !foundToken {
c, err := r.Cookie("authToken")
if err != nil {
if err == http.ErrNoCookie {
log.WithError(err).Error("error while fetching authToken")
w.WriteHeader(http.StatusBadRequest)
}
log.WithError(err).Error("error while fetching authToken")
w.WriteHeader(http.StatusBadRequest)
return
}
tokenRaw = c.Value
}
authTokenID, err := uuid.Parse(tokenRaw)
log.Info("checking if logged in")
ctx := r.Context()
if err == nil {
token, err := m.repo.GetAuthTokenByID(r.Context(), authTokenID)
if err == nil {
log.WithField("tokenID", authTokenID).WithField("userID", token.UserID).Info("setting auth token")
ctx = context.WithValue(ctx, utils.UserIDKey, token.UserID)
}
}

View File

@ -110,7 +110,7 @@ func NewRouter(dbConnection *sqlx.DB, job *machinery.Server, appConfig config.Ap
r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Handle("/graphql", graph.NewHandler(*repository, appConfig))
mux.Mount("/graphql", graph.NewHandler(*repository, appConfig))
})
frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"}

36
internal/utils/cursor.go Normal file
View File

@ -0,0 +1,36 @@
package utils
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
func DecodeCursor(encodedCursor string) (res time.Time, id uuid.UUID, err error) {
byt, err := base64.StdEncoding.DecodeString(encodedCursor)
if err != nil {
return
}
arrStr := strings.Split(string(byt), ",")
if len(arrStr) != 2 {
err = errors.New("cursor is invalid")
return
}
res, err = time.Parse(time.RFC3339Nano, arrStr[0])
if err != nil {
return
}
id = uuid.MustParse(arrStr[1])
return
}
func EncodeCursor(t time.Time, id uuid.UUID) string {
key := fmt.Sprintf("%s,%s", t.Format(time.RFC3339Nano), id.String())
return base64.StdEncoding.EncodeToString([]byte(key))
}