feat!: due date reminder notifications

This commit is contained in:
Jordan Knott 2021-11-17 17:11:28 -06:00
parent 0d00fc7518
commit 886b2763ee
32 changed files with 1244 additions and 287 deletions

View File

@ -10,6 +10,8 @@ windows:
- yarn: - yarn:
- cd frontend - cd frontend
- yarn start - yarn start
- worker:
- go run cmd/taskcafe/main.go worker
- web/editor: - web/editor:
root: ./frontend root: ./frontend
panes: panes:

View File

@ -19,17 +19,11 @@ services:
ports: ports:
- 1025:1025 - 1025:1025
- 8025:8025 - 8025:8025
broker: redis:
image: rabbitmq:3-management image: redis:6.2
restart: always restart: always
ports: ports:
- 8060:15672 - 6379:6379
- 5672:5672
result_store:
image: memcached:1.6-alpine
restart: always
ports:
- 11211:11211
volumes: volumes:
taskcafe-postgres: taskcafe-postgres:

View File

@ -73,7 +73,9 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
}); });
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser(); const { setUser } = useCurrentUser();
const { data: unreadData } = useHasUnreadNotificationsQuery({ pollInterval: polling.UNREAD_NOTIFICATIONS }); const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
pollInterval: polling.UNREAD_NOTIFICATIONS,
});
const history = useHistory(); const history = useHistory();
const onLogout = () => { const onLogout = () => {
fetch('/auth/logout', { fetch('/auth/logout', {
@ -118,9 +120,11 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
// TODO: rewrite popup to contain subscription and notification fetch // TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => { const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) { showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
showPopup($target, <NotificationPopup />, { width: 605, borders: false, diamondColor: theme.colors.primary }); width: 605,
} borders: false,
diamondColor: theme.colors.primary,
});
}; };
// TODO: readd permision check // TODO: readd permision check

View File

@ -446,10 +446,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</LeftWrapper> </LeftWrapper>
<RightWrapper> <RightWrapper>
<ActionIcon <ActionIcon
// disabled={notifications.length === 3} disabled={notifications.length === 3}
disabled
onClick={() => { onClick={() => {
/*
setNotifications((prev) => [ setNotifications((prev) => [
...prev, ...prev,
{ {
@ -459,7 +457,6 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
period: 10, period: 10,
}, },
]); ]);
*/
}} }}
> >
<Bell width={16} height={16} /> <Bell width={16} height={16} />

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import styled, { css } 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 { Link } from 'react-router-dom';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import { import {
useNotificationMarkAllReadMutation,
useNotificationsQuery, useNotificationsQuery,
NotificationFilter, NotificationFilter,
ActionType, ActionType,
@ -13,10 +14,24 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Popup, usePopup } from 'shared/components/PopupMenu'; import { Popup, usePopup } from 'shared/components/PopupMenu';
import { CheckCircleOutline, Circle, CircleSolid, UserCircle } from 'shared/icons'; import { Bell, CheckCircleOutline, Circle, Ellipsis, UserCircle } from 'shared/icons';
import produce from 'immer'; import produce from 'immer';
import { useLocalStorage } from 'shared/hooks/useStateWithLocalStorage'; import { useLocalStorage } from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage'; import localStorage from 'shared/utils/localStorage';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
function getFilterMessage(filter: NotificationFilter) {
switch (filter) {
case NotificationFilter.Unread:
return 'no unread';
case NotificationFilter.Assigned:
return 'no assigned';
case NotificationFilter.Mentioned:
return 'no mentioned';
default:
return 'no';
}
}
const ItemWrapper = styled.div` const ItemWrapper = styled.div`
cursor: pointer; cursor: pointer;
@ -98,6 +113,17 @@ const NotificationHeaderTitle = styled.span`
color: ${(props) => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
`; `;
const EmptyMessage = styled.div`
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
height: 448px;
`;
const EmptyMessageLabel = styled.span`
margin-bottom: 80px;
`;
const Notifications = styled.div` const Notifications = styled.div`
border-right: 1px solid rgba(0, 0, 0, 0.1); border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1); border-left: 1px solid rgba(0, 0, 0, 0.1);
@ -180,7 +206,6 @@ const NotificationTab = styled.div<{ active: boolean }>`
const NotificationLink = styled(Link)` const NotificationLink = styled(Link)`
display: flex; display: flex;
align-items: center;
text-decoration: none; text-decoration: none;
padding: 16px 8px; padding: 16px 8px;
width: 100%; width: 100%;
@ -213,8 +238,8 @@ const NotificationButton = styled.div`
} }
`; `;
const NotificationWrapper = styled.li` const NotificationWrapper = styled.li<{ read: boolean }>`
min-height: 112px; min-height: 80px;
display: flex; display: flex;
font-size: 14px; font-size: 14px;
transition: background-color 0.1s ease-in-out; transition: background-color 0.1s ease-in-out;
@ -231,20 +256,28 @@ const NotificationWrapper = styled.li`
&:hover ${NotificationControls} { &:hover ${NotificationControls} {
visibility: visible; visibility: visible;
} }
${(props) =>
!props.read &&
css`
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.5)};
&:hover {
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.6)};
}
`}
`; `;
const NotificationContentFooter = styled.div` const NotificationContentFooter = styled.div`
margin-top: 8px; margin-top: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
color: ${(props) => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
const NotificationCausedBy = styled.div` const NotificationCausedBy = styled.div`
height: 60px; height: 48px;
width: 60px; width: 48px;
min-height: 60px; min-height: 48px;
min-width: 60px; min-width: 48px;
`; `;
const NotificationCausedByInitials = styled.div` const NotificationCausedByInitials = styled.div`
position: relative; position: relative;
@ -292,7 +325,6 @@ const NotificationContentHeader = styled.div`
`; `;
const NotificationBody = styled.div` const NotificationBody = styled.div`
margin-top: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
color: #fff; color: #fff;
@ -328,17 +360,39 @@ const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data,
let link = '#'; let link = '#';
switch (actionType) { switch (actionType) {
case ActionType.TaskAssigned: case ActionType.TaskAssigned:
prefix.push(<UserCircle width={14} height={16} />); prefix.push(<UserCircle key="profile" width={14} height={16} />);
prefix.push(<NotificationPrefix>Assigned </NotificationPrefix>); prefix.push(
prefix.push(<span>you to the task "{dataMap.get('TaskName')}"</span>); <NotificationPrefix key="prefix">
<span style={{ fontWeight: 'bold' }}>{causedBy ? causedBy.fullname : 'Removed user'}</span>
</NotificationPrefix>,
);
prefix.push(<span key="content">assigned you to the task "{dataMap.get('TaskName')}"</span>);
link = `/p/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`; link = `/p/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break; break;
case ActionType.DueDateReminder:
prefix.push(<Bell key="profile" width={14} height={16} />);
prefix.push(<NotificationPrefix key="prefix">{dataMap.get('TaskName')}</NotificationPrefix>);
const now = dayjs();
if (dayjs(dataMap.get('DueDate')).isBefore(dayjs())) {
prefix.push(
<span key="content">is due {dayjs.duration(now.diff(dayjs(dataMap.get('DueAt')))).humanize(true)}</span>,
);
} else {
prefix.push(
<span key="content">
has passed the due date {dayjs.duration(dayjs(dataMap.get('DueAt')).diff(now)).humanize(true)}
</span>,
);
}
link = `/p/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break;
default: default:
throw new Error('unknown action type'); throw new Error('unknown action type');
} }
return ( return (
<NotificationWrapper> <NotificationWrapper read={read}>
<NotificationLink to={link} onClick={hidePopup}> <NotificationLink to={link} onClick={hidePopup}>
<NotificationCausedBy> <NotificationCausedBy>
<NotificationCausedByInitials> <NotificationCausedByInitials>
@ -351,10 +405,6 @@ const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data,
</NotificationCausedByInitials> </NotificationCausedByInitials>
</NotificationCausedBy> </NotificationCausedBy>
<NotificationContent> <NotificationContent>
<NotificationContentHeader>
{causedBy ? causedBy.fullname : 'Removed user'}
{!read && <CircleSolid width={10} height={10} />}
</NotificationContentHeader>
<NotificationBody>{prefix}</NotificationBody> <NotificationBody>{prefix}</NotificationBody>
<NotificationContentFooter> <NotificationContentFooter>
<span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span> <span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span>
@ -404,7 +454,59 @@ type NotificationEntry = {
createdAt: string; createdAt: string;
}; };
}; };
const NotificationPopup: React.FC = ({ children }) => { type NotificationPopupProps = {
onToggleRead: () => void;
};
const NotificationHeaderMenu = styled.div`
position: absolute;
right: 16px;
top: 16px;
`;
const NotificationHeaderMenuIcon = styled.div`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
svg {
fill: #fff;
stroke: #fff;
}
`;
const NotificationHeaderMenuContent = styled.div<{ show: boolean }>`
min-width: 130px;
position: absolute;
top: 16px;
background: #fff;
border-radius: 6px;
height: 50px;
visibility: ${(props) => (props.show ? 'visible' : 'hidden')};
border: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
background: #262c49;
padding: 6px;
display: flex;
flex-direction: column;
`;
const NotificationHeaderMenuButton = styled.div`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${(props) => props.theme.colors.primary};
}
`;
const NotificationPopup: React.FC<NotificationPopupProps> = ({ onToggleRead }) => {
const [filter, setFilter] = useLocalStorage<NotificationFilter>( const [filter, setFilter] = useLocalStorage<NotificationFilter>(
localStorage.NOTIFICATIONS_FILTER, localStorage.NOTIFICATIONS_FILTER,
NotificationFilter.Unread, NotificationFilter.Unread,
@ -425,10 +527,12 @@ const NotificationPopup: React.FC = ({ children }) => {
} }
}); });
}); });
onToggleRead();
}, },
}); });
const { data: nData, fetchMore } = useNotificationsQuery({ const { fetchMore } = useNotificationsQuery({
variables: { limit: 5, filter }, variables: { limit: 8, filter },
fetchPolicy: 'network-only',
onCompleted: (d) => { onCompleted: (d) => {
setData((prev) => ({ setData((prev) => ({
hasNextPage: d.notified.pageInfo.hasNextPage, hasNextPage: d.notified.pageInfo.hasNextPage,
@ -437,7 +541,7 @@ const NotificationPopup: React.FC = ({ children }) => {
})); }));
}, },
}); });
const { data: sData, loading } = useNotificationAddedSubscription({ useNotificationAddedSubscription({
onSubscriptionData: (d) => { onSubscriptionData: (d) => {
setData((n) => { setData((n) => {
if (d.subscriptionData.data) { if (d.subscriptionData.data) {
@ -450,12 +554,40 @@ const NotificationPopup: React.FC = ({ children }) => {
}); });
}, },
}); });
const [toggleAllRead] = useNotificationMarkAllReadMutation();
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
const $menuContent = useRef<HTMLDivElement>(null);
useOnOutsideClick($menuContent, true, () => setShowHeaderMenu(false), null);
return ( return (
<Popup title={null} tab={0} borders={false} padding={false}> <Popup title={null} tab={0} borders={false} padding={false}>
<PopupContent> <PopupContent>
<NotificationHeader> <NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle> <NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
<NotificationHeaderMenu>
<NotificationHeaderMenuIcon onClick={() => setShowHeaderMenu(true)}>
<Ellipsis size={18} color="#fff" vertical={false} />
<NotificationHeaderMenuContent ref={$menuContent} show={showHeaderMenu}>
<NotificationHeaderMenuButton
onClick={(e) => {
e.stopPropagation();
setShowHeaderMenu(() => false);
toggleAllRead().then(() => {
setData((prev) =>
produce(prev, (draftData) => {
draftData.nodes = draftData.nodes.map((node) => ({ ...node, read: true }));
}),
);
onToggleRead();
});
}}
>
Mark all as read
</NotificationHeaderMenuButton>
</NotificationHeaderMenuContent>
</NotificationHeaderMenuIcon>
</NotificationHeaderMenu>
</NotificationHeader> </NotificationHeader>
<NotificationTabs> <NotificationTabs>
{tabs.map((tab) => ( {tabs.map((tab) => (
@ -473,65 +605,73 @@ const NotificationPopup: React.FC = ({ children }) => {
</NotificationTab> </NotificationTab>
))} ))}
</NotificationTabs> </NotificationTabs>
<Notifications {data.nodes.length !== 0 ? (
onScroll={({ currentTarget }) => { <Notifications
if (currentTarget.scrollTop + currentTarget.clientHeight >= currentTarget.scrollHeight) { onScroll={({ currentTarget }) => {
if (data.hasNextPage) { if (Math.ceil(currentTarget.scrollTop + currentTarget.clientHeight) >= currentTarget.scrollHeight) {
console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`); if (data.hasNextPage) {
fetchMore({ console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`);
variables: { fetchMore({
limit: 5, variables: {
filter, limit: 8,
cursor: data.cursor, 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(),
}, },
}, 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],
},
};
},
});
}
} }
/> }}
))} >
</Notifications> {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(),
},
},
}).then(() => {
onToggleRead();
})
}
/>
))}
</Notifications>
) : (
<EmptyMessage>
<EmptyMessageLabel>You have {getFilterMessage(filter)} notifications</EmptyMessageLabel>
</EmptyMessage>
)}
</PopupContent> </PopupContent>
</Popup> </Popup>
); );

View File

@ -34,6 +34,7 @@ export enum ActionType {
DueDateAdded = 'DUE_DATE_ADDED', DueDateAdded = 'DUE_DATE_ADDED',
DueDateRemoved = 'DUE_DATE_REMOVED', DueDateRemoved = 'DUE_DATE_REMOVED',
DueDateChanged = 'DUE_DATE_CHANGED', DueDateChanged = 'DUE_DATE_CHANGED',
DueDateReminder = 'DUE_DATE_REMINDER',
TaskAssigned = 'TASK_ASSIGNED', TaskAssigned = 'TASK_ASSIGNED',
TaskMoved = 'TASK_MOVED', TaskMoved = 'TASK_MOVED',
TaskArchived = 'TASK_ARCHIVED', TaskArchived = 'TASK_ARCHIVED',
@ -456,6 +457,7 @@ export type Mutation = {
duplicateTaskGroup: DuplicateTaskGroupPayload; duplicateTaskGroup: DuplicateTaskGroupPayload;
inviteProjectMembers: InviteProjectMembersPayload; inviteProjectMembers: InviteProjectMembersPayload;
logoutUser: Scalars['Boolean']; logoutUser: Scalars['Boolean'];
notificationMarkAllRead: NotificationMarkAllAsReadResult;
notificationToggleRead: Notified; notificationToggleRead: Notified;
removeTaskLabel: Task; removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem; setTaskChecklistItemComplete: TaskChecklistItem;
@ -899,6 +901,11 @@ export enum NotificationFilter {
Mentioned = 'MENTIONED' Mentioned = 'MENTIONED'
} }
export type NotificationMarkAllAsReadResult = {
__typename?: 'NotificationMarkAllAsReadResult';
success: Scalars['Boolean'];
};
export type NotificationToggleReadInput = { export type NotificationToggleReadInput = {
notifiedID: Scalars['UUID']; notifiedID: Scalars['UUID'];
}; };
@ -1929,6 +1936,17 @@ export type NotificationsQuery = (
) } ) }
); );
export type NotificationMarkAllReadMutationVariables = Exact<{ [key: string]: never; }>;
export type NotificationMarkAllReadMutation = (
{ __typename?: 'Mutation' }
& { notificationMarkAllRead: (
{ __typename?: 'NotificationMarkAllAsReadResult' }
& Pick<NotificationMarkAllAsReadResult, 'success'>
) }
);
export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>; export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>;
@ -3891,6 +3909,38 @@ export function useNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti
export type NotificationsQueryHookResult = ReturnType<typeof useNotificationsQuery>; export type NotificationsQueryHookResult = ReturnType<typeof useNotificationsQuery>;
export type NotificationsLazyQueryHookResult = ReturnType<typeof useNotificationsLazyQuery>; export type NotificationsLazyQueryHookResult = ReturnType<typeof useNotificationsLazyQuery>;
export type NotificationsQueryResult = Apollo.QueryResult<NotificationsQuery, NotificationsQueryVariables>; export type NotificationsQueryResult = Apollo.QueryResult<NotificationsQuery, NotificationsQueryVariables>;
export const NotificationMarkAllReadDocument = gql`
mutation notificationMarkAllRead {
notificationMarkAllRead {
success
}
}
`;
export type NotificationMarkAllReadMutationFn = Apollo.MutationFunction<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>;
/**
* __useNotificationMarkAllReadMutation__
*
* To run a mutation, you first call `useNotificationMarkAllReadMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useNotificationMarkAllReadMutation` 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 [notificationMarkAllReadMutation, { data, loading, error }] = useNotificationMarkAllReadMutation({
* variables: {
* },
* });
*/
export function useNotificationMarkAllReadMutation(baseOptions?: Apollo.MutationHookOptions<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>(NotificationMarkAllReadDocument, options);
}
export type NotificationMarkAllReadMutationHookResult = ReturnType<typeof useNotificationMarkAllReadMutation>;
export type NotificationMarkAllReadMutationResult = Apollo.MutationResult<NotificationMarkAllReadMutation>;
export type NotificationMarkAllReadMutationOptions = Apollo.BaseMutationOptions<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>;
export const NotificationAddedDocument = gql` export const NotificationAddedDocument = gql`
subscription notificationAdded { subscription notificationAdded {
notificationAdded { notificationAdded {

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation notificationMarkAllRead {
notificationMarkAllRead {
success
}
}
`;
export default CREATE_TASK_MUTATION;

2
go.mod
View File

@ -8,6 +8,8 @@ require (
github.com/brianvoe/gofakeit/v5 v5.11.2 github.com/brianvoe/gofakeit/v5 v5.11.2
github.com/go-chi/chi v3.3.2+incompatible github.com/go-chi/chi v3.3.2+incompatible
github.com/go-chi/cors v1.2.0 github.com/go-chi/cors v1.2.0
github.com/go-redis/redis v6.15.8+incompatible
github.com/go-redis/redis/v8 v8.0.0-beta.6
github.com/golang-migrate/migrate/v4 v4.11.0 github.com/golang-migrate/migrate/v4 v4.11.0
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/jinzhu/now v1.1.1 github.com/jinzhu/now v1.1.1

View File

@ -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(newTokenCmd(), newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd()) rootCmd.AddCommand(newJobCmd(), newTokenCmd(), newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.Execute() rootCmd.Execute()
} }

60
internal/commands/job.go Normal file
View File

@ -0,0 +1,60 @@
package commands
import (
"time"
"github.com/spf13/cobra"
"github.com/RichardKnop/machinery/v1"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
queueLog "github.com/RichardKnop/machinery/v1/log"
"github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/jobs"
log "github.com/sirupsen/logrus"
)
func newJobCmd() *cobra.Command {
cc := &cobra.Command{
Use: "job",
Short: "Run a task manually",
Long: "Run a task manually",
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 {
log.Panic(err)
}
db, err := sqlx.Connect("postgres", config.GetDatabaseConfig().GetDatabaseConnectionUri())
if err != nil {
log.Panic(err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
defer db.Close()
log.Info("starting task queue server instance")
jobConfig := appConfig.Job.GetJobConfig()
server, err := machinery.NewServer(&jobConfig)
if err != nil {
// do something with the error
}
queueLog.Set(&jobs.MachineryLogger{})
signature := &mTasks.Signature{
Name: "scheduleDueDateNotifications",
}
server.SendTask(signature)
return nil
},
}
return cc
}

View File

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/RichardKnop/machinery/v1" "github.com/RichardKnop/machinery/v1"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/httpfs" "github.com/golang-migrate/migrate/v4/source/httpfs"
@ -36,6 +37,12 @@ func newWebCmd() *cobra.Command {
return err return err
} }
redisClient, err := appConfig.MessageQueue.GetMessageQueueClient()
if err != nil {
return err
}
defer redisClient.Close()
connection := appConfig.Database.GetDatabaseConnectionUri() connection := appConfig.Database.GetDatabaseConnectionUri()
var db *sqlx.DB var db *sqlx.DB
var retryDuration time.Duration var retryDuration time.Duration
@ -67,15 +74,17 @@ func newWebCmd() *cobra.Command {
} }
var server *machinery.Server var server *machinery.Server
if appConfig.Job.Enabled { jobConfig := appConfig.Job.GetJobConfig()
jobConfig := appConfig.Job.GetJobConfig() server, err = machinery.NewServer(&jobConfig)
server, err = machinery.NewServer(&jobConfig) if err != nil {
if err != nil { return err
return err
}
} }
signature := &mTasks.Signature{
Name: "scheduleDueDateNotifications",
}
server.SendTask(signature)
r, _ := route.NewRouter(db, server, appConfig) r, _ := route.NewRouter(db, redisClient, server, appConfig)
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server") log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
return http.ListenAndServe(viper.GetString("server.hostname"), r) return http.ListenAndServe(viper.GetString("server.hostname"), r)
}, },

View File

@ -47,7 +47,11 @@ func newWorkerCmd() *cobra.Command {
} }
queueLog.Set(&jobs.MachineryLogger{}) queueLog.Set(&jobs.MachineryLogger{})
repo := *repo.NewRepository(db) repo := *repo.NewRepository(db)
jobs.RegisterTasks(server, repo) redisClient, err := appConfig.MessageQueue.GetMessageQueueClient()
if err != nil {
return err
}
jobs.RegisterTasks(server, repo, appConfig, redisClient)
worker := server.NewWorker("taskcafe_worker", 10) worker := server.NewWorker("taskcafe_worker", 10)
log.Info("starting task queue worker") log.Info("starting task queue worker")

