diff --git a/frontend/src/App/TopNavbar/index.tsx b/frontend/src/App/TopNavbar/index.tsx index bfad69f..2dc5267 100644 --- a/frontend/src/App/TopNavbar/index.tsx +++ b/frontend/src/App/TopNavbar/index.tsx @@ -1,16 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; import TopNavbar, { MenuItem } from 'shared/components/TopNavbar'; import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut'; import { ProfileMenu } from 'shared/components/DropdownMenu'; import { useHistory, useRouteMatch } from 'react-router'; import { useCurrentUser } from 'App/context'; -import { RoleCode, useTopNavbarQuery } from 'shared/generated/graphql'; +import { + RoleCode, + useTopNavbarQuery, + useNotificationAddedSubscription, + useHasUnreadNotificationsQuery, +} from 'shared/generated/graphql'; import { usePopup, Popup } from 'shared/components/PopupMenu'; import MiniProfile from 'shared/components/MiniProfile'; import cache from 'App/cache'; import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup'; import theme from 'App/ThemeStyles'; import ProjectFinder from './ProjectFinder'; +import polling from 'shared/utils/polling'; // TODO: Move to context based navbar? @@ -49,9 +55,25 @@ const LoggedInNavbar: React.FC = ({ onRemoveInvitedFromBoard, onRemoveFromBoard, }) => { - const { data } = useTopNavbarQuery(); + const [notifications, setNotifications] = useState>([]); + const { data } = useTopNavbarQuery({ + onCompleted: (d) => { + setNotifications((n) => [...n, ...d.notifications]); + }, + }); + const { data: nData, loading } = useNotificationAddedSubscription({ + onSubscriptionData: (d) => { + setNotifications((n) => { + if (d.subscriptionData.data) { + return [...n, d.subscriptionData.data.notificationAdded]; + } + return n; + }); + }, + }); const { showPopup, hidePopup } = usePopup(); const { setUser } = useCurrentUser(); + const { data: unreadData } = useHasUnreadNotificationsQuery({ pollInterval: polling.UNREAD_NOTIFICATIONS }); const history = useHistory(); const onLogout = () => { fetch('/auth/logout', { @@ -94,21 +116,10 @@ const LoggedInNavbar: React.FC = ({ } }; + // TODO: rewrite popup to contain subscription and notification fetch const onNotificationClick = ($target: React.RefObject) => { if (data) { - showPopup( - $target, - - {data.notifications.map((notification) => ( - - ))} - , - { width: 415, borders: false, diamondColor: theme.colors.primary }, - ); + showPopup($target, , { width: 605, borders: false, diamondColor: theme.colors.primary }); } }; @@ -174,17 +185,12 @@ const LoggedInNavbar: React.FC = ({ } }; - if (data) { - console.log('HERE DATA'); - console.log(data.me); - } else { - console.log('NO DATA'); - } const user = data ? data.me?.user : null; return ( <> { diff --git a/frontend/src/Projects/Project/Board/index.tsx b/frontend/src/Projects/Project/Board/index.tsx index 02360d1..37ae370 100644 --- a/frontend/src/Projects/Project/Board/index.tsx +++ b/frontend/src/Projects/Project/Board/index.tsx @@ -430,6 +430,7 @@ const ProjectBoard: React.FC = ({ projectID, onCardLabelClick __typename: 'Task', id: `${Math.round(Math.random() * -1000000)}`, name, + watched: false, complete: false, completedAt: null, hasTime: false, diff --git a/frontend/src/Projects/Project/Details/index.tsx b/frontend/src/Projects/Project/Details/index.tsx index 7ac9034..a6c698d 100644 --- a/frontend/src/Projects/Project/Details/index.tsx +++ b/frontend/src/Projects/Project/Details/index.tsx @@ -7,6 +7,7 @@ import MemberManager from 'shared/components/MemberManager'; import { useRouteMatch, useHistory, useParams } from 'react-router'; import { useDeleteTaskChecklistMutation, + useToggleTaskWatchMutation, useUpdateTaskChecklistNameMutation, useUpdateTaskChecklistItemLocationMutation, useCreateTaskChecklistMutation, @@ -216,6 +217,7 @@ const Details: React.FC = ({ ); }, }); + const [toggleTaskWatch] = useToggleTaskWatchMutation(); const [createTaskComment] = useCreateTaskCommentMutation({ update: (client, response) => { updateApolloCache( @@ -440,6 +442,19 @@ const Details: React.FC = ({ ); }} task={data.findTask} + onToggleTaskWatch={(task, watched) => { + toggleTaskWatch({ + variables: { taskID: task.id }, + optimisticResponse: { + __typename: 'Mutation', + toggleTaskWatch: { + id: task.id, + __typename: 'Task', + watched, + }, + }, + }); + }} onCreateComment={(task, message) => { createTaskComment({ variables: { taskID: task.id, message } }); }} @@ -540,7 +555,8 @@ const Details: React.FC = ({ bio="None" onRemoveFromTask={() => { if (user) { - unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } }); + unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } }); + hidePopup(); } }} /> diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 77b2dcd..babbd6e 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -10,6 +10,8 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isBetween from 'dayjs/plugin/isBetween'; import weekday from 'dayjs/plugin/weekday'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; import log from 'loglevel'; import remote from 'loglevel-plugin-remote'; import cache from './App/cache'; @@ -36,6 +38,8 @@ dayjs.extend(weekday); dayjs.extend(isBetween); dayjs.extend(customParseFormat); dayjs.extend(updateLocale); +dayjs.extend(duration); +dayjs.extend(relativeTime); dayjs.updateLocale('en', { week: { dow: 1, // First day of week is Monday diff --git a/frontend/src/shared/components/Card/index.tsx b/frontend/src/shared/components/Card/index.tsx index ae4f8a6..6de6f1e 100644 --- a/frontend/src/shared/components/Card/index.tsx +++ b/frontend/src/shared/components/Card/index.tsx @@ -225,7 +225,7 @@ const Card = React.forwardRef( {watched && ( - + )} {dueDate && ( diff --git a/frontend/src/shared/components/Lists/index.tsx b/frontend/src/shared/components/Lists/index.tsx index e2f9142..16a6821 100644 --- a/frontend/src/shared/components/Lists/index.tsx +++ b/frontend/src/shared/components/Lists/index.tsx @@ -329,6 +329,7 @@ const SimpleLists: React.FC = ({ toggleLabels={toggleLabels} isPublic={isPublic} labelVariant={cardLabelVariant} + watched={task.watched} wrapperProps={{ ...taskProvided.draggableProps, ...taskProvided.dragHandleProps, diff --git a/frontend/src/shared/components/NotifcationPopup/index.tsx b/frontend/src/shared/components/NotifcationPopup/index.tsx index 0d31d0e..2cc934e 100644 --- a/frontend/src/shared/components/NotifcationPopup/index.tsx +++ b/frontend/src/shared/components/NotifcationPopup/index.tsx @@ -1,8 +1,20 @@ -import React from 'react'; -import styled from 'styled-components'; +import React, { useState } from 'react'; +import styled, { css } from 'styled-components'; import TimeAgo from 'react-timeago'; +import { Link } from 'react-router-dom'; +import { mixin } from 'shared/utils/styles'; +import { + useNotificationsQuery, + NotificationFilter, + ActionType, + useNotificationAddedSubscription, + useNotificationToggleReadMutation, +} from 'shared/generated/graphql'; +import dayjs from 'dayjs'; -import { Popup } from 'shared/components/PopupMenu'; +import { Popup, usePopup } from 'shared/components/PopupMenu'; +import { CheckCircleOutline, Circle, CircleSolid, UserCircle } from 'shared/icons'; +import produce from 'immer'; const ItemWrapper = styled.div` cursor: pointer; @@ -37,7 +49,7 @@ const ItemTextContainer = styled.div` const ItemTextTitle = styled.span` font-weight: 500; display: block; - color: ${props => props.theme.colors.primary}; + color: ${(props) => props.theme.colors.primary}; font-size: 14px; `; const ItemTextDesc = styled.span` @@ -72,38 +84,450 @@ export const NotificationItem: React.FC = ({ title, descr }; const NotificationHeader = styled.div` - padding: 0.75rem; + padding: 20px 28px; text-align: center; border-top-left-radius: 6px; border-top-right-radius: 6px; - background: ${props => props.theme.colors.primary}; + background: ${(props) => props.theme.colors.primary}; `; const NotificationHeaderTitle = styled.span` font-size: 14px; - color: ${props => props.theme.colors.text.secondary}; + color: ${(props) => props.theme.colors.text.secondary}; `; +const Notifications = styled.div` + border-right: 1px solid rgba(0, 0, 0, 0.1); + border-left: 1px solid rgba(0, 0, 0, 0.1); + border-top: 1px solid rgba(0, 0, 0, 0.1); + border-color: #414561; + height: 448px; + overflow-y: scroll; + user-select: none; +`; const NotificationFooter = styled.div` cursor: pointer; padding: 0.5rem; text-align: center; - color: ${props => props.theme.colors.primary}; + color: ${(props) => props.theme.colors.primary}; &:hover { - background: ${props => props.theme.colors.bg.primary}; + background: ${(props) => props.theme.colors.bg.primary}; } border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; + + border-right: 1px solid rgba(0, 0, 0, 0.1); + border-left: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + border-color: #414561; `; +const NotificationTabs = styled.div` + align-items: flex-end; + align-self: stretch; + display: flex; + flex: 1 0 auto; + justify-content: flex-start; + max-width: 100%; + padding-top: 4px; + border-right: 1px solid rgba(0, 0, 0, 0.1); + border-left: 1px solid rgba(0, 0, 0, 0.1); + border-color: #414561; +`; + +const NotificationTab = styled.div<{ active: boolean }>` + font-size: 80%; + color: ${(props) => props.theme.colors.text.primary}; + font-size: 15px; + cursor: pointer; + display: flex; + user-select: none; + + justify-content: center; + line-height: normal; + min-width: 1px; + transition-duration: 0.2s; + transition-property: box-shadow, color; + white-space: nowrap; + flex: 0 1 auto; + padding: 12px 16px; + + &:first-child { + margin-left: 12px; + } + + &:hover { + box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary}; + color: ${(props) => props.theme.colors.text.secondary}; + } + &:not(:last-child) { + margin-right: 12px; + } + + ${(props) => + props.active && + css` + box-shadow: inset 0 -2px ${props.theme.colors.secondary}; + color: ${props.theme.colors.secondary}; + &:hover { + box-shadow: inset 0 -2px ${props.theme.colors.secondary}; + color: ${props.theme.colors.secondary}; + } + `} +`; + +const NotificationLink = styled(Link)` + display: flex; + align-items: center; + text-decoration: none; + padding: 16px 8px; + width: 100%; +`; + +const NotificationControls = styled.div` + width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + visibility: hidden; + padding: 4px; +`; + +const NotificationButtons = styled.div` + display: flex; + align-self: flex-end; + align-items: center; + margin-top: auto; + margin-bottom: 6px; +`; + +const NotificationButton = styled.div` + padding: 4px 15px; + cursor: pointer; + &:hover svg { + fill: rgb(216, 93, 216); + stroke: rgb(216, 93, 216); + } +`; + +const NotificationWrapper = styled.li` + min-height: 112px; + display: flex; + font-size: 14px; + transition: background-color 0.1s ease-in-out; + margin: 2px 8px; + border-radius: 8px; + justify-content: space-between; + position: relative; + &:hover { + background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.5)}; + } + &:hover ${NotificationLink} { + color: #fff; + } + &:hover ${NotificationControls} { + visibility: visible; + } +`; + +const NotificationContentFooter = styled.div` + margin-top: 8px; + display: flex; + align-items: center; + color: ${(props) => props.theme.colors.text.primary}; +`; + +const NotificationCausedBy = styled.div` + height: 60px; + width: 60px; + min-height: 60px; + min-width: 60px; +`; +const NotificationCausedByInitials = styled.div` + position: relative; + display: flex; + align-items: center; + text: #fff; + font-size: 18px; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + height: 100%; + width: 100%; + border: none; + background: #7367f0; +`; + +const NotificationCausedByImage = styled.img` + position: relative; + display: flex; + border-radius: 50%; + flex-shrink: 0; + height: 100%; + width: 100%; + border: none; + background: #7367f0; +`; + +const NotificationContent = styled.div` + display: flex; + overflow: hidden; + flex-direction: column; + margin-left: 16px; +`; + +const NotificationContentHeader = styled.div` + font-weight: bold; + font-size: 14px; + color: #fff; + + svg { + margin-left: 8px; + fill: rgb(216, 93, 216); + stroke: rgb(216, 93, 216); + } +`; + +const NotificationBody = styled.div` + margin-top: 8px; + display: flex; + align-items: center; + color: #fff; + svg { + fill: rgb(216, 93, 216); + stroke: rgb(216, 93, 216); + } +`; + +const NotificationPrefix = styled.span` + color: rgb(216, 93, 216); + margin: 0 4px; +`; + +const NotificationSeparator = styled.span` + margin: 0 6px; +`; + +type NotificationProps = { + causedBy?: { fullname: string; username: string; id: string } | null; + createdAt: string; + read: boolean; + data: Array<{ key: string; value: string }>; + actionType: ActionType; + onToggleRead: () => void; +}; + +const Notification: React.FC = ({ causedBy, createdAt, data, actionType, read, onToggleRead }) => { + const prefix: any = []; + const { hidePopup } = usePopup(); + const dataMap = new Map(); + data.forEach((d) => dataMap.set(d.key, d.value)); + let link = '#'; + switch (actionType) { + case ActionType.TaskAssigned: + prefix.push(); + prefix.push(Assigned ); + prefix.push(you to the task "{dataMap.get('TaskName')}"); + link = `/projects/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`; + break; + default: + throw new Error('unknown action type'); + } + + return ( + + + + + {causedBy + ? causedBy.fullname + .split(' ') + .map((n) => n[0]) + .join('.') + : 'RU'} + + + + + {causedBy ? causedBy.fullname : 'Removed user'} + {!read && } + + {prefix} + + {dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)} + + {dataMap.get('ProjectName')} + + + + + + onToggleRead()}> + {read ? : } + + + + + ); +}; + +const PopupContent = styled.div` + display: flex; + flex-direction: column; + + border-right: 1px solid rgba(0, 0, 0, 0.1); + border-left: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding-bottom: 10px; + border-color: #414561; +`; + +const tabs = [ + { label: 'All', key: NotificationFilter.All }, + { label: 'Unread', key: NotificationFilter.Unread }, + { label: 'I was mentioned', key: NotificationFilter.Mentioned }, + { label: 'Assigned to me', key: NotificationFilter.Assigned }, +]; + +type NotificationEntry = { + id: string; + read: boolean; + readAt?: string | undefined | null; + notification: { + id: string; + data: Array<{ key: string; value: string }>; + actionType: ActionType; + causedBy?: { id: string; username: string; fullname: string } | undefined | null; + createdAt: string; + }; +}; const NotificationPopup: React.FC = ({ children }) => { + const [filter, setFilter] = useState(NotificationFilter.Unread); + const [data, setData] = useState<{ nodes: Array; hasNextPage: boolean; cursor: string }>({ + nodes: [], + hasNextPage: false, + cursor: '', + }); + const [toggleRead] = useNotificationToggleReadMutation({ + onCompleted: (data) => { + setData((prev) => { + return produce(prev, (draft) => { + const idx = draft.nodes.findIndex((n) => n.id === data.notificationToggleRead.id); + if (idx !== -1) { + draft.nodes[idx].read = data.notificationToggleRead.read; + draft.nodes[idx].readAt = data.notificationToggleRead.readAt; + } + }); + }); + }, + }); + const { data: nData, fetchMore } = useNotificationsQuery({ + variables: { limit: 5, filter }, + onCompleted: (d) => { + setData((prev) => ({ + hasNextPage: d.notified.pageInfo.hasNextPage, + cursor: d.notified.pageInfo.endCursor, + nodes: [...prev.nodes, ...d.notified.notified], + })); + }, + }); + const { data: sData, loading } = useNotificationAddedSubscription({ + onSubscriptionData: (d) => { + setData((n) => { + if (d.subscriptionData.data) { + return { + ...n, + nodes: [d.subscriptionData.data.notificationAdded, ...n.nodes], + }; + } + return n; + }); + }, + }); + return ( - - Notifications - -
    {children}
