diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3d03124..8b19b98 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,7 +12,7 @@ services: volumes: - taskcafe-postgres:/var/lib/postgresql/data ports: - - 5432:5432 + - 8855:5432 mailhog: image: mailhog/mailhog:latest restart: always diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index edd54b0..8b2f4c6 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -31,7 +31,9 @@ "@typescript-eslint/no-unused-vars": "off", "react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }], "no-case-declarations": "off", + "no-plusplus": "off", "react/prop-types": 0, + "no-continue": "off", "react/jsx-props-no-spreading": "off", "no-param-reassign": "off", "import/extensions": [ diff --git a/frontend/package.json b/frontend/package.json index c38672e..ce65095 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "history": "^4.10.1", "immer": "^6.0.3", "jwt-decode": "^2.2.0", - "lodash": "^4.17.15", + "lodash": "^4.17.20", "prop-types": "^15.7.2", "react": "^16.12.0", "react-autosize-textarea": "^7.0.0", diff --git a/frontend/src/Admin/index.tsx b/frontend/src/Admin/index.tsx index 02884c5..d9dee21 100644 --- a/frontend/src/Admin/index.tsx +++ b/frontend/src/Admin/index.tsx @@ -5,6 +5,7 @@ import GlobalTopNavbar from 'App/TopNavbar'; import { useUsersQuery, useDeleteUserAccountMutation, + useDeleteInvitedUserAccountMutation, useCreateUserAccountMutation, UsersDocument, UsersQuery, @@ -176,6 +177,17 @@ const AdminRoute = () => { const { loading, data } = useUsersQuery(); const { showPopup, hidePopup } = usePopup(); const { user } = useCurrentUser(); + const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({ + update: (client, response) => { + updateApolloCache(client, UsersDocument, cache => + produce(cache, draftCache => { + draftCache.invitedUsers = cache.invitedUsers.filter( + u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id, + ); + }), + ); + }, + }); const [deleteUser] = useDeleteUserAccountMutation({ update: (client, response) => { updateApolloCache(client, UsersDocument, cache => @@ -215,11 +227,16 @@ const AdminRoute = () => { { hidePopup(); }} + onDeleteInvitedUser={invitedUserID => { + deleteInvitedUser({ variables: { invitedUserID } }); + hidePopup(); + }} onDeleteUser={(userID, newOwnerID) => { deleteUser({ variables: { userID, newOwnerID } }); hidePopup(); diff --git a/frontend/src/App/Routes.tsx b/frontend/src/App/Routes.tsx index 9123761..215400a 100644 --- a/frontend/src/App/Routes.tsx +++ b/frontend/src/App/Routes.tsx @@ -5,6 +5,7 @@ import * as H from 'history'; import Dashboard from 'Dashboard'; import Admin from 'Admin'; import Projects from 'Projects'; +import Outline from 'Outline'; import Project from 'Projects/Project'; import Teams from 'Teams'; import Login from 'Auth'; @@ -36,6 +37,7 @@ const Routes: React.FC = () => ( + ); diff --git a/frontend/src/App/TopNavbar.tsx b/frontend/src/App/TopNavbar.tsx index 31a8ea7..d50e655 100644 --- a/frontend/src/App/TopNavbar.tsx +++ b/frontend/src/App/TopNavbar.tsx @@ -230,10 +230,12 @@ type GlobalTopNavbarProps = { menuType?: Array; onChangeRole?: (userID: string, roleCode: RoleCode) => void; projectMembers?: null | Array; + projectInvitedMembers?: null | Array; onSaveProjectName?: (projectName: string) => void; onInviteUser?: ($target: React.RefObject) => void; onSetTab?: (tab: number) => void; onRemoveFromBoard?: (userID: string) => void; + onRemoveInvitedFromBoard?: (email: string) => void; }; const GlobalTopNavbar: React.FC = ({ @@ -246,8 +248,10 @@ const GlobalTopNavbar: React.FC = ({ name, popupContent, projectMembers, + projectInvitedMembers, onInviteUser, onSaveProjectName, + onRemoveInvitedFromBoard, onRemoveFromBoard, }) => { const { user, setUserRoles, setUser } = useCurrentUser(); @@ -333,6 +337,34 @@ const GlobalTopNavbar: React.FC = ({ return null; } const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID); + const onInvitedMemberProfile = ($targetRef: React.RefObject, email: string) => { + const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null; + if (member) { + showPopup( + $targetRef, + { + if (onRemoveInvitedFromBoard) { + onRemoveInvitedFromBoard(member.email); + } + }} + invited + user={{ + id: member.email, + fullName: member.email, + bio: 'Invited', + profileIcon: { + bgColor: '#000', + url: null, + initials: member.email.charAt(0), + }, + }} + bio="" + />, + ); + } + }; + const onMemberProfile = ($targetRef: React.RefObject, memberID: string) => { const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null; const warning = @@ -382,6 +414,7 @@ const GlobalTopNavbar: React.FC = ({ canEditProjectName={userIsTeamOrProjectAdmin} canInviteUser={userIsTeamOrProjectAdmin} onMemberProfile={onMemberProfile} + onInvitedMemberProfile={onInvitedMemberProfile} onInviteUser={onInviteUser} onChangeRole={onChangeRole} onChangeProjectOwner={onChangeProjectOwner} @@ -392,6 +425,7 @@ const GlobalTopNavbar: React.FC = ({ history.push('/'); }} projectMembers={projectMembers} + projectInvitedMembers={projectInvitedMembers} onProfileClick={onProfileClick} onSaveName={onSaveProjectName} onOpenSettings={onOpenSettings} diff --git a/frontend/src/Outline/DragDebug.tsx b/frontend/src/Outline/DragDebug.tsx new file mode 100644 index 0000000..1fed4e4 --- /dev/null +++ b/frontend/src/Outline/DragDebug.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { DragDebugWrapper } from './Styles'; + +type DragDebugProps = { + zone: ImpactZone | null; + depthTarget: number; + draggingID: string | null; +}; + +const DragDebug: React.FC = ({ zone, depthTarget, draggingID }) => { + let aboveID = null; + let belowID = null; + if (zone) { + aboveID = zone.above ? zone.above.node.id : null; + belowID = zone.below ? zone.below.node.id : null; + } + return ( + {`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggingID=${draggingID}`} + ); +}; + +export default DragDebug; diff --git a/frontend/src/Outline/DragIndicator.tsx b/frontend/src/Outline/DragIndicator.tsx new file mode 100644 index 0000000..a66bc02 --- /dev/null +++ b/frontend/src/Outline/DragIndicator.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { getDimensions } from './utils'; +import { DragIndicatorBar } from './Styles'; + +type DragIndicatorProps = { + container: React.RefObject; + zone: ImpactZone; + depthTarget: number; +}; + +const DragIndicator: React.FC = ({ container, zone, depthTarget }) => { + let top = 0; + let width = 0; + if (zone.below === null) { + if (zone.above) { + const entry = getDimensions(zone.above.dimensions.entry); + const children = getDimensions(zone.above.dimensions.children); + if (children) { + top = children.top; + width = children.width - depthTarget * 35; + } else if (entry) { + top = entry.bottom; + width = entry.width - depthTarget * 35; + } + } + } else if (zone.below) { + const entry = getDimensions(zone.below.dimensions.entry); + if (entry) { + top = entry.top; + width = entry.width - depthTarget * 35; + } + } + let left = 0; + if (container && container.current) { + left = container.current.getBoundingClientRect().left + 25 + depthTarget * 35; + } + return ; +}; + +export default DragIndicator; diff --git a/frontend/src/Outline/Dragger.tsx b/frontend/src/Outline/Dragger.tsx new file mode 100644 index 0000000..e46ac0b --- /dev/null +++ b/frontend/src/Outline/Dragger.tsx @@ -0,0 +1,216 @@ +import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'; +import { DotCircle } from 'shared/icons'; +import { findNextDraggable, getDimensions, getTargetDepth, getNodeAbove, getBelowParent, findNodeAbove } from './utils'; +import { useDrag } from './useDrag'; + +type DraggerProps = { + container: React.RefObject; + draggingID: string; + isDragging: boolean; + onDragEnd: (zone: ImpactZone) => void; + initialPos: { x: number; y: number }; +}; + +const Dragger: React.FC = ({ draggingID, container, onDragEnd, isDragging, initialPos }) => { + const [pos, setPos] = useState<{ x: number; y: number }>(initialPos); + const { outline, impact, setImpact } = useDrag(); + const $handle = useRef(null); + const handleMouseUp = useCallback(() => { + onDragEnd(impact ? impact.zone : { below: null, above: null }); + }, [impact]); + const handleMouseMove = useCallback( + e => { + e.preventDefault(); + const { clientX, clientY, pageX, pageY } = e; + console.log(clientX, clientY); + setPos({ x: clientX, y: clientY }); + let curDepth = 1; + let curDraggables: any; + let prevDraggable: any; + let curDraggable: any; + let depthTarget = 1; + let curPosition: ImpactPosition = 'after'; + + // get hovered over node + // decide if node is bottom or top + // calculate the missing node, if it exists + // calculate available depth + // calulcate current selected depth + + while (outline.current.nodes.size + 1 > curDepth) { + curDraggables = outline.current.nodes.get(curDepth); + if (curDraggables) { + const nextDraggable = findNextDraggable({ x: clientX, y: clientY }, outline.current, curDepth, draggingID); + if (nextDraggable) { + prevDraggable = curDraggable; + curDraggable = nextDraggable.node; + curPosition = nextDraggable.position; + if (nextDraggable.found) { + break; + } + curDepth += 1; + } else { + break; + } + } + } + let aboveNode: null | OutlineNode = null; + let belowNode: null | OutlineNode = null; + + if (curPosition === 'before') { + belowNode = curDraggable; + } else { + aboveNode = curDraggable; + } + + // if belowNode has the depth of 1, then the above element will be a part of a different branch + + const { relationships, nodes } = outline.current; + if (belowNode) { + aboveNode = findNodeAbove(outline.current, curDepth, belowNode); + } else if (aboveNode) { + let targetBelowNode: RelationshipChild | null = null; + const parent = relationships.get(aboveNode.parent); + if (aboveNode.children !== 0) { + const abr = relationships.get(aboveNode.id); + if (abr) { + const newTarget = abr.children[0]; + if (newTarget) { + targetBelowNode = newTarget; + } + } + } else if (parent) { + const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id); + if (aboveNodeIndex !== -1) { + if (aboveNodeIndex === parent.children.length - 1) { + targetBelowNode = getBelowParent(aboveNode, outline.current); + } else { + const nextChild = parent.children[aboveNodeIndex + 1]; + targetBelowNode = nextChild ?? null; + } + } + } + if (targetBelowNode) { + const depthNodes = nodes.get(targetBelowNode.depth); + if (depthNodes) { + belowNode = depthNodes.get(targetBelowNode.id) ?? null; + } + } + } + + // if outside outline, get either first or last item in list based on mouse Y + if (!aboveNode && !belowNode) { + if (container && container.current) { + const bounds = container.current.getBoundingClientRect(); + if (clientY < bounds.top + bounds.height / 2) { + const rootChildren = outline.current.relationships.get('root'); + const rootDepth = outline.current.nodes.get(1); + if (rootChildren && rootDepth) { + const firstChild = rootChildren.children[0]; + belowNode = rootDepth.get(firstChild.id) ?? null; + aboveNode = null; + } + } else { + const rootChildren = outline.current.relationships.get('root'); + const rootDepth = outline.current.nodes.get(1); + if (rootChildren && rootDepth) { + const lastChild = rootChildren.children[rootChildren.children.length - 1]; + aboveNode = rootDepth.get(lastChild.id) ?? null; + } + } + } + } + + if (aboveNode && aboveNode.id === draggingID) { + belowNode = aboveNode; + aboveNode = findNodeAbove(outline.current, aboveNode.depth, aboveNode); + } + + // calculate available depths + + let minDepth = 1; + let maxDepth = 2; + if (aboveNode) { + const aboveParent = relationships.get(aboveNode.parent); + if (aboveNode.children !== 0) { + minDepth = aboveNode.depth + 1; + maxDepth = aboveNode.depth + 1; + } else if (aboveParent) { + minDepth = aboveNode.depth; + maxDepth = aboveNode.depth + 1; + const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id); + if (aboveNodeIndex === aboveParent.children.length - 1) { + minDepth = belowNode ? belowNode.depth : minDepth; + } + } + } + if (aboveNode) { + const dimensions = outline.current.dimensions.get(aboveNode.id); + const entry = getDimensions(dimensions?.entry); + if (entry) { + depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth }); + } + } + + let aboveImpact: null | ImpactZoneData = null; + let belowImpact: null | ImpactZoneData = null; + if (aboveNode) { + const aboveDim = outline.current.dimensions.get(aboveNode.id); + if (aboveDim) { + aboveImpact = { + node: aboveNode, + dimensions: aboveDim, + }; + } + } + if (belowNode) { + const belowDim = outline.current.dimensions.get(belowNode.id); + if (belowDim) { + belowImpact = { + node: belowNode, + dimensions: belowDim, + }; + } + } + + setImpact({ + zone: { + above: aboveImpact, + below: belowImpact, + }, + depth: depthTarget, + }); + }, + [outline.current.nodes], + ); + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mousemove', handleMouseMove); + return () => { + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + const styles = useMemo(() => { + const position: 'absolute' | 'relative' = isDragging ? 'absolute' : 'relative'; + return { + cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab', + transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`, + transition: isDragging ? 'none' : 'transform 500ms', + zIndex: isDragging ? 2 : 1, + position, + }; + }, [isDragging, pos]); + + return ( + <> + {pos && ( +
+ +
+ )} + + ); +}; + +export default Dragger; diff --git a/frontend/src/Outline/Entry.tsx b/frontend/src/Outline/Entry.tsx new file mode 100644 index 0000000..ace4190 --- /dev/null +++ b/frontend/src/Outline/Entry.tsx @@ -0,0 +1,81 @@ +import React, { useRef, useEffect } from 'react'; +import { DotCircle } from 'shared/icons'; + +import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle } from './Styles'; +import { useDrag } from './useDrag'; + +type EntryProps = { + id: string; + parentID: string; + onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void; + isRoot?: boolean; + draggingID: null | string; + entries: Array; + position: number; + chain?: Array; + depth?: number; +}; + +const Entry: React.FC = ({ + id, + parentID, + isRoot = false, + position, + onStartDrag, + draggingID, + entries, + chain = [], + depth = 0, +}) => { + const $entry = useRef(null); + const $children = useRef(null); + const { setNodeDimensions } = useDrag(); + useEffect(() => { + if (isRoot) return; + if ($entry && $entry.current) { + setNodeDimensions(id, { + entry: $entry, + children: entries.length !== 0 ? $children : null, + }); + } + }, [position, depth, entries]); + let showHandle = true; + if (draggingID && draggingID === id) { + showHandle = false; + } + return ( + + {!isRoot && ( + + {showHandle && ( + onStartDrag({ id, clientX: e.clientX, clientY: e.clientY })}> + + + )} + {id.toString()} + + )} + {entries.length !== 0 && ( + + {entries + .sort((a, b) => a.position - b.position) + .map(entry => ( + + ))} + + )} + + ); +}; + +export default Entry; diff --git a/frontend/src/Outline/Styles.ts b/frontend/src/Outline/Styles.ts new file mode 100644 index 0000000..f478073 --- /dev/null +++ b/frontend/src/Outline/Styles.ts @@ -0,0 +1,116 @@ +import styled, { css } from 'styled-components'; +import { mixin } from 'shared/utils/styles'; + +export const EntryWrapper = styled.div<{ isDragging: boolean }>` + position: relative; + ${props => + props.isDragging && + css` + &:before { + border-radius: 3px; + content: ''; + position: absolute; + top: 2px; + right: -2px; + left: -2px; + bottom: -2px; + background-color: #eceef0; + } + `} +`; + +export const EntryChildren = styled.div<{ isRoot: boolean }>` + position: relative; + ${props => + !props.isRoot && + css` + margin-left: 10px; + padding-left: 25px; + border-left: 1px solid rgb(236, 238, 240); + `} +`; + +export const PageContent = styled.div` + min-height: calc(100vh - 146px); + width: 100%; + position: relative; + display: flex; + flex-direction: column; + box-shadow: none; + user-select: none; + margin-left: auto; + margin-right: auto; + max-width: 700px; + padding-left: 56px; + padding-right: 56px; + padding-top: 24px; + padding-bottom: 24px; + margin-top: 72px; + text-size-adjust: none; + background: rgb(255, 255, 255); + border-radius: 6px; +`; +export const EntryContent = styled.div` + position: relative; + margin-left: -500px; + padding-left: 524px; +`; + +export const DragHandle = styled.div<{ top: number; left: number }>` + display: flex; + align-items: center; + justify-content: center; + position: fixed; + left: 0; + top: 0; + transform: translate3d(${props => props.left}px, ${props => props.top}px, 0); + transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1); + width: 18px; + height: 18px; + color: rgb(75, 81, 85); + border-radius: 9px; +`; + +export const EntryHandle = styled.div` + display: flex; + align-items: center; + justify-content: center; + position: absolute; + left: 501px; + top: 7px; + width: 18px; + height: 18px; + color: rgb(75, 81, 85); + border-radius: 9px; +`; + +export const EntryInnerContent = styled.div` + padding-top: 4px; + font-size: 15px; + white-space: pre-wrap; + line-height: 24px; + min-height: 24px; + overflow-wrap: break-word; + position: relative; + user-select: text; + &:focus { + outline: 0; + } +`; + +export const DragDebugWrapper = styled.div` + position: absolute; + left: 42px; + bottom: 24px; + color: #fff; +`; + +export const DragIndicatorBar = styled.div<{ left: number; top: number; width: number }>` + position: absolute; + width: ${props => props.width}px; + top: ${props => props.top}px; + left: ${props => props.left}px; + height: 4px; + border-radius: 3px; + background: rgb(204, 204, 204); +`; diff --git a/frontend/src/Outline/index.tsx b/frontend/src/Outline/index.tsx new file mode 100644 index 0000000..5c9f69e --- /dev/null +++ b/frontend/src/Outline/index.tsx @@ -0,0 +1,301 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react'; +import { DotCircle } from 'shared/icons'; +import _ from 'lodash'; +import produce from 'immer'; +import Entry from './Entry'; +import DragIndicator from './DragIndicator'; +import Dragger from './Dragger'; +import DragDebug from './DragDebug'; +import { DragContext } from './useDrag'; + +import { + DragDebugWrapper, + DragIndicatorBar, + PageContent, + EntryChildren, + EntryInnerContent, + EntryWrapper, + EntryContent, + EntryHandle, +} from './Styles'; +import { transformToTree, findNode, findNodeDepth, getNumberOfChildren, validateDepth } from './utils'; + +const listItems: Array = [ + { id: 'root', position: 4096, parent: null }, + { id: 'entry-1', position: 4096, parent: 'root' }, + { id: 'entry-1_1', position: 4096, parent: 'entry-1' }, + { id: 'entry-1_1_1', position: 4096, parent: 'entry-1_1' }, + { id: 'entry-1_2', position: 4096 * 2, parent: 'entry-1' }, + { id: 'entry-1_2_1', position: 4096, parent: 'entry-1_2' }, + { id: 'entry-1_2_2', position: 4096 * 2, parent: 'entry-1_2' }, + { id: 'entry-1_2_3', position: 4096 * 3, parent: 'entry-1_2' }, + { id: 'entry-2', position: 4096 * 2, parent: 'root' }, + { id: 'entry-3', position: 4096 * 3, parent: 'root' }, + { id: 'entry-4', position: 4096 * 4, parent: 'root' }, + { id: 'entry-5', position: 4096 * 5, parent: 'root' }, +]; + +const Outline: React.FC = () => { + const [items, setItems] = useState(listItems); + const [dragging, setDragging] = useState<{ + show: boolean; + draggableID: null | string; + initialPos: { x: number; y: number }; + }>({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } }); + const [impact, setImpact] = useState(null); + const impactRef = useRef(null); + useEffect(() => { + if (impact) { + impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition }; + } + }, [impact]); + + const $content = useRef(null); + const outline = useRef({ + published: new Map(), + dimensions: new Map(), + nodes: new Map>(), + relationships: new Map(), + }); + + const tree = transformToTree(_.cloneDeep(items)); + let root: any = null; + if (tree.length === 1) { + root = tree[0]; + } + useEffect(() => { + outline.current.relationships = new Map(); + outline.current.published = new Map(); + outline.current.nodes = new Map>(); + items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root')); + + for (let i = 0; i < items.length; i++) { + const { position, id, parent: curParent } = items[i]; + if (id === 'root') { + continue; + } + const parent = curParent ?? 'root'; + outline.current.published.set(id, parent ?? 'root'); + const { depth, ancestors } = findNodeDepth(outline.current.published, id); + const children = getNumberOfChildren(root, ancestors); + if (!outline.current.nodes.has(depth)) { + outline.current.nodes.set(depth, new Map()); + } + const targetDepthNodes = outline.current.nodes.get(depth); + if (targetDepthNodes) { + targetDepthNodes.set(id, { + id, + children, + position, + depth, + ancestors, + parent, + }); + } + if (!outline.current.relationships.has(parent)) { + outline.current.relationships.set(parent, { + self: { + depth: depth - 1, + id: parent, + }, + children: [], + numberOfSubChildren: 0, + }); + } + const nodeRelations = outline.current.relationships.get(parent); + if (nodeRelations) { + outline.current.relationships.set(parent, { + self: nodeRelations.self, + numberOfSubChildren: nodeRelations.numberOfSubChildren + children, + children: [...nodeRelations.children, { id, position, depth, children }].sort( + (a, b) => a.position - b.position, + ), + }); + } + } + }, [items]); + + if (!root) { + return null; + } + return ( + <> + { + if (data) { + const { zone, depth } = data; + let listPosition = 65535; + const listAbove = validateDepth(zone.above ? zone.above.node : null, depth); + const listBelow = validateDepth(zone.below ? zone.below.node : null, depth); + if (listAbove && listBelow) { + listPosition = (listAbove.position + listBelow.position) / 2.0; + } else if (listAbove && !listBelow) { + listPosition = listAbove.position * 2.0; + } else if (!listAbove && listBelow) { + listPosition = listBelow.position / 2.0; + } + + if (!zone.above && zone.below) { + const newPosition = zone.below.node.position / 2.0; + setImpact(() => ({ + zone, + listPosition: newPosition, + depthTarget: depth, + })); + } + if (zone.above) { + // console.log(`prev=${prev} next=${next} targetPosition=${targetPosition}`); + // let targetID = depthTarget === 1 ? 'root' : node.ancestors[depthTarget - 1]; + // targetID = targetID ?? node.id; + setImpact(() => ({ + zone, + listPosition, + depthTarget: depth, + })); + } + } else { + setImpact(null); + } + }, + setNodeDimensions: (nodeID, ref) => { + outline.current.dimensions.set(nodeID, ref); + }, + }} + > + <> + + { + if (e.id !== 'root') { + setImpact(null); + setDragging({ show: true, draggableID: e.id, initialPos: { x: e.clientX, y: e.clientY } }); + } + }} + /> + + {dragging.show && dragging.draggableID && ( + { + const draggingID = dragging.draggableID; + if (draggingID && impactRef.current) { + const { zone, depth, listPosition } = impactRef.current; + const noZone = !zone.above && !zone.below; + const curParentID = outline.current.published.get(draggingID); + if (!noZone && curParentID) { + let parentID = 'root'; + if (zone.above) { + parentID = zone.above.node.ancestors[depth - 1]; + } + const node = findNode(curParentID, draggingID, outline.current); + console.log(`${node ? node.parent : null} => ${parentID}`); + // UPDATE OUTLINE DATA AFTER NODE MOVE + if (node) { + if (node.depth !== depth) { + const oldParentDepth = outline.current.nodes.get(node.depth - 1); + if (oldParentDepth) { + const oldParentNode = oldParentDepth.get(node.parent); + if (oldParentNode) { + oldParentNode.children -= 1; + } + } + const oldDepth = outline.current.nodes.get(node.depth); + if (oldDepth) { + oldDepth.delete(node.id); + } + if (!outline.current.nodes.has(depth)) { + outline.current.nodes.set(depth, new Map()); + } + const newParentDepth = outline.current.nodes.get(depth - 1); + if (newParentDepth) { + const newParentNode = newParentDepth.get(parentID); + if (newParentNode) { + newParentNode.children += 1; + } + } + const newDepth = outline.current.nodes.get(depth); + if (newDepth) { + // TODO: rebuild ancestors + newDepth.set(node.id, { + ...node, + depth, + position: listPosition, + parent: parentID, + }); + } + } + if (!outline.current.relationships.has(parentID)) { + outline.current.relationships.set(parentID, { + self: { + depth: depth - 1, + id: parentID, + }, + children: [{ id: draggingID, position: listPosition, depth, children: node.children }], + numberOfSubChildren: 0, + }); + } + const nodeRelations = outline.current.relationships.get(parentID); + if (parentID !== node.parent) { + // ?? + } + if (nodeRelations) { + nodeRelations.children = produce(nodeRelations.children, draftChildren => { + const nodeIdx = draftChildren.findIndex(c => c.id === node.id); + if (nodeIdx !== -1) { + draftChildren[nodeIdx] = { + children: node.children, + depth, + position: listPosition, + id: node.id, + }; + } + draftChildren.sort((a, b) => a.position - b.position); + }); + } + } + outline.current.published.set(draggingID, parentID); + setItems(itemsPrev => + produce(itemsPrev, draftItems => { + const curDragging = itemsPrev.findIndex(i => i.id === draggingID); + // console.log(`parent=${impactRef.current} target=${draggingID}`); + if (impactRef.current) { + // console.log(`updating position = ${impactRef.current.targetPosition}`); + draftItems[curDragging].parent = parentID; + draftItems[curDragging].position = listPosition; + } + }), + ); + } + } + setImpact(null); + setDragging({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } }); + }} + /> + )} + + + {impact && } + {impact && ( + + )} + + ); +}; + +export default Outline; diff --git a/frontend/src/Outline/useDrag.ts b/frontend/src/Outline/useDrag.ts new file mode 100644 index 0000000..81a3fd0 --- /dev/null +++ b/frontend/src/Outline/useDrag.ts @@ -0,0 +1,21 @@ +import React, { useContext } from 'react'; + +type DragContextData = { + impact: null | { zone: ImpactZone; depthTarget: number }; + outline: React.MutableRefObject; + setNodeDimensions: ( + nodeID: string, + ref: { entry: React.RefObject; children: React.RefObject | null }, + ) => void; + setImpact: (data: ImpactData | null) => void; +}; + +export const DragContext = React.createContext(null); + +export const useDrag = () => { + const ctx = useContext(DragContext); + if (ctx) { + return ctx; + } + throw new Error('context is null'); +}; diff --git a/frontend/src/Outline/utils.ts b/frontend/src/Outline/utils.ts new file mode 100644 index 0000000..8efa624 --- /dev/null +++ b/frontend/src/Outline/utils.ts @@ -0,0 +1,262 @@ +import _ from 'lodash'; + +export function validateDepth(node: OutlineNode | null, depth: number) { + if (node) { + return node.depth === depth ? node : null; + } + return null; +} + +export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChild, outline: OutlineData) { + let hasChildren = true; + let nodeAbove: null | RelationshipChild = null; + let aboveTargetID = startingParent.id; + while (hasChildren) { + const targetParent = outline.relationships.get(aboveTargetID); + if (targetParent) { + if (targetParent.children.length === 0) { + const parentNodes = outline.nodes.get(targetParent.self.depth); + const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null; + if (parentNode) { + nodeAbove = { + id: parentNode.id, + depth: parentNode.depth, + position: parentNode.position, + children: parentNode.children, + }; + } + continue; + } + nodeAbove = targetParent.children[targetParent.children.length - 1]; + if (targetParent.numberOfSubChildren === 0) { + hasChildren = false; + } else { + aboveTargetID = nodeAbove.id; + } + } else { + const target = outline.relationships.get(node.ancestors[0]); + if (target) { + const targetChild = target.children.find(i => i.id === aboveTargetID); + if (targetChild) { + nodeAbove = targetChild; + } + hasChildren = false; + } + } + } + return nodeAbove; +} + +export function getBelowParent(node: OutlineNode, outline: OutlineData) { + const { relationships, nodes } = outline; + const parentDepth = nodes.get(node.depth - 1); + const parent = parentDepth ? parentDepth.get(node.parent) : null; + if (parent) { + const grandfather = relationships.get(parent.parent); + if (grandfather) { + const parentIndex = grandfather.children.findIndex(c => c.id === parent.id); + if (parentIndex !== -1) { + if (parentIndex === grandfather.children.length - 1) { + const root = relationships.get(node.ancestors[0]); + if (root) { + const ancestorIndex = root.children.findIndex(c => c.id === node.ancestors[1]); + if (ancestorIndex !== -1) { + const nextAncestor = root.children[ancestorIndex + 1]; + if (nextAncestor) { + return nextAncestor; + } + } + } + } else { + const nextChild = grandfather.children[parentIndex + 1]; + if (nextChild) { + return nextChild; + } + } + } + } + } + return null; +} + +export function getDimensions(ref: React.RefObject | null | undefined) { + if (ref && ref.current) { + return ref.current.getBoundingClientRect(); + } + return null; +} + +export function getTargetDepth(mouseX: number, handleLeft: number, availableDepths: { min: number; max: number }) { + if (mouseX > handleLeft) { + return availableDepths.max; + } + let curDepth = availableDepths.max - 1; + for (let x = availableDepths.min; x < availableDepths.max; x++) { + const breakpoint = handleLeft - x * 35; + // console.log(`mouseX=${mouseX} breakpoint=${breakpoint} x=${x} curDepth=${curDepth}`); + if (mouseX > breakpoint) { + return curDepth; + } + curDepth -= 1; + } + + return availableDepths.min; +} + +export function findNextDraggable( + pos: { x: number; y: number }, + outline: OutlineData, + curDepth: number, + draggingID: string, +) { + let index = 0; + const currentDepthNodes = outline.nodes.get(curDepth); + let nodeAbove: null | RelationshipChild = null; + if (!currentDepthNodes) { + return null; + } + for (const [id, node] of currentDepthNodes) { + const dimensions = outline.dimensions.get(id); + const target = dimensions ? getDimensions(dimensions.entry) : null; + const children = dimensions ? getDimensions(dimensions.children) : null; + if (target) { + console.log( + `[${id}] ${pos.y} <= ${target.bottom} = ${pos.y <= target.bottom} / ${pos.y} >= ${target.top} = ${pos.y >= + target.top}`, + ); + if (pos.y <= target.bottom && pos.y >= target.top) { + const middlePoint = target.top + target.height / 2; + const position: ImpactPosition = pos.y > middlePoint ? 'after' : 'before'; + return { + found: true, + node, + position, + }; + } + } + if (children) { + console.log( + `[${id}] ${pos.y} <= ${children.bottom} = ${pos.y <= children.bottom} / ${pos.y} >= ${children.top} = ${pos.y >= + children.top}`, + ); + if (pos.y <= children.bottom && pos.y >= children.top) { + const position: ImpactPosition = 'after'; + return { found: false, node, position }; + } + } + index += 1; + } + return null; +} + +export function transformToTree(arr: any) { + const nodes: any = {}; + return arr.filter(function(obj: any) { + var id = obj['id'], + parentId = obj['parent']; + + nodes[id] = _.defaults(obj, nodes[id], { children: [] }); + parentId && (nodes[parentId] = nodes[parentId] || { children: [] })['children'].push(obj); + + return !parentId; + }); +} + +export function findNode(parentID: string, nodeID: string, data: OutlineData) { + const nodeRelations = data.relationships.get(parentID); + if (nodeRelations) { + const nodeDepth = data.nodes.get(nodeRelations.self.depth + 1); + if (nodeDepth) { + const node = nodeDepth.get(nodeID); + return node ?? null; + } + } + return null; +} + +export function findNodeDepth(published: Map, id: string) { + let currentID = id; + let breaker = 0; + let depth = 0; + let ancestors = [id]; + while (currentID !== 'root') { + const nextID = published.get(currentID); + if (nextID) { + ancestors = [nextID, ...ancestors]; + currentID = nextID; + depth += 1; + breaker += 1; + if (breaker > 100) { + throw new Error('node depth breaker was thrown'); + } + } else { + throw new Error('unable to find nextID'); + } + } + return { depth, ancestors }; +} + +export function getNumberOfChildren(root: ItemElement, ancestors: Array) { + let currentBranch = root; + for (let i = 1; i < ancestors.length; i++) { + const nextBranch = currentBranch.children ? currentBranch.children.find(c => c.id === ancestors[i]) : null; + if (nextBranch) { + currentBranch = nextBranch; + } else { + throw new Error('unable to find next branch'); + } + } + return currentBranch.children ? currentBranch.children.length : 0; +} + +export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode: OutlineNode) { + let targetAboveNode: null | RelationshipChild = null; + if (curDepth === 1) { + const relations = outline.relationships.get(belowNode.ancestors[0]); + if (relations) { + const parentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.ancestors[1]); + if (parentIndex !== -1) { + const aboveParent = relations.children[parentIndex - 1]; + if (parentIndex === 0) { + targetAboveNode = null; + } else { + targetAboveNode = getNodeAbove(belowNode, aboveParent, outline); + } + } + } + } else { + const relations = outline.relationships.get(belowNode.parent); + if (relations) { + const currentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.id); + // is first child, so use parent + if (currentIndex === 0) { + const parentNodes = outline.nodes.get(belowNode.depth - 1); + const parentNode = parentNodes ? parentNodes.get(belowNode.parent) : null; + if (parentNode) { + targetAboveNode = { + id: belowNode.parent, + depth: belowNode.depth - 1, + position: parentNode.position, + children: parentNode.children, + }; + } + } else if (currentIndex !== -1) { + // is not first child, so first prev sibling + const aboveParentNode = relations.children[currentIndex - 1]; + if (aboveParentNode) { + targetAboveNode = getNodeAbove(belowNode, aboveParentNode, outline); + if (targetAboveNode === null) { + targetAboveNode = aboveParentNode; + } + } + } + } + } + if (targetAboveNode) { + const depthNodes = outline.nodes.get(targetAboveNode.depth); + if (depthNodes) { + return depthNodes.get(targetAboveNode.id) ?? null; + } + } + return null; +} diff --git a/frontend/src/Projects/Project/index.tsx b/frontend/src/Projects/Project/index.tsx index 7f96c63..07da9b0 100644 --- a/frontend/src/Projects/Project/index.tsx +++ b/frontend/src/Projects/Project/index.tsx @@ -21,6 +21,7 @@ import { useToggleTaskLabelMutation, useUpdateProjectNameMutation, useFindProjectQuery, + useDeleteInvitedProjectMemberMutation, useUpdateTaskNameMutation, useCreateTaskMutation, useDeleteTaskMutation, @@ -431,6 +432,25 @@ const Project = () => { ...cache.findProject.members, ...response.data.inviteProjectMembers.members, ]; + draftCache.findProject.invitedMembers = [ + ...cache.findProject.invitedMembers, + ...response.data.inviteProjectMembers.invitedMembers, + ]; + }), + { projectID }, + ); + }, + }); + const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({ + update: (client, response) => { + updateApolloCache( + client, + FindProjectDocument, + cache => + produce(cache, draftCache => { + draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter( + m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email, + ); }), { projectID }, ); @@ -490,6 +510,10 @@ const Project = () => { deleteProjectMember({ variables: { userID, projectID } }); hidePopup(); }} + onRemoveInvitedFromBoard={email => { + deleteInvitedProjectMember({ variables: { projectID, email } }); + hidePopup(); + }} onSaveProjectName={projectName => { updateProjectName({ variables: { projectID, name: projectName } }); }} @@ -511,6 +535,7 @@ const Project = () => { menuType={[{ name: 'Board', link: location.pathname }]} currentTab={0} projectMembers={data.findProject.members} + projectInvitedMembers={data.findProject.invitedMembers} projectID={projectID} teamID={data.findProject.team ? data.findProject.team.id : null} name={data.findProject.name} diff --git a/frontend/src/outline.d.ts b/frontend/src/outline.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/shared/components/Admin/Admin.stories.tsx b/frontend/src/shared/components/Admin/Admin.stories.tsx index 64a1f58..71dbae6 100644 --- a/frontend/src/shared/components/Admin/Admin.stories.tsx +++ b/frontend/src/shared/components/Admin/Admin.stories.tsx @@ -51,7 +51,9 @@ export const Default = () => { }, }, ]} + invitedUsers={[]} onAddUser={action('add user')} + onDeleteInvitedUser={action('delete invited user')} /> diff --git a/frontend/src/shared/components/Admin/index.tsx b/frontend/src/shared/components/Admin/index.tsx index b4763f0..feaa6ab 100644 --- a/frontend/src/shared/components/Admin/index.tsx +++ b/frontend/src/shared/components/Admin/index.tsx @@ -104,8 +104,8 @@ type TeamRoleManagerPopupProps = { user: User; users: Array; warning?: string | null; - canChangeRole: boolean; - onChangeRole: (roleCode: RoleCode) => void; + canChangeRole?: boolean; + onChangeRole?: (roleCode: RoleCode) => void; updateUserPassword?: (user: TaskUser, password: string) => void; onDeleteUser?: (userID: string, newOwnerID: string | null) => void; }; @@ -530,8 +530,10 @@ type AdminProps = { onDeleteUser: (userID: string, newOwnerID: string | null) => void; onInviteUser: ($target: React.RefObject) => void; users: Array; + invitedUsers: Array; canInviteUser: boolean; onUpdateUserPassword: (user: TaskUser, password: string) => void; + onDeleteInvitedUser: (invitedUserID: string) => void; }; const Admin: React.FC = ({ @@ -540,7 +542,9 @@ const Admin: React.FC = ({ onUpdateUserPassword, canInviteUser, onDeleteUser, + onDeleteInvitedUser, onInviteUser, + invitedUsers, users, }) => { const warning = @@ -577,7 +581,7 @@ const Admin: React.FC = ({ - {`Members (${users.length})`} + {`Members (${users.length + invitedUsers.length})`} Organization admins can create / manage / delete all projects & teams. Members only have access to teams or projects they have been added to. @@ -635,6 +639,65 @@ const Admin: React.FC = ({ ); })} + {invitedUsers.map(member => { + return ( + + + + {member.email} + Invited + + + { + showPopup( + $target, + { + onDeleteInvitedUser(member.id); + }} + />, + ); + }} + > + Manage + + + + ); + })} diff --git a/frontend/src/shared/components/MiniProfile/index.tsx b/frontend/src/shared/components/MiniProfile/index.tsx index 57c07e1..6f56442 100644 --- a/frontend/src/shared/components/MiniProfile/index.tsx +++ b/frontend/src/shared/components/MiniProfile/index.tsx @@ -47,6 +47,7 @@ const permissions = [ type MiniProfileProps = { bio: string; user: TaskUser; + invited?: boolean; onRemoveFromTask?: () => void; onChangeRole?: (roleCode: RoleCode) => void; onRemoveFromBoard?: () => void; @@ -56,6 +57,7 @@ type MiniProfileProps = { const MiniProfile: React.FC = ({ user, bio, + invited, canChangeRole, onRemoveFromTask, onChangeRole, @@ -74,7 +76,7 @@ const MiniProfile: React.FC = ({ )} {user.fullName} - {`@${user.username}`} + {invited ? Invited : {`@${user.username}`}} {bio} diff --git a/frontend/src/shared/components/TaskAssignee/index.tsx b/frontend/src/shared/components/TaskAssignee/index.tsx index 3c48fe4..60b5afc 100644 --- a/frontend/src/shared/components/TaskAssignee/index.tsx +++ b/frontend/src/shared/components/TaskAssignee/index.tsx @@ -55,11 +55,19 @@ type TaskAssigneeProps = { size: number | string; showRoleIcons?: boolean; member: TaskUser; + invited?: boolean; onMemberProfile?: ($targetRef: React.RefObject, memberID: string) => void; className?: string; }; -const TaskAssignee: React.FC = ({ showRoleIcons, member, onMemberProfile, size, className }) => { +const TaskAssignee: React.FC = ({ + showRoleIcons, + member, + invited, + onMemberProfile, + size, + className, +}) => { const $memberRef = useRef(null); let profileIcon: ProfileIcon = { url: null, diff --git a/frontend/src/shared/components/TopNavbar/index.tsx b/frontend/src/shared/components/TopNavbar/index.tsx index 00ac13d..a013c18 100644 --- a/frontend/src/shared/components/TopNavbar/index.tsx +++ b/frontend/src/shared/components/TopNavbar/index.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect } from 'react'; -import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons'; +import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons'; import styled from 'styled-components'; import ProfileIcon from 'shared/components/ProfileIcon'; import { usePopup } from 'shared/components/PopupMenu'; @@ -30,6 +30,7 @@ import { ProjectMember, ProjectMembers, } from './Styles'; +import { useHistory } from 'react-router'; type IconContainerProps = { disabled?: boolean; @@ -173,8 +174,11 @@ type NavBarProps = { user: TaskUser | null; onOpenSettings: ($target: React.RefObject) => void; projectMembers?: Array | null; + projectInvitedMembers?: Array | null; + onRemoveFromBoard?: (userID: string) => void; onMemberProfile?: ($targetRef: React.RefObject, memberID: string) => void; + onInvitedMemberProfile?: ($targetRef: React.RefObject, email: string) => void; }; const NavBar: React.FC = ({ @@ -184,10 +188,12 @@ const NavBar: React.FC = ({ onChangeProjectOwner, currentTab, onMemberProfile, + onInvitedMemberProfile, canEditProjectName = false, onOpenProjectFinder, onFavorite, onSetTab, + projectInvitedMembers, onChangeRole, name, onRemoveFromBoard, @@ -204,6 +210,7 @@ const NavBar: React.FC = ({ onProfileClick($target); } }; + const history = useHistory(); const { showPopup } = usePopup(); return ( @@ -245,19 +252,38 @@ const NavBar: React.FC = ({ Taskcafé - {projectMembers && onMemberProfile && ( + {projectMembers && projectInvitedMembers && onMemberProfile && onInvitedMemberProfile && ( <> {projectMembers.map((member, idx) => ( ))} + {projectInvitedMembers.map((member, idx) => ( + + ))} {canInviteUser && ( { @@ -283,6 +309,9 @@ const NavBar: React.FC = ({ + history.push('/outline')}> + + diff --git a/frontend/src/shared/generated/graphql.tsx b/frontend/src/shared/generated/graphql.tsx index 501e62d..3824239 100644 --- a/frontend/src/shared/generated/graphql.tsx +++ b/frontend/src/shared/generated/graphql.tsx @@ -113,6 +113,14 @@ export type UserAccount = { member: MemberList; }; +export type InvitedUserAccount = { + __typename?: 'InvitedUserAccount'; + id: Scalars['ID']; + email: Scalars['String']; + invitedOn: Scalars['Time']; + member: MemberList; +}; + export type Team = { __typename?: 'Team'; id: Scalars['ID']; @@ -121,6 +129,12 @@ export type Team = { members: Array; }; +export type InvitedMember = { + __typename?: 'InvitedMember'; + email: Scalars['String']; + invitedOn: Scalars['Time']; +}; + export type Project = { __typename?: 'Project'; id: Scalars['ID']; @@ -129,6 +143,7 @@ export type Project = { team?: Maybe; taskGroups: Array; members: Array; + invitedMembers: Array; labels: Array; }; @@ -221,6 +236,7 @@ export type Query = { findTask: Task; findTeam: Team; findUser: UserAccount; + invitedUsers: Array; labelColors: Array; me: MePayload; notifications: Array; @@ -277,6 +293,8 @@ export type Mutation = { createTeam: Team; createTeamMember: CreateTeamMemberPayload; createUserAccount: UserAccount; + deleteInvitedProjectMember: DeleteInvitedProjectMemberPayload; + deleteInvitedUserAccount: DeleteInvitedUserAccountPayload; deleteProject: DeleteProjectPayload; deleteProjectLabel: ProjectLabel; deleteProjectMember: DeleteProjectMemberPayload; @@ -379,6 +397,16 @@ export type MutationCreateUserAccountArgs = { }; +export type MutationDeleteInvitedProjectMemberArgs = { + input: DeleteInvitedProjectMember; +}; + + +export type MutationDeleteInvitedUserAccountArgs = { + input: DeleteInvitedUserAccount; +}; + + export type MutationDeleteProjectArgs = { input: DeleteProject; }; @@ -694,6 +722,16 @@ export type UpdateProjectLabelColor = { labelColorID: Scalars['UUID']; }; +export type DeleteInvitedProjectMember = { + projectID: Scalars['UUID']; + email: Scalars['String']; +}; + +export type DeleteInvitedProjectMemberPayload = { + __typename?: 'DeleteInvitedProjectMemberPayload'; + invitedMember: InvitedMember; +}; + export type MemberInvite = { userID?: Maybe; email?: Maybe; @@ -709,6 +747,7 @@ export type InviteProjectMembersPayload = { ok: Scalars['Boolean']; projectID: Scalars['UUID']; members: Array; + invitedMembers: Array; }; export type DeleteProjectMember = { @@ -1001,6 +1040,15 @@ export type UpdateTeamMemberRolePayload = { member: Member; }; +export type DeleteInvitedUserAccount = { + invitedUserID: Scalars['UUID']; +}; + +export type DeleteInvitedUserAccountPayload = { + __typename?: 'DeleteInvitedUserAccountPayload'; + invitedUser: InvitedUserAccount; +}; + export type MemberSearchFilter = { SearchFilter: Scalars['String']; projectID?: Maybe; @@ -1231,6 +1279,9 @@ export type FindProjectQuery = ( { __typename?: 'ProfileIcon' } & Pick ) } + )>, invitedMembers: Array<( + { __typename?: 'InvitedMember' } + & Pick )>, labels: Array<( { __typename?: 'ProjectLabel' } & Pick @@ -1433,6 +1484,23 @@ export type DeleteProjectMutation = ( ) } ); +export type DeleteInvitedProjectMemberMutationVariables = { + projectID: Scalars['UUID']; + email: Scalars['String']; +}; + + +export type DeleteInvitedProjectMemberMutation = ( + { __typename?: 'Mutation' } + & { deleteInvitedProjectMember: ( + { __typename?: 'DeleteInvitedProjectMemberPayload' } + & { invitedMember: ( + { __typename?: 'InvitedMember' } + & Pick + ) } + ) } +); + export type DeleteProjectMemberMutationVariables = { projectID: Scalars['UUID']; userID: Scalars['UUID']; @@ -1462,7 +1530,10 @@ export type InviteProjectMembersMutation = ( & { inviteProjectMembers: ( { __typename?: 'InviteProjectMembersPayload' } & Pick - & { members: Array<( + & { invitedMembers: Array<( + { __typename?: 'InvitedMember' } + & Pick + )>, members: Array<( { __typename?: 'Member' } & Pick & { profileIcon: ( @@ -2156,6 +2227,22 @@ export type CreateUserAccountMutation = ( ) } ); +export type DeleteInvitedUserAccountMutationVariables = { + invitedUserID: Scalars['UUID']; +}; + + +export type DeleteInvitedUserAccountMutation = ( + { __typename?: 'Mutation' } + & { deleteInvitedUserAccount: ( + { __typename?: 'DeleteInvitedUserAccountPayload' } + & { invitedUser: ( + { __typename?: 'InvitedUserAccount' } + & Pick + ) } + ) } +); + export type DeleteUserAccountMutationVariables = { userID: Scalars['UUID']; newOwnerID?: Maybe; @@ -2237,7 +2324,10 @@ export type UsersQueryVariables = {}; export type UsersQuery = ( { __typename?: 'Query' } - & { users: Array<( + & { invitedUsers: Array<( + { __typename?: 'InvitedUserAccount' } + & Pick + )>, users: Array<( { __typename?: 'UserAccount' } & Pick & { role: ( @@ -2629,6 +2719,10 @@ export const FindProjectDocument = gql` bgColor } } + invitedMembers { + email + invitedOn + } labels { id createdDate @@ -2945,6 +3039,41 @@ export function useDeleteProjectMutation(baseOptions?: ApolloReactHooks.Mutation export type DeleteProjectMutationHookResult = ReturnType; export type DeleteProjectMutationResult = ApolloReactCommon.MutationResult; export type DeleteProjectMutationOptions = ApolloReactCommon.BaseMutationOptions; +export const DeleteInvitedProjectMemberDocument = gql` + mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) { + deleteInvitedProjectMember(input: {projectID: $projectID, email: $email}) { + invitedMember { + email + } + } +} + `; +export type DeleteInvitedProjectMemberMutationFn = ApolloReactCommon.MutationFunction; + +/** + * __useDeleteInvitedProjectMemberMutation__ + * + * To run a mutation, you first call `useDeleteInvitedProjectMemberMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteInvitedProjectMemberMutation` 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 [deleteInvitedProjectMemberMutation, { data, loading, error }] = useDeleteInvitedProjectMemberMutation({ + * variables: { + * projectID: // value for 'projectID' + * email: // value for 'email' + * }, + * }); + */ +export function useDeleteInvitedProjectMemberMutation(baseOptions?: ApolloReactHooks.MutationHookOptions) { + return ApolloReactHooks.useMutation(DeleteInvitedProjectMemberDocument, baseOptions); + } +export type DeleteInvitedProjectMemberMutationHookResult = ReturnType; +export type DeleteInvitedProjectMemberMutationResult = ApolloReactCommon.MutationResult; +export type DeleteInvitedProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions; export const DeleteProjectMemberDocument = gql` mutation deleteProjectMember($projectID: UUID!, $userID: UUID!) { deleteProjectMember(input: {projectID: $projectID, userID: $userID}) { @@ -2986,6 +3115,10 @@ export const InviteProjectMembersDocument = gql` mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) { inviteProjectMembers(input: {projectID: $projectID, members: $members}) { ok + invitedMembers { + email + invitedOn + } members { id fullName @@ -4407,6 +4540,40 @@ export function useCreateUserAccountMutation(baseOptions?: ApolloReactHooks.Muta export type CreateUserAccountMutationHookResult = ReturnType; export type CreateUserAccountMutationResult = ApolloReactCommon.MutationResult; export type CreateUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions; +export const DeleteInvitedUserAccountDocument = gql` + mutation deleteInvitedUserAccount($invitedUserID: UUID!) { + deleteInvitedUserAccount(input: {invitedUserID: $invitedUserID}) { + invitedUser { + id + } + } +} + `; +export type DeleteInvitedUserAccountMutationFn = ApolloReactCommon.MutationFunction; + +/** + * __useDeleteInvitedUserAccountMutation__ + * + * To run a mutation, you first call `useDeleteInvitedUserAccountMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteInvitedUserAccountMutation` 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 [deleteInvitedUserAccountMutation, { data, loading, error }] = useDeleteInvitedUserAccountMutation({ + * variables: { + * invitedUserID: // value for 'invitedUserID' + * }, + * }); + */ +export function useDeleteInvitedUserAccountMutation(baseOptions?: ApolloReactHooks.MutationHookOptions) { + return ApolloReactHooks.useMutation(DeleteInvitedUserAccountDocument, baseOptions); + } +export type DeleteInvitedUserAccountMutationHookResult = ReturnType; +export type DeleteInvitedUserAccountMutationResult = ApolloReactCommon.MutationResult; +export type DeleteInvitedUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions; export const DeleteUserAccountDocument = gql` mutation deleteUserAccount($userID: UUID!, $newOwnerID: UUID) { deleteUserAccount(input: {userID: $userID, newOwnerID: $newOwnerID}) { @@ -4560,6 +4727,11 @@ export type UpdateUserRoleMutationResult = ApolloReactCommon.MutationResult; export const UsersDocument = gql` query users { + invitedUsers { + id + email + invitedOn + } users { id email @@ -4621,4 +4793,4 @@ export function useUsersLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOp } export type UsersQueryHookResult = ReturnType; export type UsersLazyQueryHookResult = ReturnType; -export type UsersQueryResult = ApolloReactCommon.QueryResult; +export type UsersQueryResult = ApolloReactCommon.QueryResult; \ No newline at end of file diff --git a/frontend/src/shared/graphql/findProject.ts b/frontend/src/shared/graphql/findProject.ts index 4230ecd..caf9624 100644 --- a/frontend/src/shared/graphql/findProject.ts +++ b/frontend/src/shared/graphql/findProject.ts @@ -22,6 +22,10 @@ query findProject($projectID: UUID!) { bgColor } } + invitedMembers { + email + invitedOn + } labels { id createdDate diff --git a/frontend/src/shared/graphql/project/deleteProjectInvitedMember.ts b/frontend/src/shared/graphql/project/deleteProjectInvitedMember.ts new file mode 100644 index 0000000..ca03ade --- /dev/null +++ b/frontend/src/shared/graphql/project/deleteProjectInvitedMember.ts @@ -0,0 +1,13 @@ +import gql from 'graphql-tag'; + +export const DELETE_PROJECT_INVITED_MEMBER_MUTATION = gql` + mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) { + deleteInvitedProjectMember(input: { projectID: $projectID, email: $email }) { + invitedMember { + email + } + } + } +`; + +export default DELETE_PROJECT_INVITED_MEMBER_MUTATION; diff --git a/frontend/src/shared/graphql/project/inviteProjectMembers.ts b/frontend/src/shared/graphql/project/inviteProjectMembers.ts index e13b7cc..6f80303 100644 --- a/frontend/src/shared/graphql/project/inviteProjectMembers.ts +++ b/frontend/src/shared/graphql/project/inviteProjectMembers.ts @@ -4,6 +4,10 @@ export const INVITE_PROJECT_MEMBERS_MUTATION = gql` mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) { inviteProjectMembers(input: { projectID: $projectID, members: $members }) { ok + invitedMembers { + email + invitedOn + } members { id fullName diff --git a/frontend/src/shared/graphql/user/deleteInvitedUser.ts b/frontend/src/shared/graphql/user/deleteInvitedUser.ts new file mode 100644 index 0000000..06c4f0c --- /dev/null +++ b/frontend/src/shared/graphql/user/deleteInvitedUser.ts @@ -0,0 +1,13 @@ +import gql from 'graphql-tag'; + +export const DELETE_INVITED_USER_MUTATION = gql` + mutation deleteInvitedUserAccount($invitedUserID: UUID!) { + deleteInvitedUserAccount(input: { invitedUserID: $invitedUserID }) { + invitedUser { + id + } + } + } +`; + +export default DELETE_INVITED_USER_MUTATION; diff --git a/frontend/src/shared/graphql/users.graphqls b/frontend/src/shared/graphql/users.graphqls index 6e9190f..61c0455 100644 --- a/frontend/src/shared/graphql/users.graphqls +++ b/frontend/src/shared/graphql/users.graphqls @@ -1,4 +1,9 @@ query users { + invitedUsers { + id + email + invitedOn + } users { id email diff --git a/frontend/src/shared/icons/DotCircle.tsx b/frontend/src/shared/icons/DotCircle.tsx new file mode 100644 index 0000000..3c1985a --- /dev/null +++ b/frontend/src/shared/icons/DotCircle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const DotCircle: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default DotCircle; diff --git a/frontend/src/shared/icons/ListUnordered.tsx b/frontend/src/shared/icons/ListUnordered.tsx new file mode 100644 index 0000000..81a217b --- /dev/null +++ b/frontend/src/shared/icons/ListUnordered.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const ListUnordered: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default ListUnordered; diff --git a/frontend/src/shared/icons/index.ts b/frontend/src/shared/icons/index.ts index 5976a9a..b05d8bd 100644 --- a/frontend/src/shared/icons/index.ts +++ b/frontend/src/shared/icons/index.ts @@ -1,10 +1,12 @@ import Cross from './Cross'; import Cog from './Cog'; +import ListUnordered from './ListUnordered'; import Eye from './Eye'; import EyeSlash from './EyeSlash'; import List from './List'; import At from './At'; import Task from './Task'; +import DotCircle from './DotCircle'; import Smile from './Smile'; import Paperclip from './Paperclip'; import Calendar from './Calendar'; @@ -89,6 +91,8 @@ export { Paperclip, Share, Eye, + ListUnordered, EyeSlash, List, + DotCircle, }; diff --git a/frontend/src/taskcafe.d.ts b/frontend/src/taskcafe.d.ts index 32e9dfc..529eaf6 100644 --- a/frontend/src/taskcafe.d.ts +++ b/frontend/src/taskcafe.d.ts @@ -127,3 +127,139 @@ type ElementBounds = { }; type CardLabelVariant = 'large' | 'small'; + +type InvitedUser = { + email: string; + invitedOn: string; +}; + +type InvitedUserAccount = { + id: string; + email: string; + invitedOn: string; +}; + +type NodeDimensions = { + entry: React.RefObject; + children: React.RefObject | null; +}; + +type OutlineNode = { + id: string; + parent: string; + depth: number; + position: number; + ancestors: Array; + children: number; +}; + +type RelationshipChild = { + position: number; + id: string; + depth: number; + children: number; +}; + +type NodeRelationships = { + self: { id: string; depth: number }; + children: Array; + numberOfSubChildren: number; +}; + +type OutlineData = { + published: Map; + nodes: Map>; + relationships: Map; + dimensions: Map; +}; + +type ImpactZoneData = { + node: OutlineNode; + dimensions: NodeDimensions; +}; + +type ImpactZone = { + above: ImpactZoneData | null; + below: ImpactZoneData | null; +}; + +type ImpactData = { + zone: ImpactZone; + depth: number; +}; + +type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren'; + +type ImpactAction = { + on: 'children' | 'entry'; + position: ImpactPosition; +}; + +type ItemElement = { + id: string; + parent: null | string; + position: number; + children?: Array; +}; +type NodeDimensions = { + entry: React.RefObject; + children: React.RefObject | null; +}; + +type OutlineNode = { + id: string; + parent: string; + depth: number; + position: number; + ancestors: Array; + children: number; +}; + +type RelationshipChild = { + position: number; + id: string; + depth: number; + children: number; +}; + +type NodeRelationships = { + self: { id: string; depth: number }; + children: Array; + numberOfSubChildren: number; +}; + +type OutlineData = { + published: Map; + nodes: Map>; + relationships: Map; + dimensions: Map; +}; + +type ImpactZoneData = { + node: OutlineNode; + dimensions: NodeDimensions; +}; + +type ImpactZone = { + above: ImpactZoneData | null; + below: ImpactZoneData | null; +}; + +type ImpactData = { + zone: ImpactZone; + depth: number; +}; + +type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren'; + +type ImpactAction = { + on: 'children' | 'entry'; + position: ImpactPosition; +}; + +type ItemElement = { + id: string; + parent: null | string; + position: number; + children?: Array; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 984164e..38a58c7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -10600,6 +10600,11 @@ lodash@4.17.15, "lodash@>=3.5 <5", lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17. resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.20: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + log-symbols@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" diff --git a/internal/commands/commands.go b/internal/commands/commands.go index fe33e95..b7cf7b0 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -81,6 +81,7 @@ func Execute() { viper.SetDefault("database.name", "taskcafe") viper.SetDefault("database.user", "taskcafe") viper.SetDefault("database.password", "taskcafe_test") + viper.SetDefault("database.port", "5432") viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/") viper.SetDefault("queue.store", "memcache://localhost:11211") diff --git a/internal/commands/web.go b/internal/commands/web.go index d51da7c..5a128a9 100644 --- a/internal/commands/web.go +++ b/internal/commands/web.go @@ -32,11 +32,12 @@ func newWebCmd() *cobra.Command { log.SetFormatter(Formatter) log.SetLevel(log.InfoLevel) - connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=disable", + connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable", viper.GetString("database.user"), viper.GetString("database.password"), viper.GetString("database.host"), viper.GetString("database.name"), + viper.GetString("database.port"), ) var db *sqlx.DB var err error diff --git a/internal/db/models.go b/internal/db/models.go index 7e5662d..6202648 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -67,6 +67,12 @@ type ProjectMember struct { RoleCode string `json:"role_code"` } +type ProjectMemberInvited struct { + ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"` + ProjectID uuid.UUID `json:"project_id"` + UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"` +} + type RefreshToken struct { TokenID uuid.UUID `json:"token_id"` UserID uuid.UUID `json:"user_id"` @@ -165,3 +171,10 @@ type UserAccount struct { RoleCode string `json:"role_code"` Bio string `json:"bio"` } + +type UserAccountInvited struct { + UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"` + Email string `json:"email"` + InvitedOn time.Time `json:"invited_on"` + HasJoined bool `json:"has_joined"` +} diff --git a/internal/db/project.sql.go b/internal/db/project.sql.go index 9e8d3e5..f47d831 100644 --- a/internal/db/project.sql.go +++ b/internal/db/project.sql.go @@ -99,6 +99,15 @@ func (q *Queries) CreateTeamProject(ctx context.Context, arg CreateTeamProjectPa return i, err } +const deleteInvitedProjectMemberByID = `-- name: DeleteInvitedProjectMemberByID :exec +DELETE FROM project_member_invited WHERE project_member_invited_id = $1 +` + +func (q *Queries) DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteInvitedProjectMemberByID, projectMemberInvitedID) + return err +} + const deleteProjectByID = `-- name: DeleteProjectByID :exec DELETE FROM project WHERE project_id = $1 ` @@ -219,6 +228,41 @@ func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uui return items, nil } +const getInvitedMembersForProjectID = `-- name: GetInvitedMembersForProjectID :many +SELECT email, invited_on FROM project_member_invited AS pmi + INNER JOIN user_account_invited AS uai + ON uai.user_account_invited_id = pmi.user_account_invited_id + WHERE project_id = $1 +` + +type GetInvitedMembersForProjectIDRow struct { + Email string `json:"email"` + InvitedOn time.Time `json:"invited_on"` +} + +func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) { + rows, err := q.db.QueryContext(ctx, getInvitedMembersForProjectID, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetInvitedMembersForProjectIDRow + for rows.Next() { + var i GetInvitedMembersForProjectIDRow + if err := rows.Scan(&i.Email, &i.InvitedOn); 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 getMemberProjectIDsForUserID = `-- name: GetMemberProjectIDsForUserID :many SELECT project_id FROM project_member WHERE user_id = $1 ` @@ -296,6 +340,26 @@ func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Proj return i, err } +const getProjectMemberInvitedIDByEmail = `-- name: GetProjectMemberInvitedIDByEmail :one +SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai + inner join project_member_invited AS pmi + ON pmi.user_account_invited_id = uai.user_account_invited_id + WHERE email = $1 +` + +type GetProjectMemberInvitedIDByEmailRow struct { + Email string `json:"email"` + InvitedOn time.Time `json:"invited_on"` + ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"` +} + +func (q *Queries) GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error) { + row := q.db.QueryRowContext(ctx, getProjectMemberInvitedIDByEmail, email) + var i GetProjectMemberInvitedIDByEmailRow + err := row.Scan(&i.Email, &i.InvitedOn, &i.ProjectMemberInvitedID) + return i, err +} + const getProjectMembersForProjectID = `-- name: GetProjectMembersForProjectID :many SELECT project_member_id, project_id, user_id, added_at, role_code FROM project_member WHERE project_id = $1 ` diff --git a/internal/db/querier.go b/internal/db/querier.go index 7ffe0f0..b7c125a 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -9,6 +9,8 @@ import ( ) type Querier interface { + CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error) + CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error) CreateLabelColor(ctx context.Context, arg CreateLabelColorParams) (LabelColor, error) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error) @@ -31,6 +33,8 @@ type Querier interface { CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) DeleteExpiredTokens(ctx context.Context) error + DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error + DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) error @@ -59,6 +63,9 @@ type Querier interface { GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error) GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error) GetEntityIDForNotificationID(ctx context.Context, notificationID uuid.UUID) (uuid.UUID, error) + GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) + GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error) + GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error) GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error) GetLabelColors(ctx context.Context) ([]LabelColor, error) GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error) @@ -73,6 +80,7 @@ type Querier interface { GetProjectIDForTaskGroup(ctx context.Context, taskGroupID uuid.UUID) (uuid.UUID, error) GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error) GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error) + GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error) GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error) GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error) GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error) diff --git a/internal/db/query/project.sql b/internal/db/query/project.sql index 34e3c20..ac8b4b5 100644 --- a/internal/db/query/project.sql +++ b/internal/db/query/project.sql @@ -43,6 +43,21 @@ SELECT project_id, role_code FROM project_member WHERE user_id = $1; -- name: GetMemberProjectIDsForUserID :many SELECT project_id FROM project_member WHERE user_id = $1; +-- name: GetInvitedMembersForProjectID :many +SELECT email, invited_on FROM project_member_invited AS pmi + INNER JOIN user_account_invited AS uai + ON uai.user_account_invited_id = pmi.user_account_invited_id + WHERE project_id = $1; + +-- name: GetProjectMemberInvitedIDByEmail :one +SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai + inner join project_member_invited AS pmi + ON pmi.user_account_invited_id = uai.user_account_invited_id + WHERE email = $1; + +-- name: DeleteInvitedProjectMemberByID :exec +DELETE FROM project_member_invited WHERE project_member_invited_id = $1; + -- name: GetAllVisibleProjectsForUserID :many SELECT project.* FROM project LEFT JOIN project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1; diff --git a/internal/db/query/user_accounts.sql b/internal/db/query/user_accounts.sql index 3b5c4da..7e3cf04 100644 --- a/internal/db/query/user_accounts.sql +++ b/internal/db/query/user_accounts.sql @@ -37,3 +37,19 @@ UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING *; -- name: SetUserPassword :one UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING *; + +-- name: CreateInvitedUser :one +INSERT INTO user_account_invited (email) VALUES ($1) RETURNING *; + +-- name: GetInvitedUserByEmail :one +SELECT * FROM user_account_invited WHERE email = $1; + +-- name: CreateInvitedProjectMember :one +INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2) + RETURNING *; + +-- name: GetInvitedUserAccounts :many +SELECT * FROM user_account_invited; + +-- name: DeleteInvitedUserAccount :one +DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING *; diff --git a/internal/db/user_accounts.sql.go b/internal/db/user_accounts.sql.go index ca2fb77..c70ff42 100644 --- a/internal/db/user_accounts.sql.go +++ b/internal/db/user_accounts.sql.go @@ -11,6 +11,39 @@ import ( "github.com/google/uuid" ) +const createInvitedProjectMember = `-- name: CreateInvitedProjectMember :one +INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2) + RETURNING project_member_invited_id, project_id, user_account_invited_id +` + +type CreateInvitedProjectMemberParams struct { + ProjectID uuid.UUID `json:"project_id"` + UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"` +} + +func (q *Queries) CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error) { + row := q.db.QueryRowContext(ctx, createInvitedProjectMember, arg.ProjectID, arg.UserAccountInvitedID) + var i ProjectMemberInvited + err := row.Scan(&i.ProjectMemberInvitedID, &i.ProjectID, &i.UserAccountInvitedID) + return i, err +} + +const createInvitedUser = `-- name: CreateInvitedUser :one +INSERT INTO user_account_invited (email) VALUES ($1) RETURNING user_account_invited_id, email, invited_on, has_joined +` + +func (q *Queries) CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error) { + row := q.db.QueryRowContext(ctx, createInvitedUser, email) + var i UserAccountInvited + err := row.Scan( + &i.UserAccountInvitedID, + &i.Email, + &i.InvitedOn, + &i.HasJoined, + ) + return i, err +} + const createUserAccount = `-- name: CreateUserAccount :one INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio @@ -53,6 +86,22 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa return i, err } +const deleteInvitedUserAccount = `-- name: DeleteInvitedUserAccount :one +DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING user_account_invited_id, email, invited_on, has_joined +` + +func (q *Queries) DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error) { + row := q.db.QueryRowContext(ctx, deleteInvitedUserAccount, userAccountInvitedID) + var i UserAccountInvited + err := row.Scan( + &i.UserAccountInvitedID, + &i.Email, + &i.InvitedOn, + &i.HasJoined, + ) + return i, err +} + const deleteUserAccountByID = `-- name: DeleteUserAccountByID :exec DELETE FROM user_account WHERE user_id = $1 ` @@ -101,6 +150,54 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) return items, nil } +const getInvitedUserAccounts = `-- name: GetInvitedUserAccounts :many +SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited +` + +func (q *Queries) GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error) { + rows, err := q.db.QueryContext(ctx, getInvitedUserAccounts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserAccountInvited + for rows.Next() { + var i UserAccountInvited + if err := rows.Scan( + &i.UserAccountInvitedID, + &i.Email, + &i.InvitedOn, + &i.HasJoined, + ); 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 getInvitedUserByEmail = `-- name: GetInvitedUserByEmail :one +SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited WHERE email = $1 +` + +func (q *Queries) GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error) { + row := q.db.QueryRowContext(ctx, getInvitedUserByEmail, email) + var i UserAccountInvited + err := row.Scan( + &i.UserAccountInvitedID, + &i.Email, + &i.InvitedOn, + &i.HasJoined, + ) + return i, err +} + const getMemberData = `-- name: GetMemberData :many SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE username != 'system' diff --git a/internal/graph/generated.go b/internal/graph/generated.go index a0c1acb..31ee2f1 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -70,6 +70,14 @@ type ComplexityRoot struct { TeamMember func(childComplexity int) int } + DeleteInvitedProjectMemberPayload struct { + InvitedMember func(childComplexity int) int + } + + DeleteInvitedUserAccountPayload struct { + InvitedUser func(childComplexity int) int + } + DeleteProjectMemberPayload struct { Member func(childComplexity int) int Ok func(childComplexity int) int @@ -128,9 +136,22 @@ type ComplexityRoot struct { } InviteProjectMembersPayload struct { - Members func(childComplexity int) int - Ok func(childComplexity int) int - ProjectID func(childComplexity int) int + InvitedMembers func(childComplexity int) int + Members func(childComplexity int) int + Ok func(childComplexity int) int + ProjectID func(childComplexity int) int + } + + InvitedMember struct { + Email func(childComplexity int) int + InvitedOn func(childComplexity int) int + } + + InvitedUserAccount struct { + Email func(childComplexity int) int + ID func(childComplexity int) int + InvitedOn func(childComplexity int) int + Member func(childComplexity int) int } LabelColor struct { @@ -183,6 +204,8 @@ type ComplexityRoot struct { CreateTeam func(childComplexity int, input NewTeam) int CreateTeamMember func(childComplexity int, input CreateTeamMember) int CreateUserAccount func(childComplexity int, input NewUserAccount) int + DeleteInvitedProjectMember func(childComplexity int, input DeleteInvitedProjectMember) int + DeleteInvitedUserAccount func(childComplexity int, input DeleteInvitedUserAccount) int DeleteProject func(childComplexity int, input DeleteProject) int DeleteProjectLabel func(childComplexity int, input DeleteProjectLabel) int DeleteProjectMember func(childComplexity int, input DeleteProjectMember) int @@ -267,13 +290,14 @@ type ComplexityRoot struct { } Project struct { - CreatedAt func(childComplexity int) int - ID func(childComplexity int) int - Labels func(childComplexity int) int - Members func(childComplexity int) int - Name func(childComplexity int) int - TaskGroups func(childComplexity int) int - Team func(childComplexity int) int + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + InvitedMembers func(childComplexity int) int + Labels func(childComplexity int) int + Members func(childComplexity int) int + Name func(childComplexity int) int + TaskGroups func(childComplexity int) int + Team func(childComplexity int) int } ProjectLabel struct { @@ -293,6 +317,7 @@ type ComplexityRoot struct { FindTask func(childComplexity int, input FindTask) int FindTeam func(childComplexity int, input FindTeam) int FindUser func(childComplexity int, input FindUser) int + InvitedUsers func(childComplexity int) int LabelColors func(childComplexity int) int Me func(childComplexity int) int Notifications func(childComplexity int) int @@ -458,6 +483,7 @@ type MutationResolver interface { InviteProjectMembers(ctx context.Context, input InviteProjectMembers) (*InviteProjectMembersPayload, error) DeleteProjectMember(ctx context.Context, input DeleteProjectMember) (*DeleteProjectMemberPayload, error) UpdateProjectMemberRole(ctx context.Context, input UpdateProjectMemberRole) (*UpdateProjectMemberRolePayload, error) + DeleteInvitedProjectMember(ctx context.Context, input DeleteInvitedProjectMember) (*DeleteInvitedProjectMemberPayload, error) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) DeleteTask(ctx context.Context, input DeleteTaskInput) (*DeleteTaskPayload, error) UpdateTaskDescription(ctx context.Context, input UpdateTaskDescriptionInput) (*db.Task, error) @@ -494,6 +520,7 @@ type MutationResolver interface { CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*db.RefreshToken, error) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) + DeleteInvitedUserAccount(ctx context.Context, input DeleteInvitedUserAccount) (*DeleteInvitedUserAccountPayload, error) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) ClearProfileAvatar(ctx context.Context) (*db.UserAccount, error) UpdateUserPassword(ctx context.Context, input UpdateUserPassword) (*UpdateUserPasswordPayload, error) @@ -517,6 +544,7 @@ type ProjectResolver interface { Team(ctx context.Context, obj *db.Project) (*db.Team, error) TaskGroups(ctx context.Context, obj *db.Project) ([]db.TaskGroup, error) Members(ctx context.Context, obj *db.Project) ([]Member, error) + InvitedMembers(ctx context.Context, obj *db.Project) ([]InvitedMember, error) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) } type ProjectLabelResolver interface { @@ -528,6 +556,7 @@ type ProjectLabelResolver interface { type QueryResolver interface { Organizations(ctx context.Context) ([]db.Organization, error) Users(ctx context.Context) ([]db.UserAccount, error) + InvitedUsers(ctx context.Context) ([]InvitedUserAccount, error) FindUser(ctx context.Context, input FindUser) (*db.UserAccount, error) FindProject(ctx context.Context, input FindProject) (*db.Project, error) FindTask(ctx context.Context, input FindTask) (*db.Task, error) @@ -634,6 +663,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.CreateTeamMemberPayload.TeamMember(childComplexity), true + case "DeleteInvitedProjectMemberPayload.invitedMember": + if e.complexity.DeleteInvitedProjectMemberPayload.InvitedMember == nil { + break + } + + return e.complexity.DeleteInvitedProjectMemberPayload.InvitedMember(childComplexity), true + + case "DeleteInvitedUserAccountPayload.invitedUser": + if e.complexity.DeleteInvitedUserAccountPayload.InvitedUser == nil { + break + } + + return e.complexity.DeleteInvitedUserAccountPayload.InvitedUser(childComplexity), true + case "DeleteProjectMemberPayload.member": if e.complexity.DeleteProjectMemberPayload.Member == nil { break @@ -802,6 +845,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.DuplicateTaskGroupPayload.TaskGroup(childComplexity), true + case "InviteProjectMembersPayload.invitedMembers": + if e.complexity.InviteProjectMembersPayload.InvitedMembers == nil { + break + } + + return e.complexity.InviteProjectMembersPayload.InvitedMembers(childComplexity), true + case "InviteProjectMembersPayload.members": if e.complexity.InviteProjectMembersPayload.Members == nil { break @@ -823,6 +873,48 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.InviteProjectMembersPayload.ProjectID(childComplexity), true + case "InvitedMember.email": + if e.complexity.InvitedMember.Email == nil { + break + } + + return e.complexity.InvitedMember.Email(childComplexity), true + + case "InvitedMember.invitedOn": + if e.complexity.InvitedMember.InvitedOn == nil { + break + } + + return e.complexity.InvitedMember.InvitedOn(childComplexity), true + + case "InvitedUserAccount.email": + if e.complexity.InvitedUserAccount.Email == nil { + break + } + + return e.complexity.InvitedUserAccount.Email(childComplexity), true + + case "InvitedUserAccount.id": + if e.complexity.InvitedUserAccount.ID == nil { + break + } + + return e.complexity.InvitedUserAccount.ID(childComplexity), true + + case "InvitedUserAccount.invitedOn": + if e.complexity.InvitedUserAccount.InvitedOn == nil { + break + } + + return e.complexity.InvitedUserAccount.InvitedOn(childComplexity), true + + case "InvitedUserAccount.member": + if e.complexity.InvitedUserAccount.Member == nil { + break + } + + return e.complexity.InvitedUserAccount.Member(childComplexity), true + case "LabelColor.colorHex": if e.complexity.LabelColor.ColorHex == nil { break @@ -1121,6 +1213,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CreateUserAccount(childComplexity, args["input"].(NewUserAccount)), true + case "Mutation.deleteInvitedProjectMember": + if e.complexity.Mutation.DeleteInvitedProjectMember == nil { + break + } + + args, err := ec.field_Mutation_deleteInvitedProjectMember_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteInvitedProjectMember(childComplexity, args["input"].(DeleteInvitedProjectMember)), true + + case "Mutation.deleteInvitedUserAccount": + if e.complexity.Mutation.DeleteInvitedUserAccount == nil { + break + } + + args, err := ec.field_Mutation_deleteInvitedUserAccount_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteInvitedUserAccount(childComplexity, args["input"].(DeleteInvitedUserAccount)), true + case "Mutation.deleteProject": if e.complexity.Mutation.DeleteProject == nil { break @@ -1750,6 +1866,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Project.ID(childComplexity), true + case "Project.invitedMembers": + if e.complexity.Project.InvitedMembers == nil { + break + } + + return e.complexity.Project.InvitedMembers(childComplexity), true + case "Project.labels": if e.complexity.Project.Labels == nil { break @@ -1875,6 +1998,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.FindUser(childComplexity, args["input"].(FindUser)), true + case "Query.invitedUsers": + if e.complexity.Query.InvitedUsers == nil { + break + } + + return e.complexity.Query.InvitedUsers(childComplexity), true + case "Query.labelColors": if e.complexity.Query.LabelColors == nil { break @@ -2625,6 +2755,13 @@ type UserAccount { member: MemberList! } +type InvitedUserAccount { + id: ID! + email: String! + invitedOn: Time! + member: MemberList! +} + type Team { id: ID! createdAt: Time! @@ -2632,6 +2769,12 @@ type Team { members: [Member!]! } + +type InvitedMember { + email: String! + invitedOn: Time! +} + type Project { id: ID! createdAt: Time! @@ -2639,6 +2782,7 @@ type Project { team: Team taskGroups: [TaskGroup!]! members: [Member!]! + invitedMembers: [InvitedMember!]! labels: [ProjectLabel!]! } @@ -2723,6 +2867,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) type Query { organizations: [Organization!]! users: [UserAccount!]! + invitedUsers: [InvitedUserAccount!]! findUser(input: FindUser!): UserAccount! findProject(input: FindProject!): Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT) @@ -2883,6 +3028,18 @@ extend type Mutation { DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) updateProjectMemberRole(input: UpdateProjectMemberRole!): UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) + +deleteInvitedProjectMember(input: DeleteInvitedProjectMember!): + DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) +} + +input DeleteInvitedProjectMember { + projectID: UUID! + email: String! +} + +type DeleteInvitedProjectMemberPayload { + invitedMember: InvitedMember! } input MemberInvite { @@ -2899,6 +3056,7 @@ type InviteProjectMembersPayload { ok: Boolean! projectID: UUID! members: [Member!]! + invitedMembers: [InvitedMember!]! } input DeleteProjectMember { @@ -3268,6 +3426,8 @@ extend type Mutation { UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG) deleteUserAccount(input: DeleteUserAccount!): DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) + deleteInvitedUserAccount(input: DeleteInvitedUserAccount!): + DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) logoutUser(input: LogoutUser!): Boolean! clearProfileAvatar: UserAccount! @@ -3283,6 +3443,14 @@ extend type Query { searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! } +input DeleteInvitedUserAccount { + invitedUserID: UUID! +} + +type DeleteInvitedUserAccountPayload { + invitedUser: InvitedUserAccount! +} + input MemberSearchFilter { SearchFilter: String! projectID: UUID @@ -3559,6 +3727,34 @@ func (ec *executionContext) field_Mutation_createUserAccount_args(ctx context.Co return args, nil } +func (ec *executionContext) field_Mutation_deleteInvitedProjectMember_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 DeleteInvitedProjectMember + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNDeleteInvitedProjectMember2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedProjectMember(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_deleteInvitedUserAccount_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 DeleteInvitedUserAccount + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNDeleteInvitedUserAccount2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedUserAccount(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteProjectLabel_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -4375,6 +4571,74 @@ func (ec *executionContext) _CreateTeamMemberPayload_teamMember(ctx context.Cont return ec.marshalNMember2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMember(ctx, field.Selections, res) } +func (ec *executionContext) _DeleteInvitedProjectMemberPayload_invitedMember(ctx context.Context, field graphql.CollectedField, obj *DeleteInvitedProjectMemberPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "DeleteInvitedProjectMemberPayload", + Field: field, + Args: nil, + IsMethod: 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.InvitedMember, 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.(*InvitedMember) + fc.Result = res + return ec.marshalNInvitedMember2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMember(ctx, field.Selections, res) +} + +func (ec *executionContext) _DeleteInvitedUserAccountPayload_invitedUser(ctx context.Context, field graphql.CollectedField, obj *DeleteInvitedUserAccountPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "DeleteInvitedUserAccountPayload", + Field: field, + Args: nil, + IsMethod: 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.InvitedUser, 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.(*InvitedUserAccount) + fc.Result = res + return ec.marshalNInvitedUserAccount2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccount(ctx, field.Selections, res) +} + func (ec *executionContext) _DeleteProjectMemberPayload_ok(ctx context.Context, field graphql.CollectedField, obj *DeleteProjectMemberPayload) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -5293,6 +5557,244 @@ func (ec *executionContext) _InviteProjectMembersPayload_members(ctx context.Con return ec.marshalNMember2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _InviteProjectMembersPayload_invitedMembers(ctx context.Context, field graphql.CollectedField, obj *InviteProjectMembersPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InviteProjectMembersPayload", + Field: field, + Args: nil, + IsMethod: 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.InvitedMembers, 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.([]InvitedMember) + fc.Result = res + return ec.marshalNInvitedMember2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMemberᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedMember_email(ctx context.Context, field graphql.CollectedField, obj *InvitedMember) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedMember", + Field: field, + Args: nil, + IsMethod: 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.Email, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedMember_invitedOn(ctx context.Context, field graphql.CollectedField, obj *InvitedMember) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedMember", + Field: field, + Args: nil, + IsMethod: 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.InvitedOn, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedUserAccount_id(ctx context.Context, field graphql.CollectedField, obj *InvitedUserAccount) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedUserAccount", + Field: field, + Args: nil, + IsMethod: 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.ID, 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.(uuid.UUID) + fc.Result = res + return ec.marshalNID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedUserAccount_email(ctx context.Context, field graphql.CollectedField, obj *InvitedUserAccount) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedUserAccount", + Field: field, + Args: nil, + IsMethod: 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.Email, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedUserAccount_invitedOn(ctx context.Context, field graphql.CollectedField, obj *InvitedUserAccount) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedUserAccount", + Field: field, + Args: nil, + IsMethod: 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.InvitedOn, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) _InvitedUserAccount_member(ctx context.Context, field graphql.CollectedField, obj *InvitedUserAccount) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "InvitedUserAccount", + Field: field, + Args: nil, + IsMethod: 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.Member, 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.(*MemberList) + fc.Result = res + return ec.marshalNMemberList2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberList(ctx, field.Selections, res) +} + func (ec *executionContext) _LabelColor_id(ctx context.Context, field graphql.CollectedField, obj *db.LabelColor) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6810,6 +7312,79 @@ func (ec *executionContext) _Mutation_updateProjectMemberRole(ctx context.Contex return ec.marshalNUpdateProjectMemberRolePayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐUpdateProjectMemberRolePayload(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_deleteInvitedProjectMember(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, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_deleteInvitedProjectMember_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeleteInvitedProjectMember(rctx, args["input"].(DeleteInvitedProjectMember)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + roles, err := ec.unmarshalNRoleLevel2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐRoleLevelᚄ(ctx, []interface{}{"ADMIN"}) + if err != nil { + return nil, err + } + level, err := ec.unmarshalNActionLevel2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐActionLevel(ctx, "PROJECT") + if err != nil { + return nil, err + } + typeArg, err := ec.unmarshalNObjectType2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐObjectType(ctx, "PROJECT") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, roles, level, typeArg) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, err + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*DeleteInvitedProjectMemberPayload); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/jordanknott/taskcafe/internal/graph.DeleteInvitedProjectMemberPayload`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*DeleteInvitedProjectMemberPayload) + fc.Result = res + return ec.marshalNDeleteInvitedProjectMemberPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedProjectMemberPayload(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_createTask(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -9406,6 +9981,79 @@ func (ec *executionContext) _Mutation_deleteUserAccount(ctx context.Context, fie return ec.marshalNDeleteUserAccountPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteUserAccountPayload(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_deleteInvitedUserAccount(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, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_deleteInvitedUserAccount_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeleteInvitedUserAccount(rctx, args["input"].(DeleteInvitedUserAccount)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + roles, err := ec.unmarshalNRoleLevel2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐRoleLevelᚄ(ctx, []interface{}{"ADMIN"}) + if err != nil { + return nil, err + } + level, err := ec.unmarshalNActionLevel2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐActionLevel(ctx, "ORG") + if err != nil { + return nil, err + } + typeArg, err := ec.unmarshalNObjectType2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐObjectType(ctx, "ORG") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, roles, level, typeArg) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, err + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*DeleteInvitedUserAccountPayload); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/jordanknott/taskcafe/internal/graph.DeleteInvitedUserAccountPayload`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*DeleteInvitedUserAccountPayload) + fc.Result = res + return ec.marshalNDeleteInvitedUserAccountPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedUserAccountPayload(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_logoutUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -10574,6 +11222,40 @@ func (ec *executionContext) _Project_members(ctx context.Context, field graphql. return ec.marshalNMember2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Project_invitedMembers(ctx context.Context, field graphql.CollectedField, obj *db.Project) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Project", + Field: field, + Args: nil, + IsMethod: 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.Project().InvitedMembers(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]InvitedMember) + fc.Result = res + return ec.marshalNInvitedMember2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMemberᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Project_labels(ctx context.Context, field graphql.CollectedField, obj *db.Project) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -10877,6 +11559,40 @@ func (ec *executionContext) _Query_users(ctx context.Context, field graphql.Coll return ec.marshalNUserAccount2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐUserAccountᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Query_invitedUsers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().InvitedUsers(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.([]InvitedUserAccount) + fc.Result = res + return ec.marshalNInvitedUserAccount2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccountᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_findUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -15119,6 +15835,48 @@ func (ec *executionContext) unmarshalInputCreateTeamMember(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputDeleteInvitedProjectMember(ctx context.Context, obj interface{}) (DeleteInvitedProjectMember, error) { + var it DeleteInvitedProjectMember + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "projectID": + var err error + it.ProjectID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + case "email": + var err error + it.Email, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputDeleteInvitedUserAccount(ctx context.Context, obj interface{}) (DeleteInvitedUserAccount, error) { + var it DeleteInvitedUserAccount + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "invitedUserID": + var err error + it.InvitedUserID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputDeleteProject(ctx context.Context, obj interface{}) (DeleteProject, error) { var it DeleteProject var asMap = obj.(map[string]interface{}) @@ -16499,6 +17257,60 @@ func (ec *executionContext) _CreateTeamMemberPayload(ctx context.Context, sel as return out } +var deleteInvitedProjectMemberPayloadImplementors = []string{"DeleteInvitedProjectMemberPayload"} + +func (ec *executionContext) _DeleteInvitedProjectMemberPayload(ctx context.Context, sel ast.SelectionSet, obj *DeleteInvitedProjectMemberPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deleteInvitedProjectMemberPayloadImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteInvitedProjectMemberPayload") + case "invitedMember": + out.Values[i] = ec._DeleteInvitedProjectMemberPayload_invitedMember(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 deleteInvitedUserAccountPayloadImplementors = []string{"DeleteInvitedUserAccountPayload"} + +func (ec *executionContext) _DeleteInvitedUserAccountPayload(ctx context.Context, sel ast.SelectionSet, obj *DeleteInvitedUserAccountPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deleteInvitedUserAccountPayloadImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteInvitedUserAccountPayload") + case "invitedUser": + out.Values[i] = ec._DeleteInvitedUserAccountPayload_invitedUser(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 deleteProjectMemberPayloadImplementors = []string{"DeleteProjectMemberPayload"} func (ec *executionContext) _DeleteProjectMemberPayload(ctx context.Context, sel ast.SelectionSet, obj *DeleteProjectMemberPayload) graphql.Marshaler { @@ -16887,6 +17699,85 @@ func (ec *executionContext) _InviteProjectMembersPayload(ctx context.Context, se if out.Values[i] == graphql.Null { invalids++ } + case "invitedMembers": + out.Values[i] = ec._InviteProjectMembersPayload_invitedMembers(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 invitedMemberImplementors = []string{"InvitedMember"} + +func (ec *executionContext) _InvitedMember(ctx context.Context, sel ast.SelectionSet, obj *InvitedMember) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, invitedMemberImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("InvitedMember") + case "email": + out.Values[i] = ec._InvitedMember_email(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "invitedOn": + out.Values[i] = ec._InvitedMember_invitedOn(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 invitedUserAccountImplementors = []string{"InvitedUserAccount"} + +func (ec *executionContext) _InvitedUserAccount(ctx context.Context, sel ast.SelectionSet, obj *InvitedUserAccount) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, invitedUserAccountImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("InvitedUserAccount") + case "id": + out.Values[i] = ec._InvitedUserAccount_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "email": + out.Values[i] = ec._InvitedUserAccount_email(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "invitedOn": + out.Values[i] = ec._InvitedUserAccount_invitedOn(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "member": + out.Values[i] = ec._InvitedUserAccount_member(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -17192,6 +18083,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "deleteInvitedProjectMember": + out.Values[i] = ec._Mutation_deleteInvitedProjectMember(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "createTask": out.Values[i] = ec._Mutation_createTask(ctx, field) if out.Values[i] == graphql.Null { @@ -17372,6 +18268,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "deleteInvitedUserAccount": + out.Values[i] = ec._Mutation_deleteInvitedUserAccount(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "logoutUser": out.Values[i] = ec._Mutation_logoutUser(ctx, field) if out.Values[i] == graphql.Null { @@ -17786,6 +18687,20 @@ func (ec *executionContext) _Project(ctx context.Context, sel ast.SelectionSet, } return res }) + case "invitedMembers": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Project_invitedMembers(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "labels": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -17952,6 +18867,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "invitedUsers": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_invitedUsers(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "findUser": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -19492,6 +20421,42 @@ func (ec *executionContext) marshalNCreateTeamMemberPayload2ᚖgithubᚗcomᚋjo return ec._CreateTeamMemberPayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNDeleteInvitedProjectMember2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedProjectMember(ctx context.Context, v interface{}) (DeleteInvitedProjectMember, error) { + return ec.unmarshalInputDeleteInvitedProjectMember(ctx, v) +} + +func (ec *executionContext) marshalNDeleteInvitedProjectMemberPayload2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedProjectMemberPayload(ctx context.Context, sel ast.SelectionSet, v DeleteInvitedProjectMemberPayload) graphql.Marshaler { + return ec._DeleteInvitedProjectMemberPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeleteInvitedProjectMemberPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedProjectMemberPayload(ctx context.Context, sel ast.SelectionSet, v *DeleteInvitedProjectMemberPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._DeleteInvitedProjectMemberPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNDeleteInvitedUserAccount2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedUserAccount(ctx context.Context, v interface{}) (DeleteInvitedUserAccount, error) { + return ec.unmarshalInputDeleteInvitedUserAccount(ctx, v) +} + +func (ec *executionContext) marshalNDeleteInvitedUserAccountPayload2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedUserAccountPayload(ctx context.Context, sel ast.SelectionSet, v DeleteInvitedUserAccountPayload) graphql.Marshaler { + return ec._DeleteInvitedUserAccountPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeleteInvitedUserAccountPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteInvitedUserAccountPayload(ctx context.Context, sel ast.SelectionSet, v *DeleteInvitedUserAccountPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._DeleteInvitedUserAccountPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNDeleteProject2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐDeleteProject(ctx context.Context, v interface{}) (DeleteProject, error) { return ec.unmarshalInputDeleteProject(ctx, v) } @@ -19779,6 +20744,108 @@ func (ec *executionContext) marshalNInviteProjectMembersPayload2ᚖgithubᚗcom return ec._InviteProjectMembersPayload(ctx, sel, v) } +func (ec *executionContext) marshalNInvitedMember2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMember(ctx context.Context, sel ast.SelectionSet, v InvitedMember) graphql.Marshaler { + return ec._InvitedMember(ctx, sel, &v) +} + +func (ec *executionContext) marshalNInvitedMember2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMemberᚄ(ctx context.Context, sel ast.SelectionSet, v []InvitedMember) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNInvitedMember2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMember(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNInvitedMember2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedMember(ctx context.Context, sel ast.SelectionSet, v *InvitedMember) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._InvitedMember(ctx, sel, v) +} + +func (ec *executionContext) marshalNInvitedUserAccount2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccount(ctx context.Context, sel ast.SelectionSet, v InvitedUserAccount) graphql.Marshaler { + return ec._InvitedUserAccount(ctx, sel, &v) +} + +func (ec *executionContext) marshalNInvitedUserAccount2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccountᚄ(ctx context.Context, sel ast.SelectionSet, v []InvitedUserAccount) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNInvitedUserAccount2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccount(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNInvitedUserAccount2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐInvitedUserAccount(ctx context.Context, sel ast.SelectionSet, v *InvitedUserAccount) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._InvitedUserAccount(ctx, sel, v) +} + func (ec *executionContext) marshalNLabelColor2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐLabelColor(ctx context.Context, sel ast.SelectionSet, v db.LabelColor) graphql.Marshaler { return ec._LabelColor(ctx, sel, &v) } diff --git a/internal/graph/models_gen.go b/internal/graph/models_gen.go index 2a293ce..502a445 100644 --- a/internal/graph/models_gen.go +++ b/internal/graph/models_gen.go @@ -49,6 +49,23 @@ type CreateTeamMemberPayload struct { TeamMember *Member `json:"teamMember"` } +type DeleteInvitedProjectMember struct { + ProjectID uuid.UUID `json:"projectID"` + Email string `json:"email"` +} + +type DeleteInvitedProjectMemberPayload struct { + InvitedMember *InvitedMember `json:"invitedMember"` +} + +type DeleteInvitedUserAccount struct { + InvitedUserID uuid.UUID `json:"invitedUserID"` +} + +type DeleteInvitedUserAccountPayload struct { + InvitedUser *InvitedUserAccount `json:"invitedUser"` +} + type DeleteProject struct { ProjectID uuid.UUID `json:"projectID"` } @@ -183,9 +200,22 @@ type InviteProjectMembers struct { } type InviteProjectMembersPayload struct { - Ok bool `json:"ok"` - ProjectID uuid.UUID `json:"projectID"` - Members []Member `json:"members"` + Ok bool `json:"ok"` + ProjectID uuid.UUID `json:"projectID"` + Members []Member `json:"members"` + InvitedMembers []InvitedMember `json:"invitedMembers"` +} + +type InvitedMember struct { + Email string `json:"email"` + InvitedOn time.Time `json:"invitedOn"` +} + +type InvitedUserAccount struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + InvitedOn time.Time `json:"invitedOn"` + Member *MemberList `json:"member"` } type LogoutUser struct { diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index ddd5757..359cc9a 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -86,6 +86,13 @@ type UserAccount { member: MemberList! } +type InvitedUserAccount { + id: ID! + email: String! + invitedOn: Time! + member: MemberList! +} + type Team { id: ID! createdAt: Time! @@ -93,6 +100,12 @@ type Team { members: [Member!]! } + +type InvitedMember { + email: String! + invitedOn: Time! +} + type Project { id: ID! createdAt: Time! @@ -100,6 +113,7 @@ type Project { team: Team taskGroups: [TaskGroup!]! members: [Member!]! + invitedMembers: [InvitedMember!]! labels: [ProjectLabel!]! } @@ -184,6 +198,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) type Query { organizations: [Organization!]! users: [UserAccount!]! + invitedUsers: [InvitedUserAccount!]! findUser(input: FindUser!): UserAccount! findProject(input: FindProject!): Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT) @@ -344,6 +359,18 @@ extend type Mutation { DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) updateProjectMemberRole(input: UpdateProjectMemberRole!): UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) + +deleteInvitedProjectMember(input: DeleteInvitedProjectMember!): + DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) +} + +input DeleteInvitedProjectMember { + projectID: UUID! + email: String! +} + +type DeleteInvitedProjectMemberPayload { + invitedMember: InvitedMember! } input MemberInvite { @@ -360,6 +387,7 @@ type InviteProjectMembersPayload { ok: Boolean! projectID: UUID! members: [Member!]! + invitedMembers: [InvitedMember!]! } input DeleteProjectMember { @@ -729,6 +757,8 @@ extend type Mutation { UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG) deleteUserAccount(input: DeleteUserAccount!): DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) + deleteInvitedUserAccount(input: DeleteInvitedUserAccount!): + DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) logoutUser(input: LogoutUser!): Boolean! clearProfileAvatar: UserAccount! @@ -744,6 +774,14 @@ extend type Query { searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! } +input DeleteInvitedUserAccount { + invitedUserID: UUID! +} + +type DeleteInvitedUserAccountPayload { + invitedUser: InvitedUserAccount! +} + input MemberSearchFilter { SearchFilter: String! projectID: UUID @@ -813,3 +851,4 @@ type DeleteUserAccountPayload { ok: Boolean! userAccount: UserAccount! } + diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index f4e26f6..d55dc95 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -127,6 +127,7 @@ func (r *mutationResolver) UpdateProjectLabelColor(ctx context.Context, input Up func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input InviteProjectMembers) (*InviteProjectMembersPayload, error) { members := []Member{} + invitedMembers := []InvitedMember{} for _, invitedMember := range input.Members { if invitedMember.Email != nil && invitedMember.UserID != nil { return &InviteProjectMembersPayload{Ok: false}, &gqlerror.Error{ @@ -144,6 +145,7 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit } } if invitedMember.UserID != nil { + // Invite by user ID addedAt := time.Now().UTC() _, err := r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: input.ProjectID, UserID: *invitedMember.UserID, AddedAt: addedAt, RoleCode: "member"}) if err != nil { @@ -171,9 +173,39 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit ProfileIcon: profileIcon, Role: &db.Role{Code: role.Code, Name: role.Name}, }) + } else { + // Invite by email + + // if invited user does not exist, create entry + invitedUser, err := r.Repository.GetInvitedUserByEmail(ctx, *invitedMember.Email) + now := time.Now().UTC() + if err != nil { + if err == sql.ErrNoRows { + invitedUser, err = r.Repository.CreateInvitedUser(ctx, *invitedMember.Email) + if err != nil { + return &InviteProjectMembersPayload{Ok: false}, err + } + } else { + return &InviteProjectMembersPayload{Ok: false}, err + } + } + + _, err = r.Repository.CreateInvitedProjectMember(ctx, db.CreateInvitedProjectMemberParams{ + ProjectID: input.ProjectID, + UserAccountInvitedID: invitedUser.UserAccountInvitedID, + }) + if err != nil { + return &InviteProjectMembersPayload{Ok: false}, err + } + logger.New(ctx).Info("adding invited member") + invitedMembers = append(invitedMembers, InvitedMember{Email: *invitedMember.Email, InvitedOn: now}) + // send out invitation + // add project invite entry + // send out notification? + } } - return &InviteProjectMembersPayload{Ok: false, ProjectID: input.ProjectID, Members: members}, nil + return &InviteProjectMembersPayload{Ok: false, ProjectID: input.ProjectID, Members: members, InvitedMembers: invitedMembers}, nil } func (r *mutationResolver) DeleteProjectMember(ctx context.Context, input DeleteProjectMember) (*DeleteProjectMemberPayload, error) { @@ -233,6 +265,20 @@ func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input Up return &UpdateProjectMemberRolePayload{Ok: true, Member: &member}, err } +func (r *mutationResolver) DeleteInvitedProjectMember(ctx context.Context, input DeleteInvitedProjectMember) (*DeleteInvitedProjectMemberPayload, error) { + member, err := r.Repository.GetProjectMemberInvitedIDByEmail(ctx, input.Email) + if err != nil { + return &DeleteInvitedProjectMemberPayload{}, err + } + err = r.Repository.DeleteInvitedProjectMemberByID(ctx, member.ProjectMemberInvitedID) + if err != nil { + return &DeleteInvitedProjectMemberPayload{}, err + } + return &DeleteInvitedProjectMemberPayload{ + InvitedMember: &InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn}, + }, nil +} + func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) { createdAt := time.Now().UTC() logger.New(ctx).WithFields(log.Fields{"positon": input.Position, "taskGroupID": input.TaskGroupID}).Info("creating task") @@ -809,6 +855,20 @@ func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUs return &DeleteUserAccountPayload{UserAccount: &user, Ok: true}, nil } +func (r *mutationResolver) DeleteInvitedUserAccount(ctx context.Context, input DeleteInvitedUserAccount) (*DeleteInvitedUserAccountPayload, error) { + user, err := r.Repository.DeleteInvitedUserAccount(ctx, input.InvitedUserID) + if err != nil { + return &DeleteInvitedUserAccountPayload{}, err + } + return &DeleteInvitedUserAccountPayload{ + InvitedUser: &InvitedUserAccount{ + Email: user.Email, + ID: user.UserAccountInvitedID, + InvitedOn: user.InvitedOn, + }, + }, err +} + func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) { err := r.Repository.DeleteRefreshTokenByUserID(ctx, input.UserID) return true, err @@ -979,6 +1039,18 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe return members, nil } +func (r *projectResolver) InvitedMembers(ctx context.Context, obj *db.Project) ([]InvitedMember, error) { + members, err := r.Repository.GetInvitedMembersForProjectID(ctx, obj.ProjectID) + if err != nil && err == sql.ErrNoRows { + return []InvitedMember{}, nil + } + invited := []InvitedMember{} + for _, member := range members { + invited = append(invited, InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn}) + } + return invited, err +} + func (r *projectResolver) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) { labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID) return labels, err @@ -1012,6 +1084,25 @@ func (r *queryResolver) Users(ctx context.Context) ([]db.UserAccount, error) { return r.Repository.GetAllUserAccounts(ctx) } +func (r *queryResolver) InvitedUsers(ctx context.Context) ([]InvitedUserAccount, error) { + invitedMembers, err := r.Repository.GetInvitedUserAccounts(ctx) + if err != nil { + if err == sql.ErrNoRows { + return []InvitedUserAccount{}, nil + } + return []InvitedUserAccount{}, err + } + members := []InvitedUserAccount{} + for _, invitedMember := range invitedMembers { + members = append(members, InvitedUserAccount{ + ID: invitedMember.UserAccountInvitedID, + Email: invitedMember.Email, + InvitedOn: invitedMember.InvitedOn, + }) + } + return members, nil +} + func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserAccount, error) { account, err := r.Repository.GetUserAccountByID(ctx, input.UserID) if err == sql.ErrNoRows { diff --git a/internal/graph/schema/_models.gql b/internal/graph/schema/_models.gql index 53f3201..de7d599 100644 --- a/internal/graph/schema/_models.gql +++ b/internal/graph/schema/_models.gql @@ -86,6 +86,13 @@ type UserAccount { member: MemberList! } +type InvitedUserAccount { + id: ID! + email: String! + invitedOn: Time! + member: MemberList! +} + type Team { id: ID! createdAt: Time! @@ -93,6 +100,12 @@ type Team { members: [Member!]! } + +type InvitedMember { + email: String! + invitedOn: Time! +} + type Project { id: ID! createdAt: Time! @@ -100,6 +113,7 @@ type Project { team: Team taskGroups: [TaskGroup!]! members: [Member!]! + invitedMembers: [InvitedMember!]! labels: [ProjectLabel!]! } diff --git a/internal/graph/schema/_root.gql b/internal/graph/schema/_root.gql index 55f1875..1f20627 100644 --- a/internal/graph/schema/_root.gql +++ b/internal/graph/schema/_root.gql @@ -24,6 +24,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) type Query { organizations: [Organization!]! users: [UserAccount!]! + invitedUsers: [InvitedUserAccount!]! findUser(input: FindUser!): UserAccount! findProject(input: FindProject!): Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT) diff --git a/internal/graph/schema/project_member.gql b/internal/graph/schema/project_member.gql index 29df16b..4c2edb8 100644 --- a/internal/graph/schema/project_member.gql +++ b/internal/graph/schema/project_member.gql @@ -5,6 +5,18 @@ extend type Mutation { DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) updateProjectMemberRole(input: UpdateProjectMemberRole!): UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) + +deleteInvitedProjectMember(input: DeleteInvitedProjectMember!): + DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) +} + +input DeleteInvitedProjectMember { + projectID: UUID! + email: String! +} + +type DeleteInvitedProjectMemberPayload { + invitedMember: InvitedMember! } input MemberInvite { @@ -21,6 +33,7 @@ type InviteProjectMembersPayload { ok: Boolean! projectID: UUID! members: [Member!]! + invitedMembers: [InvitedMember!]! } input DeleteProjectMember { diff --git a/internal/graph/schema/user.gql b/internal/graph/schema/user.gql index 16c5103..eabdc65 100644 --- a/internal/graph/schema/user.gql +++ b/internal/graph/schema/user.gql @@ -4,6 +4,8 @@ extend type Mutation { UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG) deleteUserAccount(input: DeleteUserAccount!): DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) + deleteInvitedUserAccount(input: DeleteInvitedUserAccount!): + DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) logoutUser(input: LogoutUser!): Boolean! clearProfileAvatar: UserAccount! @@ -19,6 +21,14 @@ extend type Query { searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! } +input DeleteInvitedUserAccount { + invitedUserID: UUID! +} + +type DeleteInvitedUserAccountPayload { + invitedUser: InvitedUserAccount! +} + input MemberSearchFilter { SearchFilter: String! projectID: UUID diff --git a/migrations/0056_add-user_account_invited-table.up.sql b/migrations/0056_add-user_account_invited-table.up.sql new file mode 100644 index 0000000..fc3c315 --- /dev/null +++ b/migrations/0056_add-user_account_invited-table.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE user_account_invited ( + user_account_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + invited_on timestamptz NOT NULL DEFAULT NOW(), + has_joined boolean NOT NULL DEFAULT false +); diff --git a/migrations/0057_add-project_member_invited-table.up.sql b/migrations/0057_add-project_member_invited-table.up.sql new file mode 100644 index 0000000..0e5ade7 --- /dev/null +++ b/migrations/0057_add-project_member_invited-table.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE project_member_invited ( + project_member_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id uuid NOT NULL + REFERENCES project(project_id) ON DELETE CASCADE, + user_account_invited_id uuid NOT NULL + REFERENCES user_account_invited(user_account_invited_id) ON DELETE CASCADE +);