feat: add bell notification system for task assignment

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

View File

@ -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<GlobalTopNavbarProps> = ({
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { data } = useTopNavbarQuery();
const [notifications, setNotifications] = useState<Array<{ id: string; notification: { actionType: string } }>>([]);
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<GlobalTopNavbarProps> = ({
}
};
// TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup(
$target,
<NotificationPopup>
{data.notifications.map((notification) => (
<NotificationItem
title={notification.notification.actionType}
description={`${notification.notification.causedBy.fullname} added you as a meber to the task "${notification.notification.actionType}"`}
createdAt={notification.notification.createdAt}
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: theme.colors.primary },
);
showPopup($target, <NotificationPopup />, { width: 605, borders: false, diamondColor: theme.colors.primary });
}
};
@ -174,17 +185,12 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
}
};
if (data) {
console.log('HERE DATA');
console.log(data.me);
} else {
console.log('NO DATA');
}
const user = data ? data.me?.user : null;
return (
<>
<TopNavbar
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
name={name}
menuType={menuType}
onOpenProjectFinder={($target) => {

View File

@ -430,6 +430,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
__typename: 'Task',
id: `${Math.round(Math.random() * -1000000)}`,
name,
watched: false,
complete: false,
completedAt: null,
hasTime: false,

View File

@ -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<DetailsProps> = ({
);
},
});
const [toggleTaskWatch] = useToggleTaskWatchMutation();
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
@ -440,6 +442,19 @@ const Details: React.FC<DetailsProps> = ({
);
}}
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<DetailsProps> = ({
bio="None"
onRemoveFromTask={() => {
if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } });
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } });
hidePopup();
}
}}
/>

View File

@ -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

View File