- View All + + + Notifications + + + {tabs.map((tab) => ( + { + if (filter !== tab.key) { + setData({ cursor: '', hasNextPage: false, nodes: [] }); + setFilter(tab.key); + } + }} + active={tab.key === filter} + > + {tab.label} + + ))} + + { + if (currentTarget.scrollTop + currentTarget.clientHeight >= currentTarget.scrollHeight) { + if (data.hasNextPage) { + console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`); + fetchMore({ + variables: { + limit: 5, + filter, + cursor: data.cursor, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + setData((d) => ({ + cursor: fetchMoreResult.notified.pageInfo.endCursor, + hasNextPage: fetchMoreResult.notified.pageInfo.hasNextPage, + nodes: [...d.nodes, ...fetchMoreResult.notified.notified], + })); + return { + ...prev, + notified: { + ...prev.notified, + pageInfo: { + ...fetchMoreResult.notified.pageInfo, + }, + notified: [...prev.notified.notified, ...fetchMoreResult.notified.notified], + }, + }; + }, + }); + } + } + }} + > + {data.nodes.map((n) => ( + + toggleRead({ + variables: { notifiedID: n.id }, + optimisticResponse: { + __typename: 'Mutation', + notificationToggleRead: { + __typename: 'Notified', + id: n.id, + read: !n.read, + readAt: new Date().toUTCString(), + }, + }, + }) + } + /> + ))} + +
); }; diff --git a/frontend/src/shared/components/TaskDetails/Styles.ts b/frontend/src/shared/components/TaskDetails/Styles.ts index c8461ad..e6d4c54 100644 --- a/frontend/src/shared/components/TaskDetails/Styles.ts +++ b/frontend/src/shared/components/TaskDetails/Styles.ts @@ -4,6 +4,7 @@ import { mixin } from 'shared/utils/styles'; import Button from 'shared/components/Button'; import TaskAssignee from 'shared/components/TaskAssignee'; import theme from 'App/ThemeStyles'; +import { Checkmark } from 'shared/icons'; export const Container = styled.div` display: flex; @@ -309,6 +310,7 @@ export const ActionButton = styled(Button)` text-align: left; transition: transform 0.2s ease; & span { + position: unset; justify-content: flex-start; } &:hover { @@ -717,3 +719,8 @@ export const TaskDetailsEditor = styled(TextareaAutosize)` outline: none; border: none; `; + +export const WatchedCheckmark = styled(Checkmark)` + position: absolute; + right: 16px; +`; diff --git a/frontend/src/shared/components/TaskDetails/index.tsx b/frontend/src/shared/components/TaskDetails/index.tsx index c80599e..3c42a8f 100644 --- a/frontend/src/shared/components/TaskDetails/index.tsx +++ b/frontend/src/shared/components/TaskDetails/index.tsx @@ -12,6 +12,7 @@ import { CheckSquareOutline, At, Smile, + Eye, } from 'shared/icons'; import { toArray } from 'react-emoji-render'; import DOMPurify from 'dompurify'; @@ -80,6 +81,7 @@ import { ActivityItemHeaderTitleName, ActivityItemComment, TabBarButton, + WatchedCheckmark, } from './Styles'; import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist'; import onDragEnd from './onDragEnd'; @@ -237,6 +239,7 @@ type TaskDetailsProps = { onToggleChecklistItem: (itemID: string, complete: boolean) => void; onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject) => void; onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject) => void; + onToggleTaskWatch: (task: Task, watched: boolean) => void; onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject) => void; onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject) => void; onCreateComment: (task: Task, message: string) => void; @@ -258,6 +261,7 @@ const TaskDetails: React.FC = ({ task, editableComment = null, onDeleteChecklist, + onToggleTaskWatch, onTaskNameChange, onCommentShowActions, onOpenAddChecklistPopup, @@ -328,6 +332,7 @@ const TaskDetails: React.FC = ({ const saveDescription = () => { onTaskDescriptionChange(task, taskDescriptionRef.current); }; + console.log(task.watched); return ( @@ -418,6 +423,14 @@ const TaskDetails: React.FC = ({ Checklist Cover + { + onToggleTaskWatch(task, !task.watched); + }} + icon={} + > + Watch {task.watched && } + )} diff --git a/frontend/src/shared/components/TopNavbar/Styles.ts b/frontend/src/shared/components/TopNavbar/Styles.ts index c214a10..2fd4340 100644 --- a/frontend/src/shared/components/TopNavbar/Styles.ts +++ b/frontend/src/shared/components/TopNavbar/Styles.ts @@ -6,11 +6,11 @@ import { NavLink, Link } from 'react-router-dom'; import TaskAssignee from 'shared/components/TaskAssignee'; export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>` - z-index: ${props => props.zIndex}; + z-index: ${(props) => props.zIndex}; position: relative; - box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.primary}, - inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.primary, 0.07)}; + box-shadow: 0 0 0 2px ${(props) => props.theme.colors.bg.primary}, + inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.07)}; `; export const NavbarWrapper = styled.div` @@ -27,9 +27,9 @@ export const NavbarHeader = styled.header` display: flex; align-items: center; justify-content: space-between; - background: ${props => props.theme.colors.bg.primary}; + background: ${(props) => props.theme.colors.bg.primary}; box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.05); - border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)}; + border-bottom: 1px solid ${(props) => mixin.rgba(props.theme.colors.alternate, 0.65)}; `; export const Breadcrumbs = styled.div` color: rgb(94, 108, 132); @@ -59,7 +59,7 @@ export const ProjectSwitchInner = styled.div` flex-direction: column; justify-content: center; - background-color: ${props => props.theme.colors.primary}; + background-color: ${(props) => props.theme.colors.primary}; `; export const ProjectSwitch = styled.div` @@ -109,10 +109,27 @@ export const NavbarLink = styled(Link)` cursor: pointer; `; +export const NotificationCount = styled.div` + position: absolute; + top: -6px; + right: -6px; + background: #7367f0; + border-radius: 50%; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid rgb(16, 22, 58); + color: #fff; + font-size: 14px; +`; + export const IconContainerWrapper = styled.div<{ disabled?: boolean }>` margin-right: 20px; + position: relative; cursor: pointer; - ${props => + ${(props) => props.disabled && css` opacity: 0.5; @@ -142,14 +159,14 @@ export const ProfileIcon = styled.div<{ justify-content: center; color: #fff; font-weight: 700; - background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)}; + background: ${(props) => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)}; background-position: center; background-size: contain; `; export const ProjectMeta = styled.div<{ nameOnly?: boolean }>` display: flex; - ${props => !props.nameOnly && 'padding-top: 9px;'} + ${(props) => !props.nameOnly && 'padding-top: 9px;'} margin-left: -6px; align-items: center; max-width: 100%; @@ -167,7 +184,7 @@ export const ProjectTabs = styled.div` export const ProjectTab = styled(NavLink)` font-size: 80%; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; font-size: 15px; cursor: pointer; display: flex; @@ -184,22 +201,22 @@ export const ProjectTab = styled(NavLink)` } &:hover { - box-shadow: inset 0 -2px ${props => props.theme.colors.text.secondary}; - color: ${props => props.theme.colors.text.secondary}; + box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary}; + color: ${(props) => props.theme.colors.text.secondary}; } &.active { - box-shadow: inset 0 -2px ${props => props.theme.colors.secondary}; - color: ${props => props.theme.colors.secondary}; + box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary}; + color: ${(props) => props.theme.colors.secondary}; } &.active:hover { - box-shadow: inset 0 -2px ${props => props.theme.colors.secondary}; - color: ${props => props.theme.colors.secondary}; + box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary}; + color: ${(props) => props.theme.colors.secondary}; } `; export const ProjectName = styled.h1` - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; font-weight: 600; font-size: 20px; padding: 3px 10px 3px 8px; @@ -241,7 +258,7 @@ export const ProjectNameTextarea = styled.input` font-size: 20px; padding: 3px 10px 3px 8px; &:focus { - box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px; + box-shadow: ${(props) => props.theme.colors.primary} 0px 0px 0px 1px; } `; @@ -259,7 +276,7 @@ export const ProjectSwitcher = styled.button` color: #c2c6dc; cursor: pointer; &:hover { - background: ${props => props.theme.colors.primary}; + background: ${(props) => props.theme.colors.primary}; } `; @@ -283,7 +300,7 @@ export const ProjectSettingsButton = styled.button` justify-content: center; cursor: pointer; &:hover { - background: ${props => props.theme.colors.primary}; + background: ${(props) => props.theme.colors.primary}; } `; @@ -309,7 +326,7 @@ export const SignIn = styled(Button)` export const NavSeparator = styled.div` width: 1px; - background: ${props => props.theme.colors.border}; + background: ${(props) => props.theme.colors.border}; height: 34px; margin: 0 20px; `; @@ -326,11 +343,11 @@ export const LogoContainer = styled(Link)` export const TaskcafeTitle = styled.h2` margin-left: 5px; - color: ${props => props.theme.colors.text.primary}; + color: ${(props) => props.theme.colors.text.primary}; font-size: 20px; `; export const TaskcafeLogo = styled(Taskcafe)` - fill: ${props => props.theme.colors.text.primary}; - stroke: ${props => props.theme.colors.text.primary}; + fill: ${(props) => props.theme.colors.text.primary}; + stroke: ${(props) => props.theme.colors.text.primary}; `; diff --git a/frontend/src/shared/components/TopNavbar/index.tsx b/frontend/src/shared/components/TopNavbar/index.tsx index 88b2e66..e47e3a4 100644 --- a/frontend/src/shared/components/TopNavbar/index.tsx +++ b/frontend/src/shared/components/TopNavbar/index.tsx @@ -36,6 +36,7 @@ import { ProjectMember, ProjectMembers, ProjectSwitchInner, + NotificationCount, } from './Styles'; type IconContainerProps = { @@ -185,6 +186,7 @@ type NavBarProps = { projectMembers?: Array | null; projectInvitedMembers?: Array | null; + hasUnread: boolean; onRemoveFromBoard?: (userID: string) => void; onMemberProfile?: ($targetRef: React.RefObject, memberID: string) => void; onInvitedMemberProfile?: ($targetRef: React.RefObject, email: string) => void; @@ -203,6 +205,7 @@ const NavBar: React.FC = ({ onOpenProjectFinder, onFavorite, onSetTab, + hasUnread, projectInvitedMembers, onChangeRole, name, @@ -330,8 +333,9 @@ const NavBar: React.FC = ({ - + + {hasUnread && } diff --git a/frontend/src/shared/generated/graphql.tsx b/frontend/src/shared/generated/graphql.tsx index 0e788a5..97618c6 100644 --- a/frontend/src/shared/generated/graphql.tsx +++ b/frontend/src/shared/generated/graphql.tsx @@ -26,7 +26,20 @@ export enum ActionLevel { } export enum ActionType { - TaskMemberAdded = 'TASK_MEMBER_ADDED' + TeamAdded = 'TEAM_ADDED', + TeamRemoved = 'TEAM_REMOVED', + ProjectAdded = 'PROJECT_ADDED', + ProjectRemoved = 'PROJECT_REMOVED', + ProjectArchived = 'PROJECT_ARCHIVED', + DueDateAdded = 'DUE_DATE_ADDED', + DueDateRemoved = 'DUE_DATE_REMOVED', + DueDateChanged = 'DUE_DATE_CHANGED', + TaskAssigned = 'TASK_ASSIGNED', + TaskMoved = 'TASK_MOVED', + TaskArchived = 'TASK_ARCHIVED', + TaskAttachmentUploaded = 'TASK_ATTACHMENT_UPLOADED', + CommentMentioned = 'COMMENT_MENTIONED', + CommentOther = 'COMMENT_OTHER' } export enum ActivityType { @@ -280,6 +293,11 @@ export type FindUser = { userID: Scalars['UUID']; }; +export type HasUnreadNotificationsResult = { + __typename?: 'HasUnreadNotificationsResult'; + unread: Scalars['Boolean']; +}; + export type InviteProjectMembers = { projectID: Scalars['UUID']; members: Array; @@ -394,12 +412,14 @@ export type Mutation = { duplicateTaskGroup: DuplicateTaskGroupPayload; inviteProjectMembers: InviteProjectMembersPayload; logoutUser: Scalars['Boolean']; + notificationToggleRead: Notified; removeTaskLabel: Task; setTaskChecklistItemComplete: TaskChecklistItem; setTaskComplete: Task; sortTaskGroup: SortTaskGroupPayload; toggleProjectVisibility: ToggleProjectVisibilityPayload; toggleTaskLabel: ToggleTaskLabelPayload; + toggleTaskWatch: Task; unassignTask: Task; updateProjectLabel: ProjectLabel; updateProjectLabelColor: ProjectLabel; @@ -569,6 +589,11 @@ export type MutationLogoutUserArgs = { }; +export type MutationNotificationToggleReadArgs = { + input: NotificationToggleReadInput; +}; + + export type MutationRemoveTaskLabelArgs = { input?: Maybe; }; @@ -599,6 +624,11 @@ export type MutationToggleTaskLabelArgs = { }; +export type MutationToggleTaskWatchArgs = { + input: ToggleTaskWatch; +}; + + export type MutationUnassignTaskArgs = { input?: Maybe; }; @@ -784,7 +814,7 @@ export type Notification = { __typename?: 'Notification'; id: Scalars['ID']; actionType: ActionType; - causedBy: NotificationCausedBy; + causedBy?: Maybe; data: Array; createdAt: Scalars['Time']; }; @@ -802,6 +832,17 @@ export type NotificationData = { value: Scalars['String']; }; +export enum NotificationFilter { + All = 'ALL', + Unread = 'UNREAD', + Assigned = 'ASSIGNED', + Mentioned = 'MENTIONED' +} + +export type NotificationToggleReadInput = { + notifiedID: Scalars['UUID']; +}; + export type Notified = { __typename?: 'Notified'; id: Scalars['ID']; @@ -810,6 +851,19 @@ export type Notified = { readAt?: Maybe; }; +export type NotifiedInput = { + limit: Scalars['Int']; + cursor?: Maybe; + filter: NotificationFilter; +}; + +export type NotifiedResult = { + __typename?: 'NotifiedResult'; + totalCount: Scalars['Int']; + notified: Array; + pageInfo: PageInfo; +}; + export enum ObjectType { Org = 'ORG', Team = 'TEAM', @@ -838,6 +892,12 @@ export type OwnersList = { teams: Array; }; +export type PageInfo = { + __typename?: 'PageInfo'; + endCursor: Scalars['String']; + hasNextPage: Scalars['Boolean']; +}; + export type ProfileIcon = { __typename?: 'ProfileIcon'; url?: Maybe; @@ -896,11 +956,13 @@ export type Query = { findTask: Task; findTeam: Team; findUser: UserAccount; + hasUnreadNotifications: HasUnreadNotificationsResult; invitedUsers: Array; labelColors: Array; me?: Maybe; myTasks: MyTasksPayload; notifications: Array; + notified: NotifiedResult; organizations: Array; projects: Array; searchMembers: Array; @@ -935,6 +997,11 @@ export type QueryMyTasksArgs = { }; +export type QueryNotifiedArgs = { + input: NotifiedInput; +}; + + export type QueryProjectsArgs = { input?: Maybe; }; @@ -1006,6 +1073,7 @@ export type Task = { name: Scalars['String']; position: Scalars['Float']; description?: Maybe; + watched: Scalars['Boolean']; dueDate?: Maybe; hasTime: Scalars['Boolean']; complete: Scalars['Boolean']; @@ -1132,6 +1200,10 @@ export type ToggleTaskLabelPayload = { task: Task; }; +export type ToggleTaskWatch = { + taskID: Scalars['UUID']; +}; + export type UnassignTaskInput = { taskID: Scalars['UUID']; @@ -1520,7 +1592,7 @@ export type FindTaskQuery = ( { __typename?: 'Query' } & { findTask: ( { __typename?: 'Task' } - & Pick + & Pick & { taskGroup: ( { __typename?: 'TaskGroup' } & Pick @@ -1596,7 +1668,7 @@ export type FindTaskQuery = ( export type TaskFieldsFragment = ( { __typename?: 'Task' } - & Pick + & Pick & { badges: ( { __typename?: 'TaskBadges' } & { checklist?: Maybe<( @@ -1725,6 +1797,74 @@ export type MyTasksQuery = ( ) } ); +export type NotificationToggleReadMutationVariables = Exact<{ + notifiedID: Scalars['UUID']; +}>; + + +export type NotificationToggleReadMutation = ( + { __typename?: 'Mutation' } + & { notificationToggleRead: ( + { __typename?: 'Notified' } + & Pick + ) } +); + +export type NotificationsQueryVariables = Exact<{ + limit: Scalars['Int']; + cursor?: Maybe; + filter: NotificationFilter; +}>; + + +export type NotificationsQuery = ( + { __typename?: 'Query' } + & { notified: ( + { __typename?: 'NotifiedResult' } + & Pick + & { pageInfo: ( + { __typename?: 'PageInfo' } + & Pick + ), notified: Array<( + { __typename?: 'Notified' } + & Pick + & { notification: ( + { __typename?: 'Notification' } + & Pick + & { data: Array<( + { __typename?: 'NotificationData' } + & Pick + )>, causedBy?: Maybe<( + { __typename?: 'NotificationCausedBy' } + & Pick + )> } + ) } + )> } + ) } +); + +export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type NotificationAddedSubscription = ( + { __typename?: 'Subscription' } + & { notificationAdded: ( + { __typename?: 'Notified' } + & Pick + & { notification: ( + { __typename?: 'Notification' } + & Pick + & { data: Array<( + { __typename?: 'NotificationData' } + & Pick + )>, causedBy?: Maybe<( + { __typename?: 'NotificationCausedBy' } + & Pick + )> } + ) } + ) } +); + export type DeleteProjectMutationVariables = Exact<{ projectID: Scalars['UUID']; }>; @@ -1979,6 +2119,19 @@ export type SetTaskCompleteMutation = ( ) } ); +export type ToggleTaskWatchMutationVariables = Exact<{ + taskID: Scalars['UUID']; +}>; + + +export type ToggleTaskWatchMutation = ( + { __typename?: 'Mutation' } + & { toggleTaskWatch: ( + { __typename?: 'Task' } + & Pick + ) } +); + export type UpdateTaskChecklistItemLocationMutationVariables = Exact<{ taskChecklistID: Scalars['UUID']; taskChecklistItemID: Scalars['UUID']; @@ -2363,10 +2516,10 @@ export type TopNavbarQuery = ( & { notification: ( { __typename?: 'Notification' } & Pick - & { causedBy: ( + & { causedBy?: Maybe<( { __typename?: 'NotificationCausedBy' } & Pick - ) } + )> } ) } )>, me?: Maybe<( { __typename?: 'MePayload' } @@ -2405,6 +2558,17 @@ export type UnassignTaskMutation = ( ) } ); +export type HasUnreadNotificationsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type HasUnreadNotificationsQuery = ( + { __typename?: 'Query' } + & { hasUnreadNotifications: ( + { __typename?: 'HasUnreadNotificationsResult' } + & Pick + ) } +); + export type UpdateProjectLabelMutationVariables = Exact<{ projectLabelID: Scalars['UUID']; labelColorID: Scalars['UUID']; @@ -2700,6 +2864,7 @@ export const TaskFieldsFragmentDoc = gql` dueDate hasTime complete + watched completedAt position badges { @@ -3171,6 +3336,7 @@ export const FindTaskDocument = gql` findTask(input: {taskID: $taskID}) { id name + watched description dueDate position @@ -3505,6 +3671,146 @@ export function useMyTasksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type MyTasksLazyQueryHookResult = ReturnType; export type MyTasksQueryResult = Apollo.QueryResult; +export const NotificationToggleReadDocument = gql` + mutation notificationToggleRead($notifiedID: UUID!) { + notificationToggleRead(input: {notifiedID: $notifiedID}) { + id + read + readAt + } +} + `; +export type NotificationToggleReadMutationFn = Apollo.MutationFunction; + +/** + * __useNotificationToggleReadMutation__ + * + * To run a mutation, you first call `useNotificationToggleReadMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useNotificationToggleReadMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [notificationToggleReadMutation, { data, loading, error }] = useNotificationToggleReadMutation({ + * variables: { + * notifiedID: // value for 'notifiedID' + * }, + * }); + */ +export function useNotificationToggleReadMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(NotificationToggleReadDocument, options); + } +export type NotificationToggleReadMutationHookResult = ReturnType; +export type NotificationToggleReadMutationResult = Apollo.MutationResult; +export type NotificationToggleReadMutationOptions = Apollo.BaseMutationOptions; +export const NotificationsDocument = gql` + query notifications($limit: Int!, $cursor: String, $filter: NotificationFilter!) { + notified(input: {limit: $limit, cursor: $cursor, filter: $filter}) { + totalCount + pageInfo { + endCursor + hasNextPage + } + notified { + id + read + readAt + notification { + id + actionType + data { + key + value + } + causedBy { + username + fullname + id + } + createdAt + } + } + } +} + `; + +/** + * __useNotificationsQuery__ + * + * To run a query within a React component, call `useNotificationsQuery` and pass it any options that fit your needs. + * When your component renders, `useNotificationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useNotificationsQuery({ + * variables: { + * limit: // value for 'limit' + * cursor: // value for 'cursor' + * filter: // value for 'filter' + * }, + * }); + */ +export function useNotificationsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(NotificationsDocument, options); + } +export function useNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(NotificationsDocument, options); + } +export type NotificationsQueryHookResult = ReturnType; +export type NotificationsLazyQueryHookResult = ReturnType; +export type NotificationsQueryResult = Apollo.QueryResult; +export const NotificationAddedDocument = gql` + subscription notificationAdded { + notificationAdded { + id + read + readAt + notification { + id + actionType + data { + key + value + } + causedBy { + username + fullname + id + } + createdAt + } + } +} + `; + +/** + * __useNotificationAddedSubscription__ + * + * To run a query within a React component, call `useNotificationAddedSubscription` and pass it any options that fit your needs. + * When your component renders, `useNotificationAddedSubscription` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useNotificationAddedSubscription({ + * variables: { + * }, + * }); + */ +export function useNotificationAddedSubscription(baseOptions?: Apollo.SubscriptionHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSubscription(NotificationAddedDocument, options); + } +export type NotificationAddedSubscriptionHookResult = ReturnType; +export type NotificationAddedSubscriptionResult = Apollo.SubscriptionResult; export const DeleteProjectDocument = gql` mutation deleteProject($projectID: UUID!) { deleteProject(input: {projectID: $projectID}) { @@ -4061,6 +4367,40 @@ export function useSetTaskCompleteMutation(baseOptions?: Apollo.MutationHookOpti export type SetTaskCompleteMutationHookResult = ReturnType; export type SetTaskCompleteMutationResult = Apollo.MutationResult; export type SetTaskCompleteMutationOptions = Apollo.BaseMutationOptions; +export const ToggleTaskWatchDocument = gql` + mutation toggleTaskWatch($taskID: UUID!) { + toggleTaskWatch(input: {taskID: $taskID}) { + id + watched + } +} + `; +export type ToggleTaskWatchMutationFn = Apollo.MutationFunction; + +/** + * __useToggleTaskWatchMutation__ + * + * To run a mutation, you first call `useToggleTaskWatchMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useToggleTaskWatchMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [toggleTaskWatchMutation, { data, loading, error }] = useToggleTaskWatchMutation({ + * variables: { + * taskID: // value for 'taskID' + * }, + * }); + */ +export function useToggleTaskWatchMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ToggleTaskWatchDocument, options); + } +export type ToggleTaskWatchMutationHookResult = ReturnType; +export type ToggleTaskWatchMutationResult = Apollo.MutationResult; +export type ToggleTaskWatchMutationOptions = Apollo.BaseMutationOptions; export const UpdateTaskChecklistItemLocationDocument = gql` mutation updateTaskChecklistItemLocation($taskChecklistID: UUID!, $taskChecklistItemID: UUID!, $position: Float!) { updateTaskChecklistItemLocation( @@ -4923,6 +5263,40 @@ export function useUnassignTaskMutation(baseOptions?: Apollo.MutationHookOptions export type UnassignTaskMutationHookResult = ReturnType; export type UnassignTaskMutationResult = Apollo.MutationResult; export type UnassignTaskMutationOptions = Apollo.BaseMutationOptions; +export const HasUnreadNotificationsDocument = gql` + query hasUnreadNotifications { + hasUnreadNotifications { + unread + } +} + `; + +/** + * __useHasUnreadNotificationsQuery__ + * + * To run a query within a React component, call `useHasUnreadNotificationsQuery` and pass it any options that fit your needs. + * When your component renders, `useHasUnreadNotificationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useHasUnreadNotificationsQuery({ + * variables: { + * }, + * }); + */ +export function useHasUnreadNotificationsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(HasUnreadNotificationsDocument, options); + } +export function useHasUnreadNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(HasUnreadNotificationsDocument, options); + } +export type HasUnreadNotificationsQueryHookResult = ReturnType; +export type HasUnreadNotificationsLazyQueryHookResult = ReturnType; +export type HasUnreadNotificationsQueryResult = Apollo.QueryResult; export const UpdateProjectLabelDocument = gql` mutation updateProjectLabel($projectLabelID: UUID!, $labelColorID: UUID!, $name: String!) { updateProjectLabel( diff --git a/frontend/src/shared/graphql/findTask.graphqls b/frontend/src/shared/graphql/findTask.graphqls index 738c942..482ce90 100644 --- a/frontend/src/shared/graphql/findTask.graphqls +++ b/frontend/src/shared/graphql/findTask.graphqls @@ -2,6 +2,7 @@ query findTask($taskID: UUID!) { findTask(input: {taskID: $taskID}) { id name + watched description dueDate position diff --git a/frontend/src/shared/graphql/fragments/task.ts b/frontend/src/shared/graphql/fragments/task.ts index 6e56d64..27cb4ae 100644 --- a/frontend/src/shared/graphql/fragments/task.ts +++ b/frontend/src/shared/graphql/fragments/task.ts @@ -8,6 +8,7 @@ const TASK_FRAGMENT = gql` dueDate hasTime complete + watched completedAt position badges { diff --git a/frontend/src/shared/graphql/notificationToggleRead.ts b/frontend/src/shared/graphql/notificationToggleRead.ts new file mode 100644 index 0000000..f55facb --- /dev/null +++ b/frontend/src/shared/graphql/notificationToggleRead.ts @@ -0,0 +1,13 @@ +import gql from 'graphql-tag'; + +const CREATE_TASK_MUTATION = gql` + mutation notificationToggleRead($notifiedID: UUID!) { + notificationToggleRead(input: { notifiedID: $notifiedID }) { + id + read + readAt + } + } +`; + +export default CREATE_TASK_MUTATION; diff --git a/frontend/src/shared/graphql/notifications.ts b/frontend/src/shared/graphql/notifications.ts new file mode 100644 index 0000000..8774740 --- /dev/null +++ b/frontend/src/shared/graphql/notifications.ts @@ -0,0 +1,34 @@ +import gql from 'graphql-tag'; + +export const TOP_NAVBAR_QUERY = gql` + query notifications($limit: Int!, $cursor: String, $filter: NotificationFilter!) { + notified(input: { limit: $limit, cursor: $cursor, filter: $filter }) { + totalCount + pageInfo { + endCursor + hasNextPage + } + notified { + id + read + readAt + notification { + id + actionType + data { + key + value + } + causedBy { + username + fullname + id + } + createdAt + } + } + } + } +`; + +export default TOP_NAVBAR_QUERY; diff --git a/frontend/src/shared/graphql/onNotificationAdded.ts b/frontend/src/shared/graphql/onNotificationAdded.ts new file mode 100644 index 0000000..f40cdc6 --- /dev/null +++ b/frontend/src/shared/graphql/onNotificationAdded.ts @@ -0,0 +1,26 @@ +import gql from 'graphql-tag'; +import TASK_FRAGMENT from './fragments/task'; + +const FIND_PROJECT_QUERY = gql` + subscription notificationAdded { + notificationAdded { + id + read + readAt + notification { + id + actionType + data { + key + value + } + causedBy { + username + fullname + id + } + createdAt + } + } + } +`; diff --git a/frontend/src/shared/graphql/task/toggleTaskWatcher.ts b/frontend/src/shared/graphql/task/toggleTaskWatcher.ts new file mode 100644 index 0000000..8be03a6 --- /dev/null +++ b/frontend/src/shared/graphql/task/toggleTaskWatcher.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag'; + +const CREATE_TASK_MUTATION = gql` + mutation toggleTaskWatch($taskID: UUID!) { + toggleTaskWatch(input: { taskID: $taskID }) { + id + watched + } + } +`; + +export default CREATE_TASK_MUTATION; diff --git a/frontend/src/shared/graphql/unreadNotifications.ts b/frontend/src/shared/graphql/unreadNotifications.ts new file mode 100644 index 0000000..b05bd7d --- /dev/null +++ b/frontend/src/shared/graphql/unreadNotifications.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; + +export const TOP_NAVBAR_QUERY = gql` + query hasUnreadNotifications { + hasUnreadNotifications { + unread + } + } +`; + +export default TOP_NAVBAR_QUERY; diff --git a/frontend/src/shared/icons/Bell.tsx b/frontend/src/shared/icons/Bell.tsx index a319b23..465fea3 100644 --- a/frontend/src/shared/icons/Bell.tsx +++ b/frontend/src/shared/icons/Bell.tsx @@ -8,7 +8,7 @@ type Props = { const Bell = ({ size, color }: Props) => { return ( - + ); }; diff --git a/frontend/src/shared/icons/Circle.tsx b/frontend/src/shared/icons/Circle.tsx new file mode 100644 index 0000000..7c98ff4 --- /dev/null +++ b/frontend/src/shared/icons/Circle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const Circle: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default Circle; diff --git a/frontend/src/shared/icons/CircleSolid.tsx b/frontend/src/shared/icons/CircleSolid.tsx new file mode 100644 index 0000000..2c8a014 --- /dev/null +++ b/frontend/src/shared/icons/CircleSolid.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const CircleSolid: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default CircleSolid; diff --git a/frontend/src/shared/icons/UserCircle.tsx b/frontend/src/shared/icons/UserCircle.tsx new file mode 100644 index 0000000..ab48ed6 --- /dev/null +++ b/frontend/src/shared/icons/UserCircle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const UserCircle: React.FC = ({ width = '16px', height = '16px', className, onClick }) => { + return ( + + + + ); +}; + +export default UserCircle; diff --git a/frontend/src/shared/icons/index.ts b/frontend/src/shared/icons/index.ts index 345c651..e6378d9 100644 --- a/frontend/src/shared/icons/index.ts +++ b/frontend/src/shared/icons/index.ts @@ -1,6 +1,9 @@ import Cross from './Cross'; import Cog from './Cog'; import Cogs from './Cogs'; +import Circle from './Circle'; +import CircleSolid from './CircleSolid'; +import UserCircle from './UserCircle'; import Bubble from './Bubble'; import ArrowDown from './ArrowDown'; import CheckCircleOutline from './CheckCircleOutline'; @@ -111,6 +114,9 @@ export { Briefcase, DotCircle, ChevronRight, + Circle, + CircleSolid, Bubble, + UserCircle, Cogs, }; diff --git a/frontend/src/shared/utils/polling.ts b/frontend/src/shared/utils/polling.ts index 897ceae..b75b55b 100644 --- a/frontend/src/shared/utils/polling.ts +++ b/frontend/src/shared/utils/polling.ts @@ -8,6 +8,7 @@ const polling = { MEMBERS: resolve(3000), TEAM_PROJECTS: resolve(3000), TASK_DETAILS: resolve(3000), + UNREAD_NOTIFICATIONS: resolve(30000), }; export default polling; diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 442cac5..bb3c0eb 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -105,6 +105,7 @@ type Task = { id: string; taskGroup: InnerTaskGroup; name: string; + watched?: boolean; badges?: TaskBadges; position: number; hasTime?: boolean; diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 546be68..6a38e8f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -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() } diff --git a/internal/commands/token.go b/internal/commands/token.go new file mode 100644 index 0000000..1fc4e05 --- /dev/null +++ b/internal/commands/token.go @@ -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 +} diff --git a/internal/db/models.go b/internal/db/models.go index c7a8504..7273378 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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"` diff --git a/internal/db/notification.sql.go b/internal/db/notification.sql.go index c339b28..7e071c1 100644 --- a/internal/db/notification.sql.go +++ b/internal/db/notification.sql.go @@ -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 } diff --git a/internal/db/querier.go b/internal/db/querier.go index 842cc38..9713574 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -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 diff --git a/internal/db/query/notification.sql b/internal/db/query/notification.sql index f4615ac..2edcd08 100644 --- a/internal/db/query/notification.sql +++ b/internal/db/query/notification.sql @@ -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; diff --git a/internal/db/query/task.sql b/internal/db/query/task.sql index 712fd5d..2747a59 100644 --- a/internal/db/query/task.sql +++ b/internal/db/query/task.sql @@ -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 *; diff --git a/internal/db/task.sql.go b/internal/db/task.sql.go index b9cc2c3..21ff803 100644 --- a/internal/db/task.sql.go +++ b/internal/db/task.sql.go @@ -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 ` diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 8e13a3d..5b0be8b 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -166,6 +166,10 @@ type ComplexityRoot struct { TaskGroup func(childComplexity int) int } + HasUnreadNotificationsResult struct { + Unread func(childComplexity int) int + } + InviteProjectMembersPayload struct { InvitedMembers func(childComplexity int) int Members func(childComplexity int) int @@ -252,12 +256,14 @@ type ComplexityRoot struct { DuplicateTaskGroup func(childComplexity int, input DuplicateTaskGroup) int InviteProjectMembers func(childComplexity int, input InviteProjectMembers) int LogoutUser func(childComplexity int, input LogoutUser) int + NotificationToggleRead func(childComplexity int, input NotificationToggleReadInput) int RemoveTaskLabel func(childComplexity int, input *RemoveTaskLabelInput) int SetTaskChecklistItemComplete func(childComplexity int, input SetTaskChecklistItemComplete) int SetTaskComplete func(childComplexity int, input SetTaskComplete) int SortTaskGroup func(childComplexity int, input SortTaskGroup) int ToggleProjectVisibility func(childComplexity int, input ToggleProjectVisibility) int ToggleTaskLabel func(childComplexity int, input ToggleTaskLabelInput) int + ToggleTaskWatch func(childComplexity int, input ToggleTaskWatch) int UnassignTask func(childComplexity int, input *UnassignTaskInput) int UpdateProjectLabel func(childComplexity int, input UpdateProjectLabel) int UpdateProjectLabelColor func(childComplexity int, input UpdateProjectLabelColor) int @@ -312,6 +318,12 @@ type ComplexityRoot struct { ReadAt func(childComplexity int) int } + NotifiedResult struct { + Notified func(childComplexity int) int + PageInfo func(childComplexity int) int + TotalCount func(childComplexity int) int + } + Organization struct { ID func(childComplexity int) int Name func(childComplexity int) int @@ -327,6 +339,11 @@ type ComplexityRoot struct { Teams func(childComplexity int) int } + PageInfo struct { + EndCursor func(childComplexity int) int + HasNextPage func(childComplexity int) int + } + ProfileIcon struct { BgColor func(childComplexity int) int Initials func(childComplexity int) int @@ -370,21 +387,23 @@ type ComplexityRoot struct { } Query struct { - FindProject func(childComplexity int, input FindProject) int - FindTask func(childComplexity int, input FindTask) int - FindTeam func(childComplexity int, input FindTeam) int - FindUser func(childComplexity int, input FindUser) int - InvitedUsers func(childComplexity int) int - LabelColors func(childComplexity int) int - Me func(childComplexity int) int - MyTasks func(childComplexity int, input MyTasks) int - Notifications func(childComplexity int) int - Organizations func(childComplexity int) int - Projects func(childComplexity int, input *ProjectsFilter) int - SearchMembers func(childComplexity int, input MemberSearchFilter) int - TaskGroups func(childComplexity int) int - Teams func(childComplexity int) int - Users func(childComplexity int) int + FindProject func(childComplexity int, input FindProject) int + FindTask func(childComplexity int, input FindTask) int + FindTeam func(childComplexity int, input FindTeam) int + FindUser func(childComplexity int, input FindUser) int + HasUnreadNotifications func(childComplexity int) int + InvitedUsers func(childComplexity int) int + LabelColors func(childComplexity int) int + Me func(childComplexity int) int + MyTasks func(childComplexity int, input MyTasks) int + Notifications func(childComplexity int) int + Notified func(childComplexity int, input NotifiedInput) int + Organizations func(childComplexity int) int + Projects func(childComplexity int, input *ProjectsFilter) int + SearchMembers func(childComplexity int, input MemberSearchFilter) int + TaskGroups func(childComplexity int) int + Teams func(childComplexity int) int + Users func(childComplexity int) int } Role struct { @@ -418,6 +437,7 @@ type ComplexityRoot struct { Name func(childComplexity int) int Position func(childComplexity int) int TaskGroup func(childComplexity int) int + Watched func(childComplexity int) int } TaskActivity struct { @@ -568,6 +588,7 @@ type LabelColorResolver interface { ID(ctx context.Context, obj *db.LabelColor) (uuid.UUID, error) } type MutationResolver interface { + NotificationToggleRead(ctx context.Context, input NotificationToggleReadInput) (*Notified, error) CreateProjectLabel(ctx context.Context, input NewProjectLabel) (*db.ProjectLabel, error) DeleteProjectLabel(ctx context.Context, input DeleteProjectLabel) (*db.ProjectLabel, error) UpdateProjectLabel(ctx context.Context, input UpdateProjectLabel) (*db.ProjectLabel, error) @@ -610,6 +631,7 @@ type MutationResolver interface { UpdateTaskName(ctx context.Context, input UpdateTaskName) (*db.Task, error) SetTaskComplete(ctx context.Context, input SetTaskComplete) (*db.Task, error) UpdateTaskDueDate(ctx context.Context, input UpdateTaskDueDate) (*db.Task, error) + ToggleTaskWatch(ctx context.Context, input ToggleTaskWatch) (*db.Task, error) AssignTask(ctx context.Context, input *AssignTaskInput) (*db.Task, error) UnassignTask(ctx context.Context, input *UnassignTaskInput) (*db.Task, error) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) @@ -668,6 +690,8 @@ type QueryResolver interface { TaskGroups(ctx context.Context) ([]db.TaskGroup, error) Me(ctx context.Context) (*MePayload, error) Notifications(ctx context.Context) ([]Notified, error) + Notified(ctx context.Context, input NotifiedInput) (*NotifiedResult, error) + HasUnreadNotifications(ctx context.Context) (*HasUnreadNotificationsResult, error) SearchMembers(ctx context.Context, input MemberSearchFilter) ([]MemberSearchResult, error) } type SubscriptionResolver interface { @@ -678,6 +702,7 @@ type TaskResolver interface { TaskGroup(ctx context.Context, obj *db.Task) (*db.TaskGroup, error) Description(ctx context.Context, obj *db.Task) (*string, error) + Watched(ctx context.Context, obj *db.Task) (bool, error) DueDate(ctx context.Context, obj *db.Task) (*time.Time, error) CompletedAt(ctx context.Context, obj *db.Task) (*time.Time, error) @@ -1046,6 +1071,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.DuplicateTaskGroupPayload.TaskGroup(childComplexity), true + case "HasUnreadNotificationsResult.unread": + if e.complexity.HasUnreadNotificationsResult.Unread == nil { + break + } + + return e.complexity.HasUnreadNotificationsResult.Unread(childComplexity), true + case "InviteProjectMembersPayload.invitedMembers": if e.complexity.InviteProjectMembersPayload.InvitedMembers == nil { break @@ -1618,6 +1650,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.LogoutUser(childComplexity, args["input"].(LogoutUser)), true + case "Mutation.notificationToggleRead": + if e.complexity.Mutation.NotificationToggleRead == nil { + break + } + + args, err := ec.field_Mutation_notificationToggleRead_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.NotificationToggleRead(childComplexity, args["input"].(NotificationToggleReadInput)), true + case "Mutation.removeTaskLabel": if e.complexity.Mutation.RemoveTaskLabel == nil { break @@ -1690,6 +1734,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ToggleTaskLabel(childComplexity, args["input"].(ToggleTaskLabelInput)), true + case "Mutation.toggleTaskWatch": + if e.complexity.Mutation.ToggleTaskWatch == nil { + break + } + + args, err := ec.field_Mutation_toggleTaskWatch_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ToggleTaskWatch(childComplexity, args["input"].(ToggleTaskWatch)), true + case "Mutation.unassignTask": if e.complexity.Mutation.UnassignTask == nil { break @@ -2054,6 +2110,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Notified.ReadAt(childComplexity), true + case "NotifiedResult.notified": + if e.complexity.NotifiedResult.Notified == nil { + break + } + + return e.complexity.NotifiedResult.Notified(childComplexity), true + + case "NotifiedResult.pageInfo": + if e.complexity.NotifiedResult.PageInfo == nil { + break + } + + return e.complexity.NotifiedResult.PageInfo(childComplexity), true + + case "NotifiedResult.totalCount": + if e.complexity.NotifiedResult.TotalCount == nil { + break + } + + return e.complexity.NotifiedResult.TotalCount(childComplexity), true + case "Organization.id": if e.complexity.Organization.ID == nil { break @@ -2096,6 +2173,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.OwnersList.Teams(childComplexity), true + case "PageInfo.endCursor": + if e.complexity.PageInfo.EndCursor == nil { + break + } + + return e.complexity.PageInfo.EndCursor(childComplexity), true + + case "PageInfo.hasNextPage": + if e.complexity.PageInfo.HasNextPage == nil { + break + } + + return e.complexity.PageInfo.HasNextPage(childComplexity), true + case "ProfileIcon.bgColor": if e.complexity.ProfileIcon.BgColor == nil { break @@ -2312,6 +2403,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.FindUser(childComplexity, args["input"].(FindUser)), true + case "Query.hasUnreadNotifications": + if e.complexity.Query.HasUnreadNotifications == nil { + break + } + + return e.complexity.Query.HasUnreadNotifications(childComplexity), true + case "Query.invitedUsers": if e.complexity.Query.InvitedUsers == nil { break @@ -2352,6 +2450,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Notifications(childComplexity), true + case "Query.notified": + if e.complexity.Query.Notified == nil { + break + } + + args, err := ec.field_Query_notified_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Notified(childComplexity, args["input"].(NotifiedInput)), true + case "Query.organizations": if e.complexity.Query.Organizations == nil { break @@ -2551,6 +2661,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Task.TaskGroup(childComplexity), true + case "Task.watched": + if e.complexity.Task.Watched == nil { + break + } + + return e.complexity.Task.Watched(childComplexity), true + case "TaskActivity.causedBy": if e.complexity.TaskActivity.CausedBy == nil { break @@ -3156,10 +3273,60 @@ var sources = []*ast.Source{ 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 { @@ -3176,7 +3343,7 @@ type NotificationCausedBy { type Notification { id: ID! actionType: ActionType! - causedBy: NotificationCausedBy! + causedBy: NotificationCausedBy data: [NotificationData!]! createdAt: Time! } @@ -3565,6 +3732,7 @@ type Task { name: String! position: Float! description: String + watched: Boolean! dueDate: Time hasTime: Boolean! complete: Boolean! @@ -3890,6 +4058,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) @@ -3897,6 +4067,10 @@ extend type Mutation { Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK) } +input ToggleTaskWatch { + taskID: UUID! +} + input NewTask { taskGroupID: UUID! name: String! @@ -4639,6 +4813,21 @@ func (ec *executionContext) field_Mutation_logoutUser_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_notificationToggleRead_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 NotificationToggleReadInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNNotificationToggleReadInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationToggleReadInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_removeTaskLabel_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -4729,6 +4918,21 @@ func (ec *executionContext) field_Mutation_toggleTaskLabel_args(ctx context.Cont return args, nil } +func (ec *executionContext) field_Mutation_toggleTaskWatch_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 ToggleTaskWatch + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNToggleTaskWatch2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐToggleTaskWatch(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_unassignTask_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -5134,6 +5338,21 @@ func (ec *executionContext) field_Query_myTasks_args(ctx context.Context, rawArg return args, nil } +func (ec *executionContext) field_Query_notified_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 NotifiedInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNNotifiedInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_projects_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -6669,6 +6888,41 @@ func (ec *executionContext) _DuplicateTaskGroupPayload_taskGroup(ctx context.Con return ec.marshalNTaskGroup2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTaskGroup(ctx, field.Selections, res) } +func (ec *executionContext) _HasUnreadNotificationsResult_unread(ctx context.Context, field graphql.CollectedField, obj *HasUnreadNotificationsResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "HasUnreadNotificationsResult", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Unread, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _InviteProjectMembersPayload_ok(ctx context.Context, field graphql.CollectedField, obj *InviteProjectMembersPayload) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -7748,6 +8002,48 @@ func (ec *executionContext) _MemberSearchResult_status(ctx context.Context, fiel return ec.marshalNShareStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐShareStatus(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_notificationToggleRead(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_notificationToggleRead_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().NotificationToggleRead(rctx, args["input"].(NotificationToggleReadInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*Notified) + fc.Result = res + return ec.marshalNNotified2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotified(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_createProjectLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -10856,6 +11152,80 @@ func (ec *executionContext) _Mutation_updateTaskDueDate(ctx context.Context, fie return ec.marshalNTask2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTask(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_toggleTaskWatch(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_toggleTaskWatch_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ToggleTaskWatch(rctx, args["input"].(ToggleTaskWatch)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + roles, err := ec.unmarshalNRoleLevel2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐRoleLevelᚄ(ctx, []interface{}{"ADMIN", "MEMBER"}) + if err != nil { + return nil, err + } + level, err := ec.unmarshalNActionLevel2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐActionLevel(ctx, "PROJECT") + if err != nil { + return nil, err + } + typeArg, err := ec.unmarshalNObjectType2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐObjectType(ctx, "TASK") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, roles, level, typeArg) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*db.Task); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/jordanknott/taskcafe/internal/db.Task`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*db.Task) + fc.Result = res + return ec.marshalNTask2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTask(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_assignTask(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -12028,14 +12398,11 @@ func (ec *executionContext) _Notification_causedBy(ctx context.Context, field gr return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } res := resTmp.(*NotificationCausedBy) fc.Result = res - return ec.marshalNNotificationCausedBy2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationCausedBy(ctx, field.Selections, res) + return ec.marshalONotificationCausedBy2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationCausedBy(ctx, field.Selections, res) } func (ec *executionContext) _Notification_data(ctx context.Context, field graphql.CollectedField, obj *db.Notification) (ret graphql.Marshaler) { @@ -12420,6 +12787,111 @@ func (ec *executionContext) _Notified_readAt(ctx context.Context, field graphql. return ec.marshalOTime2ᚖtimeᚐTime(ctx, field.Selections, res) } +func (ec *executionContext) _NotifiedResult_totalCount(ctx context.Context, field graphql.CollectedField, obj *NotifiedResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "NotifiedResult", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TotalCount, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _NotifiedResult_notified(ctx context.Context, field graphql.CollectedField, obj *NotifiedResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "NotifiedResult", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Notified, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]Notified) + fc.Result = res + return ec.marshalNNotified2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) _NotifiedResult_pageInfo(ctx context.Context, field graphql.CollectedField, obj *NotifiedResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "NotifiedResult", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.PageInfo, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*PageInfo) + fc.Result = res + return ec.marshalNPageInfo2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐPageInfo(ctx, field.Selections, res) +} + func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *db.Organization) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -12630,6 +13102,76 @@ func (ec *executionContext) _OwnersList_teams(ctx context.Context, field graphql return ec.marshalNUUID2ᚕgithubᚗcomᚋgoogleᚋuuidᚐUUIDᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.EndCursor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *PageInfo) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "PageInfo", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasNextPage, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _ProfileIcon_url(ctx context.Context, field graphql.CollectedField, obj *ProfileIcon) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -13981,6 +14523,83 @@ func (ec *executionContext) _Query_notifications(ctx context.Context, field grap return ec.marshalNNotified2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Query_notified(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_notified_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Notified(rctx, args["input"].(NotifiedInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*NotifiedResult) + fc.Result = res + return ec.marshalNNotifiedResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedResult(ctx, field.Selections, res) +} + +func (ec *executionContext) _Query_hasUnreadNotifications(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().HasUnreadNotifications(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*HasUnreadNotificationsResult) + fc.Result = res + return ec.marshalNHasUnreadNotificationsResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐHasUnreadNotificationsResult(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_searchMembers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -14486,6 +15105,41 @@ func (ec *executionContext) _Task_description(ctx context.Context, field graphql return ec.marshalOString2ᚖstring(ctx, field.Selections, res) } +func (ec *executionContext) _Task_watched(ctx context.Context, field graphql.CollectedField, obj *db.Task) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Task", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Task().Watched(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Task_dueDate(ctx context.Context, field graphql.CollectedField, obj *db.Task) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -19554,6 +20208,62 @@ func (ec *executionContext) unmarshalInputNewUserAccount(ctx context.Context, ob return it, nil } +func (ec *executionContext) unmarshalInputNotificationToggleReadInput(ctx context.Context, obj interface{}) (NotificationToggleReadInput, error) { + var it NotificationToggleReadInput + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "notifiedID": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("notifiedID")) + it.NotifiedID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputNotifiedInput(ctx context.Context, obj interface{}) (NotifiedInput, error) { + var it NotifiedInput + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "limit": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) + it.Limit, err = ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + case "cursor": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("cursor")) + it.Cursor, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "filter": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("filter")) + it.Filter, err = ec.unmarshalNNotificationFilter2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationFilter(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputProjectsFilter(ctx context.Context, obj interface{}) (ProjectsFilter, error) { var it ProjectsFilter var asMap = obj.(map[string]interface{}) @@ -19770,6 +20480,26 @@ func (ec *executionContext) unmarshalInputToggleTaskLabelInput(ctx context.Conte return it, nil } +func (ec *executionContext) unmarshalInputToggleTaskWatch(ctx context.Context, obj interface{}) (ToggleTaskWatch, error) { + var it ToggleTaskWatch + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "taskID": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("taskID")) + it.TaskID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUnassignTaskInput(ctx context.Context, obj interface{}) (UnassignTaskInput, error) { var it UnassignTaskInput var asMap = obj.(map[string]interface{}) @@ -21013,6 +21743,33 @@ func (ec *executionContext) _DuplicateTaskGroupPayload(ctx context.Context, sel return out } +var hasUnreadNotificationsResultImplementors = []string{"HasUnreadNotificationsResult"} + +func (ec *executionContext) _HasUnreadNotificationsResult(ctx context.Context, sel ast.SelectionSet, obj *HasUnreadNotificationsResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, hasUnreadNotificationsResultImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("HasUnreadNotificationsResult") + case "unread": + out.Values[i] = ec._HasUnreadNotificationsResult_unread(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var inviteProjectMembersPayloadImplementors = []string{"InviteProjectMembersPayload"} func (ec *executionContext) _InviteProjectMembersPayload(ctx context.Context, sel ast.SelectionSet, obj *InviteProjectMembersPayload) graphql.Marshaler { @@ -21362,6 +22119,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("Mutation") + case "notificationToggleRead": + out.Values[i] = ec._Mutation_notificationToggleRead(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "createProjectLabel": out.Values[i] = ec._Mutation_createProjectLabel(ctx, field) if out.Values[i] == graphql.Null { @@ -21572,6 +22334,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "toggleTaskWatch": + out.Values[i] = ec._Mutation_toggleTaskWatch(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "assignTask": out.Values[i] = ec._Mutation_assignTask(ctx, field) if out.Values[i] == graphql.Null { @@ -21738,9 +22505,6 @@ func (ec *executionContext) _Notification(ctx context.Context, sel ast.Selection } }() res = ec._Notification_causedBy(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&invalids, 1) - } return res }) case "data": @@ -21890,6 +22654,43 @@ func (ec *executionContext) _Notified(ctx context.Context, sel ast.SelectionSet, return out } +var notifiedResultImplementors = []string{"NotifiedResult"} + +func (ec *executionContext) _NotifiedResult(ctx context.Context, sel ast.SelectionSet, obj *NotifiedResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, notifiedResultImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("NotifiedResult") + case "totalCount": + out.Values[i] = ec._NotifiedResult_totalCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "notified": + out.Values[i] = ec._NotifiedResult_notified(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "pageInfo": + out.Values[i] = ec._NotifiedResult_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var organizationImplementors = []string{"Organization"} func (ec *executionContext) _Organization(ctx context.Context, sel ast.SelectionSet, obj *db.Organization) graphql.Marshaler { @@ -21995,6 +22796,38 @@ func (ec *executionContext) _OwnersList(ctx context.Context, sel ast.SelectionSe return out } +var pageInfoImplementors = []string{"PageInfo"} + +func (ec *executionContext) _PageInfo(ctx context.Context, sel ast.SelectionSet, obj *PageInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, pageInfoImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("PageInfo") + case "endCursor": + out.Values[i] = ec._PageInfo_endCursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "hasNextPage": + out.Values[i] = ec._PageInfo_hasNextPage(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var profileIconImplementors = []string{"ProfileIcon"} func (ec *executionContext) _ProfileIcon(ctx context.Context, sel ast.SelectionSet, obj *ProfileIcon) graphql.Marshaler { @@ -22536,6 +23369,34 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "notified": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_notified(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) + case "hasUnreadNotifications": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_hasUnreadNotifications(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "searchMembers": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -22714,6 +23575,20 @@ func (ec *executionContext) _Task(ctx context.Context, sel ast.SelectionSet, obj res = ec._Task_description(ctx, field, obj) return res }) + case "watched": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Task_watched(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "dueDate": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -24586,6 +25461,20 @@ func (ec *executionContext) marshalNFloat2float64(ctx context.Context, sel ast.S return res } +func (ec *executionContext) marshalNHasUnreadNotificationsResult2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐHasUnreadNotificationsResult(ctx context.Context, sel ast.SelectionSet, v HasUnreadNotificationsResult) graphql.Marshaler { + return ec._HasUnreadNotificationsResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNHasUnreadNotificationsResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐHasUnreadNotificationsResult(ctx context.Context, sel ast.SelectionSet, v *HasUnreadNotificationsResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._HasUnreadNotificationsResult(ctx, sel, v) +} + func (ec *executionContext) unmarshalNID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx context.Context, v interface{}) (uuid.UUID, error) { res, err := UnmarshalUUID(v) return res, graphql.ErrorOnPath(ctx, err) @@ -25019,20 +25908,6 @@ func (ec *executionContext) marshalNNotification2ᚖgithubᚗcomᚋjordanknott return ec._Notification(ctx, sel, v) } -func (ec *executionContext) marshalNNotificationCausedBy2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationCausedBy(ctx context.Context, sel ast.SelectionSet, v NotificationCausedBy) graphql.Marshaler { - return ec._NotificationCausedBy(ctx, sel, &v) -} - -func (ec *executionContext) marshalNNotificationCausedBy2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationCausedBy(ctx context.Context, sel ast.SelectionSet, v *NotificationCausedBy) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - return ec._NotificationCausedBy(ctx, sel, v) -} - func (ec *executionContext) marshalNNotificationData2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationData(ctx context.Context, sel ast.SelectionSet, v NotificationData) graphql.Marshaler { return ec._NotificationData(ctx, sel, &v) } @@ -25074,6 +25949,21 @@ func (ec *executionContext) marshalNNotificationData2ᚕgithubᚗcomᚋjordankno return ret } +func (ec *executionContext) unmarshalNNotificationFilter2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationFilter(ctx context.Context, v interface{}) (NotificationFilter, error) { + var res NotificationFilter + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNNotificationFilter2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationFilter(ctx context.Context, sel ast.SelectionSet, v NotificationFilter) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNNotificationToggleReadInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationToggleReadInput(ctx context.Context, v interface{}) (NotificationToggleReadInput, error) { + res, err := ec.unmarshalInputNotificationToggleReadInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNNotified2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotified(ctx context.Context, sel ast.SelectionSet, v Notified) graphql.Marshaler { return ec._Notified(ctx, sel, &v) } @@ -25125,6 +26015,25 @@ func (ec *executionContext) marshalNNotified2ᚖgithubᚗcomᚋjordanknottᚋtas return ec._Notified(ctx, sel, v) } +func (ec *executionContext) unmarshalNNotifiedInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedInput(ctx context.Context, v interface{}) (NotifiedInput, error) { + res, err := ec.unmarshalInputNotifiedInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNNotifiedResult2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedResult(ctx context.Context, sel ast.SelectionSet, v NotifiedResult) graphql.Marshaler { + return ec._NotifiedResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNNotifiedResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotifiedResult(ctx context.Context, sel ast.SelectionSet, v *NotifiedResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._NotifiedResult(ctx, sel, v) +} + func (ec *executionContext) unmarshalNObjectType2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐObjectType(ctx context.Context, v interface{}) (ObjectType, error) { var res ObjectType err := res.UnmarshalGQL(v) @@ -25190,6 +26099,16 @@ func (ec *executionContext) marshalNOwnedList2ᚖgithubᚗcomᚋjordanknottᚋta return ec._OwnedList(ctx, sel, v) } +func (ec *executionContext) marshalNPageInfo2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐPageInfo(ctx context.Context, sel ast.SelectionSet, v *PageInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._PageInfo(ctx, sel, v) +} + func (ec *executionContext) marshalNProfileIcon2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProfileIcon(ctx context.Context, sel ast.SelectionSet, v ProfileIcon) graphql.Marshaler { return ec._ProfileIcon(ctx, sel, &v) } @@ -26146,6 +27065,11 @@ func (ec *executionContext) marshalNToggleTaskLabelPayload2ᚖgithubᚗcomᚋjor return ec._ToggleTaskLabelPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNToggleTaskWatch2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐToggleTaskWatch(ctx context.Context, v interface{}) (ToggleTaskWatch, error) { + res, err := ec.unmarshalInputToggleTaskWatch(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx context.Context, v interface{}) (uuid.UUID, error) { res, err := UnmarshalUUID(v) return res, graphql.ErrorOnPath(ctx, err) @@ -26754,6 +27678,13 @@ func (ec *executionContext) marshalOMePayload2ᚖgithubᚗcomᚋjordanknottᚋta return ec._MePayload(ctx, sel, v) } +func (ec *executionContext) marshalONotificationCausedBy2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationCausedBy(ctx context.Context, sel ast.SelectionSet, v *NotificationCausedBy) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._NotificationCausedBy(ctx, sel, v) +} + func (ec *executionContext) marshalOProfileIcon2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProfileIcon(ctx context.Context, sel ast.SelectionSet, v *ProfileIcon) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 95e9479..d15fba7 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -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 ( diff --git a/internal/graph/helpers.go b/internal/graph/helpers.go index a6a8c6f..0d646c7 100644 --- a/internal/graph/helpers.go +++ b/internal/graph/helpers.go @@ -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 +} diff --git a/internal/graph/models_gen.go b/internal/graph/models_gen.go index b62eaaf..ca9dd91 100644 --- a/internal/graph/models_gen.go +++ b/internal/graph/models_gen.go @@ -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 ( diff --git a/internal/graph/notification.resolvers.go b/internal/graph/notification.resolvers.go index 42addee..ee3af53 100644 --- a/internal/graph/notification.resolvers.go +++ b/internal/graph/notification.resolvers.go @@ -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. diff --git a/internal/graph/resolver.go b/internal/graph/resolver.go index 5f6a7a8..2d9dd7c 100644 --- a/internal/graph/resolver.go +++ b/internal/graph/resolver.go @@ -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 } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index af90e66..202a7d4 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -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) diff --git a/internal/graph/schema/notification.gql b/internal/graph/schema/notification.gql index 2ca1483..7cf5fb4 100755 --- a/internal/graph/schema/notification.gql +++ b/internal/graph/schema/notification.gql @@ -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! } diff --git a/internal/graph/schema/notification/notification.gql b/internal/graph/schema/notification/notification.gql index 419a7f6..72e925e 100644 --- a/internal/graph/schema/notification/notification.gql +++ b/internal/graph/schema/notification/notification.gql @@ -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! } diff --git a/internal/graph/schema/task.gql b/internal/graph/schema/task.gql index 9d77065..ae8cdc5 100755 --- a/internal/graph/schema/task.gql +++ b/internal/graph/schema/task.gql @@ -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! diff --git a/internal/graph/schema/task/_model.gql b/internal/graph/schema/task/_model.gql index e8d4f9e..aec8a43 100644 --- a/internal/graph/schema/task/_model.gql +++ b/internal/graph/schema/task/_model.gql @@ -27,6 +27,7 @@ type Task { name: String! position: Float! description: String + watched: Boolean! dueDate: Time hasTime: Boolean! complete: Boolean! diff --git a/internal/graph/schema/task/task.gql b/internal/graph/schema/task/task.gql index ded5c21..8e28dc3 100644 --- a/internal/graph/schema/task/task.gql +++ b/internal/graph/schema/task/task.gql @@ -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! diff --git a/internal/graph/task.resolvers.go b/internal/graph/task.resolvers.go index 930f76f..dfe9211 100644 --- a/internal/graph/task.resolvers.go +++ b/internal/graph/task.resolvers.go @@ -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 diff --git a/internal/route/middleware.go b/internal/route/middleware.go index d63033e..c0eafe6 100644 --- a/internal/route/middleware.go +++ b/internal/route/middleware.go @@ -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) } } diff --git a/internal/route/route.go b/internal/route/route.go index 8e9c4d8..78e624d 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -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"} diff --git a/internal/utils/cursor.go b/internal/utils/cursor.go new file mode 100644 index 0000000..6196434 --- /dev/null +++ b/internal/utils/cursor.go @@ -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)) +} diff --git a/migrations/0066_redesign-notification-table.up.sql b/migrations/0066_redesign-notification-table.up.sql index f4c0805..7be7680 100644 --- a/migrations/0066_redesign-notification-table.up.sql +++ b/migrations/0066_redesign-notification-table.up.sql @@ -16,3 +16,5 @@ CREATE TABLE notification_notified ( read boolean NOT NULL DEFAULT false, read_at timestamptz ); + +CREATE INDEX idx_notification_pagination ON notification (created_on, notification_id); diff --git a/migrations/0067_add-user_account_settings.up.sql b/migrations/0067_add-user_account_settings.up.sql index be46111..5d57e54 100644 --- a/migrations/0067_add-user_account_settings.up.sql +++ b/migrations/0067_add-user_account_settings.up.sql @@ -10,7 +10,7 @@ CREATE TABLE account_setting ( data_type text NOT NULL REFERENCES account_setting_data_type(data_type_id) ON DELETE CASCADE, constrained_default_value text REFERENCES account_setting_allowed_values(allowed_value_id) ON DELETE CASCADE, - unconstrained_default_value text, + unconstrained_default_value text ); INSERT INTO account_setting VALUES ('email_notification_frequency', true, 'string'); @@ -25,7 +25,7 @@ INSERT INTO account_setting_allowed_values (setting_id, item_value) VALUES (0, ' INSERT INTO account_setting_allowed_values (setting_id, item_value) VALUES (0, 'hourly'); INSERT INTO account_setting_allowed_values (setting_id, item_value) VALUES (0, 'instant'); -CREATE TABLE account_setting ( +CREATE TABLE account_setting_value ( account_setting_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), user_id uuid NOT NULL REFERENCES user_account(user_id) ON DELETE CASCADE, setting_id int NOT NULL REFERENCES account_setting(account_setting_id) ON DELETE CASCADE, diff --git a/migrations/0068_add-task_watcher-table.up.sql b/migrations/0068_add-task_watcher-table.up.sql new file mode 100644 index 0000000..c688b38 --- /dev/null +++ b/migrations/0068_add-task_watcher-table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE task_watcher ( + task_watcher_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + task_id uuid NOT NULL REFERENCES task(task_id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES user_account(user_id) ON DELETE CASCADE, + watched_at timestamptz NOT NULL +);