View File

@ -1,10 +1,14 @@
package config package config
import ( import (
"context"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/go-redis/redis/v8"
mConfig "github.com/RichardKnop/machinery/v1/config" mConfig "github.com/RichardKnop/machinery/v1/config"
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -28,6 +32,8 @@ const (
JobStore = "job.store" JobStore = "job.store"
JobQueueName = "job.queue_name" JobQueueName = "job.queue_name"
MessageQueue = "message.queue"
SmtpFrom = "smtp.from" SmtpFrom = "smtp.from"
SmtpHost = "smtp.host" SmtpHost = "smtp.host"
SmtpPort = "smtp.port" SmtpPort = "smtp.port"
@ -46,9 +52,10 @@ var defaults = map[string]interface{}{
DatabaseSslMode: "disable", DatabaseSslMode: "disable",
SecurityTokenExpiration: "15m", SecurityTokenExpiration: "15m",
SecuritySecret: "", SecuritySecret: "",
MessageQueue: "localhost:6379",
JobEnabled: false, JobEnabled: false,
JobBroker: "amqp://guest:guest@localhost:5672/", JobBroker: "redis://localhost:6379",
JobStore: "memcache://localhost:11211", JobStore: "redis://localhost:6379",
JobQueueName: "taskcafe_tasks", JobQueueName: "taskcafe_tasks",
SmtpFrom: "no-reply@example.com", SmtpFrom: "no-reply@example.com",
SmtpHost: "localhost", SmtpHost: "localhost",
@ -65,10 +72,15 @@ func InitDefaults() {
} }
type AppConfig struct { type AppConfig struct {
Email EmailConfig Email EmailConfig
Security SecurityConfig Security SecurityConfig
Database DatabaseConfig Database DatabaseConfig
Job JobConfig Job JobConfig
MessageQueue MessageQueueConfig
}
type MessageQueueConfig struct {
URI string
} }
type JobConfig struct { type JobConfig struct {
@ -92,11 +104,12 @@ func (cfg *JobConfig) GetJobConfig() mConfig.Config {
Broker: cfg.Broker, Broker: cfg.Broker,
DefaultQueue: cfg.QueueName, DefaultQueue: cfg.QueueName,
ResultBackend: cfg.Store, ResultBackend: cfg.Store,
AMQP: &mConfig.AMQPConfig{ /*
Exchange: "machinery_exchange", AMQP: &mConfig.AMQPConfig{
ExchangeType: "direct", Exchange: "machinery_exchange",
BindingKey: "machinery_task", ExchangeType: "direct",
}, BindingKey: "machinery_task",
} */
} }
} }
@ -149,12 +162,14 @@ func GetAppConfig() (AppConfig, error) {
jobCfg := GetJobConfig() jobCfg := GetJobConfig()
databaseCfg := GetDatabaseConfig() databaseCfg := GetDatabaseConfig()
emailCfg := GetEmailConfig() emailCfg := GetEmailConfig()
messageCfg := MessageQueueConfig{URI: viper.GetString("message.queue")}
return AppConfig{ return AppConfig{
Email: emailCfg, Email: emailCfg,
Security: securityCfg, Security: securityCfg,
Database: databaseCfg, Database: databaseCfg,
Job: jobCfg, Job: jobCfg,
}, nil MessageQueue: messageCfg,
}, err
} }
func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) { func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) {
@ -166,6 +181,19 @@ func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, er
return SecurityConfig{AccessTokenExpiration: exp, Secret: secret}, nil return SecurityConfig{AccessTokenExpiration: exp, Secret: secret}, nil
} }
func (c MessageQueueConfig) GetMessageQueueClient() (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: c.URI,
})
_, err := client.Ping(context.Background()).Result()
if !errors.Is(err, nil) {
return nil, err
}
return client, nil
}
func GetEmailConfig() EmailConfig { func GetEmailConfig() EmailConfig {
return EmailConfig{ return EmailConfig{
From: viper.GetString(SmtpFrom), From: viper.GetString(SmtpFrom),

View File

@ -192,6 +192,7 @@ type TaskDueDateReminder struct {
TaskID uuid.UUID `json:"task_id"` TaskID uuid.UUID `json:"task_id"`
Period int32 `json:"period"` Period int32 `json:"period"`
Duration string `json:"duration"` Duration string `json:"duration"`
RemindAt time.Time `json:"remind_at"`
} }
type TaskDueDateReminderDuration struct { type TaskDueDateReminderDuration struct {

View File

@ -66,9 +66,8 @@ func (q *Queries) CreateNotificationNotifed(ctx context.Context, arg CreateNotif
} }
const getAllNotificationsForUserID = `-- name: GetAllNotificationsForUserID :many const getAllNotificationsForUserID = `-- name: GetAllNotificationsForUserID :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 SELECT notified_id, nn.notification_id, user_id, read, read_at, n.notification_id, caused_by, action_type, data, created_on FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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 WHERE nn.user_id = $1
` `
@ -83,18 +82,6 @@ type GetAllNotificationsForUserIDRow struct {
ActionType string `json:"action_type"` ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"` 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) GetAllNotificationsForUserID(ctx context.Context, userID uuid.UUID) ([]GetAllNotificationsForUserIDRow, error) { func (q *Queries) GetAllNotificationsForUserID(ctx context.Context, userID uuid.UUID) ([]GetAllNotificationsForUserIDRow, error) {
@ -117,18 +104,6 @@ func (q *Queries) GetAllNotificationsForUserID(ctx context.Context, userID uuid.
&i.ActionType, &i.ActionType,
&i.Data, &i.Data,
&i.CreatedOn, &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 { ); err != nil {
return nil, err return nil, err
} }
@ -143,10 +118,26 @@ func (q *Queries) GetAllNotificationsForUserID(ctx context.Context, userID uuid.
return items, nil return items, nil
} }
const getNotificationByID = `-- name: GetNotificationByID :one
SELECT notification_id, caused_by, action_type, data, created_on FROM notification WHERE notification_id = $1
`
func (q *Queries) GetNotificationByID(ctx context.Context, notificationID uuid.UUID) (Notification, error) {
row := q.db.QueryRowContext(ctx, getNotificationByID, notificationID)
var i Notification
err := row.Scan(
&i.NotificationID,
&i.CausedBy,
&i.ActionType,
&i.Data,
&i.CreatedOn,
)
return i, err
}
const getNotificationsForUserIDCursor = `-- name: GetNotificationsForUserIDCursor :many 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 SELECT n.notification_id, n.caused_by, n.action_type, n.data, n.created_on, nn.notified_id, nn.notification_id, nn.user_id, nn.read, nn.read_at FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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) WHERE (n.created_on, n.notification_id) < ($1::timestamptz, $2::uuid)
AND nn.user_id = $3::uuid AND nn.user_id = $3::uuid
AND ($4::boolean = false OR nn.read = false) AND ($4::boolean = false OR nn.read = false)
@ -166,28 +157,16 @@ type GetNotificationsForUserIDCursorParams struct {
} }
type GetNotificationsForUserIDCursorRow struct { type GetNotificationsForUserIDCursorRow struct {
NotifiedID uuid.UUID `json:"notified_id"`
NotificationID uuid.UUID `json:"notification_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"` CausedBy uuid.UUID `json:"caused_by"`
ActionType string `json:"action_type"` ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"` CreatedOn time.Time `json:"created_on"`
UserID_2 uuid.UUID `json:"user_id_2"` NotifiedID uuid.UUID `json:"notified_id"`
CreatedAt time.Time `json:"created_at"` NotificationID_2 uuid.UUID `json:"notification_id_2"`
Email string `json:"email"` UserID uuid.UUID `json:"user_id"`
Username string `json:"username"` Read bool `json:"read"`
PasswordHash string `json:"password_hash"` ReadAt sql.NullTime `json:"read_at"`
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) { func (q *Queries) GetNotificationsForUserIDCursor(ctx context.Context, arg GetNotificationsForUserIDCursorParams) ([]GetNotificationsForUserIDCursorRow, error) {
@ -208,28 +187,16 @@ func (q *Queries) GetNotificationsForUserIDCursor(ctx context.Context, arg GetNo
for rows.Next() { for rows.Next() {
var i GetNotificationsForUserIDCursorRow var i GetNotificationsForUserIDCursorRow
if err := rows.Scan( if err := rows.Scan(
&i.NotifiedID,
&i.NotificationID, &i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
&i.NotificationID_2,
&i.CausedBy, &i.CausedBy,
&i.ActionType, &i.ActionType,
&i.Data, &i.Data,
&i.CreatedOn, &i.CreatedOn,
&i.UserID_2, &i.NotifiedID,
&i.CreatedAt, &i.NotificationID_2,
&i.Email, &i.UserID,
&i.Username, &i.Read,
&i.PasswordHash, &i.ReadAt,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -245,9 +212,8 @@ func (q *Queries) GetNotificationsForUserIDCursor(ctx context.Context, arg GetNo
} }
const getNotificationsForUserIDPaged = `-- name: GetNotificationsForUserIDPaged :many 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 SELECT n.notification_id, n.caused_by, n.action_type, n.data, n.created_on, nn.notified_id, nn.notification_id, nn.user_id, nn.read, nn.read_at FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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 WHERE nn.user_id = $1::uuid
AND ($2::boolean = false OR nn.read = false) AND ($2::boolean = false OR nn.read = false)
AND ($3::boolean = false OR n.action_type = ANY($4::text[])) AND ($3::boolean = false OR n.action_type = ANY($4::text[]))
@ -264,28 +230,16 @@ type GetNotificationsForUserIDPagedParams struct {
} }
type GetNotificationsForUserIDPagedRow struct { type GetNotificationsForUserIDPagedRow struct {
NotifiedID uuid.UUID `json:"notified_id"`
NotificationID uuid.UUID `json:"notification_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"` CausedBy uuid.UUID `json:"caused_by"`
ActionType string `json:"action_type"` ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"` CreatedOn time.Time `json:"created_on"`
UserID_2 uuid.UUID `json:"user_id_2"` NotifiedID uuid.UUID `json:"notified_id"`
CreatedAt time.Time `json:"created_at"` NotificationID_2 uuid.UUID `json:"notification_id_2"`
Email string `json:"email"` UserID uuid.UUID `json:"user_id"`
Username string `json:"username"` Read bool `json:"read"`
PasswordHash string `json:"password_hash"` ReadAt sql.NullTime `json:"read_at"`
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) { func (q *Queries) GetNotificationsForUserIDPaged(ctx context.Context, arg GetNotificationsForUserIDPagedParams) ([]GetNotificationsForUserIDPagedRow, error) {
@ -304,28 +258,16 @@ func (q *Queries) GetNotificationsForUserIDPaged(ctx context.Context, arg GetNot
for rows.Next() { for rows.Next() {
var i GetNotificationsForUserIDPagedRow var i GetNotificationsForUserIDPagedRow
if err := rows.Scan( if err := rows.Scan(
&i.NotifiedID,
&i.NotificationID, &i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
&i.NotificationID_2,
&i.CausedBy, &i.CausedBy,
&i.ActionType, &i.ActionType,
&i.Data, &i.Data,
&i.CreatedOn, &i.CreatedOn,
&i.UserID_2, &i.NotifiedID,
&i.CreatedAt, &i.NotificationID_2,
&i.Email, &i.UserID,
&i.Username, &i.Read,
&i.PasswordHash, &i.ReadAt,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -341,9 +283,8 @@ func (q *Queries) GetNotificationsForUserIDPaged(ctx context.Context, arg GetNot
} }
const getNotifiedByID = `-- name: GetNotifiedByID :one 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 SELECT notified_id, nn.notification_id, user_id, read, read_at, n.notification_id, caused_by, action_type, data, created_on FROM notification_notified as nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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 WHERE notified_id = $1
` `
@ -358,18 +299,6 @@ type GetNotifiedByIDRow struct {
ActionType string `json:"action_type"` ActionType string `json:"action_type"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
CreatedOn time.Time `json:"created_on"` 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) { func (q *Queries) GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (GetNotifiedByIDRow, error) {
@ -386,18 +315,23 @@ func (q *Queries) GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (Ge
&i.ActionType, &i.ActionType,
&i.Data, &i.Data,
&i.CreatedOn, &i.CreatedOn,
&i.UserID_2, )
&i.CreatedAt, return i, err
&i.Email, }
&i.Username,
&i.PasswordHash, const getNotifiedByIDNoExtra = `-- name: GetNotifiedByIDNoExtra :one
&i.ProfileBgColor, SELECT notified_id, notification_id, user_id, read, read_at FROM notification_notified as nn WHERE nn.notified_id = $1
&i.FullName, `
&i.Initials,
&i.ProfileAvatarUrl, func (q *Queries) GetNotifiedByIDNoExtra(ctx context.Context, notifiedID uuid.UUID) (NotificationNotified, error) {
&i.RoleCode, row := q.db.QueryRowContext(ctx, getNotifiedByIDNoExtra, notifiedID)
&i.Bio, var i NotificationNotified
&i.Active, err := row.Scan(
&i.NotifiedID,
&i.NotificationID,
&i.UserID,
&i.Read,
&i.ReadAt,
) )
return i, err return i, err
} }
@ -413,6 +347,20 @@ func (q *Queries) HasUnreadNotification(ctx context.Context, userID uuid.UUID) (
return exists, err return exists, err
} }
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :exec
UPDATE notification_notified SET read = true, read_at = $2 WHERE user_id = $1
`
type MarkAllNotificationsReadParams struct {
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
}
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) error {
_, err := q.db.ExecContext(ctx, markAllNotificationsRead, arg.UserID, arg.ReadAt)
return err
}
const markNotificationAsRead = `-- name: MarkNotificationAsRead :exec const markNotificationAsRead = `-- name: MarkNotificationAsRead :exec
UPDATE notification_notified SET read = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4 UPDATE notification_notified SET read = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4
` `

View File

@ -5,6 +5,7 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -82,6 +83,8 @@ type Querier interface {
GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error)
GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error) GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error)
GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error) GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error)
GetDueDateReminderByID(ctx context.Context, dueDateReminderID uuid.UUID) (TaskDueDateReminder, error)
GetDueDateRemindersForDuration(ctx context.Context, startAt time.Time) ([]TaskDueDateReminder, error)
GetDueDateRemindersForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskDueDateReminder, error) GetDueDateRemindersForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskDueDateReminder, error)
GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error)
GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error) GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error)
@ -92,9 +95,11 @@ 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)
GetNotificationByID(ctx context.Context, notificationID uuid.UUID) (Notification, error)
GetNotificationsForUserIDCursor(ctx context.Context, arg GetNotificationsForUserIDCursorParams) ([]GetNotificationsForUserIDCursorRow, error) GetNotificationsForUserIDCursor(ctx context.Context, arg GetNotificationsForUserIDCursorParams) ([]GetNotificationsForUserIDCursorRow, error)
GetNotificationsForUserIDPaged(ctx context.Context, arg GetNotificationsForUserIDPagedParams) ([]GetNotificationsForUserIDPagedRow, error) GetNotificationsForUserIDPaged(ctx context.Context, arg GetNotificationsForUserIDPagedParams) ([]GetNotificationsForUserIDPagedRow, error)
GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (GetNotifiedByIDRow, error) GetNotifiedByID(ctx context.Context, notifiedID uuid.UUID) (GetNotifiedByIDRow, error)
GetNotifiedByIDNoExtra(ctx context.Context, notifiedID uuid.UUID) (NotificationNotified, 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)
GetProjectIDByShortID(ctx context.Context, shortID string) (uuid.UUID, error) GetProjectIDByShortID(ctx context.Context, shortID string) (uuid.UUID, error)
@ -121,6 +126,7 @@ type Querier interface {
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error) GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
GetTaskChecklistItemsForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) ([]TaskChecklistItem, error) GetTaskChecklistItemsForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) ([]TaskChecklistItem, error)
GetTaskChecklistsForTask(ctx context.Context, taskID uuid.UUID) ([]TaskChecklist, error) GetTaskChecklistsForTask(ctx context.Context, taskID uuid.UUID) ([]TaskChecklist, error)
GetTaskForDueDateReminder(ctx context.Context, dueDateReminderID uuid.UUID) (Task, error)
GetTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (TaskGroup, error) GetTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (TaskGroup, error)
GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error) GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error)
GetTaskIDByShortID(ctx context.Context, shortID string) (uuid.UUID, error) GetTaskIDByShortID(ctx context.Context, shortID string) (uuid.UUID, error)
@ -128,6 +134,7 @@ type Querier interface {
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) GetTaskWatcher(ctx context.Context, arg GetTaskWatcherParams) (TaskWatcher, error)
GetTaskWatchersForTask(ctx context.Context, taskID uuid.UUID) ([]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)
@ -144,6 +151,7 @@ type Querier interface {
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) HasUnreadNotification(ctx context.Context, userID uuid.UUID) (bool, error)
MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) 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
@ -154,6 +162,7 @@ type Querier interface {
SetUserActiveByEmail(ctx context.Context, email string) (UserAccount, error) SetUserActiveByEmail(ctx context.Context, email string) (UserAccount, error)
SetUserPassword(ctx context.Context, arg SetUserPasswordParams) (UserAccount, error) SetUserPassword(ctx context.Context, arg SetUserPasswordParams) (UserAccount, error)
UpdateDueDateReminder(ctx context.Context, arg UpdateDueDateReminderParams) (TaskDueDateReminder, error) UpdateDueDateReminder(ctx context.Context, arg UpdateDueDateReminderParams) (TaskDueDateReminder, error)
UpdateDueDateReminderRemindAt(ctx context.Context, arg UpdateDueDateReminderRemindAtParams) (TaskDueDateReminder, error)
UpdateProjectLabel(ctx context.Context, arg UpdateProjectLabelParams) (ProjectLabel, error) UpdateProjectLabel(ctx context.Context, arg UpdateProjectLabelParams) (ProjectLabel, error)
UpdateProjectLabelColor(ctx context.Context, arg UpdateProjectLabelColorParams) (ProjectLabel, error) UpdateProjectLabelColor(ctx context.Context, arg UpdateProjectLabelColorParams) (ProjectLabel, error)
UpdateProjectLabelName(ctx context.Context, arg UpdateProjectLabelNameParams) (ProjectLabel, error) UpdateProjectLabelName(ctx context.Context, arg UpdateProjectLabelNameParams) (ProjectLabel, error)

View File

@ -1,21 +1,25 @@
-- name: GetAllNotificationsForUserID :many -- name: GetAllNotificationsForUserID :many
SELECT * FROM notification_notified AS nn SELECT * FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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; WHERE nn.user_id = $1;
-- name: GetNotifiedByID :one -- name: GetNotifiedByID :one
SELECT * FROM notification_notified as nn SELECT * FROM notification_notified as nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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; WHERE notified_id = $1;
-- name: GetNotifiedByIDNoExtra :one
SELECT * FROM notification_notified as nn WHERE nn.notified_id = $1;
-- name: HasUnreadNotification :one -- name: HasUnreadNotification :one
SELECT EXISTS (SELECT 1 FROM notification_notified WHERE read = false AND user_id = $1); 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 = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4; UPDATE notification_notified SET read = $3, read_at = $2 WHERE user_id = $1 AND notified_id = $4;
-- name: MarkAllNotificationsRead :exec
UPDATE notification_notified SET read = true, read_at = $2 WHERE user_id = $1;
-- 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)
VALUES ($1, $2, $3, $4) RETURNING *; VALUES ($1, $2, $3, $4) RETURNING *;
@ -23,10 +27,12 @@ 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: GetNotificationByID :one
SELECT * FROM notification WHERE notification_id = $1;
-- name: GetNotificationsForUserIDPaged :many -- name: GetNotificationsForUserIDPaged :many
SELECT * FROM notification_notified AS nn SELECT n.*, nn.* FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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 WHERE nn.user_id = @user_id::uuid
AND (@enable_unread::boolean = false OR nn.read = false) AND (@enable_unread::boolean = false OR nn.read = false)
AND (@enable_action_type::boolean = false OR n.action_type = ANY(@action_type::text[])) AND (@enable_action_type::boolean = false OR n.action_type = ANY(@action_type::text[]))
@ -34,9 +40,8 @@ SELECT * FROM notification_notified AS nn
LIMIT @limit_rows::int; LIMIT @limit_rows::int;
-- name: GetNotificationsForUserIDCursor :many -- name: GetNotificationsForUserIDCursor :many
SELECT * FROM notification_notified AS nn SELECT n.*, nn.* FROM notification_notified AS nn
INNER JOIN notification AS n ON n.notification_id = nn.notification_id 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) WHERE (n.created_on, n.notification_id) < (@created_on::timestamptz, @notification_id::uuid)
AND nn.user_id = @user_id::uuid AND nn.user_id = @user_id::uuid
AND (@enable_unread::boolean = false OR nn.read = false) AND (@enable_unread::boolean = false OR nn.read = false)

View File

@ -1,6 +1,9 @@
-- name: GetTaskWatcher :one -- name: GetTaskWatcher :one
SELECT * FROM task_watcher WHERE user_id = $1 AND task_id = $2; SELECT * FROM task_watcher WHERE user_id = $1 AND task_id = $2;
-- name: GetTaskWatchersForTask :many
SELECT * FROM task_watcher WHERE task_id = $1;
-- name: CreateTaskWatcher :one -- name: CreateTaskWatcher :one
INSERT INTO task_watcher (user_id, task_id, watched_at) VALUES ($1, $2, $3) RETURNING *; INSERT INTO task_watcher (user_id, task_id, watched_at) VALUES ($1, $2, $3) RETURNING *;
@ -119,13 +122,28 @@ SELECT COUNT(*) FROM task_comment WHERE task_id = $1;
-- name: CreateDueDateReminder :one -- name: CreateDueDateReminder :one
INSERT INTO task_due_date_reminder (task_id, period, duration) VALUES ($1, $2, $3) RETURNING *; INSERT INTO task_due_date_reminder (task_id, period, duration, remind_at) VALUES ($1, $2, $3, $4) RETURNING *;
-- name: UpdateDueDateReminder :one -- name: UpdateDueDateReminder :one
UPDATE task_due_date_reminder SET period = $2, duration = $3 WHERE due_date_reminder_id = $1 RETURNING *; UPDATE task_due_date_reminder SET remind_at = $4, period = $2, duration = $3 WHERE due_date_reminder_id = $1 RETURNING *;
-- name: GetTaskForDueDateReminder :one
SELECT task.* FROM task_due_date_reminder
INNER JOIN task ON task.task_id = task_due_date_reminder.task_id
WHERE task_due_date_reminder.due_date_reminder_id = $1;
-- name: UpdateDueDateReminderRemindAt :one
UPDATE task_due_date_reminder SET remind_at = $2 WHERE due_date_reminder_id = $1 RETURNING *;
-- name: GetDueDateRemindersForTaskID :many -- name: GetDueDateRemindersForTaskID :many
SELECT * FROM task_due_date_reminder WHERE task_id = $1; SELECT * FROM task_due_date_reminder WHERE task_id = $1;
-- name: GetDueDateReminderByID :one
SELECT * FROM task_due_date_reminder WHERE due_date_reminder_id = $1;
-- name: DeleteDueDateReminder :exec -- name: DeleteDueDateReminder :exec
DELETE FROM task_due_date_reminder WHERE due_date_reminder_id = $1; DELETE FROM task_due_date_reminder WHERE due_date_reminder_id = $1;
-- name: GetDueDateRemindersForDuration :many
SELECT * FROM task_due_date_reminder WHERE remind_at >= @start_at::timestamptz;

View File

@ -13,23 +13,30 @@ import (
) )
const createDueDateReminder = `-- name: CreateDueDateReminder :one const createDueDateReminder = `-- name: CreateDueDateReminder :one
INSERT INTO task_due_date_reminder (task_id, period, duration) VALUES ($1, $2, $3) RETURNING due_date_reminder_id, task_id, period, duration INSERT INTO task_due_date_reminder (task_id, period, duration, remind_at) VALUES ($1, $2, $3, $4) RETURNING due_date_reminder_id, task_id, period, duration, remind_at
` `
type CreateDueDateReminderParams struct { type CreateDueDateReminderParams struct {
TaskID uuid.UUID `json:"task_id"` TaskID uuid.UUID `json:"task_id"`
Period int32 `json:"period"` Period int32 `json:"period"`
Duration string `json:"duration"` Duration string `json:"duration"`
RemindAt time.Time `json:"remind_at"`
} }
func (q *Queries) CreateDueDateReminder(ctx context.Context, arg CreateDueDateReminderParams) (TaskDueDateReminder, error) { func (q *Queries) CreateDueDateReminder(ctx context.Context, arg CreateDueDateReminderParams) (TaskDueDateReminder, error) {
row := q.db.QueryRowContext(ctx, createDueDateReminder, arg.TaskID, arg.Period, arg.Duration) row := q.db.QueryRowContext(ctx, createDueDateReminder,
arg.TaskID,
arg.Period,
arg.Duration,
arg.RemindAt,
)
var i TaskDueDateReminder var i TaskDueDateReminder
err := row.Scan( err := row.Scan(
&i.DueDateReminderID, &i.DueDateReminderID,
&i.TaskID, &i.TaskID,
&i.Period, &i.Period,
&i.Duration, &i.Duration,
&i.RemindAt,
) )
return i, err return i, err
} }
@ -434,8 +441,58 @@ func (q *Queries) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([
return items, nil return items, nil
} }
const getDueDateReminderByID = `-- name: GetDueDateReminderByID :one
SELECT due_date_reminder_id, task_id, period, duration, remind_at FROM task_due_date_reminder WHERE due_date_reminder_id = $1
`
func (q *Queries) GetDueDateReminderByID(ctx context.Context, dueDateReminderID uuid.UUID) (TaskDueDateReminder, error) {
row := q.db.QueryRowContext(ctx, getDueDateReminderByID, dueDateReminderID)
var i TaskDueDateReminder
err := row.Scan(
&i.DueDateReminderID,
&i.TaskID,
&i.Period,
&i.Duration,
&i.RemindAt,
)
return i, err
}
const getDueDateRemindersForDuration = `-- name: GetDueDateRemindersForDuration :many
SELECT due_date_reminder_id, task_id, period, duration, remind_at FROM task_due_date_reminder WHERE remind_at >= $1::timestamptz
`
func (q *Queries) GetDueDateRemindersForDuration(ctx context.Context, startAt time.Time) ([]TaskDueDateReminder, error) {
rows, err := q.db.QueryContext(ctx, getDueDateRemindersForDuration, startAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskDueDateReminder
for rows.Next() {
var i TaskDueDateReminder
if err := rows.Scan(
&i.DueDateReminderID,
&i.TaskID,
&i.Period,
&i.Duration,
&i.RemindAt,
); 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 getDueDateRemindersForTaskID = `-- name: GetDueDateRemindersForTaskID :many const getDueDateRemindersForTaskID = `-- name: GetDueDateRemindersForTaskID :many
SELECT due_date_reminder_id, task_id, period, duration FROM task_due_date_reminder WHERE task_id = $1 SELECT due_date_reminder_id, task_id, period, duration, remind_at FROM task_due_date_reminder WHERE task_id = $1
` `
func (q *Queries) GetDueDateRemindersForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskDueDateReminder, error) { func (q *Queries) GetDueDateRemindersForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskDueDateReminder, error) {
@ -452,6 +509,7 @@ func (q *Queries) GetDueDateRemindersForTaskID(ctx context.Context, taskID uuid.
&i.TaskID, &i.TaskID,
&i.Period, &i.Period,
&i.Duration, &i.Duration,
&i.RemindAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -614,6 +672,31 @@ func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, erro
return i, err return i, err
} }
const getTaskForDueDateReminder = `-- name: GetTaskForDueDateReminder :one
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, task.short_id FROM task_due_date_reminder
INNER JOIN task ON task.task_id = task_due_date_reminder.task_id
WHERE task_due_date_reminder.due_date_reminder_id = $1
`
func (q *Queries) GetTaskForDueDateReminder(ctx context.Context, dueDateReminderID uuid.UUID) (Task, error) {
row := q.db.QueryRowContext(ctx, getTaskForDueDateReminder, dueDateReminderID)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
&i.ShortID,
)
return i, err
}
const getTaskIDByShortID = `-- name: GetTaskIDByShortID :one const getTaskIDByShortID = `-- name: GetTaskIDByShortID :one
SELECT task_id FROM task WHERE short_id = $1 SELECT task_id FROM task WHERE short_id = $1
` `
@ -646,6 +729,38 @@ func (q *Queries) GetTaskWatcher(ctx context.Context, arg GetTaskWatcherParams)
return i, err return i, err
} }
const getTaskWatchersForTask = `-- name: GetTaskWatchersForTask :many
SELECT task_watcher_id, task_id, user_id, watched_at FROM task_watcher WHERE task_id = $1
`
func (q *Queries) GetTaskWatchersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskWatcher, error) {
rows, err := q.db.QueryContext(ctx, getTaskWatchersForTask, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskWatcher
for rows.Next() {
var i TaskWatcher
if err := rows.Scan(
&i.TaskWatcherID,
&i.TaskID,
&i.UserID,
&i.WatchedAt,
); 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 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, short_id 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, short_id FROM task WHERE task_group_id = $1
` `
@ -715,23 +830,52 @@ func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams
} }
const updateDueDateReminder = `-- name: UpdateDueDateReminder :one const updateDueDateReminder = `-- name: UpdateDueDateReminder :one
UPDATE task_due_date_reminder SET period = $2, duration = $3 WHERE due_date_reminder_id = $1 RETURNING due_date_reminder_id, task_id, period, duration UPDATE task_due_date_reminder SET remind_at = $4, period = $2, duration = $3 WHERE due_date_reminder_id = $1 RETURNING due_date_reminder_id, task_id, period, duration, remind_at
` `
type UpdateDueDateReminderParams struct { type UpdateDueDateReminderParams struct {
DueDateReminderID uuid.UUID `json:"due_date_reminder_id"` DueDateReminderID uuid.UUID `json:"due_date_reminder_id"`
Period int32 `json:"period"` Period int32 `json:"period"`
Duration string `json:"duration"` Duration string `json:"duration"`
RemindAt time.Time `json:"remind_at"`
} }
func (q *Queries) UpdateDueDateReminder(ctx context.Context, arg UpdateDueDateReminderParams) (TaskDueDateReminder, error) { func (q *Queries) UpdateDueDateReminder(ctx context.Context, arg UpdateDueDateReminderParams) (TaskDueDateReminder, error) {
row := q.db.QueryRowContext(ctx, updateDueDateReminder, arg.DueDateReminderID, arg.Period, arg.Duration) row := q.db.QueryRowContext(ctx, updateDueDateReminder,
arg.DueDateReminderID,
arg.Period,
arg.Duration,
arg.RemindAt,
)
var i TaskDueDateReminder var i TaskDueDateReminder
err := row.Scan( err := row.Scan(
&i.DueDateReminderID, &i.DueDateReminderID,
&i.TaskID, &i.TaskID,
&i.Period, &i.Period,
&i.Duration, &i.Duration,
&i.RemindAt,
)
return i, err
}
const updateDueDateReminderRemindAt = `-- name: UpdateDueDateReminderRemindAt :one
UPDATE task_due_date_reminder SET remind_at = $2 WHERE due_date_reminder_id = $1 RETURNING due_date_reminder_id, task_id, period, duration, remind_at
`
type UpdateDueDateReminderRemindAtParams struct {
DueDateReminderID uuid.UUID `json:"due_date_reminder_id"`
RemindAt time.Time `json:"remind_at"`
}
func (q *Queries) UpdateDueDateReminderRemindAt(ctx context.Context, arg UpdateDueDateReminderRemindAtParams) (TaskDueDateReminder, error) {
row := q.db.QueryRowContext(ctx, updateDueDateReminderRemindAt, arg.DueDateReminderID, arg.RemindAt)
var i TaskDueDateReminder
err := row.Scan(
&i.DueDateReminderID,
&i.TaskID,
&i.Period,
&i.Duration,
&i.RemindAt,
) )
return i, err return i, err
} }

View File

@ -277,6 +277,7 @@ type ComplexityRoot struct {
DuplicateTaskGroup func(childComplexity int, input DuplicateTaskGroup) int DuplicateTaskGroup func(childComplexity int, input DuplicateTaskGroup) int
InviteProjectMembers func(childComplexity int, input InviteProjectMembers) int InviteProjectMembers func(childComplexity int, input InviteProjectMembers) int
LogoutUser func(childComplexity int, input LogoutUser) int LogoutUser func(childComplexity int, input LogoutUser) int
NotificationMarkAllRead func(childComplexity int) int
NotificationToggleRead func(childComplexity int, input NotificationToggleReadInput) int NotificationToggleRead func(childComplexity int, input NotificationToggleReadInput) int
RemoveTaskLabel func(childComplexity int, input *RemoveTaskLabelInput) int RemoveTaskLabel func(childComplexity int, input *RemoveTaskLabelInput) int
SetTaskChecklistItemComplete func(childComplexity int, input SetTaskChecklistItemComplete) int SetTaskChecklistItemComplete func(childComplexity int, input SetTaskChecklistItemComplete) int
@ -333,6 +334,10 @@ type ComplexityRoot struct {
Value func(childComplexity int) int Value func(childComplexity int) int
} }
NotificationMarkAllAsReadResult struct {
Success func(childComplexity int) int
}
Notified struct { Notified struct {
ID func(childComplexity int) int ID func(childComplexity int) int
Notification func(childComplexity int) int Notification func(childComplexity int) int
@ -617,6 +622,7 @@ type LabelColorResolver interface {
} }
type MutationResolver interface { type MutationResolver interface {
NotificationToggleRead(ctx context.Context, input NotificationToggleReadInput) (*Notified, error) NotificationToggleRead(ctx context.Context, input NotificationToggleReadInput) (*Notified, error)
NotificationMarkAllRead(ctx context.Context) (*NotificationMarkAllAsReadResult, error)
CreateProjectLabel(ctx context.Context, input NewProjectLabel) (*db.ProjectLabel, error) CreateProjectLabel(ctx context.Context, input NewProjectLabel) (*db.ProjectLabel, error)
DeleteProjectLabel(ctx context.Context, input DeleteProjectLabel) (*db.ProjectLabel, error) DeleteProjectLabel(ctx context.Context, input DeleteProjectLabel) (*db.ProjectLabel, error)
UpdateProjectLabel(ctx context.Context, input UpdateProjectLabel) (*db.ProjectLabel, error) UpdateProjectLabel(ctx context.Context, input UpdateProjectLabel) (*db.ProjectLabel, error)
@ -1755,6 +1761,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.LogoutUser(childComplexity, args["input"].(LogoutUser)), true return e.complexity.Mutation.LogoutUser(childComplexity, args["input"].(LogoutUser)), true
case "Mutation.notificationMarkAllRead":
if e.complexity.Mutation.NotificationMarkAllRead == nil {
break
}
return e.complexity.Mutation.NotificationMarkAllRead(childComplexity), true
case "Mutation.notificationToggleRead": case "Mutation.notificationToggleRead":
if e.complexity.Mutation.NotificationToggleRead == nil { if e.complexity.Mutation.NotificationToggleRead == nil {
break break
@ -2199,6 +2212,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.NotificationData.Value(childComplexity), true return e.complexity.NotificationData.Value(childComplexity), true
case "NotificationMarkAllAsReadResult.success":
if e.complexity.NotificationMarkAllAsReadResult.Success == nil {
break
}
return e.complexity.NotificationMarkAllAsReadResult.Success(childComplexity), true
case "Notified.id": case "Notified.id":
if e.complexity.Notified.ID == nil { if e.complexity.Notified.ID == nil {
break break
@ -3417,6 +3437,10 @@ extend type Query {
extend type Mutation { extend type Mutation {
notificationToggleRead(input: NotificationToggleReadInput!): Notified! notificationToggleRead(input: NotificationToggleReadInput!): Notified!
notificationMarkAllRead: NotificationMarkAllAsReadResult!
}
type NotificationMarkAllAsReadResult {
success: Boolean!
} }
type HasUnreadNotificationsResult { type HasUnreadNotificationsResult {
@ -3452,6 +3476,7 @@ enum ActionType {
DUE_DATE_ADDED DUE_DATE_ADDED
DUE_DATE_REMOVED DUE_DATE_REMOVED
DUE_DATE_CHANGED DUE_DATE_CHANGED
DUE_DATE_REMINDER
TASK_ASSIGNED TASK_ASSIGNED
TASK_MOVED TASK_MOVED
TASK_ARCHIVED TASK_ARCHIVED
@ -8535,6 +8560,41 @@ func (ec *executionContext) _Mutation_notificationToggleRead(ctx context.Context
return ec.marshalNNotified2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotified(ctx, field.Selections, res) return ec.marshalNNotified2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotified(ctx, field.Selections, res)
} }
func (ec *executionContext) _Mutation_notificationMarkAllRead(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
IsResolver: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().NotificationMarkAllRead(rctx)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*NotificationMarkAllAsReadResult)
fc.Result = res
return ec.marshalNNotificationMarkAllAsReadResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationMarkAllAsReadResult(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_createProjectLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Mutation_createProjectLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -13267,6 +13327,41 @@ func (ec *executionContext) _NotificationData_value(ctx context.Context, field g
return ec.marshalNString2string(ctx, field.Selections, res) return ec.marshalNString2string(ctx, field.Selections, res)
} }
func (ec *executionContext) _NotificationMarkAllAsReadResult_success(ctx context.Context, field graphql.CollectedField, obj *NotificationMarkAllAsReadResult) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "NotificationMarkAllAsReadResult",
Field: field,
Args: nil,
IsMethod: false,
IsResolver: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _Notified_id(ctx context.Context, field graphql.CollectedField, obj *Notified) (ret graphql.Marshaler) { func (ec *executionContext) _Notified_id(ctx context.Context, field graphql.CollectedField, obj *Notified) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -23074,6 +23169,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
case "notificationMarkAllRead":
out.Values[i] = ec._Mutation_notificationMarkAllRead(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "createProjectLabel": case "createProjectLabel":
out.Values[i] = ec._Mutation_createProjectLabel(ctx, field) out.Values[i] = ec._Mutation_createProjectLabel(ctx, field)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@ -23580,6 +23680,33 @@ func (ec *executionContext) _NotificationData(ctx context.Context, sel ast.Selec
return out return out
} }
var notificationMarkAllAsReadResultImplementors = []string{"NotificationMarkAllAsReadResult"}
func (ec *executionContext) _NotificationMarkAllAsReadResult(ctx context.Context, sel ast.SelectionSet, obj *NotificationMarkAllAsReadResult) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, notificationMarkAllAsReadResultImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("NotificationMarkAllAsReadResult")
case "success":
out.Values[i] = ec._NotificationMarkAllAsReadResult_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch()
if invalids > 0 {
return graphql.Null
}
return out
}
var notifiedImplementors = []string{"Notified"} var notifiedImplementors = []string{"Notified"}
func (ec *executionContext) _Notified(ctx context.Context, sel ast.SelectionSet, obj *Notified) graphql.Marshaler { func (ec *executionContext) _Notified(ctx context.Context, sel ast.SelectionSet, obj *Notified) graphql.Marshaler {
@ -27106,6 +27233,20 @@ func (ec *executionContext) marshalNNotificationFilter2githubᚗcomᚋjordanknot
return v return v
} }
func (ec *executionContext) marshalNNotificationMarkAllAsReadResult2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationMarkAllAsReadResult(ctx context.Context, sel ast.SelectionSet, v NotificationMarkAllAsReadResult) graphql.Marshaler {
return ec._NotificationMarkAllAsReadResult(ctx, sel, &v)
}
func (ec *executionContext) marshalNNotificationMarkAllAsReadResult2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationMarkAllAsReadResult(ctx context.Context, sel ast.SelectionSet, v *NotificationMarkAllAsReadResult) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
return ec._NotificationMarkAllAsReadResult(ctx, sel, v)
}
func (ec *executionContext) unmarshalNNotificationToggleReadInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationToggleReadInput(ctx context.Context, v interface{}) (NotificationToggleReadInput, error) { func (ec *executionContext) unmarshalNNotificationToggleReadInput2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNotificationToggleReadInput(ctx context.Context, v interface{}) (NotificationToggleReadInput, error) {
res, err := ec.unmarshalInputNotificationToggleReadInput(ctx, v) res, err := ec.unmarshalInputNotificationToggleReadInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)

View File

@ -17,27 +17,38 @@ import (
"github.com/99designs/gqlgen/graphql/handler/lru" "github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport" "github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground" "github.com/99designs/gqlgen/graphql/playground"
"github.com/go-redis/redis/v8"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/config" "github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/jobs"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror" "github.com/vektah/gqlparser/v2/gqlerror"
) )
type NotificationObservers struct {
Mu sync.Mutex
Subscribers map[string]map[string]chan *Notified
}
// NewHandler returns a new graphql endpoint handler. // NewHandler returns a new graphql endpoint handler.
func NewHandler(repo db.Repository, appConfig config.AppConfig) http.Handler { func NewHandler(repo db.Repository, appConfig config.AppConfig, jobQueue jobs.JobQueue, redisClient *redis.Client) http.Handler {
c := Config{ resolver := &Resolver{
Resolvers: &Resolver{ Repository: repo,
Repository: repo, Redis: redisClient,
AppConfig: appConfig, AppConfig: appConfig,
Notifications: NotificationObservers{ Job: jobQueue,
Mu: sync.Mutex{}, Notifications: &NotificationObservers{
Subscribers: make(map[string]map[string]chan *Notified), Mu: sync.Mutex{},
}, Subscribers: make(map[string]map[string]chan *Notified),
}, },
} }
resolver.SubscribeRedis()
c := Config{
Resolvers: resolver,
}
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) {
userID, ok := GetUser(ctx) userID, ok := GetUser(ctx)
if !ok { if !ok {

View File

@ -402,6 +402,10 @@ type NotificationData struct {
Value string `json:"value"` Value string `json:"value"`
} }
type NotificationMarkAllAsReadResult struct {
Success bool `json:"success"`
}
type NotificationToggleReadInput struct { type NotificationToggleReadInput struct {
NotifiedID uuid.UUID `json:"notifiedID"` NotifiedID uuid.UUID `json:"notifiedID"`
} }
@ -749,6 +753,7 @@ const (
ActionTypeDueDateAdded ActionType = "DUE_DATE_ADDED" ActionTypeDueDateAdded ActionType = "DUE_DATE_ADDED"
ActionTypeDueDateRemoved ActionType = "DUE_DATE_REMOVED" ActionTypeDueDateRemoved ActionType = "DUE_DATE_REMOVED"
ActionTypeDueDateChanged ActionType = "DUE_DATE_CHANGED" ActionTypeDueDateChanged ActionType = "DUE_DATE_CHANGED"
ActionTypeDueDateReminder ActionType = "DUE_DATE_REMINDER"
ActionTypeTaskAssigned ActionType = "TASK_ASSIGNED" ActionTypeTaskAssigned ActionType = "TASK_ASSIGNED"
ActionTypeTaskMoved ActionType = "TASK_MOVED" ActionTypeTaskMoved ActionType = "TASK_MOVED"
ActionTypeTaskArchived ActionType = "TASK_ARCHIVED" ActionTypeTaskArchived ActionType = "TASK_ARCHIVED"
@ -766,6 +771,7 @@ var AllActionType = []ActionType{
ActionTypeDueDateAdded, ActionTypeDueDateAdded,
ActionTypeDueDateRemoved, ActionTypeDueDateRemoved,
ActionTypeDueDateChanged, ActionTypeDueDateChanged,
ActionTypeDueDateReminder,
ActionTypeTaskAssigned, ActionTypeTaskAssigned,
ActionTypeTaskMoved, ActionTypeTaskMoved,
ActionTypeTaskArchived, ActionTypeTaskArchived,
@ -776,7 +782,7 @@ var AllActionType = []ActionType{
func (e ActionType) IsValid() bool { func (e ActionType) IsValid() bool {
switch e { switch e {
case ActionTypeTeamAdded, ActionTypeTeamRemoved, ActionTypeProjectAdded, ActionTypeProjectRemoved, ActionTypeProjectArchived, ActionTypeDueDateAdded, ActionTypeDueDateRemoved, ActionTypeDueDateChanged, ActionTypeTaskAssigned, ActionTypeTaskMoved, ActionTypeTaskArchived, ActionTypeTaskAttachmentUploaded, ActionTypeCommentMentioned, ActionTypeCommentOther: case ActionTypeTeamAdded, ActionTypeTeamRemoved, ActionTypeProjectAdded, ActionTypeProjectRemoved, ActionTypeProjectArchived, ActionTypeDueDateAdded, ActionTypeDueDateRemoved, ActionTypeDueDateChanged, ActionTypeDueDateReminder, ActionTypeTaskAssigned, ActionTypeTaskMoved, ActionTypeTaskArchived, ActionTypeTaskAttachmentUploaded, ActionTypeCommentMentioned, ActionTypeCommentOther:
return true return true
} }
return false return false

View File

@ -70,6 +70,19 @@ func (r *mutationResolver) NotificationToggleRead(ctx context.Context, input Not
}, nil }, nil
} }
func (r *mutationResolver) NotificationMarkAllRead(ctx context.Context) (*NotificationMarkAllAsReadResult, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &NotificationMarkAllAsReadResult{}, errors.New("invalid user ID")
}
now := time.Now().UTC()
err := r.Repository.MarkAllNotificationsRead(ctx, db.MarkAllNotificationsReadParams{UserID: userID, ReadAt: sql.NullTime{Valid: true, Time: now}})
if err != nil {
return &NotificationMarkAllAsReadResult{}, err
}
return &NotificationMarkAllAsReadResult{Success: false}, 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
} }

View File

@ -4,20 +4,67 @@
package graph package graph
import ( import (
"sync" "context"
"encoding/json"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/config" "github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/jobs"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus"
) )
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
Notifications NotificationObservers Notifications *NotificationObservers
Job jobs.JobQueue
Redis *redis.Client
}
func (r Resolver) SubscribeRedis() {
ctx := context.Background()
go func() {
subscriber := r.Redis.Subscribe(ctx, "notification-created")
log.Info("Stream starting...")
for {
msg, err := subscriber.ReceiveMessage(ctx)
if err != nil {
log.WithError(err).Error("while receiving message")
panic(err)
}
var message utils.NotificationCreatedMessage
if err := json.Unmarshal([]byte(msg.Payload), &message); err != nil {
log.WithError(err).Error("while unmarshalling notifiction created message")
panic(err)
}
log.WithField("notID", message.NotifiedID).Info("received notification message")
notified, err := r.Repository.GetNotifiedByIDNoExtra(ctx, uuid.MustParse(message.NotifiedID))
if err != nil {
log.WithError(err).Error("while getting notified by id")
panic(err)
}
notification, err := r.Repository.GetNotificationByID(ctx, uuid.MustParse(message.NotificationID))
if err != nil {
log.WithError(err).Error("while getting notified by id")
panic(err)
}
for _, observers := range r.Notifications.Subscribers {
for _, ochan := range observers {
ochan <- &Notified{
ID: notified.NotifiedID,
Read: notified.Read,
ReadAt: &notified.ReadAt.Time,
Notification: &notification,
}
}
}
}
}()
} }

View File

@ -10,6 +10,10 @@ extend type Query {
extend type Mutation { extend type Mutation {
notificationToggleRead(input: NotificationToggleReadInput!): Notified! notificationToggleRead(input: NotificationToggleReadInput!): Notified!
notificationMarkAllRead: NotificationMarkAllAsReadResult!
}
type NotificationMarkAllAsReadResult {
success: Boolean!
} }
type HasUnreadNotificationsResult { type HasUnreadNotificationsResult {
@ -45,6 +49,7 @@ enum ActionType {
DUE_DATE_ADDED DUE_DATE_ADDED
DUE_DATE_REMOVED DUE_DATE_REMOVED
DUE_DATE_CHANGED DUE_DATE_CHANGED
DUE_DATE_REMINDER
TASK_ASSIGNED TASK_ASSIGNED
TASK_MOVED TASK_MOVED
TASK_ARCHIVED TASK_ARCHIVED

View File

@ -10,6 +10,10 @@ extend type Query {
extend type Mutation { extend type Mutation {
notificationToggleRead(input: NotificationToggleReadInput!): Notified! notificationToggleRead(input: NotificationToggleReadInput!): Notified!
notificationMarkAllRead: NotificationMarkAllAsReadResult!
}
type NotificationMarkAllAsReadResult {
success: Boolean!
} }
type HasUnreadNotificationsResult { type HasUnreadNotificationsResult {
@ -45,6 +49,7 @@ enum ActionType {
DUE_DATE_ADDED DUE_DATE_ADDED
DUE_DATE_REMOVED DUE_DATE_REMOVED
DUE_DATE_CHANGED DUE_DATE_CHANGED
DUE_DATE_REMINDER
TASK_ASSIGNED TASK_ASSIGNED
TASK_MOVED TASK_MOVED
TASK_ARCHIVED TASK_ARCHIVED

View File

@ -11,7 +11,10 @@ import (
"strconv" "strconv"
"time" "time"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
"github.com/go-redis/redis/v8"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jinzhu/now"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -539,6 +542,42 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
DueDate: dueDate, DueDate: dueDate,
HasTime: input.HasTime, HasTime: input.HasTime,
}) })
reminders, err := r.Repository.GetDueDateRemindersForTaskID(ctx, input.TaskID)
if err != nil {
log.WithError(err).Error("error while getting due date reminders for task ID")
return &db.Task{}, err
}
if input.DueDate != nil {
for _, rem := range reminders {
remindAt := now.With(*input.DueDate).BeginningOfDay()
if input.HasTime {
remindAt = *input.DueDate
}
switch rem.Duration {
case "MINUTE":
remindAt = remindAt.Add(time.Duration(-rem.Period) * time.Minute)
break
case "HOUR":
remindAt = remindAt.Add(time.Duration(-rem.Period) * time.Hour)
break
case "DAY":
remindAt = remindAt.AddDate(0, 0, int(-rem.Period))
break
case "WEEK":
remindAt = remindAt.AddDate(0, 0, 7*int(-rem.Period))
break
}
_, err := r.Repository.UpdateDueDateReminderRemindAt(ctx, db.UpdateDueDateReminderRemindAtParams{
DueDateReminderID: rem.DueDateReminderID,
RemindAt: remindAt,
})
if err != nil {
log.WithError(err).Error("error while updating due date reminder remind at")
return &db.Task{}, err
}
}
}
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
d, _ := json.Marshal(data) d, _ := json.Marshal(data)
if !isSame { if !isSame {
@ -686,12 +725,55 @@ func (r *mutationResolver) UnassignTask(ctx context.Context, input *UnassignTask
func (r *mutationResolver) CreateTaskDueDateNotifications(ctx context.Context, input []CreateTaskDueDateNotification) (*CreateTaskDueDateNotificationsResult, error) { func (r *mutationResolver) CreateTaskDueDateNotifications(ctx context.Context, input []CreateTaskDueDateNotification) (*CreateTaskDueDateNotificationsResult, error) {
reminders := []DueDateNotification{} reminders := []DueDateNotification{}
if len(input) == 0 {
return &CreateTaskDueDateNotificationsResult{}, nil
}
task, err := r.Repository.GetTaskByID(ctx, input[0].TaskID)
if err != nil {
log.WithError(err).Error("error while getting task by id")
return &CreateTaskDueDateNotificationsResult{}, nil
}
for _, in := range input { for _, in := range input {
remindAt := now.With(task.DueDate.Time).BeginningOfDay()
if task.HasTime {
remindAt = task.DueDate.Time
}
switch in.Duration {
case "MINUTE":
remindAt = remindAt.Add(time.Duration(-in.Period) * time.Minute)
break
case "HOUR":
remindAt = remindAt.Add(time.Duration(-in.Period) * time.Hour)
break
case "DAY":
remindAt = remindAt.AddDate(0, 0, int(-in.Period))
break
case "WEEK":
remindAt = remindAt.AddDate(0, 0, 7*int(-in.Period))
break
}
log.Info("task not found, sending task")
n, err := r.Repository.CreateDueDateReminder(ctx, db.CreateDueDateReminderParams{ n, err := r.Repository.CreateDueDateReminder(ctx, db.CreateDueDateReminderParams{
TaskID: in.TaskID, TaskID: in.TaskID,
Period: int32(in.Period), Period: int32(in.Period),
Duration: in.Duration.String(), Duration: in.Duration.String(),
RemindAt: remindAt,
}) })
signature := &mTasks.Signature{
UUID: "due_date_reminder_" + n.DueDateReminderID.String(),
Name: "dueDateNotification",
ETA: &remindAt,
Args: []mTasks.Arg{{
Type: "string",
Value: n.DueDateReminderID.String(),
}, {
Type: "string",
Value: in.TaskID.String(),
}},
}
r.Job.Server.SendTask(signature)
if err != nil { if err != nil {
return &CreateTaskDueDateNotificationsResult{}, err return &CreateTaskDueDateNotificationsResult{}, err
} }
@ -713,15 +795,71 @@ func (r *mutationResolver) CreateTaskDueDateNotifications(ctx context.Context, i
func (r *mutationResolver) UpdateTaskDueDateNotifications(ctx context.Context, input []UpdateTaskDueDateNotification) (*UpdateTaskDueDateNotificationsResult, error) { func (r *mutationResolver) UpdateTaskDueDateNotifications(ctx context.Context, input []UpdateTaskDueDateNotification) (*UpdateTaskDueDateNotificationsResult, error) {
reminders := []DueDateNotification{} reminders := []DueDateNotification{}
if len(input) == 0 {
return &UpdateTaskDueDateNotificationsResult{}, nil
}
for _, in := range input { for _, in := range input {
task, err := r.Repository.GetTaskForDueDateReminder(ctx, in.ID)
if err != nil {
log.WithError(err).Error("error while getting task by id")
return &UpdateTaskDueDateNotificationsResult{}, nil
}
current, err := r.Repository.GetDueDateReminderByID(ctx, in.ID)
if err != nil {
log.WithError(err).Error("error while getting task by id")
return &UpdateTaskDueDateNotificationsResult{}, nil
}
remindAt := now.With(task.DueDate.Time).BeginningOfDay()
if task.HasTime {
remindAt = task.DueDate.Time
}
switch in.Duration {
case "MINUTE":
remindAt = remindAt.Add(time.Duration(-in.Period) * time.Minute)
break
case "HOUR":
remindAt = remindAt.Add(time.Duration(-in.Period) * time.Hour)
break
case "DAY":
remindAt = remindAt.AddDate(0, 0, int(-in.Period))
break
case "WEEK":
remindAt = remindAt.AddDate(0, 0, 7*int(-in.Period))
break
}
n, err := r.Repository.UpdateDueDateReminder(ctx, db.UpdateDueDateReminderParams{ n, err := r.Repository.UpdateDueDateReminder(ctx, db.UpdateDueDateReminderParams{
DueDateReminderID: in.ID, DueDateReminderID: in.ID,
Period: int32(in.Period), Period: int32(in.Period),
Duration: in.Duration.String(), Duration: in.Duration.String(),
RemindAt: remindAt,
}) })
if err != nil { if err != nil {
return &UpdateTaskDueDateNotificationsResult{}, err return &UpdateTaskDueDateNotificationsResult{}, err
} }
etaNano := strconv.FormatInt(current.RemindAt.UnixNano(), 10)
result, err := r.Redis.ZRangeByScore(ctx, "delayed_tasks", &redis.ZRangeBy{Max: etaNano, Min: etaNano}).Result()
if err != nil {
log.WithError(err).Error("error while getting due date reminder")
}
log.WithField("result", result).Info("result raw")
if len(result) != 0 {
r.Redis.ZRem(ctx, "delayed_tasks", result)
}
signature := &mTasks.Signature{
UUID: "due_date_reminder_" + n.DueDateReminderID.String(),
Name: "dueDateNotification",
ETA: &remindAt,
Args: []mTasks.Arg{{
Type: "string",
Value: n.DueDateReminderID.String(),
}, {
Type: "string",
Value: task.TaskID.String(),
}},
}
r.Job.Server.SendTask(signature)
duration := DueDateNotificationDuration(n.Duration) duration := DueDateNotificationDuration(n.Duration)
if !duration.IsValid() { if !duration.IsValid() {
log.WithField("duration", n.Duration).Error("invalid duration found") log.WithField("duration", n.Duration).Error("invalid duration found")
@ -741,11 +879,21 @@ func (r *mutationResolver) UpdateTaskDueDateNotifications(ctx context.Context, i
func (r *mutationResolver) DeleteTaskDueDateNotifications(ctx context.Context, input []DeleteTaskDueDateNotification) (*DeleteTaskDueDateNotificationsResult, error) { func (r *mutationResolver) DeleteTaskDueDateNotifications(ctx context.Context, input []DeleteTaskDueDateNotification) (*DeleteTaskDueDateNotificationsResult, error) {
ids := []uuid.UUID{} ids := []uuid.UUID{}
for _, n := range input { for _, n := range input {
err := r.Repository.DeleteDueDateReminder(ctx, n.ID) reminder, err := r.Repository.GetDueDateReminderByID(ctx, n.ID)
err = r.Repository.DeleteDueDateReminder(ctx, n.ID)
if err != nil { if err != nil {
log.WithError(err).Error("error while deleting task due date notification") log.WithError(err).Error("error while deleting task due date notification")
return &DeleteTaskDueDateNotificationsResult{}, err return &DeleteTaskDueDateNotificationsResult{}, err
} }
etaNano := strconv.FormatInt(reminder.RemindAt.UnixNano(), 10)
result, err := r.Redis.ZRangeByScore(ctx, "delayed_tasks", &redis.ZRangeBy{Max: etaNano, Min: etaNano}).Result()
if err != nil {
log.WithError(err).Error("error while getting due date reminder")
}
log.WithField("result", result).Info("result raw")
if len(result) != 0 {
r.Redis.ZRem(ctx, "delayed_tasks", result)
}
ids = append(ids, n.ID) ids = append(ids, n.ID)
} }
return &DeleteTaskDueDateNotificationsResult{Notifications: ids}, nil return &DeleteTaskDueDateNotificationsResult{Notifications: ids}, nil

View File

@ -1,33 +1,172 @@
package jobs package jobs
import ( import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/RichardKnop/machinery/v1" "github.com/RichardKnop/machinery/v1"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
"github.com/go-redis/redis/v8"
"github.com/jinzhu/now"
log "github.com/sirupsen/logrus"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/config" "github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/utils"
) )
func RegisterTasks(server *machinery.Server, repo db.Repository) { type NotifiedData struct {
tasks := JobTasks{repo} Data map[string]string
}
func RegisterTasks(server *machinery.Server, repo db.Repository, appConfig config.AppConfig, messageQueue *redis.Client) {
tasks := JobTasks{Repository: repo, Server: server, AppConfig: appConfig, MessageQueue: messageQueue}
server.RegisterTasks(map[string]interface{}{ server.RegisterTasks(map[string]interface{}{
"taskMemberWasAdded": tasks.TaskMemberWasAdded, "dueDateNotification": tasks.DueDateNotification,
"scheduleDueDateNotifications": tasks.ScheduleDueDateNotifications,
}) })
} }
type JobTasks struct { type JobTasks struct {
Repository db.Repository AppConfig config.AppConfig
Repository db.Repository
Server *machinery.Server
MessageQueue *redis.Client
} }
func (t *JobTasks) TaskMemberWasAdded(taskID, notifierID, notifiedID string) (bool, error) { func (t *JobTasks) ScheduleDueDateNotifications() (bool, error) {
ctx := context.Background()
// tomorrow := now.With(time.Now().UTC().AddDate(0, 0, 1))
today := now.With(time.Now().UTC())
start := today.BeginningOfDay()
log.WithFields(log.Fields{
"start": start,
}).Info("fetching duration")
reminders, err := t.Repository.GetDueDateRemindersForDuration(ctx, start)
if err != nil {
log.WithError(err).Error("error while getting due date reminder")
}
for _, rem := range reminders {
log.WithField("id", rem.DueDateReminderID).Info("found reminder")
signature := &mTasks.Signature{
UUID: "due_date_reminder_" + rem.DueDateReminderID.String(),
Name: "dueDateNotification",
ETA: &rem.RemindAt,
Args: []mTasks.Arg{{
Type: "string",
Value: rem.DueDateReminderID.String(),
}, {
Type: "string",
Value: rem.TaskID.String(),
}},
}
log.WithField("nanoTime", signature.ETA.UnixNano()).Info("rem time")
etaNano := strconv.FormatInt(signature.ETA.UnixNano(), 10)
result, err := t.MessageQueue.ZRangeByScore(ctx, "delayed_tasks", &redis.ZRangeBy{Max: etaNano, Min: etaNano}).Result()
if err != nil {
log.WithError(err).Error("error while getting due date reminder")
}
log.WithField("result", result).Info("result raw")
if len(result) == 0 {
log.Info("task not found, sending task")
t.Server.SendTask(signature)
}
}
return true, nil
}
func (t *JobTasks) DueDateNotification(dueDateIDEncoded string, taskIDEncoded string) (bool, error) {
ctx := context.Background()
dueDateID, err := uuid.Parse(dueDateIDEncoded)
if err != nil {
log.WithError(err).Error("while parsing task ID")
return false, err
}
taskID, err := uuid.Parse(taskIDEncoded)
if err != nil {
log.WithError(err).Error("while parsing task ID")
return false, err
}
dueAt, err := t.Repository.GetDueDateReminderByID(ctx, dueDateID)
if err != nil {
log.WithError(err).Error("while getting task by id")
return false, err
}
task, err := t.Repository.GetTaskByID(ctx, taskID)
if err != nil {
log.WithError(err).Error("while getting task by id")
return false, err
}
projectInfo, err := t.Repository.GetProjectInfoForTask(ctx, taskID)
if err != nil {
log.WithError(err).Error("error while getting project info for task")
return false, err
}
data := map[string]string{
"TaskID": task.ShortID,
"TaskName": task.Name,
"ProjectID": projectInfo.ProjectShortID,
"ProjectName": projectInfo.Name,
"DueAt": dueAt.RemindAt.String(),
}
now := time.Now().UTC()
raw, err := json.Marshal(NotifiedData{Data: data})
if err != nil {
log.WithError(err).Error("error while marshal json data for notification")
return false, err
}
n, err := t.Repository.CreateNotification(ctx, db.CreateNotificationParams{
CausedBy: uuid.UUID{},
ActionType: "DUE_DATE_REMINDER",
CreatedOn: now,
Data: json.RawMessage(raw),
})
if err != nil {
log.WithError(err).Error("error while creating notification")
return false, err
}
watchers, err := t.Repository.GetTaskWatchersForTask(ctx, taskID)
if err != nil {
log.WithError(err).Error("while getting watchers")
return false, err
}
for _, watcher := range watchers {
notified, err := t.Repository.CreateNotificationNotifed(ctx, db.CreateNotificationNotifedParams{
UserID: watcher.UserID,
NotificationID: n.NotificationID,
})
if err != nil {
log.WithError(err).Error("error while creating notification notified object")
return false, err
}
payload, err := json.Marshal(utils.NotificationCreatedMessage{
NotifiedID: notified.NotifiedID.String(),
NotificationID: n.NotificationID.String(),
})
if err != nil {
panic(err)
}
if err := t.MessageQueue.Publish(context.Background(), "notification-created", payload).Err(); err != nil {
panic(err)
}
}
return true, nil return true, nil
} }
type JobQueue struct { type JobQueue struct {
AppConfig config.AppConfig AppConfig config.AppConfig
Server *machinery.Server Repository db.Repository
Server *machinery.Server
} }
func (q *JobQueue) TaskMemberWasAdded(taskID, notifier, notified uuid.UUID) error { func (q *JobQueue) DueDateNotification(notificationId uuid.UUID) error {
return nil return nil
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/go-redis/redis/v8"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -18,6 +19,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/frontend" "github.com/jordanknott/taskcafe/internal/frontend"
"github.com/jordanknott/taskcafe/internal/graph" "github.com/jordanknott/taskcafe/internal/graph"
"github.com/jordanknott/taskcafe/internal/jobs"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
) )
@ -67,7 +69,7 @@ type TaskcafeHandler struct {
} }
// NewRouter creates a new router for chi // NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB, job *machinery.Server, appConfig config.AppConfig) (chi.Router, error) { func NewRouter(dbConnection *sqlx.DB, redisClient *redis.Client, jobServer *machinery.Server, appConfig config.AppConfig) (chi.Router, error) {
formatter := new(log.TextFormatter) formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05" formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true formatter.FullTimestamp = true
@ -107,10 +109,15 @@ func NewRouter(dbConnection *sqlx.DB, job *machinery.Server, appConfig config.Ap
mux.Post("/logger", taskcafeHandler.HandleClientLog) mux.Post("/logger", taskcafeHandler.HandleClientLog)
}) })
auth := AuthenticationMiddleware{*repository} auth := AuthenticationMiddleware{*repository}
jobQueue := jobs.JobQueue{
Repository: *repository,
AppConfig: appConfig,
Server: jobServer,
}
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.Mount("/graphql", graph.NewHandler(*repository, appConfig)) mux.Mount("/graphql", graph.NewHandler(*repository, appConfig, jobQueue, redisClient))
}) })
frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"} frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"}

6
internal/utils/redis.go Normal file
View File

@ -0,0 +1,6 @@
package utils
type NotificationCreatedMessage struct {
NotifiedID string
NotificationID string
}

View File

@ -0,0 +1 @@
ALTER TABLE task_due_date_reminder ADD COLUMN remind_at timestamptz NOT NULL DEFAULT NOW();

View File

@ -0,0 +1,2 @@
ALTER TABLE notification ALTER COLUMN caused_by DROP NOT NULL;
UPDATE notification SET caused_by = null WHERE caused_by = '00000000-0000-0000-0000-000000000000';