@ -225,7 +225,7 @@ const Card = React.forwardRef(
<ListCardBadges>
{watched && (
<ListCardBadge>
<Eye width={8} height={8} />
<Eye width={12} height={12} />
</ListCardBadge>
)}
{dueDate && (

View File

@ -329,6 +329,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
toggleLabels={toggleLabels}
isPublic={isPublic}
labelVariant={cardLabelVariant}
watched={task.watched}
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,

View File

@ -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<NotificationItemProps> = ({ 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<NotificationProps> = ({ causedBy, createdAt, data, actionType, read, onToggleRead }) => {
const prefix: any = [];
const { hidePopup } = usePopup();
const dataMap = new Map<string, string>();
data.forEach((d) => dataMap.set(d.key, d.value));
let link = '#';
switch (actionType) {
case ActionType.TaskAssigned:
prefix.push(<UserCircle width={14} height={16} />);
prefix.push(<NotificationPrefix>Assigned </NotificationPrefix>);
prefix.push(<span>you to the task "{dataMap.get('TaskName')}"</span>);
link = `/projects/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break;
default:
throw new Error('unknown action type');
}
return (
<NotificationWrapper>
<NotificationLink to={link} onClick={hidePopup}>
<NotificationCausedBy>
<NotificationCausedByInitials>
{causedBy
? causedBy.fullname
.split(' ')
.map((n) => n[0])
.join('.')
: 'RU'}
</NotificationCausedByInitials>
</NotificationCausedBy>
<NotificationContent>
<NotificationContentHeader>
{causedBy ? causedBy.fullname : 'Removed user'}
{!read && <CircleSolid width={10} height={10} />}
</NotificationContentHeader>
<NotificationBody>{prefix}</NotificationBody>
<NotificationContentFooter>
<span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span>
<NotificationSeparator></NotificationSeparator>
<span>{dataMap.get('ProjectName')}</span>
</NotificationContentFooter>
</NotificationContent>
</NotificationLink>
<NotificationControls>
<NotificationButtons>
<NotificationButton onClick={() => onToggleRead()}>
{read ? <Circle width={18} height={18} /> : <CheckCircleOutline width={18} height={18} />}
</NotificationButton>
</NotificationButtons>
</NotificationControls>
</NotificationWrapper>
);
};
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>(NotificationFilter.Unread);
const [data, setData] = useState<{ nodes: Array<NotificationEntry>; 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 (
<Popup title={null} tab={0} borders={false} padding={false}>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
</NotificationHeader>
<ul>{children}</ul>
<NotificationFooter>View All</NotificationFooter>
<PopupContent>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
</NotificationHeader>
<NotificationTabs>
{tabs.map((tab) => (
<NotificationTab
key={tab.key}
onClick={() => {
if (filter !== tab.key) {
setData({ cursor: '', hasNextPage: false, nodes: [] });
setFilter(tab.key);
}
}}
active={tab.key === filter}
>
{tab.label}
</NotificationTab>
))}
</NotificationTabs>
<Notifications
onScroll={({ currentTarget }) => {
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) => (
<Notification
key={n.id}
read={n.read}
actionType={n.notification.actionType}
data={n.notification.data}
createdAt={n.notification.createdAt}
causedBy={n.notification.causedBy}
onToggleRead={() =>
toggleRead({
variables: { notifiedID: n.id },
optimisticResponse: {
__typename: 'Mutation',
notificationToggleRead: {
__typename: 'Notified',
id: n.id,
read: !n.read,
readAt: new Date().toUTCString(),
},
},
})
}
/>
))}
</Notifications>
</PopupContent>
</Popup>
);
};

View File

@ -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;
`;

View File

@ -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<HTMLElement>) => void;
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onToggleTaskWatch: (task: Task, watched: boolean) => void;
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void;
@ -258,6 +261,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
task,
editableComment = null,
onDeleteChecklist,
onToggleTaskWatch,
onTaskNameChange,
onCommentShowActions,
onOpenAddChecklistPopup,
@ -328,6 +332,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current);
};
console.log(task.watched);
return (
<Container>
<LeftSidebar>
@ -418,6 +423,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Checklist
</ActionButton>
<ActionButton>Cover</ActionButton>
<ActionButton
onClick={() => {
onToggleTaskWatch(task, !task.watched);
}}
icon={<Eye width={12} height={12} />}
>
Watch {task.watched && <WatchedCheckmark width={18} height={18} />}
</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>

View File

@ -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};
`;

View File

@ -36,6 +36,7 @@ import {
ProjectMember,
ProjectMembers,
ProjectSwitchInner,
NotificationCount,
} from './Styles';
type IconContainerProps = {
@ -185,6 +186,7 @@ type NavBarProps = {
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
hasUnread: boolean;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
@ -203,6 +205,7 @@ const NavBar: React.FC<NavBarProps> = ({
onOpenProjectFinder,
onFavorite,
onSetTab,
hasUnread,
projectInvitedMembers,
onChangeRole,
name,
@ -330,8 +333,9 @@ const NavBar: React.FC<NavBarProps> = ({
<IconContainer disabled onClick={NOOP}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<IconContainer onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
{hasUnread && <NotificationCount />}
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<BarChart width={20} height={20} />

View File

@ -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<MemberInvite>;
@ -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<RemoveTaskLabelInput>;
};
@ -599,6 +624,11 @@ export type MutationToggleTaskLabelArgs = {
};
export type MutationToggleTaskWatchArgs = {
input: ToggleTaskWatch;
};
export type MutationUnassignTaskArgs = {
input?: Maybe<UnassignTaskInput>;
};
@ -784,7 +814,7 @@ export type Notification = {
__typename?: 'Notification';
id: Scalars['ID'];
actionType: ActionType;
causedBy: NotificationCausedBy;
causedBy?: Maybe<NotificationCausedBy>;
data: Array<NotificationData>;
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<Scalars['Time']>;
};
export type NotifiedInput = {
limit: Scalars['Int'];
cursor?: Maybe<Scalars['String']>;
filter: NotificationFilter;
};
export type NotifiedResult = {
__typename?: 'NotifiedResult';
totalCount: Scalars['Int'];
notified: Array<Notified>;
pageInfo: PageInfo;
};
export enum ObjectType {
Org = 'ORG',
Team = 'TEAM',
@ -838,6 +892,12 @@ export type OwnersList = {
teams: Array<Scalars['UUID']>;
};
export type PageInfo = {
__typename?: 'PageInfo';
endCursor: Scalars['String'];
hasNextPage: Scalars['Boolean'];
};
export type ProfileIcon = {
__typename?: 'ProfileIcon';
url?: Maybe<Scalars['String']>;
@ -896,11 +956,13 @@ export type Query = {
findTask: Task;
findTeam: Team;
findUser: UserAccount;
hasUnreadNotifications: HasUnreadNotificationsResult;
invitedUsers: Array<InvitedUserAccount>;
labelColors: Array<LabelColor>;
me?: Maybe<MePayload>;
myTasks: MyTasksPayload;
notifications: Array<Notified>;
notified: NotifiedResult;
organizations: Array<Organization>;
projects: Array<Project>;
searchMembers: Array<MemberSearchResult>;
@ -935,6 +997,11 @@ export type QueryMyTasksArgs = {
};
export type QueryNotifiedArgs = {
input: NotifiedInput;
};
export type QueryProjectsArgs = {
input?: Maybe<ProjectsFilter>;
};
@ -1006,6 +1073,7 @@ export type Task = {
name: Scalars['String'];
position: Scalars['Float'];
description?: Maybe<Scalars['String']>;
watched: Scalars['Boolean'];
dueDate?: Maybe<Scalars['Time']>;
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<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete' | 'hasTime'>
& Pick<Task, 'id' | 'name' | 'watched' | 'description' | 'dueDate' | 'position' | 'complete' | 'hasTime'>
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name'>
@ -1596,7 +1668,7 @@ export type FindTaskQuery = (
export type TaskFieldsFragment = (
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'hasTime' | 'complete' | 'completedAt' | 'position'>
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'hasTime' | 'complete' | 'watched' | 'completedAt' | 'position'>
& { 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<Notified, 'id' | 'read' | 'readAt'>
) }
);
export type NotificationsQueryVariables = Exact<{
limit: Scalars['Int'];
cursor?: Maybe<Scalars['String']>;
filter: NotificationFilter;
}>;
export type NotificationsQuery = (
{ __typename?: 'Query' }
& { notified: (
{ __typename?: 'NotifiedResult' }
& Pick<NotifiedResult, 'totalCount'>
& { pageInfo: (
{ __typename?: 'PageInfo' }
& Pick<PageInfo, 'endCursor' | 'hasNextPage'>
), notified: Array<(
{ __typename?: 'Notified' }
& Pick<Notified, 'id' | 'read' | 'readAt'>
& { notification: (
{ __typename?: 'Notification' }
& Pick<Notification, 'id' | 'actionType' | 'createdAt'>
& { data: Array<(
{ __typename?: 'NotificationData' }
& Pick<NotificationData, 'key' | 'value'>
)>, causedBy?: Maybe<(
{ __typename?: 'NotificationCausedBy' }
& Pick<NotificationCausedBy, 'username' | 'fullname' | 'id'>
)> }
) }
)> }
) }
);
export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>;
export type NotificationAddedSubscription = (
{ __typename?: 'Subscription' }
& { notificationAdded: (
{ __typename?: 'Notified' }
& Pick<Notified, 'id' | 'read' | 'readAt'>
& { notification: (
{ __typename?: 'Notification' }
& Pick<Notification, 'id' | 'actionType' | 'createdAt'>
& { data: Array<(
{ __typename?: 'NotificationData' }
& Pick<NotificationData, 'key' | 'value'>
)>, causedBy?: Maybe<(
{ __typename?: 'NotificationCausedBy' }
& Pick<NotificationCausedBy, 'username' | 'fullname' | 'id'>
)> }
) }
) }
);
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<Task, 'id' | 'watched'>
) }
);
export type UpdateTaskChecklistItemLocationMutationVariables = Exact<{
taskChecklistID: Scalars['UUID'];
taskChecklistItemID: Scalars['UUID'];
@ -2363,10 +2516,10 @@ export type TopNavbarQuery = (
& { notification: (
{ __typename?: 'Notification' }
& Pick<Notification, 'id' | 'actionType' | 'createdAt'>
& { causedBy: (
& { causedBy?: Maybe<(
{ __typename?: 'NotificationCausedBy' }
& Pick<NotificationCausedBy, 'username' | 'fullname' | 'id'>
) }
)> }
) }
)>, 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<HasUnreadNotificationsResult, 'unread'>
) }
);
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<My
export type MyTasksQueryHookResult = ReturnType<typeof useMyTasksQuery>;
export type MyTasksLazyQueryHookResult = ReturnType<typeof useMyTasksLazyQuery>;
export type MyTasksQueryResult = Apollo.QueryResult<MyTasksQuery, MyTasksQueryVariables>;
export const NotificationToggleReadDocument = gql`
mutation notificationToggleRead($notifiedID: UUID!) {
notificationToggleRead(input: {notifiedID: $notifiedID}) {
id
read
readAt
}
}
`;
export type NotificationToggleReadMutationFn = Apollo.MutationFunction<NotificationToggleReadMutation, NotificationToggleReadMutationVariables>;
/**
* __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<NotificationToggleReadMutation, NotificationToggleReadMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<NotificationToggleReadMutation, NotificationToggleReadMutationVariables>(NotificationToggleReadDocument, options);
}
export type NotificationToggleReadMutationHookResult = ReturnType<typeof useNotificationToggleReadMutation>;
export type NotificationToggleReadMutationResult = Apollo.MutationResult<NotificationToggleReadMutation>;
export type NotificationToggleReadMutationOptions = Apollo.BaseMutationOptions<NotificationToggleReadMutation, NotificationToggleReadMutationVariables>;
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<NotificationsQuery, NotificationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<NotificationsQuery, NotificationsQueryVariables>(NotificationsDocument, options);
}
export function useNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<NotificationsQuery, NotificationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<NotificationsQuery, NotificationsQueryVariables>(NotificationsDocument, options);
}
export type NotificationsQueryHookResult = ReturnType<typeof useNotificationsQuery>;
export type NotificationsLazyQueryHookResult = ReturnType<typeof useNotificationsLazyQuery>;
export type NotificationsQueryResult = Apollo.QueryResult<NotificationsQuery, NotificationsQueryVariables>;
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<NotificationAddedSubscription, NotificationAddedSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<NotificationAddedSubscription, NotificationAddedSubscriptionVariables>(NotificationAddedDocument, options);
}
export type NotificationAddedSubscriptionHookResult = ReturnType<typeof useNotificationAddedSubscription>;
export type NotificationAddedSubscriptionResult = Apollo.SubscriptionResult<NotificationAddedSubscription>;
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<typeof useSetTaskCompleteMutation>;
export type SetTaskCompleteMutationResult = Apollo.MutationResult<SetTaskCompleteMutation>;
export type SetTaskCompleteMutationOptions = Apollo.BaseMutationOptions<SetTaskCompleteMutation, SetTaskCompleteMutationVariables>;
export const ToggleTaskWatchDocument = gql`
mutation toggleTaskWatch($taskID: UUID!) {
toggleTaskWatch(input: {taskID: $taskID}) {
id
watched
}
}
`;
export type ToggleTaskWatchMutationFn = Apollo.MutationFunction<ToggleTaskWatchMutation, ToggleTaskWatchMutationVariables>;
/**
* __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<ToggleTaskWatchMutation, ToggleTaskWatchMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ToggleTaskWatchMutation, ToggleTaskWatchMutationVariables>(ToggleTaskWatchDocument, options);
}
export type ToggleTaskWatchMutationHookResult = ReturnType<typeof useToggleTaskWatchMutation>;
export type ToggleTaskWatchMutationResult = Apollo.MutationResult<ToggleTaskWatchMutation>;
export type ToggleTaskWatchMutationOptions = Apollo.BaseMutationOptions<ToggleTaskWatchMutation, ToggleTaskWatchMutationVariables>;
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<typeof useUnassignTaskMutation>;
export type UnassignTaskMutationResult = Apollo.MutationResult<UnassignTaskMutation>;
export type UnassignTaskMutationOptions = Apollo.BaseMutationOptions<UnassignTaskMutation, UnassignTaskMutationVariables>;
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<HasUnreadNotificationsQuery, HasUnreadNotificationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<HasUnreadNotificationsQuery, HasUnreadNotificationsQueryVariables>(HasUnreadNotificationsDocument, options);
}
export function useHasUnreadNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<HasUnreadNotificationsQuery, HasUnreadNotificationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<HasUnreadNotificationsQuery, HasUnreadNotificationsQueryVariables>(HasUnreadNotificationsDocument, options);
}
export type HasUnreadNotificationsQueryHookResult = ReturnType<typeof useHasUnreadNotificationsQuery>;
export type HasUnreadNotificationsLazyQueryHookResult = ReturnType<typeof useHasUnreadNotificationsLazyQuery>;
export type HasUnreadNotificationsQueryResult = Apollo.QueryResult<HasUnreadNotificationsQuery, HasUnreadNotificationsQueryVariables>;
export const UpdateProjectLabelDocument = gql`
mutation updateProjectLabel($projectLabelID: UUID!, $labelColorID: UUID!, $name: String!) {
updateProjectLabel(

View File

@ -2,6 +2,7 @@ query findTask($taskID: UUID!) {
findTask(input: {taskID: $taskID}) {
id
name
watched
description
dueDate
position

View File

@ -8,6 +8,7 @@ const TASK_FRAGMENT = gql`
dueDate
hasTime
complete
watched
completedAt
position
badges {

View File

@ -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;

View File

@ -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;

View File

@ -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
}
}
}
`;

View File

@ -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;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export const TOP_NAVBAR_QUERY = gql`
query hasUnreadNotifications {
hasUnreadNotifications {
unread
}
}
`;
export default TOP_NAVBAR_QUERY;

View File

@ -8,7 +8,7 @@ type Props = {
const Bell = ({ size, color }: Props) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 448 512">
<path d="M439.39 362.29c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM67.53 368c21.22-27.97 44.42-74.33 44.53-159.42 0-.2-.06-.38-.06-.58 0-61.86 50.14-112 112-112s112 50.14 112 112c0 .2-.06.38-.06.58.11 85.1 23.31 131.46 44.53 159.42H67.53zM224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64z" />
<path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z" />
</svg>
);
};

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Circle: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z" />
</Icon>
);
};
export default Circle;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CircleSolid: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z" />
</Icon>
);
};
export default CircleSolid;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const UserCircle: React.FC<IconProps> = ({ width = '16px', height = '16px', className, onClick }) => {
return (
<Icon onClick={onClick} width={width} height={height} className={className} viewBox="0 0 496 512">
<path d="M248 104c-53 0-96 43-96 96s43 96 96 96 96-43 96-96-43-96-96-96zm0 144c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48zm0-240C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-49.7 0-95.1-18.3-130.1-48.4 14.9-23 40.4-38.6 69.6-39.5 20.8 6.4 40.6 9.6 60.5 9.6s39.7-3.1 60.5-9.6c29.2 1 54.7 16.5 69.6 39.5-35 30.1-80.4 48.4-130.1 48.4zm162.7-84.1c-24.4-31.4-62.1-51.9-105.1-51.9-10.2 0-26 9.6-57.6 9.6-31.5 0-47.4-9.6-57.6-9.6-42.9 0-80.6 20.5-105.1 51.9C61.9 339.2 48 299.2 48 256c0-110.3 89.7-200 200-200s200 89.7 200 200c0 43.2-13.9 83.2-37.3 115.9z" />
</Icon>
);
};
export default UserCircle;

View File

@ -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,
};

View File

@ -8,6 +8,7 @@ const polling = {
MEMBERS: resolve(3000),
TEAM_PROJECTS: resolve(3000),
TASK_DETAILS: resolve(3000),
UNREAD_NOTIFICATIONS: resolve(30000),
};
export default polling;

View File

@ -105,6 +105,7 @@ type Task = {
id: string;
taskGroup: InnerTaskGroup;
name: string;
watched?: boolean;
badges?: TaskBadges;
position: number;
hasTime?: boolean;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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);

View File

@ -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,

View File

@ -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
);