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 TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
||||||
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
|
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
|
||||||
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||||
import { useHistory, useRouteMatch } from 'react-router';
|
import { useHistory, useRouteMatch } from 'react-router';
|
||||||
import { useCurrentUser } from 'App/context';
|
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 { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
import MiniProfile from 'shared/components/MiniProfile';
|
||||||
import cache from 'App/cache';
|
import cache from 'App/cache';
|
||||||
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
|
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
|
||||||
import theme from 'App/ThemeStyles';
|
import theme from 'App/ThemeStyles';
|
||||||
import ProjectFinder from './ProjectFinder';
|
import ProjectFinder from './ProjectFinder';
|
||||||
|
import polling from 'shared/utils/polling';
|
||||||
|
|
||||||
// TODO: Move to context based navbar?
|
// TODO: Move to context based navbar?
|
||||||
|
|
||||||
@ -49,9 +55,25 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
|
|||||||
onRemoveInvitedFromBoard,
|
onRemoveInvitedFromBoard,
|
||||||
onRemoveFromBoard,
|
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 { showPopup, hidePopup } = usePopup();
|
||||||
const { setUser } = useCurrentUser();
|
const { setUser } = useCurrentUser();
|
||||||
|
const { data: unreadData } = useHasUnreadNotificationsQuery({ pollInterval: polling.UNREAD_NOTIFICATIONS });
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const onLogout = () => {
|
const onLogout = () => {
|
||||||
fetch('/auth/logout', {
|
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>) => {
|
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
showPopup(
|
showPopup($target, <NotificationPopup />, { width: 605, borders: false, diamondColor: theme.colors.primary });
|
||||||
$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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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;
|
const user = data ? data.me?.user : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopNavbar
|
<TopNavbar
|
||||||
|
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
|
||||||
name={name}
|
name={name}
|
||||||
menuType={menuType}
|
menuType={menuType}
|
||||||
onOpenProjectFinder={($target) => {
|
onOpenProjectFinder={($target) => {
|
||||||
|
@ -430,6 +430,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
|||||||
__typename: 'Task',
|
__typename: 'Task',
|
||||||
id: `${Math.round(Math.random() * -1000000)}`,
|
id: `${Math.round(Math.random() * -1000000)}`,
|
||||||
name,
|
name,
|
||||||
|
watched: false,
|
||||||
complete: false,
|
complete: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
hasTime: false,
|
hasTime: false,
|
||||||
|
@ -7,6 +7,7 @@ import MemberManager from 'shared/components/MemberManager';
|
|||||||
import { useRouteMatch, useHistory, useParams } from 'react-router';
|
import { useRouteMatch, useHistory, useParams } from 'react-router';
|
||||||
import {
|
import {
|
||||||
useDeleteTaskChecklistMutation,
|
useDeleteTaskChecklistMutation,
|
||||||
|
useToggleTaskWatchMutation,
|
||||||
useUpdateTaskChecklistNameMutation,
|
useUpdateTaskChecklistNameMutation,
|
||||||
useUpdateTaskChecklistItemLocationMutation,
|
useUpdateTaskChecklistItemLocationMutation,
|
||||||
useCreateTaskChecklistMutation,
|
useCreateTaskChecklistMutation,
|
||||||
@ -216,6 +217,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const [toggleTaskWatch] = useToggleTaskWatchMutation();
|
||||||
const [createTaskComment] = useCreateTaskCommentMutation({
|
const [createTaskComment] = useCreateTaskCommentMutation({
|
||||||
update: (client, response) => {
|
update: (client, response) => {
|
||||||
updateApolloCache<FindTaskQuery>(
|
updateApolloCache<FindTaskQuery>(
|
||||||
@ -440,6 +442,19 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
task={data.findTask}
|
task={data.findTask}
|
||||||
|
onToggleTaskWatch={(task, watched) => {
|
||||||
|
toggleTaskWatch({
|
||||||
|
variables: { taskID: task.id },
|
||||||
|
optimisticResponse: {
|
||||||
|
__typename: 'Mutation',
|
||||||
|
toggleTaskWatch: {
|
||||||
|
id: task.id,
|
||||||
|
__typename: 'Task',
|
||||||
|
watched,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
onCreateComment={(task, message) => {
|
onCreateComment={(task, message) => {
|
||||||
createTaskComment({ variables: { taskID: task.id, message } });
|
createTaskComment({ variables: { taskID: task.id, message } });
|
||||||
}}
|
}}
|
||||||
@ -540,7 +555,8 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
bio="None"
|
bio="None"
|
||||||
onRemoveFromTask={() => {
|
onRemoveFromTask={() => {
|
||||||
if (user) {
|
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 customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
import isBetween from 'dayjs/plugin/isBetween';
|
import isBetween from 'dayjs/plugin/isBetween';
|
||||||
import weekday from 'dayjs/plugin/weekday';
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import log from 'loglevel';
|
import log from 'loglevel';
|
||||||
import remote from 'loglevel-plugin-remote';
|
import remote from 'loglevel-plugin-remote';
|
||||||
import cache from './App/cache';
|
import cache from './App/cache';
|
||||||
@ -36,6 +38,8 @@ dayjs.extend(weekday);
|
|||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
dayjs.extend(updateLocale);
|
dayjs.extend(updateLocale);
|
||||||
|
dayjs.extend(duration);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
dayjs.updateLocale('en', {
|
dayjs.updateLocale('en', {
|
||||||
week: {
|
week: {
|
||||||
dow: 1, // First day of week is Monday
|
dow: 1, // First day of week is Monday
|
||||||
|
@ -225,7 +225,7 @@ const Card = React.forwardRef(
|
|||||||
<ListCardBadges>
|
<ListCardBadges>
|
||||||
{watched && (
|
{watched && (
|
||||||
<ListCardBadge>
|
<ListCardBadge>
|
||||||
<Eye width={8} height={8} />
|
<Eye width={12} height={12} />
|
||||||
</ListCardBadge>
|
</ListCardBadge>
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
|
@ -329,6 +329,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
|||||||
toggleLabels={toggleLabels}
|
toggleLabels={toggleLabels}
|
||||||
isPublic={isPublic}
|
isPublic={isPublic}
|
||||||
labelVariant={cardLabelVariant}
|
labelVariant={cardLabelVariant}
|
||||||
|
watched={task.watched}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
...taskProvided.draggableProps,
|
...taskProvided.draggableProps,
|
||||||
...taskProvided.dragHandleProps,
|
...taskProvided.dragHandleProps,
|
||||||
|
@ -1,8 +1,20 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled, { css } from 'styled-components';
|
||||||
import TimeAgo from 'react-timeago';
|
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`
|
const ItemWrapper = styled.div`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -37,7 +49,7 @@ const ItemTextContainer = styled.div`
|
|||||||
const ItemTextTitle = styled.span`
|
const ItemTextTitle = styled.span`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: block;
|
display: block;
|
||||||
color: ${props => props.theme.colors.primary};
|
color: ${(props) => props.theme.colors.primary};
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
const ItemTextDesc = styled.span`
|
const ItemTextDesc = styled.span`
|
||||||
@ -72,38 +84,450 @@ export const NotificationItem: React.FC<NotificationItemProps> = ({ title, descr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NotificationHeader = styled.div`
|
const NotificationHeader = styled.div`
|
||||||
padding: 0.75rem;
|
padding: 20px 28px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-top-left-radius: 6px;
|
border-top-left-radius: 6px;
|
||||||
border-top-right-radius: 6px;
|
border-top-right-radius: 6px;
|
||||||
background: ${props => props.theme.colors.primary};
|
background: ${(props) => props.theme.colors.primary};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NotificationHeaderTitle = styled.span`
|
const NotificationHeaderTitle = styled.span`
|
||||||
font-size: 14px;
|
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`
|
const NotificationFooter = styled.div`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${props => props.theme.colors.primary};
|
color: ${(props) => props.theme.colors.primary};
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${props => props.theme.colors.bg.primary};
|
background: ${(props) => props.theme.colors.bg.primary};
|
||||||
}
|
}
|
||||||
border-bottom-left-radius: 6px;
|
border-bottom-left-radius: 6px;
|
||||||
border-bottom-right-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 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 (
|
return (
|
||||||
<Popup title={null} tab={0} borders={false} padding={false}>
|
<Popup title={null} tab={0} borders={false} padding={false}>
|
||||||
|
<PopupContent>
|
||||||
<NotificationHeader>
|
<NotificationHeader>
|
||||||
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
|
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
|
||||||
</NotificationHeader>
|
</NotificationHeader>
|
||||||
<ul>{children}</ul>
|
<NotificationTabs>
|
||||||
<NotificationFooter>View All</NotificationFooter>
|
{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>
|
</Popup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import { mixin } from 'shared/utils/styles';
|
|||||||
import Button from 'shared/components/Button';
|
import Button from 'shared/components/Button';
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
import theme from 'App/ThemeStyles';
|
import theme from 'App/ThemeStyles';
|
||||||
|
import { Checkmark } from 'shared/icons';
|
||||||
|
|
||||||
export const Container = styled.div`
|
export const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -309,6 +310,7 @@ export const ActionButton = styled(Button)`
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
& span {
|
& span {
|
||||||
|
position: unset;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -717,3 +719,8 @@ export const TaskDetailsEditor = styled(TextareaAutosize)`
|
|||||||
outline: none;
|
outline: none;
|
||||||
border: none;
|
border: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const WatchedCheckmark = styled(Checkmark)`
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
`;
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
CheckSquareOutline,
|
CheckSquareOutline,
|
||||||
At,
|
At,
|
||||||
Smile,
|
Smile,
|
||||||
|
Eye,
|
||||||
} from 'shared/icons';
|
} from 'shared/icons';
|
||||||
import { toArray } from 'react-emoji-render';
|
import { toArray } from 'react-emoji-render';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
@ -80,6 +81,7 @@ import {
|
|||||||
ActivityItemHeaderTitleName,
|
ActivityItemHeaderTitleName,
|
||||||
ActivityItemComment,
|
ActivityItemComment,
|
||||||
TabBarButton,
|
TabBarButton,
|
||||||
|
WatchedCheckmark,
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
||||||
import onDragEnd from './onDragEnd';
|
import onDragEnd from './onDragEnd';
|
||||||
@ -237,6 +239,7 @@ type TaskDetailsProps = {
|
|||||||
onToggleChecklistItem: (itemID: string, complete: boolean) => void;
|
onToggleChecklistItem: (itemID: string, complete: boolean) => void;
|
||||||
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onOpenAddLabelPopup: (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;
|
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onCreateComment: (task: Task, message: string) => void;
|
onCreateComment: (task: Task, message: string) => void;
|
||||||
@ -258,6 +261,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
task,
|
task,
|
||||||
editableComment = null,
|
editableComment = null,
|
||||||
onDeleteChecklist,
|
onDeleteChecklist,
|
||||||
|
onToggleTaskWatch,
|
||||||
onTaskNameChange,
|
onTaskNameChange,
|
||||||
onCommentShowActions,
|
onCommentShowActions,
|
||||||
onOpenAddChecklistPopup,
|
onOpenAddChecklistPopup,
|
||||||
@ -328,6 +332,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
const saveDescription = () => {
|
const saveDescription = () => {
|
||||||
onTaskDescriptionChange(task, taskDescriptionRef.current);
|
onTaskDescriptionChange(task, taskDescriptionRef.current);
|
||||||
};
|
};
|
||||||
|
console.log(task.watched);
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<LeftSidebar>
|
<LeftSidebar>
|
||||||
@ -418,6 +423,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
Checklist
|
Checklist
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton>Cover</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>
|
</ExtraActionsSection>
|
||||||
)}
|
)}
|
||||||
</LeftSidebarContent>
|
</LeftSidebarContent>
|
||||||
|
@ -6,11 +6,11 @@ import { NavLink, Link } from 'react-router-dom';
|
|||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
|
|
||||||
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||||
z-index: ${props => props.zIndex};
|
z-index: ${(props) => props.zIndex};
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.primary},
|
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)};
|
inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.07)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const NavbarWrapper = styled.div`
|
export const NavbarWrapper = styled.div`
|
||||||
@ -27,9 +27,9 @@ export const NavbarHeader = styled.header`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
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);
|
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`
|
export const Breadcrumbs = styled.div`
|
||||||
color: rgb(94, 108, 132);
|
color: rgb(94, 108, 132);
|
||||||
@ -59,7 +59,7 @@ export const ProjectSwitchInner = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
background-color: ${props => props.theme.colors.primary};
|
background-color: ${(props) => props.theme.colors.primary};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ProjectSwitch = styled.div`
|
export const ProjectSwitch = styled.div`
|
||||||
@ -109,10 +109,27 @@ export const NavbarLink = styled(Link)`
|
|||||||
cursor: pointer;
|
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 }>`
|
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
${props =>
|
${(props) =>
|
||||||
props.disabled &&
|
props.disabled &&
|
||||||
css`
|
css`
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@ -142,14 +159,14 @@ export const ProfileIcon = styled.div<{
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
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-position: center;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ProjectMeta = styled.div<{ nameOnly?: boolean }>`
|
export const ProjectMeta = styled.div<{ nameOnly?: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
${props => !props.nameOnly && 'padding-top: 9px;'}
|
${(props) => !props.nameOnly && 'padding-top: 9px;'}
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -167,7 +184,7 @@ export const ProjectTabs = styled.div`
|
|||||||
|
|
||||||
export const ProjectTab = styled(NavLink)`
|
export const ProjectTab = styled(NavLink)`
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
color: ${props => props.theme.colors.text.primary};
|
color: ${(props) => props.theme.colors.text.primary};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -184,22 +201,22 @@ export const ProjectTab = styled(NavLink)`
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: inset 0 -2px ${props => props.theme.colors.text.secondary};
|
box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary};
|
||||||
color: ${props => props.theme.colors.text.secondary};
|
color: ${(props) => props.theme.colors.text.secondary};
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
box-shadow: inset 0 -2px ${props => props.theme.colors.secondary};
|
box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary};
|
||||||
color: ${props => props.theme.colors.secondary};
|
color: ${(props) => props.theme.colors.secondary};
|
||||||
}
|
}
|
||||||
&.active:hover {
|
&.active:hover {
|
||||||
box-shadow: inset 0 -2px ${props => props.theme.colors.secondary};
|
box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary};
|
||||||
color: ${props => props.theme.colors.secondary};
|
color: ${(props) => props.theme.colors.secondary};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ProjectName = styled.h1`
|
export const ProjectName = styled.h1`
|
||||||
color: ${props => props.theme.colors.text.primary};
|
color: ${(props) => props.theme.colors.text.primary};
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
padding: 3px 10px 3px 8px;
|
padding: 3px 10px 3px 8px;
|
||||||
@ -241,7 +258,7 @@ export const ProjectNameTextarea = styled.input`
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
padding: 3px 10px 3px 8px;
|
padding: 3px 10px 3px 8px;
|
||||||
&:focus {
|
&: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;
|
color: #c2c6dc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&: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;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&: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`
|
export const NavSeparator = styled.div`
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: ${props => props.theme.colors.border};
|
background: ${(props) => props.theme.colors.border};
|
||||||
height: 34px;
|
height: 34px;
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
`;
|
`;
|
||||||
@ -326,11 +343,11 @@ export const LogoContainer = styled(Link)`
|
|||||||
|
|
||||||
export const TaskcafeTitle = styled.h2`
|
export const TaskcafeTitle = styled.h2`
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
color: ${props => props.theme.colors.text.primary};
|
color: ${(props) => props.theme.colors.text.primary};
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TaskcafeLogo = styled(Taskcafe)`
|
export const TaskcafeLogo = styled(Taskcafe)`
|
||||||
fill: ${props => props.theme.colors.text.primary};
|
fill: ${(props) => props.theme.colors.text.primary};
|
||||||
stroke: ${props => props.theme.colors.text.primary};
|
stroke: ${(props) => props.theme.colors.text.primary};
|
||||||
`;
|
`;
|
||||||
|
@ -36,6 +36,7 @@ import {
|
|||||||
ProjectMember,
|
ProjectMember,
|
||||||
ProjectMembers,
|
ProjectMembers,
|
||||||
ProjectSwitchInner,
|
ProjectSwitchInner,
|
||||||
|
NotificationCount,
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
|
|
||||||
type IconContainerProps = {
|
type IconContainerProps = {
|
||||||
@ -185,6 +186,7 @@ type NavBarProps = {
|
|||||||
projectMembers?: Array<TaskUser> | null;
|
projectMembers?: Array<TaskUser> | null;
|
||||||
projectInvitedMembers?: Array<InvitedUser> | null;
|
projectInvitedMembers?: Array<InvitedUser> | null;
|
||||||
|
|
||||||
|
hasUnread: boolean;
|
||||||
onRemoveFromBoard?: (userID: string) => void;
|
onRemoveFromBoard?: (userID: string) => void;
|
||||||
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||||
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
|
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
|
||||||
@ -203,6 +205,7 @@ const NavBar: React.FC<NavBarProps> = ({
|
|||||||
onOpenProjectFinder,
|
onOpenProjectFinder,
|
||||||
onFavorite,
|
onFavorite,
|
||||||
onSetTab,
|
onSetTab,
|
||||||
|
hasUnread,
|
||||||
projectInvitedMembers,
|
projectInvitedMembers,
|
||||||
onChangeRole,
|
onChangeRole,
|
||||||
name,
|
name,
|
||||||
@ -330,8 +333,9 @@ const NavBar: React.FC<NavBarProps> = ({
|
|||||||
<IconContainer disabled onClick={NOOP}>
|
<IconContainer disabled onClick={NOOP}>
|
||||||
<ListUnordered width={20} height={20} />
|
<ListUnordered width={20} height={20} />
|
||||||
</IconContainer>
|
</IconContainer>
|
||||||
<IconContainer disabled onClick={onNotificationClick}>
|
<IconContainer onClick={onNotificationClick}>
|
||||||
<Bell color="#c2c6dc" size={20} />
|
<Bell color="#c2c6dc" size={20} />
|
||||||
|
{hasUnread && <NotificationCount />}
|
||||||
</IconContainer>
|
</IconContainer>
|
||||||
<IconContainer disabled onClick={NOOP}>
|
<IconContainer disabled onClick={NOOP}>
|
||||||
<BarChart width={20} height={20} />
|
<BarChart width={20} height={20} />
|
||||||
|
@ -26,7 +26,20 @@ export enum ActionLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ActionType {
|
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 {
|
export enum ActivityType {
|
||||||
@ -280,6 +293,11 @@ export type FindUser = {
|
|||||||
userID: Scalars['UUID'];
|
userID: Scalars['UUID'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HasUnreadNotificationsResult = {
|
||||||
|
__typename?: 'HasUnreadNotificationsResult';
|
||||||
|
unread: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type InviteProjectMembers = {
|
export type InviteProjectMembers = {
|
||||||
projectID: Scalars['UUID'];
|
projectID: Scalars['UUID'];
|
||||||
members: Array<MemberInvite>;
|
members: Array<MemberInvite>;
|
||||||
@ -394,12 +412,14 @@ export type Mutation = {
|
|||||||
duplicateTaskGroup: DuplicateTaskGroupPayload;
|
duplicateTaskGroup: DuplicateTaskGroupPayload;
|
||||||
inviteProjectMembers: InviteProjectMembersPayload;
|
inviteProjectMembers: InviteProjectMembersPayload;
|
||||||
logoutUser: Scalars['Boolean'];
|
logoutUser: Scalars['Boolean'];
|
||||||
|
notificationToggleRead: Notified;
|
||||||
removeTaskLabel: Task;
|
removeTaskLabel: Task;
|
||||||
setTaskChecklistItemComplete: TaskChecklistItem;
|
setTaskChecklistItemComplete: TaskChecklistItem;
|
||||||
setTaskComplete: Task;
|
setTaskComplete: Task;
|
||||||
sortTaskGroup: SortTaskGroupPayload;
|
sortTaskGroup: SortTaskGroupPayload;
|
||||||
toggleProjectVisibility: ToggleProjectVisibilityPayload;
|
toggleProjectVisibility: ToggleProjectVisibilityPayload;
|
||||||
toggleTaskLabel: ToggleTaskLabelPayload;
|
toggleTaskLabel: ToggleTaskLabelPayload;
|
||||||
|
toggleTaskWatch: Task;
|
||||||
unassignTask: Task;
|
unassignTask: Task;
|
||||||
updateProjectLabel: ProjectLabel;
|
updateProjectLabel: ProjectLabel;
|
||||||
updateProjectLabelColor: ProjectLabel;
|
updateProjectLabelColor: ProjectLabel;
|
||||||
@ -569,6 +589,11 @@ export type MutationLogoutUserArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationNotificationToggleReadArgs = {
|
||||||
|
input: NotificationToggleReadInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationRemoveTaskLabelArgs = {
|
export type MutationRemoveTaskLabelArgs = {
|
||||||
input?: Maybe<RemoveTaskLabelInput>;
|
input?: Maybe<RemoveTaskLabelInput>;
|
||||||
};
|
};
|
||||||
@ -599,6 +624,11 @@ export type MutationToggleTaskLabelArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationToggleTaskWatchArgs = {
|
||||||
|
input: ToggleTaskWatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUnassignTaskArgs = {
|
export type MutationUnassignTaskArgs = {
|
||||||
input?: Maybe<UnassignTaskInput>;
|
input?: Maybe<UnassignTaskInput>;
|
||||||
};
|
};
|
||||||
@ -784,7 +814,7 @@ export type Notification = {
|
|||||||
__typename?: 'Notification';
|
__typename?: 'Notification';
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
actionType: ActionType;
|
actionType: ActionType;
|
||||||
causedBy: NotificationCausedBy;
|
causedBy?: Maybe<NotificationCausedBy>;
|
||||||
data: Array<NotificationData>;
|
data: Array<NotificationData>;
|
||||||
createdAt: Scalars['Time'];
|
createdAt: Scalars['Time'];
|
||||||
};
|
};
|
||||||
@ -802,6 +832,17 @@ export type NotificationData = {
|
|||||||
value: Scalars['String'];
|
value: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum NotificationFilter {
|
||||||
|
All = 'ALL',
|
||||||
|
Unread = 'UNREAD',
|
||||||
|
Assigned = 'ASSIGNED',
|
||||||
|
Mentioned = 'MENTIONED'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationToggleReadInput = {
|
||||||
|
notifiedID: Scalars['UUID'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Notified = {
|
export type Notified = {
|
||||||
__typename?: 'Notified';
|
__typename?: 'Notified';
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
@ -810,6 +851,19 @@ export type Notified = {
|
|||||||
readAt?: Maybe<Scalars['Time']>;
|
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 {
|
export enum ObjectType {
|
||||||
Org = 'ORG',
|
Org = 'ORG',
|
||||||
Team = 'TEAM',
|
Team = 'TEAM',
|
||||||
@ -838,6 +892,12 @@ export type OwnersList = {
|
|||||||
teams: Array<Scalars['UUID']>;
|
teams: Array<Scalars['UUID']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PageInfo = {
|
||||||
|
__typename?: 'PageInfo';
|
||||||
|
endCursor: Scalars['String'];
|
||||||
|
hasNextPage: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProfileIcon = {
|
export type ProfileIcon = {
|
||||||
__typename?: 'ProfileIcon';
|
__typename?: 'ProfileIcon';
|
||||||
url?: Maybe<Scalars['String']>;
|
url?: Maybe<Scalars['String']>;
|
||||||
@ -896,11 +956,13 @@ export type Query = {
|
|||||||
findTask: Task;
|
findTask: Task;
|
||||||
findTeam: Team;
|
findTeam: Team;
|
||||||
findUser: UserAccount;
|
findUser: UserAccount;
|
||||||
|
hasUnreadNotifications: HasUnreadNotificationsResult;
|
||||||
invitedUsers: Array<InvitedUserAccount>;
|
invitedUsers: Array<InvitedUserAccount>;
|
||||||
labelColors: Array<LabelColor>;
|
labelColors: Array<LabelColor>;
|
||||||
me?: Maybe<MePayload>;
|
me?: Maybe<MePayload>;
|
||||||
myTasks: MyTasksPayload;
|
myTasks: MyTasksPayload;
|
||||||
notifications: Array<Notified>;
|
notifications: Array<Notified>;
|
||||||
|
notified: NotifiedResult;
|
||||||
organizations: Array<Organization>;
|
organizations: Array<Organization>;
|
||||||
projects: Array<Project>;
|
projects: Array<Project>;
|
||||||
searchMembers: Array<MemberSearchResult>;
|
searchMembers: Array<MemberSearchResult>;
|
||||||
@ -935,6 +997,11 @@ export type QueryMyTasksArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryNotifiedArgs = {
|
||||||
|
input: NotifiedInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryProjectsArgs = {
|
export type QueryProjectsArgs = {
|
||||||
input?: Maybe<ProjectsFilter>;
|
input?: Maybe<ProjectsFilter>;
|
||||||
};
|
};
|
||||||
@ -1006,6 +1073,7 @@ export type Task = {
|
|||||||
name: Scalars['String'];
|
name: Scalars['String'];
|
||||||
position: Scalars['Float'];
|
position: Scalars['Float'];
|
||||||
description?: Maybe<Scalars['String']>;
|
description?: Maybe<Scalars['String']>;
|
||||||
|
watched: Scalars['Boolean'];
|
||||||
dueDate?: Maybe<Scalars['Time']>;
|
dueDate?: Maybe<Scalars['Time']>;
|
||||||
hasTime: Scalars['Boolean'];
|
hasTime: Scalars['Boolean'];
|
||||||
complete: Scalars['Boolean'];
|
complete: Scalars['Boolean'];
|
||||||
@ -1132,6 +1200,10 @@ export type ToggleTaskLabelPayload = {
|
|||||||
task: Task;
|
task: Task;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToggleTaskWatch = {
|
||||||
|
taskID: Scalars['UUID'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type UnassignTaskInput = {
|
export type UnassignTaskInput = {
|
||||||
taskID: Scalars['UUID'];
|
taskID: Scalars['UUID'];
|
||||||
@ -1520,7 +1592,7 @@ export type FindTaskQuery = (
|
|||||||
{ __typename?: 'Query' }
|
{ __typename?: 'Query' }
|
||||||
& { findTask: (
|
& { findTask: (
|
||||||
{ __typename?: 'Task' }
|
{ __typename?: 'Task' }
|
||||||
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete' | 'hasTime'>
|
& Pick<Task, 'id' | 'name' | 'watched' | 'description' | 'dueDate' | 'position' | 'complete' | 'hasTime'>
|
||||||
& { taskGroup: (
|
& { taskGroup: (
|
||||||
{ __typename?: 'TaskGroup' }
|
{ __typename?: 'TaskGroup' }
|
||||||
& Pick<TaskGroup, 'id' | 'name'>
|
& Pick<TaskGroup, 'id' | 'name'>
|
||||||
@ -1596,7 +1668,7 @@ export type FindTaskQuery = (
|
|||||||
|
|
||||||
export type TaskFieldsFragment = (
|
export type TaskFieldsFragment = (
|
||||||
{ __typename?: 'Task' }
|
{ __typename?: 'Task' }
|
||||||
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'hasTime' | 'complete' | 'completedAt' | 'position'>
|
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'hasTime' | 'complete' | 'watched' | 'completedAt' | 'position'>
|
||||||
& { badges: (
|
& { badges: (
|
||||||
{ __typename?: 'TaskBadges' }
|
{ __typename?: 'TaskBadges' }
|
||||||
& { checklist?: Maybe<(
|
& { 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<{
|
export type DeleteProjectMutationVariables = Exact<{
|
||||||
projectID: Scalars['UUID'];
|
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<{
|
export type UpdateTaskChecklistItemLocationMutationVariables = Exact<{
|
||||||
taskChecklistID: Scalars['UUID'];
|
taskChecklistID: Scalars['UUID'];
|
||||||
taskChecklistItemID: Scalars['UUID'];
|
taskChecklistItemID: Scalars['UUID'];
|
||||||
@ -2363,10 +2516,10 @@ export type TopNavbarQuery = (
|
|||||||
& { notification: (
|
& { notification: (
|
||||||
{ __typename?: 'Notification' }
|
{ __typename?: 'Notification' }
|
||||||
& Pick<Notification, 'id' | 'actionType' | 'createdAt'>
|
& Pick<Notification, 'id' | 'actionType' | 'createdAt'>
|
||||||
& { causedBy: (
|
& { causedBy?: Maybe<(
|
||||||
{ __typename?: 'NotificationCausedBy' }
|
{ __typename?: 'NotificationCausedBy' }
|
||||||
& Pick<NotificationCausedBy, 'username' | 'fullname' | 'id'>
|
& Pick<NotificationCausedBy, 'username' | 'fullname' | 'id'>
|
||||||
) }
|
)> }
|
||||||
) }
|
) }
|
||||||
)>, me?: Maybe<(
|
)>, me?: Maybe<(
|
||||||
{ __typename?: 'MePayload' }
|
{ __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<{
|
export type UpdateProjectLabelMutationVariables = Exact<{
|
||||||
projectLabelID: Scalars['UUID'];
|
projectLabelID: Scalars['UUID'];
|
||||||
labelColorID: Scalars['UUID'];
|
labelColorID: Scalars['UUID'];
|
||||||
@ -2700,6 +2864,7 @@ export const TaskFieldsFragmentDoc = gql`
|
|||||||
dueDate
|
dueDate
|
||||||
hasTime
|
hasTime
|
||||||
complete
|
complete
|
||||||
|
watched
|
||||||
completedAt
|
completedAt
|
||||||
position
|
position
|
||||||
badges {
|
badges {
|
||||||
@ -3171,6 +3336,7 @@ export const FindTaskDocument = gql`
|
|||||||
findTask(input: {taskID: $taskID}) {
|
findTask(input: {taskID: $taskID}) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
watched
|
||||||
description
|
description
|
||||||
dueDate
|
dueDate
|
||||||
position
|
position
|
||||||
@ -3505,6 +3671,146 @@ export function useMyTasksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<My
|
|||||||
export type MyTasksQueryHookResult = ReturnType<typeof useMyTasksQuery>;
|
export type MyTasksQueryHookResult = ReturnType<typeof useMyTasksQuery>;
|
||||||
export type MyTasksLazyQueryHookResult = ReturnType<typeof useMyTasksLazyQuery>;
|
export type MyTasksLazyQueryHookResult = ReturnType<typeof useMyTasksLazyQuery>;
|
||||||
export type MyTasksQueryResult = Apollo.QueryResult<MyTasksQuery, MyTasksQueryVariables>;
|
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`
|
export const DeleteProjectDocument = gql`
|
||||||
mutation deleteProject($projectID: UUID!) {
|
mutation deleteProject($projectID: UUID!) {
|
||||||
deleteProject(input: {projectID: $projectID}) {
|
deleteProject(input: {projectID: $projectID}) {
|
||||||
@ -4061,6 +4367,40 @@ export function useSetTaskCompleteMutation(baseOptions?: Apollo.MutationHookOpti
|
|||||||
export type SetTaskCompleteMutationHookResult = ReturnType<typeof useSetTaskCompleteMutation>;
|
export type SetTaskCompleteMutationHookResult = ReturnType<typeof useSetTaskCompleteMutation>;
|
||||||
export type SetTaskCompleteMutationResult = Apollo.MutationResult<SetTaskCompleteMutation>;
|
export type SetTaskCompleteMutationResult = Apollo.MutationResult<SetTaskCompleteMutation>;
|
||||||
export type SetTaskCompleteMutationOptions = Apollo.BaseMutationOptions<SetTaskCompleteMutation, SetTaskCompleteMutationVariables>;
|
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`
|
export const UpdateTaskChecklistItemLocationDocument = gql`
|
||||||
mutation updateTaskChecklistItemLocation($taskChecklistID: UUID!, $taskChecklistItemID: UUID!, $position: Float!) {
|
mutation updateTaskChecklistItemLocation($taskChecklistID: UUID!, $taskChecklistItemID: UUID!, $position: Float!) {
|
||||||
updateTaskChecklistItemLocation(
|
updateTaskChecklistItemLocation(
|
||||||
@ -4923,6 +5263,40 @@ export function useUnassignTaskMutation(baseOptions?: Apollo.MutationHookOptions
|
|||||||
export type UnassignTaskMutationHookResult = ReturnType<typeof useUnassignTaskMutation>;
|
export type UnassignTaskMutationHookResult = ReturnType<typeof useUnassignTaskMutation>;
|
||||||
export type UnassignTaskMutationResult = Apollo.MutationResult<UnassignTaskMutation>;
|
export type UnassignTaskMutationResult = Apollo.MutationResult<UnassignTaskMutation>;
|
||||||
export type UnassignTaskMutationOptions = Apollo.BaseMutationOptions<UnassignTaskMutation, UnassignTaskMutationVariables>;
|
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`
|
export const UpdateProjectLabelDocument = gql`
|
||||||
mutation updateProjectLabel($projectLabelID: UUID!, $labelColorID: UUID!, $name: String!) {
|
mutation updateProjectLabel($projectLabelID: UUID!, $labelColorID: UUID!, $name: String!) {
|
||||||
updateProjectLabel(
|
updateProjectLabel(
|
||||||
|
@ -2,6 +2,7 @@ query findTask($taskID: UUID!) {
|
|||||||
findTask(input: {taskID: $taskID}) {
|
findTask(input: {taskID: $taskID}) {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
watched
|
||||||
description
|
description
|
||||||
dueDate
|
dueDate
|
||||||
position
|
position
|
||||||
|
@ -8,6 +8,7 @@ const TASK_FRAGMENT = gql`
|
|||||||
dueDate
|
dueDate
|
||||||
hasTime
|
hasTime
|
||||||
complete
|
complete
|
||||||
|
watched
|
||||||
completedAt
|
completedAt
|
||||||
position
|
position
|
||||||
badges {
|
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) => {
|
const Bell = ({ size, color }: Props) => {
|
||||||
return (
|
return (
|
||||||
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 448 512">
|
<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>
|
</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 Cross from './Cross';
|
||||||
import Cog from './Cog';
|
import Cog from './Cog';
|
||||||
import Cogs from './Cogs';
|
import Cogs from './Cogs';
|
||||||
|
import Circle from './Circle';
|
||||||
|
import CircleSolid from './CircleSolid';
|
||||||
|
import UserCircle from './UserCircle';
|
||||||
import Bubble from './Bubble';
|
import Bubble from './Bubble';
|
||||||
import ArrowDown from './ArrowDown';
|
import ArrowDown from './ArrowDown';
|
||||||
import CheckCircleOutline from './CheckCircleOutline';
|
import CheckCircleOutline from './CheckCircleOutline';
|
||||||
@ -111,6 +114,9 @@ export {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
DotCircle,
|
DotCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Circle,
|
||||||
|
CircleSolid,
|
||||||
Bubble,
|
Bubble,
|
||||||
|
UserCircle,
|
||||||
Cogs,
|
Cogs,
|
||||||
};
|
};
|
||||||
|
@ -8,6 +8,7 @@ const polling = {
|
|||||||
MEMBERS: resolve(3000),
|
MEMBERS: resolve(3000),
|
||||||
TEAM_PROJECTS: resolve(3000),
|
TEAM_PROJECTS: resolve(3000),
|
||||||
TASK_DETAILS: resolve(3000),
|
TASK_DETAILS: resolve(3000),
|
||||||
|
UNREAD_NOTIFICATIONS: resolve(30000),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default polling;
|
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;
|
id: string;
|
||||||
taskGroup: InnerTaskGroup;
|
taskGroup: InnerTaskGroup;
|
||||||
name: string;
|
name: string;
|
||||||
|
watched?: boolean;
|
||||||
badges?: TaskBadges;
|
badges?: TaskBadges;
|
||||||
position: number;
|
position: number;
|
||||||
hasTime?: boolean;
|
hasTime?: boolean;
|
||||||
|
@ -68,6 +68,6 @@ func initConfig() {
|
|||||||
// Execute the root cobra command
|
// Execute the root cobra command
|
||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd.SetVersionTemplate(VersionTemplate())
|
rootCmd.SetVersionTemplate(VersionTemplate())
|
||||||
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
|
rootCmd.AddCommand(newTokenCmd(), newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
|
||||||
rootCmd.Execute()
|
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"
|
"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 {
|
type AuthToken struct {
|
||||||
TokenID uuid.UUID `json:"token_id"`
|
TokenID uuid.UUID `json:"token_id"`
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
@ -172,6 +200,13 @@ type TaskLabel struct {
|
|||||||
AssignedDate time.Time `json:"assigned_date"`
|
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 {
|
type Team struct {
|
||||||
TeamID uuid.UUID `json:"team_id"`
|
TeamID uuid.UUID `json:"team_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createNotification = `-- name: CreateNotification :one
|
const createNotification = `-- name: CreateNotification :one
|
||||||
@ -142,16 +143,285 @@ func (q *Queries) GetAllNotificationsForUserID(ctx context.Context, userID uuid.
|
|||||||
return items, nil
|
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
|
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 {
|
type MarkNotificationAsReadParams struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
ReadAt sql.NullTime `json:"read_at"`
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,7 @@ type Querier interface {
|
|||||||
CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error)
|
CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error)
|
||||||
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
|
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
|
||||||
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, 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)
|
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
|
||||||
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
|
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
|
||||||
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, 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)
|
DeleteTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
|
||||||
DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error
|
DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error
|
||||||
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
|
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
|
||||||
|
DeleteTaskWatcher(ctx context.Context, arg DeleteTaskWatcherParams) error
|
||||||
DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
|
DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
|
||||||
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
|
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
|
||||||
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
|
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
|
||||||
@ -87,6 +89,9 @@ type Querier interface {
|
|||||||
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
|
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
|
||||||
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
|
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
|
||||||
GetMemberTeamIDsForUserID(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)
|
GetPersonalProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
|
||||||
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
|
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
|
||||||
GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, 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)
|
GetProjectIDForTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectIDForTaskGroup(ctx context.Context, taskGroupID 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)
|
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)
|
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
|
||||||
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
|
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
|
||||||
GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, 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)
|
GetTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) (TaskLabel, error)
|
||||||
GetTaskLabelForTaskByProjectLabelID(ctx context.Context, arg GetTaskLabelForTaskByProjectLabelIDParams) (TaskLabel, error)
|
GetTaskLabelForTaskByProjectLabelID(ctx context.Context, arg GetTaskLabelForTaskByProjectLabelIDParams) (TaskLabel, error)
|
||||||
GetTaskLabelsForTaskID(ctx context.Context, taskID uuid.UUID) ([]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)
|
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
|
||||||
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
|
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
|
||||||
GetTeamMemberByID(ctx context.Context, arg GetTeamMemberByIDParams) (TeamMember, error)
|
GetTeamMemberByID(ctx context.Context, arg GetTeamMemberByIDParams) (TeamMember, error)
|
||||||
@ -131,6 +138,7 @@ type Querier interface {
|
|||||||
GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error)
|
GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error)
|
||||||
HasActiveUser(ctx context.Context) (bool, error)
|
HasActiveUser(ctx context.Context) (bool, error)
|
||||||
HasAnyUser(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
|
MarkNotificationAsRead(ctx context.Context, arg MarkNotificationAsReadParams) error
|
||||||
SetFirstUserActive(ctx context.Context) (UserAccount, error)
|
SetFirstUserActive(ctx context.Context) (UserAccount, error)
|
||||||
SetInactiveLastMoveForTaskID(ctx context.Context, taskID uuid.UUID) 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
|
LEFT JOIN user_account ON user_account.user_id = n.caused_by
|
||||||
WHERE nn.user_id = $1;
|
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
|
-- 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
|
-- name: CreateNotification :one
|
||||||
INSERT INTO notification (caused_by, data, action_type, created_on)
|
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
|
-- name: CreateNotificationNotifed :one
|
||||||
INSERT INTO notification_notified (notification_id, user_id) VALUES ($1, $2) RETURNING *;
|
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
|
-- name: CreateTask :one
|
||||||
INSERT INTO task (task_group_id, created_at, name, position)
|
INSERT INTO task (task_group_id, created_at, name, position)
|
||||||
VALUES($1, $2, $3, $4) RETURNING *;
|
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
|
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
|
||||||
WHERE task_id = $1;
|
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
|
-- name: CreateTaskComment :one
|
||||||
INSERT INTO task_comment (task_id, message, created_at, created_by)
|
INSERT INTO task_comment (task_id, message, created_at, created_by)
|
||||||
VALUES ($1, $2, $3, $4) RETURNING *;
|
VALUES ($1, $2, $3, $4) RETURNING *;
|
||||||
|
@ -120,6 +120,28 @@ func (q *Queries) CreateTaskComment(ctx context.Context, arg CreateTaskCommentPa
|
|||||||
return i, err
|
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
|
const deleteTaskByID = `-- name: DeleteTaskByID :exec
|
||||||
DELETE FROM task WHERE task_id = $1
|
DELETE FROM task WHERE task_id = $1
|
||||||
`
|
`
|
||||||
@ -148,6 +170,20 @@ func (q *Queries) DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.
|
|||||||
return i, err
|
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
|
const deleteTasksByTaskGroupID = `-- name: DeleteTasksByTaskGroupID :execrows
|
||||||
DELETE FROM task where task_group_id = $1
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
@ -31,6 +32,10 @@ func NewHandler(repo db.Repository, appConfig config.AppConfig) http.Handler {
|
|||||||
Resolvers: &Resolver{
|
Resolvers: &Resolver{
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
AppConfig: appConfig,
|
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) {
|
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
|
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
|
type MemberType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -3,8 +3,12 @@ package graph
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jordanknott/taskcafe/internal/db"
|
"github.com/jordanknott/taskcafe/internal/db"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetOwnedList todo: remove this
|
// GetOwnedList todo: remove this
|
||||||
@ -12,6 +16,57 @@ func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*O
|
|||||||
return &OwnedList{}, nil
|
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
|
// 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) {
|
func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) {
|
||||||
projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID)
|
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 {
|
type ActivityData struct {
|
||||||
Data map[string]string
|
Data map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotifiedData struct {
|
||||||
|
Data map[string]string
|
||||||
|
}
|
||||||
|
@ -230,6 +230,10 @@ type FindUser struct {
|
|||||||
UserID uuid.UUID `json:"userID"`
|
UserID uuid.UUID `json:"userID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HasUnreadNotificationsResult struct {
|
||||||
|
Unread bool `json:"unread"`
|
||||||
|
}
|
||||||
|
|
||||||
type InviteProjectMembers struct {
|
type InviteProjectMembers struct {
|
||||||
ProjectID uuid.UUID `json:"projectID"`
|
ProjectID uuid.UUID `json:"projectID"`
|
||||||
Members []MemberInvite `json:"members"`
|
Members []MemberInvite `json:"members"`
|
||||||
@ -367,6 +371,10 @@ type NotificationData struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationToggleReadInput struct {
|
||||||
|
NotifiedID uuid.UUID `json:"notifiedID"`
|
||||||
|
}
|
||||||
|
|
||||||
type Notified struct {
|
type Notified struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Notification *db.Notification `json:"notification"`
|
Notification *db.Notification `json:"notification"`
|
||||||
@ -374,6 +382,18 @@ type Notified struct {
|
|||||||
ReadAt *time.Time `json:"readAt"`
|
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 {
|
type OwnedList struct {
|
||||||
Teams []db.Team `json:"teams"`
|
Teams []db.Team `json:"teams"`
|
||||||
Projects []db.Project `json:"projects"`
|
Projects []db.Project `json:"projects"`
|
||||||
@ -384,6 +404,11 @@ type OwnersList struct {
|
|||||||
Teams []uuid.UUID `json:"teams"`
|
Teams []uuid.UUID `json:"teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PageInfo struct {
|
||||||
|
EndCursor string `json:"endCursor"`
|
||||||
|
HasNextPage bool `json:"hasNextPage"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProfileIcon struct {
|
type ProfileIcon struct {
|
||||||
URL *string `json:"url"`
|
URL *string `json:"url"`
|
||||||
Initials *string `json:"initials"`
|
Initials *string `json:"initials"`
|
||||||
@ -479,6 +504,10 @@ type ToggleTaskLabelPayload struct {
|
|||||||
Task *db.Task `json:"task"`
|
Task *db.Task `json:"task"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToggleTaskWatch struct {
|
||||||
|
TaskID uuid.UUID `json:"taskID"`
|
||||||
|
}
|
||||||
|
|
||||||
type UnassignTaskInput struct {
|
type UnassignTaskInput struct {
|
||||||
TaskID uuid.UUID `json:"taskID"`
|
TaskID uuid.UUID `json:"taskID"`
|
||||||
UserID uuid.UUID `json:"userID"`
|
UserID uuid.UUID `json:"userID"`
|
||||||
@ -671,16 +700,42 @@ func (e ActionLevel) MarshalGQL(w io.Writer) {
|
|||||||
type ActionType string
|
type ActionType string
|
||||||
|
|
||||||
const (
|
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{
|
var AllActionType = []ActionType{
|
||||||
ActionTypeTaskMemberAdded,
|
ActionTypeTeamAdded,
|
||||||
|
ActionTypeTeamRemoved,
|
||||||
|
ActionTypeProjectAdded,
|
||||||
|
ActionTypeProjectRemoved,
|
||||||
|
ActionTypeProjectArchived,
|
||||||
|
ActionTypeDueDateAdded,
|
||||||
|
ActionTypeDueDateRemoved,
|
||||||
|
ActionTypeDueDateChanged,
|
||||||
|
ActionTypeTaskAssigned,
|
||||||
|
ActionTypeTaskMoved,
|
||||||
|
ActionTypeTaskArchived,
|
||||||
|
ActionTypeTaskAttachmentUploaded,
|
||||||
|
ActionTypeCommentMentioned,
|
||||||
|
ActionTypeCommentOther,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e ActionType) IsValid() bool {
|
func (e ActionType) IsValid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
case ActionTypeTaskMemberAdded:
|
case ActionTypeTeamAdded, ActionTypeTeamRemoved, ActionTypeProjectAdded, ActionTypeProjectRemoved, ActionTypeProjectArchived, ActionTypeDueDateAdded, ActionTypeDueDateRemoved, ActionTypeDueDateChanged, ActionTypeTaskAssigned, ActionTypeTaskMoved, ActionTypeTaskArchived, ActionTypeTaskAttachmentUploaded, ActionTypeCommentMentioned, ActionTypeCommentOther:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@ -860,6 +915,51 @@ func (e MyTasksStatus) MarshalGQL(w io.Writer) {
|
|||||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
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
|
type ObjectType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -6,32 +6,88 @@ package graph
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jordanknott/taskcafe/internal/db"
|
"github.com/jordanknott/taskcafe/internal/db"
|
||||||
"github.com/jordanknott/taskcafe/internal/logger"
|
"github.com/jordanknott/taskcafe/internal/logger"
|
||||||
|
"github.com/jordanknott/taskcafe/internal/utils"
|
||||||
log "github.com/sirupsen/logrus"
|
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) {
|
func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) {
|
||||||
return obj.NotificationID, nil
|
return obj.NotificationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *notificationResolver) ActionType(ctx context.Context, obj *db.Notification) (ActionType, error) {
|
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) {
|
func (r *notificationResolver) CausedBy(ctx context.Context, obj *db.Notification) (*NotificationCausedBy, error) {
|
||||||
user, err := r.Repository.GetUserAccountByID(ctx, obj.CausedBy)
|
user, err := r.Repository.GetUserAccountByID(ctx, obj.CausedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return &NotificationCausedBy{
|
return nil, nil
|
||||||
Fullname: "Unknown user",
|
|
||||||
Username: "unknown",
|
|
||||||
ID: obj.CausedBy,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
log.WithError(err).Error("error while resolving Notification.CausedBy")
|
log.WithError(err).Error("error while resolving Notification.CausedBy")
|
||||||
return &NotificationCausedBy{}, err
|
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) {
|
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) {
|
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
|
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) {
|
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.
|
// Notification returns NotificationResolver implementation.
|
||||||
|
@ -10,9 +10,14 @@ import (
|
|||||||
"github.com/jordanknott/taskcafe/internal/db"
|
"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
|
// Resolver handles resolving GraphQL queries & mutations
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
Repository db.Repository
|
Repository db.Repository
|
||||||
AppConfig config.AppConfig
|
AppConfig config.AppConfig
|
||||||
mu sync.Mutex
|
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) {
|
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) {
|
||||||
logger.New(ctx).Info("finding project user")
|
|
||||||
_, isLoggedIn := GetUser(ctx)
|
_, isLoggedIn := GetUser(ctx)
|
||||||
if !isLoggedIn {
|
if !isLoggedIn {
|
||||||
isPublic, _ := IsProjectPublic(ctx, r.Repository, input.ProjectID)
|
isPublic, _ := IsProjectPublic(ctx, r.Repository, input.ProjectID)
|
||||||
|
@ -4,10 +4,60 @@ extend type Subscription {
|
|||||||
|
|
||||||
extend type Query {
|
extend type Query {
|
||||||
notifications: [Notified!]!
|
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 {
|
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 {
|
type NotificationData {
|
||||||
@ -24,7 +74,7 @@ type NotificationCausedBy {
|
|||||||
type Notification {
|
type Notification {
|
||||||
id: ID!
|
id: ID!
|
||||||
actionType: ActionType!
|
actionType: ActionType!
|
||||||
causedBy: NotificationCausedBy!
|
causedBy: NotificationCausedBy
|
||||||
data: [NotificationData!]!
|
data: [NotificationData!]!
|
||||||
createdAt: Time!
|
createdAt: Time!
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,60 @@ extend type Subscription {
|
|||||||
|
|
||||||
extend type Query {
|
extend type Query {
|
||||||
notifications: [Notified!]!
|
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 {
|
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 {
|
type NotificationData {
|
||||||
@ -24,7 +74,7 @@ type NotificationCausedBy {
|
|||||||
type Notification {
|
type Notification {
|
||||||
id: ID!
|
id: ID!
|
||||||
actionType: ActionType!
|
actionType: ActionType!
|
||||||
causedBy: NotificationCausedBy!
|
causedBy: NotificationCausedBy
|
||||||
data: [NotificationData!]!
|
data: [NotificationData!]!
|
||||||
createdAt: Time!
|
createdAt: Time!
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ type Task {
|
|||||||
name: String!
|
name: String!
|
||||||
position: Float!
|
position: Float!
|
||||||
description: String
|
description: String
|
||||||
|
watched: Boolean!
|
||||||
dueDate: Time
|
dueDate: Time
|
||||||
hasTime: Boolean!
|
hasTime: Boolean!
|
||||||
complete: Boolean!
|
complete: Boolean!
|
||||||
@ -352,6 +353,8 @@ extend type Mutation {
|
|||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
updateTaskDueDate(input: UpdateTaskDueDate!):
|
updateTaskDueDate(input: UpdateTaskDueDate!):
|
||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
|
toggleTaskWatch(input: ToggleTaskWatch!):
|
||||||
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
|
|
||||||
assignTask(input: AssignTaskInput):
|
assignTask(input: AssignTaskInput):
|
||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
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)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ToggleTaskWatch {
|
||||||
|
taskID: UUID!
|
||||||
|
}
|
||||||
|
|
||||||
input NewTask {
|
input NewTask {
|
||||||
taskGroupID: UUID!
|
taskGroupID: UUID!
|
||||||
name: String!
|
name: String!
|
||||||
|
@ -27,6 +27,7 @@ type Task {
|
|||||||
name: String!
|
name: String!
|
||||||
position: Float!
|
position: Float!
|
||||||
description: String
|
description: String
|
||||||
|
watched: Boolean!
|
||||||
dueDate: Time
|
dueDate: Time
|
||||||
hasTime: Boolean!
|
hasTime: Boolean!
|
||||||
complete: Boolean!
|
complete: Boolean!
|
||||||
|
@ -14,6 +14,8 @@ extend type Mutation {
|
|||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
updateTaskDueDate(input: UpdateTaskDueDate!):
|
updateTaskDueDate(input: UpdateTaskDueDate!):
|
||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
|
toggleTaskWatch(input: ToggleTaskWatch!):
|
||||||
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
|
|
||||||
assignTask(input: AssignTaskInput):
|
assignTask(input: AssignTaskInput):
|
||||||
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
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)
|
Task! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ToggleTaskWatch {
|
||||||
|
taskID: UUID!
|
||||||
|
}
|
||||||
|
|
||||||
input NewTask {
|
input NewTask {
|
||||||
taskGroupID: UUID!
|
taskGroupID: UUID!
|
||||||
name: String!
|
name: String!
|
||||||
|
@ -543,6 +543,45 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
|
|||||||
return &task, err
|
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) {
|
func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInput) (*db.Task, error) {
|
||||||
assignedDate := time.Now().UTC()
|
assignedDate := time.Now().UTC()
|
||||||
assignedTask, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{input.TaskID, input.UserID, assignedDate})
|
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,
|
"assignedTaskID": assignedTask.TaskAssignedID,
|
||||||
}).Info("assigned task")
|
}).Info("assigned task")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.WithError(err).Error("error while creating task assigned")
|
||||||
return &db.Task{}, err
|
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)
|
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
|
||||||
|
}
|
||||||
|
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
|
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) {
|
func (r *mutationResolver) UnassignTask(ctx context.Context, input *UnassignTaskInput) (*db.Task, error) {
|
||||||
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
|
task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.WithError(err).Error("error while getting task by ID")
|
||||||
return &db.Task{}, err
|
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 {
|
if err != nil {
|
||||||
|
log.WithError(err).Error("error while creating task assigned task watcher")
|
||||||
return &db.Task{}, err
|
return &db.Task{}, err
|
||||||
}
|
}
|
||||||
return &task, nil
|
return &task, nil
|
||||||
@ -591,6 +690,23 @@ func (r *taskResolver) Description(ctx context.Context, obj *db.Task) (*string,
|
|||||||
return &task.Description.String, nil
|
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) {
|
func (r *taskResolver) DueDate(ctx context.Context, obj *db.Task) (*time.Time, error) {
|
||||||
if obj.DueDate.Valid {
|
if obj.DueDate.Valid {
|
||||||
return &obj.DueDate.Time, nil
|
return &obj.DueDate.Time, nil
|
||||||
|
@ -18,30 +18,36 @@ type AuthenticationMiddleware struct {
|
|||||||
// Middleware returns the middleware handler
|
// Middleware returns the middleware handler
|
||||||
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Info("middleware")
|
|
||||||
requestID := uuid.New()
|
requestID := uuid.New()
|
||||||
foundToken := true
|
foundToken := true
|
||||||
tokenRaw := ""
|
tokenRaw := ""
|
||||||
c, err := r.Cookie("authToken")
|
|
||||||
if err != nil {
|
|
||||||
if err == http.ErrNoCookie {
|
|
||||||
foundToken = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundToken {
|
|
||||||
token := r.Header.Get("Authorization")
|
token := r.Header.Get("Authorization")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
tokenRaw = token
|
tokenRaw = token
|
||||||
}
|
|
||||||
} else {
|
} 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
|
tokenRaw = c.Value
|
||||||
}
|
}
|
||||||
authTokenID, err := uuid.Parse(tokenRaw)
|
authTokenID, err := uuid.Parse(tokenRaw)
|
||||||
log.Info("checking if logged in")
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
token, err := m.repo.GetAuthTokenByID(r.Context(), authTokenID)
|
token, err := m.repo.GetAuthTokenByID(r.Context(), authTokenID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
log.WithField("tokenID", authTokenID).WithField("userID", token.UserID).Info("setting auth token")
|
||||||
ctx = context.WithValue(ctx, utils.UserIDKey, token.UserID)
|
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) {
|
r.Group(func(mux chi.Router) {
|
||||||
mux.Use(auth.Middleware)
|
mux.Use(auth.Middleware)
|
||||||
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
|
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"}
|
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 boolean NOT NULL DEFAULT false,
|
||||||
read_at timestamptz
|
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,
|
data_type text NOT NULL REFERENCES account_setting_data_type(data_type_id) ON DELETE CASCADE,
|
||||||
constrained_default_value text
|
constrained_default_value text
|
||||||
REFERENCES account_setting_allowed_values(allowed_value_id) ON DELETE CASCADE,
|
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');
|
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, 'hourly');
|
||||||
INSERT INTO account_setting_allowed_values (setting_id, item_value) VALUES (0, 'instant');
|
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(),
|
account_setting_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id uuid NOT NULL REFERENCES user_account(user_id) ON DELETE CASCADE,
|
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,
|
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