feat: add bell notification system for task assignment
This commit is contained in:
parent
3afd860534
commit
799d7f3ad0
@ -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) => {
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -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
|
||||
|
@ -225,7 +225,7 @@ const Card = React.forwardRef(
|
||||
<ListCardBadges>
|
||||
{watched && (
|
||||
<ListCardBadge>
|
||||
<Eye width={8} height={8} />
|
||||
<Eye width={12} height={12} />
|
||||
</ListCardBadge>
|
||||
)}
|
||||
{dueDate && (
|
||||
|
@ -329,6 +329,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
||||
toggleLabels={toggleLabels}
|
||||
isPublic={isPublic}
|
||||
labelVariant={cardLabelVariant}
|
||||
watched={task.watched}
|
||||
wrapperProps={{
|
||||
...taskProvided.draggableProps,
|
||||
...taskProvided.dragHandleProps,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
`;
|
||||
|
@ -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>
|
||||
|
@ -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};
|
||||
`;
|
||||
|
@ -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} />
|
||||
|
@ -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(
|
||||
|
@ -2,6 +2,7 @@ query findTask($taskID: UUID!) {
|
||||
findTask(input: {taskID: $taskID}) {
|
||||
id
|
||||
name
|
||||
watched
|
||||
description
|
||||
dueDate
|
||||
position
|
||||
|
@ -8,6 +8,7 @@ const TASK_FRAGMENT = gql`
|
||||
dueDate
|
||||
hasTime
|
||||
complete
|
||||
watched
|
||||
completedAt
|
||||
position
|
||||
badges {
|
||||
|
13
frontend/src/shared/graphql/notificationToggleRead.ts
Normal file
13
frontend/src/shared/graphql/notificationToggleRead.ts
Normal 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;
|
34
frontend/src/shared/graphql/notifications.ts
Normal file
34
frontend/src/shared/graphql/notifications.ts
Normal 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;
|
26
frontend/src/shared/graphql/onNotificationAdded.ts
Normal file
26
frontend/src/shared/graphql/onNotificationAdded.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
12
frontend/src/shared/graphql/task/toggleTaskWatcher.ts
Normal file
12
frontend/src/shared/graphql/task/toggleTaskWatcher.ts
Normal 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;
|
11
frontend/src/shared/graphql/unreadNotifications.ts
Normal file
11
frontend/src/shared/graphql/unreadNotifications.ts
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
12
frontend/src/shared/icons/Circle.tsx
Normal file
12
frontend/src/shared/icons/Circle.tsx
Normal 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;
|
12
frontend/src/shared/icons/CircleSolid.tsx
Normal file
12
frontend/src/shared/icons/CircleSolid.tsx
Normal 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;
|
12
frontend/src/shared/icons/UserCircle.tsx
Normal file
12
frontend/src/shared/icons/UserCircle.tsx
Normal 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;
|
@ -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,
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ const polling = {
|
||||
MEMBERS: resolve(3000),
|
||||
TEAM_PROJECTS: resolve(3000),
|
||||
TASK_DETAILS: resolve(3000),
|
||||
UNREAD_NOTIFICATIONS: resolve(30000),
|
||||
};
|
||||
|
||||
export default polling;
|
||||
|
1
frontend/src/types.d.ts
vendored
1
frontend/src/types.d.ts
vendored
@ -105,6 +105,7 @@ type Task = {
|
||||
id: string;
|
||||
taskGroup: InnerTaskGroup;
|
||||
name: string;
|
||||
watched?: boolean;
|
||||
badges?: TaskBadges;
|
||||
position: number;
|
||||
hasTime?: boolean;
|
||||
|
@ -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()
|
||||
}
|
||||
|
93
internal/commands/token.go
Normal file
93
internal/commands/token.go
Normal 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
|
||||
}
|
@ -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"`
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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 *;
|
||||
|
@ -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
@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
@ -31,6 +32,10 @@ func NewHandler(repo db.Repository, appConfig config.AppConfig) http.Handler {
|
||||
Resolvers: &Resolver{
|
||||
Repository: repo,
|
||||
AppConfig: appConfig,
|
||||
Notifications: NotificationObservers{
|
||||
Mu: sync.Mutex{},
|
||||
Subscribers: make(map[string]map[string]chan *Notified),
|
||||
},
|
||||
},
|
||||
}
|
||||
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
|
||||
@ -223,16 +228,6 @@ func ConvertToRoleCode(r string) RoleCode {
|
||||
return RoleCodeObserver
|
||||
}
|
||||
|
||||
// GetActionType converts integer to ActionType enum
|
||||
func GetActionType(actionType int32) ActionType {
|
||||
switch actionType {
|
||||
case 1:
|
||||
return ActionTypeTaskMemberAdded
|
||||
default:
|
||||
panic("Not a valid entity type!")
|
||||
}
|
||||
}
|
||||
|
||||
type MemberType string
|
||||
|
||||
const (
|
||||
|
@ -3,8 +3,12 @@ package graph
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetOwnedList todo: remove this
|
||||
@ -12,6 +16,57 @@ func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*O
|
||||
return &OwnedList{}, nil
|
||||
}
|
||||
|
||||
type CreateNotificationParams struct {
|
||||
NotifiedList []uuid.UUID
|
||||
ActionType ActionType
|
||||
CausedBy uuid.UUID
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
func (r *Resolver) CreateNotification(ctx context.Context, data CreateNotificationParams) error {
|
||||
now := time.Now().UTC()
|
||||
raw, err := json.Marshal(NotifiedData{Data: data.Data})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while marshal json data for notification")
|
||||
return err
|
||||
}
|
||||
log.WithField("ActionType", data.ActionType).Info("creating notification object")
|
||||
n, err := r.Repository.CreateNotification(ctx, db.CreateNotificationParams{
|
||||
CausedBy: data.CausedBy,
|
||||
ActionType: data.ActionType.String(),
|
||||
CreatedOn: now,
|
||||
Data: json.RawMessage(raw),
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating notification")
|
||||
return err
|
||||
}
|
||||
for _, nn := range data.NotifiedList {
|
||||
log.WithFields(log.Fields{"UserID": nn, "NotificationID": n.NotificationID}).Info("creating notification notified object")
|
||||
notified, err := r.Repository.CreateNotificationNotifed(ctx, db.CreateNotificationNotifedParams{
|
||||
UserID: nn,
|
||||
NotificationID: n.NotificationID,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating notification notified object")
|
||||
return err
|
||||
}
|
||||
for ouid, observers := range r.Notifications.Subscribers {
|
||||
log.WithField("ouid", ouid).Info("checking user subscribers")
|
||||
for oid, ochan := range observers {
|
||||
log.WithField("ouid", ouid).WithField("oid", oid).Info("checking user subscriber")
|
||||
ochan <- &Notified{
|
||||
ID: notified.NotifiedID,
|
||||
Read: notified.Read,
|
||||
ReadAt: ¬ified.ReadAt.Time,
|
||||
Notification: &n,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMemberList returns a list of projects the user is a member of
|
||||
func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) {
|
||||
projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID)
|
||||
@ -45,3 +100,7 @@ func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*
|
||||
type ActivityData struct {
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
type NotifiedData struct {
|
||||
Data map[string]string
|
||||
}
|
||||
|
@ -230,6 +230,10 @@ type FindUser struct {
|
||||
UserID uuid.UUID `json:"userID"`
|
||||
}
|
||||
|
||||
type HasUnreadNotificationsResult struct {
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
type InviteProjectMembers struct {
|
||||
ProjectID uuid.UUID `json:"projectID"`
|
||||
Members []MemberInvite `json:"members"`
|
||||
@ -367,6 +371,10 @@ type NotificationData struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type NotificationToggleReadInput struct {
|
||||
NotifiedID uuid.UUID `json:"notifiedID"`
|
||||
}
|
||||
|
||||
type Notified struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Notification *db.Notification `json:"notification"`
|
||||
@ -374,6 +382,18 @@ type Notified struct {
|
||||
ReadAt *time.Time `json:"readAt"`
|
||||
}
|
||||
|
||||
type NotifiedInput struct {
|
||||
Limit int `json:"limit"`
|
||||
Cursor *string `json:"cursor"`
|
||||
Filter NotificationFilter `json:"filter"`
|
||||
}
|
||||
|
||||
type NotifiedResult struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Notified []Notified `json:"notified"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type OwnedList struct {
|
||||
Teams []db.Team `json:"teams"`
|
||||
Projects []db.Project `json:"projects"`
|
||||
@ -384,6 +404,11 @@ type OwnersList struct {
|
||||
Teams []uuid.UUID `json:"teams"`
|
||||
}
|
||||
|
||||
type PageInfo struct {
|
||||
EndCursor string `json:"endCursor"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
}
|
||||
|
||||
type ProfileIcon struct {
|
||||
URL *string `json:"url"`
|
||||
Initials *string `json:"initials"`
|
||||
@ -479,6 +504,10 @@ type ToggleTaskLabelPayload struct {
|
||||
Task *db.Task `json:"task"`
|
||||
}
|
||||
|
||||
type ToggleTaskWatch struct {
|
||||
TaskID uuid.UUID `json:"taskID"`
|
||||
}
|
||||
|
||||
type UnassignTaskInput struct {
|
||||
TaskID uuid.UUID `json:"taskID"`
|
||||
UserID uuid.UUID `json:"userID"`
|
||||
@ -671,16 +700,42 @@ func (e ActionLevel) MarshalGQL(w io.Writer) {
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionTypeTaskMemberAdded ActionType = "TASK_MEMBER_ADDED"
|
||||
ActionTypeTeamAdded ActionType = "TEAM_ADDED"
|
||||
ActionTypeTeamRemoved ActionType = "TEAM_REMOVED"
|
||||
ActionTypeProjectAdded ActionType = "PROJECT_ADDED"
|
||||
ActionTypeProjectRemoved ActionType = "PROJECT_REMOVED"
|
||||
ActionTypeProjectArchived ActionType = "PROJECT_ARCHIVED"
|
||||
ActionTypeDueDateAdded ActionType = "DUE_DATE_ADDED"
|
||||
ActionTypeDueDateRemoved ActionType = "DUE_DATE_REMOVED"
|
||||
ActionTypeDueDateChanged ActionType = "DUE_DATE_CHANGED"
|
||||
ActionTypeTaskAssigned ActionType = "TASK_ASSIGNED"
|
||||
ActionTypeTaskMoved ActionType = "TASK_MOVED"
|
||||
ActionTypeTaskArchived ActionType = "TASK_ARCHIVED"
|
||||
ActionTypeTaskAttachmentUploaded ActionType = "TASK_ATTACHMENT_UPLOADED"
|
||||
ActionTypeCommentMentioned ActionType = "COMMENT_MENTIONED"
|
||||
ActionTypeCommentOther ActionType = "COMMENT_OTHER"
|
||||
)
|
||||
|
||||
var AllActionType = []ActionType{
|
||||
ActionTypeTaskMemberAdded,
|
||||
ActionTypeTeamAdded,
|
||||
ActionTypeTeamRemoved,
|
||||
ActionTypeProjectAdded,
|
||||
ActionTypeProjectRemoved,
|
||||
ActionTypeProjectArchived,
|
||||
ActionTypeDueDateAdded,
|
||||
ActionTypeDueDateRemoved,
|
||||
ActionTypeDueDateChanged,
|
||||
ActionTypeTaskAssigned,
|
||||
ActionTypeTaskMoved,
|
||||
ActionTypeTaskArchived,
|
||||
ActionTypeTaskAttachmentUploaded,
|
||||
ActionTypeCommentMentioned,
|
||||
ActionTypeCommentOther,
|
||||
}
|
||||
|
||||
func (e ActionType) IsValid() bool {
|
||||
switch e {
|
||||
case ActionTypeTaskMemberAdded:
|
||||
case ActionTypeTeamAdded, ActionTypeTeamRemoved, ActionTypeProjectAdded, ActionTypeProjectRemoved, ActionTypeProjectArchived, ActionTypeDueDateAdded, ActionTypeDueDateRemoved, ActionTypeDueDateChanged, ActionTypeTaskAssigned, ActionTypeTaskMoved, ActionTypeTaskArchived, ActionTypeTaskAttachmentUploaded, ActionTypeCommentMentioned, ActionTypeCommentOther:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -860,6 +915,51 @@ func (e MyTasksStatus) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
type NotificationFilter string
|
||||
|
||||
const (
|
||||
NotificationFilterAll NotificationFilter = "ALL"
|
||||
NotificationFilterUnread NotificationFilter = "UNREAD"
|
||||
NotificationFilterAssigned NotificationFilter = "ASSIGNED"
|
||||
NotificationFilterMentioned NotificationFilter = "MENTIONED"
|
||||
)
|
||||
|
||||
var AllNotificationFilter = []NotificationFilter{
|
||||
NotificationFilterAll,
|
||||
NotificationFilterUnread,
|
||||
NotificationFilterAssigned,
|
||||
NotificationFilterMentioned,
|
||||
}
|
||||
|
||||
func (e NotificationFilter) IsValid() bool {
|
||||
switch e {
|
||||
case NotificationFilterAll, NotificationFilterUnread, NotificationFilterAssigned, NotificationFilterMentioned:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e NotificationFilter) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *NotificationFilter) UnmarshalGQL(v interface{}) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = NotificationFilter(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid NotificationFilter", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e NotificationFilter) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
type ObjectType string
|
||||
|
||||
const (
|
||||
|
@ -6,32 +6,88 @@ package graph
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
"github.com/jordanknott/taskcafe/internal/logger"
|
||||
"github.com/jordanknott/taskcafe/internal/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) NotificationToggleRead(ctx context.Context, input NotificationToggleReadInput) (*Notified, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &Notified{}, errors.New("unknown user ID")
|
||||
}
|
||||
notified, err := r.Repository.GetNotifiedByID(ctx, input.NotifiedID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting notified by ID")
|
||||
return &Notified{}, err
|
||||
}
|
||||
readAt := time.Now().UTC()
|
||||
read := true
|
||||
if notified.Read {
|
||||
read = false
|
||||
err = r.Repository.MarkNotificationAsRead(ctx, db.MarkNotificationAsReadParams{
|
||||
UserID: userID,
|
||||
NotifiedID: input.NotifiedID,
|
||||
Read: false,
|
||||
ReadAt: sql.NullTime{
|
||||
Valid: false,
|
||||
Time: time.Time{},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
err = r.Repository.MarkNotificationAsRead(ctx, db.MarkNotificationAsReadParams{
|
||||
UserID: userID,
|
||||
Read: true,
|
||||
NotifiedID: input.NotifiedID,
|
||||
ReadAt: sql.NullTime{
|
||||
Valid: true,
|
||||
Time: readAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while marking notification as read")
|
||||
return &Notified{}, err
|
||||
}
|
||||
|
||||
return &Notified{
|
||||
ID: notified.NotifiedID,
|
||||
Read: read,
|
||||
ReadAt: &readAt,
|
||||
Notification: &db.Notification{
|
||||
NotificationID: notified.NotificationID,
|
||||
CausedBy: notified.CausedBy,
|
||||
ActionType: notified.ActionType,
|
||||
Data: notified.Data,
|
||||
CreatedOn: notified.CreatedOn,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) {
|
||||
return obj.NotificationID, nil
|
||||
}
|
||||
|
||||
func (r *notificationResolver) ActionType(ctx context.Context, obj *db.Notification) (ActionType, error) {
|
||||
return ActionTypeTaskMemberAdded, nil // TODO
|
||||
actionType := ActionType(obj.ActionType)
|
||||
if !actionType.IsValid() {
|
||||
log.WithField("ActionType", obj.ActionType).Error("ActionType is invalid")
|
||||
return actionType, errors.New("ActionType is invalid")
|
||||
}
|
||||
return ActionType(obj.ActionType), nil // TODO
|
||||
}
|
||||
|
||||
func (r *notificationResolver) CausedBy(ctx context.Context, obj *db.Notification) (*NotificationCausedBy, error) {
|
||||
user, err := r.Repository.GetUserAccountByID(ctx, obj.CausedBy)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return &NotificationCausedBy{
|
||||
Fullname: "Unknown user",
|
||||
Username: "unknown",
|
||||
ID: obj.CausedBy,
|
||||
}, nil
|
||||
return nil, nil
|
||||
}
|
||||
log.WithError(err).Error("error while resolving Notification.CausedBy")
|
||||
return &NotificationCausedBy{}, err
|
||||
@ -44,7 +100,16 @@ func (r *notificationResolver) CausedBy(ctx context.Context, obj *db.Notificatio
|
||||
}
|
||||
|
||||
func (r *notificationResolver) Data(ctx context.Context, obj *db.Notification) ([]NotificationData, error) {
|
||||
panic(fmt.Errorf("not implemented"))
|
||||
notifiedData := NotifiedData{}
|
||||
err := json.Unmarshal(obj.Data, ¬ifiedData)
|
||||
if err != nil {
|
||||
return []NotificationData{}, err
|
||||
}
|
||||
data := []NotificationData{}
|
||||
for key, value := range notifiedData.Data {
|
||||
data = append(data, NotificationData{Key: key, Value: value})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *notificationResolver) CreatedAt(ctx context.Context, obj *db.Notification) (*time.Time, error) {
|
||||
@ -86,8 +151,183 @@ func (r *queryResolver) Notifications(ctx context.Context) ([]Notified, error) {
|
||||
return userNotifications, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) Notified(ctx context.Context, input NotifiedInput) (*NotifiedResult, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &NotifiedResult{}, errors.New("userID is not found")
|
||||
}
|
||||
log.WithField("userID", userID).Info("fetching notified")
|
||||
if input.Cursor != nil {
|
||||
t, id, err := utils.DecodeCursor(*input.Cursor)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error decoding cursor")
|
||||
return &NotifiedResult{}, err
|
||||
}
|
||||
n, err := r.Repository.GetNotificationsForUserIDCursor(ctx, db.GetNotificationsForUserIDCursorParams{
|
||||
CreatedOn: t,
|
||||
NotificationID: id,
|
||||
LimitRows: int32(input.Limit + 1),
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error decoding fetching notifications")
|
||||
return &NotifiedResult{}, err
|
||||
}
|
||||
hasNextPage := false
|
||||
log.WithFields(log.Fields{
|
||||
"nLen": len(n),
|
||||
"cursorTime": t,
|
||||
"cursorId": id,
|
||||
"limit": input.Limit,
|
||||
}).Info("fetched notified")
|
||||
endCursor := n[len(n)-1]
|
||||
if len(n) == input.Limit+1 {
|
||||
hasNextPage = true
|
||||
n = n[:len(n)-1]
|
||||
endCursor = n[len(n)-1]
|
||||
}
|
||||
userNotifications := []Notified{}
|
||||
for _, notified := range n {
|
||||
var readAt *time.Time
|
||||
if notified.ReadAt.Valid {
|
||||
readAt = ¬ified.ReadAt.Time
|
||||
}
|
||||
n := Notified{
|
||||
ID: notified.NotifiedID,
|
||||
Read: notified.Read,
|
||||
ReadAt: readAt,
|
||||
Notification: &db.Notification{
|
||||
NotificationID: notified.NotificationID,
|
||||
CausedBy: notified.CausedBy,
|
||||
ActionType: notified.ActionType,
|
||||
Data: notified.Data,
|
||||
CreatedOn: notified.CreatedOn,
|
||||
},
|
||||
}
|
||||
userNotifications = append(userNotifications, n)
|
||||
}
|
||||
pageInfo := &PageInfo{
|
||||
HasNextPage: hasNextPage,
|
||||
EndCursor: utils.EncodeCursor(endCursor.CreatedOn, endCursor.NotificationID),
|
||||
}
|
||||
log.WithField("pageInfo", pageInfo).Info("created page info")
|
||||
return &NotifiedResult{
|
||||
TotalCount: len(n) - 1,
|
||||
PageInfo: pageInfo,
|
||||
Notified: userNotifications,
|
||||
}, nil
|
||||
}
|
||||
enableRead := false
|
||||
enableActionType := false
|
||||
actionTypes := []string{}
|
||||
switch input.Filter {
|
||||
case NotificationFilterUnread:
|
||||
enableRead = true
|
||||
break
|
||||
case NotificationFilterMentioned:
|
||||
enableActionType = true
|
||||
actionTypes = []string{"COMMENT_MENTIONED"}
|
||||
break
|
||||
case NotificationFilterAssigned:
|
||||
enableActionType = true
|
||||
actionTypes = []string{"TASK_ASSIGNED"}
|
||||
break
|
||||
}
|
||||
n, err := r.Repository.GetNotificationsForUserIDPaged(ctx, db.GetNotificationsForUserIDPagedParams{
|
||||
LimitRows: int32(input.Limit + 1),
|
||||
EnableUnread: enableRead,
|
||||
EnableActionType: enableActionType,
|
||||
ActionType: actionTypes,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error decoding fetching notifications")
|
||||
return &NotifiedResult{}, err
|
||||
}
|
||||
hasNextPage := false
|
||||
log.WithFields(log.Fields{
|
||||
"nLen": len(n),
|
||||
"limit": input.Limit,
|
||||
}).Info("fetched notified")
|
||||
endCursor := n[len(n)-1]
|
||||
if len(n) == input.Limit+1 {
|
||||
hasNextPage = true
|
||||
n = n[:len(n)-1]
|
||||
endCursor = n[len(n)-1]
|
||||
}
|
||||
userNotifications := []Notified{}
|
||||
for _, notified := range n {
|
||||
var readAt *time.Time
|
||||
if notified.ReadAt.Valid {
|
||||
readAt = ¬ified.ReadAt.Time
|
||||
}
|
||||
n := Notified{
|
||||
ID: notified.NotifiedID,
|
||||
Read: notified.Read,
|
||||
ReadAt: readAt,
|
||||
Notification: &db.Notification{
|
||||
NotificationID: notified.NotificationID,
|
||||
CausedBy: notified.CausedBy,
|
||||
ActionType: notified.ActionType,
|
||||
Data: notified.Data,
|
||||
CreatedOn: notified.CreatedOn,
|
||||
},
|
||||
}
|
||||
userNotifications = append(userNotifications, n)
|
||||
}
|
||||
pageInfo := &PageInfo{
|
||||
HasNextPage: hasNextPage,
|
||||
EndCursor: utils.EncodeCursor(endCursor.CreatedOn, endCursor.NotificationID),
|
||||
}
|
||||
log.WithField("pageInfo", pageInfo).Info("created page info")
|
||||
return &NotifiedResult{
|
||||
TotalCount: len(n),
|
||||
PageInfo: pageInfo,
|
||||
Notified: userNotifications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) HasUnreadNotifications(ctx context.Context) (*HasUnreadNotificationsResult, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &HasUnreadNotificationsResult{}, errors.New("userID is missing")
|
||||
}
|
||||
unread, err := r.Repository.HasUnreadNotification(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while fetching unread notifications")
|
||||
return &HasUnreadNotificationsResult{}, err
|
||||
}
|
||||
return &HasUnreadNotificationsResult{
|
||||
Unread: unread,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionResolver) NotificationAdded(ctx context.Context) (<-chan *Notified, error) {
|
||||
panic(fmt.Errorf("not implemented"))
|
||||
notified := make(chan *Notified, 1)
|
||||
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return notified, errors.New("userID is not found")
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
r.Notifications.Mu.Lock()
|
||||
if _, ok := r.Notifications.Subscribers[userID.String()]; ok {
|
||||
delete(r.Notifications.Subscribers[userID.String()], id)
|
||||
}
|
||||
r.Notifications.Mu.Unlock()
|
||||
}()
|
||||
|
||||
r.Notifications.Mu.Lock()
|
||||
if _, ok := r.Notifications.Subscribers[userID.String()]; !ok {
|
||||
r.Notifications.Subscribers[userID.String()] = make(map[string]chan *Notified)
|
||||
}
|
||||
log.WithField("userID", userID).WithField("id", id).Info("adding new channel")
|
||||
r.Notifications.Subscribers[userID.String()][id] = notified
|
||||
r.Notifications.Mu.Unlock()
|
||||
return notified, nil
|
||||
}
|
||||
|
||||
// Notification returns NotificationResolver implementation.
|
||||
|
@ -10,9 +10,14 @@ import (
|
||||
"github.com/jordanknott/taskcafe/internal/db"
|
||||
)
|
||||
|
||||
type NotificationObservers struct {
|
||||
Subscribers map[string]map[string]chan *Notified
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
||||
// Resolver handles resolving GraphQL queries & mutations
|
||||
type Resolver struct {
|
||||
Repository db.Repository
|
||||
AppConfig config.AppConfig
|
||||
mu sync.Mutex
|
||||
Repository db.Repository
|
||||
AppConfig config.AppConfig
|
||||
Notifications NotificationObservers
|
||||
}
|
||||
|
@ -62,7 +62,6 @@ func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserA
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) {
|
||||
logger.New(ctx).Info("finding project user")
|
||||
_, isLoggedIn := GetUser(ctx)
|
||||
if !isLoggedIn {
|
||||
isPublic, _ := IsProjectPublic(ctx, r.Repository, input.ProjectID)
|
||||
|
@ -4,10 +4,60 @@ extend type Subscription {
|
||||
|
||||
extend type Query {
|
||||
notifications: [Notified!]!
|
||||
notified(input: NotifiedInput!): NotifiedResult!
|
||||
hasUnreadNotifications: HasUnreadNotificationsResult!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
notificationToggleRead(input: NotificationToggleReadInput!): Notified!
|
||||
}
|
||||
|
||||
type HasUnreadNotificationsResult {
|
||||
unread: Boolean!
|
||||
}
|
||||
input NotificationToggleReadInput {
|
||||
notifiedID: UUID!
|
||||
}
|
||||
|
||||
input NotifiedInput {
|
||||
limit: Int!
|
||||
cursor: String
|
||||
filter: NotificationFilter!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
endCursor: String!
|
||||
hasNextPage: Boolean!
|
||||
}
|
||||
|
||||
type NotifiedResult {
|
||||
totalCount: Int!
|
||||
notified: [Notified!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
TASK_MEMBER_ADDED
|
||||
TEAM_ADDED
|
||||
TEAM_REMOVED
|
||||
PROJECT_ADDED
|
||||
PROJECT_REMOVED
|
||||
PROJECT_ARCHIVED
|
||||
DUE_DATE_ADDED
|
||||
DUE_DATE_REMOVED
|
||||
DUE_DATE_CHANGED
|
||||
TASK_ASSIGNED
|
||||
TASK_MOVED
|
||||
TASK_ARCHIVED
|
||||
TASK_ATTACHMENT_UPLOADED
|
||||
COMMENT_MENTIONED
|
||||
COMMENT_OTHER
|
||||
}
|
||||
|
||||
enum NotificationFilter {
|
||||
ALL
|
||||
UNREAD
|
||||
ASSIGNED
|
||||
MENTIONED
|
||||
}
|
||||
|
||||
type NotificationData {
|
||||
@ -24,7 +74,7 @@ type NotificationCausedBy {
|
||||
type Notification {
|
||||
id: ID!
|
||||
actionType: ActionType!
|
||||
causedBy: NotificationCausedBy!
|
||||
causedBy: NotificationCausedBy
|
||||
data: [NotificationData!]!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
@ -4,10 +4,60 @@ extend type Subscription {
|
||||
|
||||
extend type Query {
|
||||
notifications: [Notified!]!
|
||||
notified(input: NotifiedInput!): NotifiedResult!
|
||||
hasUnreadNotifications: HasUnreadNotificationsResult!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
notificationToggleRead(input: NotificationToggleReadInput!): Notified!
|
||||
}
|
||||
|
||||
type HasUnreadNotificationsResult {
|
||||
unread: Boolean!
|
||||
}
|
||||
input NotificationToggleReadInput {
|
||||
notifiedID: UUID!
|
||||
}
|
||||
|
||||
input NotifiedInput {
|
||||
limit: Int!
|
||||
cursor: String
|
||||
filter: NotificationFilter!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
endCursor: String!
|
||||
hasNextPage: Boolean!
|
||||
}
|
||||
|
||||
type NotifiedResult {
|
||||
totalCount: Int!
|
||||
notified: [Notified!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
TASK_MEMBER_ADDED
|
||||
TEAM_ADDED
|
||||
TEAM_REMOVED
|
||||
PROJECT_ADDED
|
||||
PROJECT_REMOVED
|
||||
PROJECT_ARCHIVED
|
||||
DUE_DATE_ADDED
|
||||
DUE_DATE_REMOVED
|
||||
DUE_DATE_CHANGED
|
||||
TASK_ASSIGNED
|
||||
TASK_MOVED
|
||||
TASK_ARCHIVED
|
||||
TASK_ATTACHMENT_UPLOADED
|
||||
COMMENT_MENTIONED
|
||||
COMMENT_OTHER
|
||||
}
|
||||
|
||||
enum NotificationFilter {
|
||||
ALL
|
||||
UNREAD
|
||||
ASSIGNED
|
||||
MENTIONED
|
||||
}
|
||||
|
||||
type NotificationData {
|
||||
@ -24,7 +74,7 @@ type NotificationCausedBy {
|
||||
type Notification {
|
||||
id: ID!
|
||||
actionType: ActionType!
|
||||
causedBy: NotificationCausedBy!
|
||||
causedBy: NotificationCausedBy
|
||||
data: [NotificationData!]!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ type Task {
|
||||
name: String!
|
||||
position: Float!
|
||||
description: String
|
||||
watched: Boolean!
|
||||
dueDate: Time
|
||||
hasTime: Boolean!
|
||||
complete: Boolean!
|
||||
@ -352,6 +353,8 @@ extend type Mutation {
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
updateTaskDueDate(input: UpdateTaskDueDate!):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
toggleTaskWatch(input: ToggleTaskWatch!):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
|
||||
assignTask(input: AssignTaskInput):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
@ -359,6 +362,10 @@ extend type Mutation {
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
}
|
||||
|
||||
input ToggleTaskWatch {
|
||||
taskID: UUID!
|
||||
}
|
||||
|
||||
input NewTask {
|
||||
taskGroupID: UUID!
|
||||
name: String!
|
||||
|
@ -27,6 +27,7 @@ type Task {
|
||||
name: String!
|
||||
position: Float!
|
||||
description: String
|
||||
watched: Boolean!
|
||||
dueDate: Time
|
||||
hasTime: Boolean!
|
||||
complete: Boolean!
|
||||
|
@ -14,6 +14,8 @@ extend type Mutation {
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
updateTaskDueDate(input: UpdateTaskDueDate!):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
toggleTaskWatch(input: ToggleTaskWatch!):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
|
||||
assignTask(input: AssignTaskInput):
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
@ -21,6 +23,10 @@ extend type Mutation {
|
||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||
}
|
||||
|
||||
input ToggleTaskWatch {
|
||||
taskID: UUID!
|
||||
}
|
||||
|
||||
input NewTask {
|
||||
taskGroupID: UUID!
|
||||
name: String!
|
||||
|
@ -543,6 +543,45 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
|
||||
return &task, err
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ToggleTaskWatch(ctx context.Context, input ToggleTaskWatch) (*db.Task, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
log.Error("user ID is missing")
|
||||
return &db.Task{}, errors.New("user ID is unknown")
|
||||
}
|
||||
_, err := r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: userID, TaskID: input.TaskID})
|
||||
|
||||
isWatching := true
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.WithError(err).Error("error while getting task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
isWatching = false
|
||||
}
|
||||
|
||||
if isWatching {
|
||||
err := r.Repository.DeleteTaskWatcher(ctx, db.DeleteTaskWatcherParams{UserID: userID, TaskID: input.TaskID})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting deleteing task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
} else {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.Repository.CreateTaskWatcher(ctx, db.CreateTaskWatcherParams{UserID: userID, TaskID: input.TaskID, WatchedAt: now})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
}
|
||||
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting task by id")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInput) (*db.Task, error) {
|
||||
assignedDate := time.Now().UTC()
|
||||
assignedTask, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{input.TaskID, input.UserID, assignedDate})
|
||||
@ -552,20 +591,80 @@ func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInpu
|
||||
"assignedTaskID": assignedTask.TaskAssignedID,
|
||||
}).Info("assigned task")
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating task assigned")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
// r.NotificationQueue.TaskMemberWasAdded(assignedTask.TaskID, userID, assignedTask.UserID)
|
||||
_, err = r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: assignedTask.UserID, TaskID: assignedTask.TaskID})
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.WithError(err).Error("error while fetching task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
_, err = r.Repository.CreateTaskWatcher(ctx, db.CreateTaskWatcherParams{UserID: assignedTask.UserID, TaskID: assignedTask.TaskID, WatchedAt: assignedDate})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating task assigned task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
}
|
||||
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
log.Error("error getting user ID")
|
||||
return &db.Task{}, errors.New("UserID is missing")
|
||||
}
|
||||
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
|
||||
return &task, err
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting task by ID")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
if userID != assignedTask.UserID {
|
||||
causedBy, err := r.Repository.GetUserAccountByID(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting user account in assign task")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
project, err := r.Repository.GetProjectInfoForTask(ctx, input.TaskID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting project in assign task")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
err = r.CreateNotification(ctx, CreateNotificationParams{
|
||||
ActionType: ActionTypeTaskAssigned,
|
||||
CausedBy: userID,
|
||||
NotifiedList: []uuid.UUID{assignedTask.UserID},
|
||||
Data: map[string]string{
|
||||
"CausedByUsername": causedBy.Username,
|
||||
"CausedByFullName": causedBy.FullName,
|
||||
"TaskID": assignedTask.TaskID.String(),
|
||||
"TaskName": task.Name,
|
||||
"ProjectID": project.ProjectID.String(),
|
||||
"ProjectName": project.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return &task, err
|
||||
}
|
||||
|
||||
// r.NotificationQueue.TaskMemberWasAdded(assignedTask.TaskID, userID, assignedTask.UserID)
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) UnassignTask(ctx context.Context, input *UnassignTaskInput) (*db.Task, error) {
|
||||
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while getting task by ID")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
_, err = r.Repository.DeleteTaskAssignedByID(ctx, db.DeleteTaskAssignedByIDParams{input.TaskID, input.UserID})
|
||||
log.WithFields(log.Fields{"UserID": input.UserID, "TaskID": input.TaskID}).Info("deleting task assignment")
|
||||
_, err = r.Repository.DeleteTaskAssignedByID(ctx, db.DeleteTaskAssignedByIDParams{TaskID: input.TaskID, UserID: input.UserID})
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.WithError(err).Error("error while deleting task by ID")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
err = r.Repository.DeleteTaskWatcher(ctx, db.DeleteTaskWatcherParams{UserID: input.UserID, TaskID: input.TaskID})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error while creating task assigned task watcher")
|
||||
return &db.Task{}, err
|
||||
}
|
||||
return &task, nil
|
||||
@ -591,6 +690,23 @@ func (r *taskResolver) Description(ctx context.Context, obj *db.Task) (*string,
|
||||
return &task.Description.String, nil
|
||||
}
|
||||
|
||||
func (r *taskResolver) Watched(ctx context.Context, obj *db.Task) (bool, error) {
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
log.Error("user ID is missing")
|
||||
return false, errors.New("user ID is unknown")
|
||||
}
|
||||
_, err := r.Repository.GetTaskWatcher(ctx, db.GetTaskWatcherParams{UserID: userID, TaskID: obj.TaskID})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
log.WithError(err).Error("error while getting task watcher")
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *taskResolver) DueDate(ctx context.Context, obj *db.Task) (*time.Time, error) {
|
||||
if obj.DueDate.Valid {
|
||||
return &obj.DueDate.Time, nil
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
36
internal/utils/cursor.go
Normal 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))
|
||||
}
|
@ -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);
|
||||
|
@ -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,
|
||||
|
6
migrations/0068_add-task_watcher-table.up.sql
Normal file
6
migrations/0068_add-task_watcher-table.up.sql
Normal 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
|
||||
);
|
Loading…
Reference in New Issue
Block a user