diff --git a/.husky/post-checkout b/.husky/post-checkout index c96ec67b..c53fd937 100755 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1,30 +1,8 @@ #!/bin/sh FILE="$(dirname "$0")/_/husky.sh" - -DIFF=$(git diff --numstat $1 -- server/package.json) -DIFF2=$(git diff --numstat $1 -- client/package.json) - -# Check if the diff is empty -if [ -z "$DIFF" ]; then - echo 'Server Package.json unchanged' -else - if [ ! -f "$FILE" ]; then - cd server && npm ci --legacy-peer-deps && npm run prepare && cd .. - else - . "$FILE" - cd server && npm ci --legacy-peer-deps && cd .. - fi - +if [ ! -f "$FILE" ]; then + cd server && npm ci --legacy-peer-deps && npm run prepare && cd ../client && npm ci --legacy-peer-deps && npm run prepare && cd .. fi -if [ -z "$DIFF2" ]; then - echo ' Client Package.json unchanged' -else - if [ ! -f "$FILE" ]; then - cd client && npm ci --legacy-peer-deps && npm run prepare && cd .. - else - . "$FILE" - cd client && npm ci --legacy-peer-deps && cd .. - fi - -fi +. "$FILE" +cd server && npm ci --legacy-peer-deps && cd ../client && npm ci --legacy-peer-deps && cd .. diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..5d049a87 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +cd server && npm run checks && cd ../client && npm run checks && cd .. diff --git a/client/src/FetchActions.ts b/client/src/FetchActions.ts index c952c980..d6b118a9 100644 --- a/client/src/FetchActions.ts +++ b/client/src/FetchActions.ts @@ -1,4 +1,5 @@ import { APP_API_BASE_URL } from './configs'; +import { Message } from './types'; export const getRequest = ( url: string, @@ -26,6 +27,42 @@ export const getRequest = ( } }); }; + +export const patchRequest = ( + url: string, + body: any, + onSuccess: Function, + abortController?: AbortController, + onError = (statusCode?: Number, statusText?: string) => {}, +) => { + let options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: abortController?.signal, + body: JSON.stringify(body), + }; + fetch(url, options) + .then((resp) => { + if (!resp.ok) { + onError(resp.status, resp.statusText); + } else { + return resp.json(); + } + }) + .then((data) => onSuccess(data)) + .catch((e) => { + if (e.name === 'AbortError') { + // usually, this abort is intentional due to a change in props + console.log('fetch aborted'); + } else { + onError(); + } + }); +}; +// best practice for fetching is to allow fetches to be aborted, in case props change while a fetch is in progress + export const fetchNeeds = ( limit: string, offset: string, @@ -54,3 +91,21 @@ export const fetchDonations = ( getRequest(assetsApiRequest.href + assetsApiRequest.hash, onSuccess, abortController, onError); }; + +export const fetchInbox = ( + onSuccess: Function, + abortController?: AbortController, + onError = (statusCode?: Number, statusText?: string) => {}, +) => { + const assetsApiRequest = new URL(`${APP_API_BASE_URL}/transactions/inbox`); + getRequest(assetsApiRequest.href + assetsApiRequest.hash, onSuccess, abortController, onError); +}; + +export const updateMessage = ( + messageBody: Message, + onSuccess: Function, + onError = (statusCode?: Number, statusText?: string) => {}, +) => { + const messageApiRequest = new URL(`${APP_API_BASE_URL}/messages/${messageBody.id}`); + patchRequest(messageApiRequest.href, messageBody, onSuccess, new AbortController(), () => {}); +}; diff --git a/client/src/components/FAQs.tsx b/client/src/components/FAQs.tsx index 78606870..87853221 100644 --- a/client/src/components/FAQs.tsx +++ b/client/src/components/FAQs.tsx @@ -58,7 +58,7 @@ function FAQs() { return (
- FAQs Image + FAQs
diff --git a/client/src/components/Users/Auth/SetNewPassword.tsx b/client/src/components/Users/Auth/SetNewPassword.tsx index f3eaf3e0..03939c77 100644 --- a/client/src/components/Users/Auth/SetNewPassword.tsx +++ b/client/src/components/Users/Auth/SetNewPassword.tsx @@ -152,7 +152,7 @@ function SetNewPassword() { return (
- FAQs Image + FAQs
diff --git a/client/src/components/Users/Auth/SignUpCitizen/SignUpCitizen.tsx b/client/src/components/Users/Auth/SignUpCitizen/SignUpCitizen.tsx index fccd223a..8d477357 100644 --- a/client/src/components/Users/Auth/SignUpCitizen/SignUpCitizen.tsx +++ b/client/src/components/Users/Auth/SignUpCitizen/SignUpCitizen.tsx @@ -51,20 +51,6 @@ function SignupCitizen() { } }, [user]); - useEffect(() => { - if (submitForm) { - registerUserMutation.mutate(formData); - setSubmitForm(false); - } - }, [submitForm]); - - useEffect(() => { - if (submitProfile) { - updateProfileMutation.mutate({ file: image!, userId: user!['id'] }); - setSubmitProfile(false); - } - }, [submitProfile]); - const registerUserMutation = useMutation({ mutationFn: Endpoints.userRegister, onSuccess: ({ data: user }) => { @@ -82,6 +68,20 @@ function SignupCitizen() { onError: (error: AxiosError) => console.log(error), }); + useEffect(() => { + if (submitForm) { + setSubmitForm(false); + registerUserMutation.mutate(formData); + } + }, [submitForm, setSubmitForm, formData]); + + useEffect(() => { + if (submitProfile) { + updateProfileMutation.mutate({ file: image!, userId: user!['id'] }); + setSubmitProfile(false); + } + }, [submitProfile, formData, image, user]); + const handleNext = (newFormData: {}, doSubmit = false) => { setFormData((currFormData) => ({ ...currFormData, diff --git a/client/src/components/Users/Inbox/MessageCard.tsx b/client/src/components/Users/Inbox/MessageCard.tsx index 17a96fe4..0afa4122 100644 --- a/client/src/components/Users/Inbox/MessageCard.tsx +++ b/client/src/components/Users/Inbox/MessageCard.tsx @@ -3,21 +3,23 @@ import { makeStyles } from 'tss-react/mui'; import type { Theme } from '@mui/material/styles'; -import type { Message } from '../../../types'; +import { Typography } from '@mui/material'; +import { Button } from '@mui/base'; const useStyles = makeStyles()((theme: Theme) => ({ currentUserMessage: { alignSelf: 'flex-end', - border: '1px solid black', + border: '1px solid lightgrey', borderRadius: '10px', padding: '5px', maxWidth: '70%', marginLeft: '30%', + background: 'rgba(196, 196, 196, 0.3)', }, otherUserMessage: { alignSelf: 'flex-start', - background: 'rgba(196, 196, 196, 0.3)', - border: '1px solid black', + background: 'lightblue', + border: '1px solid lightgrey', borderRadius: '10px', padding: '5px', maxWidth: '70%', @@ -25,17 +27,28 @@ const useStyles = makeStyles()((theme: Theme) => ({ })); function MessageCard({ + text, + senderName, isCurrentUser, - message, + dateString, + messageReadCallback, }: { + senderName: string; + text: string; isCurrentUser: boolean; - message: Message; + dateString: string; + messageReadCallback: React.MouseEventHandler; }): JSX.Element { const { classes } = useStyles(); - return (
- {message.user.firstName}: {message.text} + + {text} + + + {dateString} + +
); } diff --git a/client/src/components/Users/Inbox/TransactionThreadCard.tsx b/client/src/components/Users/Inbox/TransactionThreadCard.tsx index 0878ec75..54039d4e 100644 --- a/client/src/components/Users/Inbox/TransactionThreadCard.tsx +++ b/client/src/components/Users/Inbox/TransactionThreadCard.tsx @@ -1,39 +1,12 @@ import * as React from 'react'; -import Box from '@mui/material/Box'; -import { makeStyles } from 'tss-react/mui'; import Typography from '@mui/material/Typography'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import PermIdentityRoundedIcon from '@mui/icons-material/PermIdentityRounded'; - -import type { Theme } from '@mui/material/styles'; +import Divider from '@mui/material/Divider'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import Avatar from '@mui/material/Avatar'; import type { Transaction, User } from '../../../types'; - -const useStyles = makeStyles()((theme: Theme) => ({ - threadCardSelected: { - background: 'rgba(196, 196, 196, 0.3)', - width: '95%', - margin: '0 auto', - }, - threadCard: { - background: 'white', - width: '95%', - margin: '0 auto', - boxShadow: 'none', - }, - threadCardContent: { - padding: '10px 4px 10px 40px', - }, - threadsSection: { - marginRight: '20px', - }, - threadCardTitle: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - }, -})); +import { ListItemButton } from '@mui/material'; function TransactionThreadCard({ isSelected, @@ -44,30 +17,77 @@ function TransactionThreadCard({ isSelected: boolean; onClick: (transaction: Transaction) => void; transaction: Transaction; - user?: User; + user?: User | null; }): JSX.Element { - const { classes } = useStyles(); - const otherUser = - user?.id === transaction.requester.id - ? transaction.requester.firstName - : transaction.donater.firstName; + const message = transaction.messages[0]; + const renderMessage = () => { + if (message) { + return ( + + + {message.sendingUserId === (user && user.id) ? ' Me: ' : ''} + + {message.text} + + ); + } + }; + const userOrg = + user && user.organizations && user.organizations[0] && user.organizations[0].organization.id; + const userIsClaimer = userOrg === transaction.claimer.id; + const isCurrentUser = + (userOrg && message && message.sendingOrgId === userOrg) || + (user && message && message.sendingUserId === user.id); + const donaterIsOrg = !!transaction.donater_organization; + + let otherUserName = ''; + let otherUserImage = '' as string | undefined; + if (userIsClaimer) { + // other user is claimer + if (donaterIsOrg) { + const otherUser = transaction.donater_organization && transaction.donater_organization; + otherUserName = otherUser.name; + } else { + const otherUser = transaction.donater_user && transaction.donater_user; + otherUserName = otherUser.firstName; + otherUserImage = otherUser.profile_image_url; + } + } else { + // other user is claimer + otherUserName = transaction.claimer && transaction.claimer.name; + otherUserImage = transaction.claimer && transaction.claimer.image_url; + } return ( - onClick(transaction)} - variant={isSelected ? 'outlined' : undefined} - > - - - - {otherUser} - - - - Re: {transaction.asset.title} - - + <> + onClick(transaction)} + > + + {otherUserImage && } + + + + {message ? (isCurrentUser ? 'To : ' : 'From: ') : ''} + {otherUserName} + + {`Topic: ${transaction.asset.title}`} + + } + secondary={renderMessage()} + /> + + + ); } diff --git a/client/src/providers/SettingsProvider.tsx b/client/src/providers/SettingsProvider.tsx index fd8c8e1c..142aa713 100644 --- a/client/src/providers/SettingsProvider.tsx +++ b/client/src/providers/SettingsProvider.tsx @@ -48,9 +48,8 @@ export const SettingsProvider = ({ children }: Props) => { const fetchCategories = async () => { try { const res = await fetch(`${APP_API_BASE_URL}/categories`); - const response = (await res.json()) satisfies Category[]; if (res.ok) { - _categoriesRef.current = [...response]; + _categoriesRef.current = [...((await res.json()) satisfies Category[])]; } } catch (error) { console.log('Error fetching Categorie', error); diff --git a/client/src/routes/routes.ts b/client/src/routes/routes.ts index e61ef9bf..4a88ad7c 100644 --- a/client/src/routes/routes.ts +++ b/client/src/routes/routes.ts @@ -24,7 +24,6 @@ import Help from '../views/Help'; import ForgotPassword from '../components/Users/Auth/ForgotPassword'; import SetNewPassword from '../components/Users/Auth/SetNewPassword'; import CookiePolicy from '../views/CookiePolicy'; -import TempChat from '../views/TempChat'; import EmailVerification from '../views/EmailVerification'; import Login from '../views/Login'; @@ -172,11 +171,6 @@ const routes: RouteMap = { roles: [], path: '/help', }, - TempChat: { - component: TempChat, - roles: [], - path: '/chat', - }, EmailVerification: { component: EmailVerification, roles: [], diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 30579e10..08a6a0df 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -25,6 +25,7 @@ export type Organization = { state: string; ein: string; nonprofit_classification: string; + image_url: string; }; export enum Role { @@ -39,12 +40,21 @@ export enum ApprovalStatus { denied = 'DENIED', } +export type UserOrg = { + organization: Organization; + organizationId: number; + user: User; + role: string; + approvalStatus: string; +}; + export type User = { id?: number; firstName: string; last_name?: string; email?: string; profile_image_url?: string; + organizations: UserOrg[]; city?: string; state?: string; zip_code?: string; @@ -54,16 +64,21 @@ export type User = { export type Transaction = { id: number; - donater: User; - requester: User; + donater_user: User; + donater_organization: Organization; asset: Pick; + claimer: Organization; + messages: Message[]; }; export type Message = { id: number; text: string; transactionId: number; - user: User; + sendingUserId: number | null; + sendingOrgId: number | null; + created_date: string; + read: boolean; }; export type Option = { diff --git a/client/src/views/Inbox.tsx b/client/src/views/Inbox.tsx index 76f8ff6e..5bcaaee3 100644 --- a/client/src/views/Inbox.tsx +++ b/client/src/views/Inbox.tsx @@ -4,20 +4,19 @@ import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import { makeStyles } from 'tss-react/mui'; import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import SendOutlinedIcon from '@mui/icons-material/SendOutlined'; -import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; import type { Theme } from '@mui/material/styles'; import SubHeader from '../components/Users/Inbox/SubHeader'; import TransactionThreadCard from '../components/Users/Inbox/TransactionThreadCard'; -import MessageCard from '../components/Users/Inbox/MessageCard'; import { UserContext } from '../providers'; import routes from '../routes/routes'; import type { Message, Transaction } from '../types'; import { APP_API_BASE_URL } from '../configs'; +import { fetchInbox } from '../FetchActions'; +import TempChat from './TempChat'; const useStyles = makeStyles()((theme: Theme) => ({ inboxWrapper: { @@ -27,7 +26,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ textAlign: 'left', }, sectionWrapper: { - border: '1px solid black', + border: '1px solid lightgrey', borderRadius: '10px', display: 'flex', flexDirection: 'column', @@ -48,6 +47,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, threadsSection: { marginRight: '20px', + maxWidth: '30%', }, messageInputWrapper: { width: '100%', @@ -76,50 +76,17 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -const fetchTransactions = (): Promise => { - return Promise.resolve([ - { - id: 1, - donater: { id: 1, firstName: 'firstName1' }, - requester: { id: 2, firstName: 'firstName2' }, - asset: { - id: 1, - title: 'title 1', - }, - }, - { - id: 2, - donater: { id: 1, firstName: 'firstName1' }, - requester: { id: 3, firstName: 'firstName3' }, - asset: { - id: 2, - title: 'title 2', - }, - }, - ]); -}; - // TODO: make the fetch find messages by transaction // TODO: seed data so that messages appear without manually creating them -const fetchMessages = async (): Promise => { - const MESSAGES_API_URL = `${APP_API_BASE_URL}/messages`; +const fetchMessages = async (id: number): Promise => { + const MESSAGES_API_URL = `${APP_API_BASE_URL}/transactions/${id}`; const res = await fetch(MESSAGES_API_URL); const data = await res.json(); - - const messages = await data.map((message: any) => { - return { - id: message.id, - text: message.text, - transactionId: message.transaction_id, - user: { - id: message.user.id, - firstName: message.user.firstName, - }, - }; - }); - - return messages; + return data.messages.sort( + (message1: Message, message2: Message) => + (new Date(message1.created_date) as any) - (new Date(message2.created_date) as any), + ); }; // TODO use SubHeader component in Offer and Assets pages @@ -132,16 +99,13 @@ function MessageInboxView(): JSX.Element { const [selectedTransaction, setSelectedTransaction] = React.useState(null); const [messages, setMessages] = React.useState([]); - const handleSendMessage = () => {}; - // todo switch to custom hook React.useEffect(() => { if (user) { - (async function () { - const transactions = await fetchTransactions(); // user.id - setTransactions(transactions); + fetchInbox((transactions: Transaction[]) => { setSelectedTransaction(transactions[0]); - })(); + setTransactions(transactions); + }); // user.id } }, [user]); @@ -149,12 +113,48 @@ function MessageInboxView(): JSX.Element { React.useEffect(() => { if (selectedTransaction) { (async function () { - const messages = await fetchMessages(); + const messages = await fetchMessages(selectedTransaction.id); setMessages(messages); })(); } }, [selectedTransaction]); + const transactionCards = ( + + {transactions && + transactions.length && + transactions.map((t) => ( + setSelectedTransaction(transaction)} + transaction={t} + user={user} + /> + ))} + + ); + + const noTransactionsMessage = ( + + Inbox empty + + Support an organization by contributingsomething they + need + + or + + {/* update to prop to use routes once set up */} + Post a need + + + ); + + const transactionList = ( +
+ {transactions.length && transactions.length > 0 ? transactionCards : noTransactionsMessage} +
+ ); + if (!user) { return ; } else { @@ -162,62 +162,17 @@ function MessageInboxView(): JSX.Element { <> - - - Inbox - - {transactions.length ? ( - transactions.map((t) => ( - setSelectedTransaction(transaction)} - transaction={t} - user={user} - /> - )) - ) : ( - - Inbox empty - - Support an organization by contributing{' '} - something they need - - or - - {/* update to prop to use routes once set up */} - Post a need - - - )} - + {transactionList} {selectedTransaction && ( - - - Re: {selectedTransaction.asset.title} - - {messages?.map((m) => ( - - ))} - + )} - -
- - - - - -
diff --git a/client/src/views/Main.tsx b/client/src/views/Main.tsx index 7629b4f6..67ef540f 100644 --- a/client/src/views/Main.tsx +++ b/client/src/views/Main.tsx @@ -29,7 +29,6 @@ const { OfferFormSkills, ContactUs, Help, - TempChat, EmailVerification, Login, } = routes; @@ -113,7 +112,6 @@ function Main() { {/* support */} - diff --git a/client/src/views/Messages.tsx b/client/src/views/Messages.tsx index 3460d466..0e8a6bb4 100644 --- a/client/src/views/Messages.tsx +++ b/client/src/views/Messages.tsx @@ -1,46 +1,50 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; +import { Message, Transaction } from '../types'; +import MessageCard from '../components/Users/Inbox/MessageCard'; +import { patchRequest, updateMessage } from '../FetchActions'; -interface IMessage { - text: string; - name: string; - id: string; - created_date: Date; -} - -function Messages({ socket }: any) { - const [messages, setMessages] = useState([]); - const [showTypingGesture, setShowTypingGesture] = useState(false); - const [userTyping, setUserTyping] = useState(''); - - useEffect(() => { - socket.on('message', (res: IMessage[]) => { - setMessages(res); - }); - - socket.on('typing', (res: { name: string; isTyping: boolean }) => { - setShowTypingGesture(res.isTyping); - setUserTyping(res.name); - }); - - socket.emit('findAllPocChat', (res: IMessage[]) => { - setMessages(res); - }); - }, [socket]); - - const formatDate = (date: Date): string => { +function Messages({ messages, transaction, user }: any) { + const formatDate = (date: string): string => { return new Date(date).toLocaleString('en-US'); }; + const getSenderName = (transaction: Transaction, message: Message) => { + if (message.sendingOrgId) { + if (message.sendingOrgId === transaction.claimer?.id) { + return transaction.claimer.name; + } else if (message.sendingOrgId === transaction.donater_organization?.id) { + return transaction.donater_organization.name; + } else { + return ''; + } + } else if (message.sendingUserId === (user && user.id)) { + return user.firstName; + } else { + return transaction.donater_user?.firstName; + } + }; + return (
- {messages.map((message: IMessage) => ( -
- {formatDate(message.created_date)}: - {message.name}: - {message.text} -
- ))} - {showTypingGesture &&

... {userTyping} is typing

} + {messages.map((message: Message) => { + const markMessageRead: React.MouseEventHandler = (event) => + updateMessage( + message, + () => {}, + () => { + console.log('error'); + }, + ); + return ( + + ); + })}
); } diff --git a/client/src/views/NewMessage.tsx b/client/src/views/NewMessage.tsx index 608d44ed..359aaf7e 100644 --- a/client/src/views/NewMessage.tsx +++ b/client/src/views/NewMessage.tsx @@ -1,11 +1,15 @@ +import { SendOutlined } from '@mui/icons-material'; +import { IconButton, TextField } from '@mui/material'; import React, { useState } from 'react'; -const NewMessage = ({ socket }: any) => { +const NewMessage = ({ socket, transactionId, classes }: any) => { const [value, setValue] = useState(''); const submitForm = (e: any) => { e.preventDefault(); - socket.emit('createPocChat', { text: value }); - setValue(''); + if (socket.connected) { + socket.emit('message', { text: value, transactionId: transactionId, fromClaimer: false }); + setValue(''); + } }; const handleOnChange = (e: any): void => { @@ -23,8 +27,9 @@ const NewMessage = ({ socket }: any) => { }; return ( -
- + { onBlur={handleBlur} onFocus={handleOnFocus} /> + + + ); }; diff --git a/client/src/views/SearchResults.tsx b/client/src/views/SearchResults.tsx index 569f6319..1d61444a 100644 --- a/client/src/views/SearchResults.tsx +++ b/client/src/views/SearchResults.tsx @@ -85,7 +85,7 @@ function SearchResults(): JSX.Element { // TODO: create Volunteer Type in types/index.ts and change Object[] to Volunteer[] below const [volunteer, setVolunteer] = React.useState([]); - function fetchSearchData() { + const fetchSearchData = React.useCallback(() => { if (querySearchCategory === 'Volunteer') { // TODO: Change API to /volunteer fetch(`${APP_API_BASE_URL}/organizations`) @@ -117,7 +117,7 @@ function SearchResults(): JSX.Element { } else { // TODO: Change API to fetch ALL combined data } - } + }, [querySearchCategory, querySearchText]); const handleCheck = (event: React.ChangeEvent) => { setSelectedFilters({ diff --git a/client/src/views/TempChat.tsx b/client/src/views/TempChat.tsx index 2e89e16e..9c1a521b 100644 --- a/client/src/views/TempChat.tsx +++ b/client/src/views/TempChat.tsx @@ -2,30 +2,99 @@ import { useEffect, useState } from 'react'; import { io, ManagerOptions, SocketOptions } from 'socket.io-client'; import Messages from './Messages'; import NewMessage from './NewMessage'; +import { Box, Typography } from '@mui/material'; +import { Message, Transaction, User } from '../types'; -const TempChat = () => { +type TempChatProps = { + classes: any; + transaction: Transaction; + messages: Message[] | []; + user: User | null; +}; +const TempChat = (props: TempChatProps) => { + const { classes, transaction, messages, user } = props; const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const [newMessages, setNewMessages] = useState([]); + const [showTypingGesture, setShowTypingGesture] = useState(false); + const [userTyping, setUserTyping] = useState(''); + useEffect(() => { const opts: Partial = { withCredentials: true, }; const newSocket = io(`http://${window.location.hostname}:3002`, opts); + + newSocket.on('connect', () => { + newSocket.emit('join', { transactionId: transaction.id }); + setConnected(true); + }); + // to do - send org id if user is logged in as an organization + setSocket(newSocket); + newSocket.on(`message_${transaction.id}`, (res: any) => { + console.log(res); + setNewMessages((prev) => [...prev, res]); + }); + + newSocket.on('join', (res: any) => { + console.log(res); + }); + newSocket.on('disconnect', (res: any) => { + setConnected(false); + }); + + newSocket.on('typing', (res: { name: string; isTyping: boolean }) => { + setShowTypingGesture(res.isTyping); + setUserTyping(res.name); + }); + return () => newSocket.close() as any; - }, [setSocket]); + }, [transaction]); + + const userOrg = + user && user.organizations && user.organizations[0] && user.organizations[0].organization.id; + const userIsClaimer = userOrg === transaction.claimer.id; + const donaterIsOrg = !!transaction.donater_organization; + + let otherUserName = ''; + if (userIsClaimer) { + // other user is claimer + if (donaterIsOrg) { + const otherUser = transaction.donater_organization && transaction.donater_organization; + otherUserName = otherUser.name; + } else { + const otherUser = transaction.donater_user && transaction.donater_user; + otherUserName = otherUser.firstName; + } + } else { + // other user is claimer + otherUserName = transaction.claimer && transaction.claimer.name; + } return ( -
-
React Chat
+ + + Message with {otherUserName} + {socket ? (
- +
) : (
Not Connected
)} - -
+ + {showTypingGesture &&

... {userTyping} is typing

} + {!connected ? 'Connecting to server...' : null} + +
+ ); }; diff --git a/server/src/acccount-manager/account-manager.controller.ts b/server/src/acccount-manager/account-manager.controller.ts index bff5bcc6..1b3b8974 100644 --- a/server/src/acccount-manager/account-manager.controller.ts +++ b/server/src/acccount-manager/account-manager.controller.ts @@ -78,7 +78,7 @@ export class AccountManagerController { } @MapTo(ReturnUserDto) - @Post('signup') + @Post('register') async signup(@Body() signupDto: CreateUserDto): Promise { const exists = await this.usersService.userEmailExists(signupDto.email); @@ -146,12 +146,12 @@ export class AccountManagerController { } // TODO: we probably need a better solution for this - if (!user.email_verified && process.env.NODE_ENV === 'staging') { - throw new HttpException( - { status: HttpStatus.UNAUTHORIZED, message: 'Unauthorized' }, - HttpStatus.UNAUTHORIZED, - ); - } + // if (!user.email_verified && process.env.NODE_ENV === 'staging') { + // throw new HttpException( + // { status: HttpStatus.UNAUTHORIZED, message: 'Unauthorized' }, + // HttpStatus.UNAUTHORIZED, + // ); + // } const jwt = await this.accountManagerService.createJwt(user); response diff --git a/server/src/acccount-manager/entities/user.entity.ts b/server/src/acccount-manager/entities/user.entity.ts index 1801601e..692a13cb 100644 --- a/server/src/acccount-manager/entities/user.entity.ts +++ b/server/src/acccount-manager/entities/user.entity.ts @@ -46,10 +46,10 @@ export class User { @OneToMany(() => Transaction, (transaction) => transaction.donater_user) transactions: Transaction[]; - @OneToMany(() => Message, (message) => message.user) - messages: Message[]; + @OneToMany(() => Message, (message) => message.sending_user) + sentMessages: Message[]; - @OneToMany(() => UserOrganization, (user_org) => user_org.user) + @OneToMany(() => UserOrganization, (user_org) => user_org.user, { eager: true }) organizations: UserOrganization[]; @Column({ type: 'text', nullable: true }) diff --git a/server/src/acccount-manager/guards/cookie-auth.guard.ts b/server/src/acccount-manager/guards/cookie-auth.guard.ts index d4ddca05..a73f86f8 100644 --- a/server/src/acccount-manager/guards/cookie-auth.guard.ts +++ b/server/src/acccount-manager/guards/cookie-auth.guard.ts @@ -19,7 +19,7 @@ export class CookieAuthGuard extends AuthGuard() { return false; } - let user: User = {} as User; + let user: User; // instantiate as user class instead of object so that relations can be loaded try { user = await this.jwtService.verify(jwt, { secret: process.env.JWT_SECRET }); } catch (_e) { diff --git a/server/src/acccount-manager/guards/ws-cookie-auth.guard.ts b/server/src/acccount-manager/guards/ws-cookie-auth.guard.ts index 9b3bce6e..66c378a7 100644 --- a/server/src/acccount-manager/guards/ws-cookie-auth.guard.ts +++ b/server/src/acccount-manager/guards/ws-cookie-auth.guard.ts @@ -38,7 +38,6 @@ export class WsCookieGuard extends AuthGuard() { } catch (error) { return false; } - context.switchToHttp().getRequest().user = user; return Boolean(user); } diff --git a/server/src/database/seeding/seed-data.ts b/server/src/database/seeding/seed-data.ts index fe216ad7..1ba8074f 100644 --- a/server/src/database/seeding/seed-data.ts +++ b/server/src/database/seeding/seed-data.ts @@ -292,10 +292,17 @@ export const seedMessages = (): CreateMessageDto[] => { { text: 'I would like to accept the paper products.', transaction: null, + sending_user: null, + sending_org: null, + read: false, }, { text: 'I would like to accept the furniture.', transaction: null, + sending_user: null, + sending_org: null, + read: false, + }, ]; return messages; diff --git a/server/src/main.ts b/server/src/main.ts index 743f7f3e..22f0c0b0 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -29,7 +29,6 @@ async function bootstrap() { .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('docs', app, document); - await app.listen(process.env.PORT || 3001); } bootstrap(); diff --git a/server/src/messages/dto/create-message.dto.ts b/server/src/messages/dto/create-message.dto.ts index 6b2a417e..3b625aa2 100644 --- a/server/src/messages/dto/create-message.dto.ts +++ b/server/src/messages/dto/create-message.dto.ts @@ -1,11 +1,22 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsOptional } from 'class-validator'; import { Transaction } from '../../transactions/entities/transaction.entity'; +import { User } from '../../acccount-manager/entities/user.entity'; +import { Organization } from '../../organizations/entities/organization.entity'; export class CreateMessageDto { @IsNotEmpty() text: string; + @IsOptional() + read: boolean; + @IsNotEmpty() transaction: Transaction; + + @IsNotEmpty() + sending_user: User; + + @IsOptional() + sending_org?: Organization; } diff --git a/server/src/messages/dto/return-message.dto.ts b/server/src/messages/dto/return-message.dto.ts index f9ad59cb..4a9490b8 100644 --- a/server/src/messages/dto/return-message.dto.ts +++ b/server/src/messages/dto/return-message.dto.ts @@ -1,8 +1,24 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsNotEmpty, IsOptional } from 'class-validator'; import { CreateMessageDto } from './create-message.dto'; +import { Transaction } from 'src/transactions/entities/transaction.entity'; +import { User } from 'src/acccount-manager/entities/user.entity'; +import { Organization } from 'src/organizations/entities/organization.entity'; export class ReturnMessageDto extends CreateMessageDto { + id: number + + text: string + + read: boolean + @IsNotEmpty() - id: number; + transaction: Transaction; + + @IsOptional() + sending_user: User; + + @IsOptional() + sending_org?: Organization; + } diff --git a/server/src/messages/dto/update-message.dto.ts b/server/src/messages/dto/update-message.dto.ts index 2e88d19c..2972523e 100644 --- a/server/src/messages/dto/update-message.dto.ts +++ b/server/src/messages/dto/update-message.dto.ts @@ -2,4 +2,4 @@ import { PickType } from '@nestjs/swagger'; import { CreateMessageDto } from './create-message.dto'; -export class UpdateMessageDto extends PickType(CreateMessageDto, ['text'] as const) {} +export class UpdateMessageDto extends PickType(CreateMessageDto, ['text', 'read' ] as const) {} diff --git a/server/src/messages/entities/message.entity.ts b/server/src/messages/entities/message.entity.ts index 5b8a224c..a0ac993c 100644 --- a/server/src/messages/entities/message.entity.ts +++ b/server/src/messages/entities/message.entity.ts @@ -1,3 +1,4 @@ +import { Organization } from '../../organizations/entities/organization.entity'; import { Entity, Column, @@ -5,10 +6,13 @@ import { ManyToOne, CreateDateColumn, JoinColumn, + OneToOne, + OneToMany, } from 'typeorm'; import { User } from '../../acccount-manager/entities/user.entity'; import { Transaction } from '../../transactions/entities/transaction.entity'; +import { IsOptional } from 'class-validator'; @Entity('messages') export class Message { @@ -24,11 +28,25 @@ export class Message { }) created_date: Date; - @ManyToOne(() => User, (user) => user.messages, { eager: true }) + @ManyToOne(() => User, (user) => user.sentMessages) @JoinColumn() - user: User; + sending_user: User; + + @Column({ nullable: true }) + sendingUserId: number; + + @ManyToOne(() => Organization, (org) => org.messages) + @JoinColumn() + sending_org?: Organization; + + @Column({ nullable: true }) + sendingOrgId: number; + + @Column() + read: boolean @ManyToOne(() => Transaction, (transaction) => transaction.messages) @JoinColumn() transaction: Transaction; + } diff --git a/server/src/messages/messages.controller.ts b/server/src/messages/messages.controller.ts index 223ef287..3345c752 100644 --- a/server/src/messages/messages.controller.ts +++ b/server/src/messages/messages.controller.ts @@ -17,7 +17,6 @@ import { UpdateMessageDto } from './dto/update-message.dto'; import { ReturnMessageDto } from './dto/return-message.dto'; import { DeleteResult } from 'typeorm'; import type { Request as ExpressRequest } from 'express'; -import { User } from '../acccount-manager/entities/user.entity'; import { CookieAuthGuard } from '../acccount-manager/guards/cookie-auth.guard'; @ApiTags('messages') @@ -32,8 +31,7 @@ export class MessagesController { @Request() request: ExpressRequest, @Body() createMessageDto: CreateMessageDto, ): Promise { - const { user } = request; - const newMessage = await this.messagesService.create(createMessageDto, user as User); + const newMessage = await this.messagesService.create(createMessageDto); return newMessage; } @@ -54,10 +52,10 @@ export class MessagesController { @Patch(':id') @ApiOperation({ summary: 'Update a message.' }) async update( - @Param('id') id: string, + @Param('id') id: number, @Body() updateMessageDto: UpdateMessageDto, ): Promise { - return this.messagesService.update(+id, updateMessageDto); + return this.messagesService.update(id, updateMessageDto); } @Delete(':id') diff --git a/server/src/messages/messages.service.ts b/server/src/messages/messages.service.ts index 4747ff91..2415e0eb 100644 --- a/server/src/messages/messages.service.ts +++ b/server/src/messages/messages.service.ts @@ -11,8 +11,8 @@ import { UpdateMessageDto } from './dto/update-message.dto'; export class MessagesService { constructor(@InjectRepository(Message) private messagesRepository: Repository) {} - async create(createMessageDto: CreateMessageDto, user: User): Promise { - return this.messagesRepository.save({ ...createMessageDto, user }); + async create(createMessageDto: CreateMessageDto): Promise { + return this.messagesRepository.save({ ...createMessageDto }); } async findAll(): Promise { @@ -23,18 +23,21 @@ export class MessagesService { return this.messagesRepository.findOneBy({ id }); } - async findByUser(user: User): Promise { - const messages = this.messagesRepository.find({ where: { user: { id: user.id } } }); + async findByUser(user_id: number): Promise { + const messages = this.messagesRepository.find({ + where: { + sending_user: { id: user_id }, + }, + }); return messages; } - // when transactions are set up - // async findByTransaction(transaction: Transaction): Promise { - // const messages = this.messagesRepository.find({ - // where: { transaction: transaction }, - // }); - // return messages; - // } + async findByTransaction(transaction_id: number): Promise { + const messages = this.messagesRepository.find({ + where: { transaction: { id: transaction_id } }, + }); + return messages; + } async update(id: number, updateMessageDto: UpdateMessageDto): Promise { await this.messagesRepository.update(id, updateMessageDto); diff --git a/server/src/migrations/.keep b/server/src/migrations/.keep new file mode 100644 index 00000000..e69de29b diff --git a/server/src/organizations/entities/organization.entity.ts b/server/src/organizations/entities/organization.entity.ts index 7c7b233a..c1c38873 100644 --- a/server/src/organizations/entities/organization.entity.ts +++ b/server/src/organizations/entities/organization.entity.ts @@ -2,6 +2,7 @@ import { Entity, Column, PrimaryGeneratedColumn, OneToMany, JoinColumn } from 't import { UserOrganization } from '../../user-org/entities/user-org.entity'; import { Transaction } from '../../transactions/entities/transaction.entity'; +import { Message } from '../../messages/entities/message.entity'; @Entity('organizations') export class Organization { @@ -50,6 +51,9 @@ export class Organization { @Column({ type: 'text' }) nonprofit_classification: string; + @OneToMany(() => Message, (message) => message.sending_org) + messages: Message[]; + @OneToMany(() => UserOrganization, (user_org) => user_org.organization) users: UserOrganization[]; diff --git a/server/src/poc-chat/poc-chat.gateway.ts b/server/src/poc-chat/poc-chat.gateway.ts index 7f8232c8..2f001bfb 100644 --- a/server/src/poc-chat/poc-chat.gateway.ts +++ b/server/src/poc-chat/poc-chat.gateway.ts @@ -2,8 +2,9 @@ import { WebSocketGateway, SubscribeMessage, MessageBody, - WebSocketServer, ConnectedSocket, + WsResponse, + WebSocketServer, } from '@nestjs/websockets'; import { PocChatService } from './poc-chat.service'; import { CreatePocChatDto } from './dto/create-poc-chat.dto'; @@ -11,7 +12,9 @@ import { Server, Socket } from 'socket.io'; import { Request, UseGuards } from '@nestjs/common'; import * as dotenv from 'dotenv'; import { WsCookieGuard } from '../acccount-manager/guards/ws-cookie-auth.guard'; - +import { TransactionsService } from '../transactions/transactions.service'; +import { MessagesService } from '../messages/messages.service'; +import { UsersService } from '../acccount-manager/user.service'; dotenv.config({ path: __dirname + '/../../.env' }); @WebSocketGateway(3002, { @@ -20,10 +23,14 @@ dotenv.config({ path: __dirname + '/../../.env' }); export class PocChatGateway { @WebSocketServer() server: Server; + constructor( + private readonly pocChatService: PocChatService, + private transactionsService: TransactionsService, + private messagesService: MessagesService, + private usersService: UsersService, + ) {} - constructor(private readonly pocChatService: PocChatService) {} - - @UseGuards(WsCookieGuard) + // @UseGuards(WsCookieGuard) @SubscribeMessage('createPocChat') async create( @MessageBody() createPocChatDto: CreatePocChatDto, @@ -37,25 +44,103 @@ export class PocChatGateway { this.server.emit('message', messages); } - @UseGuards(WsCookieGuard) - @SubscribeMessage('findAllPocChat') - findAll() { - return this.pocChatService.findAll(); - } + // @UseGuards(WsCookieGuard) + // @SubscribeMessage('findAllPocChat') + // findAll() { + // return this.pocChatService.findAll(); + // } @SubscribeMessage('join') @UseGuards(WsCookieGuard) - joinRoom(@MessageBody('name') name: string, @ConnectedSocket() client: Socket) { - return this.pocChatService.identify(name, client.id); + joinRoom( + @MessageBody('transactionId') transactionId: number, + @MessageBody('orgId') org_id: number, + @ConnectedSocket() client: Socket, + @Request() request: Request, + ) { + const user = request['user']; + const isValid = this._can_user_join(user, transactionId, org_id); + if (isValid) { + client.join(`${transactionId}`); + return { event: 'join', data: { success: true } } as WsResponse; + } else { + return { event: 'join', data: { success: false } } as WsResponse; + } } @UseGuards(WsCookieGuard) + @SubscribeMessage('message') + async sendMessage( + @MessageBody('text') text: string, + @MessageBody('transactionId') transactionId: number, + @ConnectedSocket() client: Socket, + @Request() request: Request, + ) { + if (client.rooms.has(`${transactionId}`)) { + const message = await this._createMessage(request['user'], transactionId, text); + this.server.to(`${transactionId}`).emit(`message_${transactionId}`, message); + } + } + @SubscribeMessage('typing') - async typing( + @UseGuards(WsCookieGuard) + typing( @MessageBody('isTyping') isTyping: boolean, + @MessageBody('transactionId') transactionId: number, @ConnectedSocket() client: Socket, @Request() req: Request, ) { - client.broadcast.emit('typing', { name: req['user'].firstName, isTyping }); + const name = + req['user'].organizations.length > 0 + ? req['user'].organizations[0].organization.name + : req['user'].firstName; + + if (client.rooms.has(`${transactionId}`)) { + client.to(`${transactionId}`).emit('typing', { name: name, isTyping }); + } + } + + async _createMessage(user, transaction_id, text) { + const transaction = await this.transactionsService.getTransactionWithRelations(transaction_id, { + claimer: true, + donater_organization: true, + }); + const sending_user = user; + const from_claimer = + user.organizations && + user.organizations.find((org) => org.organizationId === transaction.claimerId); + const sending_org = + (from_claimer ? transaction.claimer : transaction.donater_organization) || null; + const receiving_org = !from_claimer && transaction.donater_organization; + const message = await this.messagesService.create({ + text, + transaction, + sending_org, + sending_user, + read: false, + }); + if (receiving_org) { + // TODO: create seen message record + } + return message; + } + + async _can_user_join(user, transaction_id, org_id = null) { + const transaction = await this.transactionsService.getTransactionById(transaction_id); + if (!transaction) { + return false; + } + if (org_id) { + if (!user.organizations.find((org) => org.organizationId === org_id)) { + // user id does not match org_id so return false + return false; + } + if (org_id !== transaction.donaterOrganizationId && org_id !== transaction.claimerId) { + return false; + } + } else if (user.id !== transaction_id.donater_userId) { + return false; + } + return true; } } diff --git a/server/src/poc-chat/poc-chat.module.ts b/server/src/poc-chat/poc-chat.module.ts index 429bed7a..caec9ffb 100644 --- a/server/src/poc-chat/poc-chat.module.ts +++ b/server/src/poc-chat/poc-chat.module.ts @@ -4,9 +4,24 @@ import { PocChatGateway } from './poc-chat.gateway'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PocChat } from './entities/poc-chat.entity'; import { AcccountManagerModule } from '../acccount-manager/acccount-manager.module'; +import { TransactionsService } from 'src/transactions/transactions.service'; +import { MessagesService } from '../messages/messages.service'; +import { Transaction } from '../transactions/entities/transaction.entity'; +import { Message } from '../messages/entities/message.entity'; @Module({ - imports: [TypeOrmModule.forFeature([PocChat]), AcccountManagerModule], - providers: [PocChatGateway, PocChatService, AcccountManagerModule], + imports: [ + TypeOrmModule.forFeature([PocChat]), + AcccountManagerModule, + TypeOrmModule.forFeature([Transaction]), + TypeOrmModule.forFeature([Message]), + ], + providers: [ + PocChatGateway, + PocChatService, + AcccountManagerModule, + TransactionsService, + MessagesService, + ], }) export class PocChatModule {} diff --git a/server/src/seeder/seeder.service.ts b/server/src/seeder/seeder.service.ts index 22fc5721..84d15f2e 100644 --- a/server/src/seeder/seeder.service.ts +++ b/server/src/seeder/seeder.service.ts @@ -167,9 +167,8 @@ export class SeederService { Logger.log('Seeding a message', SeederService.name); for (const message of messages) { message.transaction = insertedTransaction; - await this.messageService - .create(message, transaction.donater_user) - .catch((err) => Logger.log(err)); + message.sending_user = transaction.donater_user; + await this.messageService.create(message).catch((err) => Logger.log(err)); newMessages.push(message); } Logger.log('at end of seeding the messages', SeederService.name); diff --git a/server/src/transactions/entities/transaction.entity.ts b/server/src/transactions/entities/transaction.entity.ts index 0affb7cd..8eadebff 100644 --- a/server/src/transactions/entities/transaction.entity.ts +++ b/server/src/transactions/entities/transaction.entity.ts @@ -33,10 +33,16 @@ export class Transaction { @JoinColumn() donater_user: User; + @Column({ nullable: true }) + donaterUserId: number; + @ManyToOne(() => Organization, (organization) => organization.donated_transactions) @JoinColumn() donater_organization?: Organization; + @Column({ nullable: true }) + donaterOrganizationId: number; + @ManyToOne(() => Asset, (asset) => asset.transactions, { eager: true }) @JoinColumn() asset: Asset; @@ -45,7 +51,9 @@ export class Transaction { @JoinColumn() claimer: Organization; + @Column({ nullable: true }) // TODO: update seeder to always have claimerID + claimerId: number; + @OneToMany(() => Message, (message) => message.transaction) - @JoinColumn() messages: Message[]; } diff --git a/server/src/transactions/transactions.controller.ts b/server/src/transactions/transactions.controller.ts index 446c8244..46320711 100644 --- a/server/src/transactions/transactions.controller.ts +++ b/server/src/transactions/transactions.controller.ts @@ -1,10 +1,12 @@ -import { Get, Post, Body, Query, Param, Patch, Delete, Controller } from '@nestjs/common'; +import { Get, Post, Body, Query, Param, Patch, Delete, Controller, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; - +import { Request } from '@nestjs/common'; import { TransactionsService } from './transactions.service'; import { CreateTransactionDto } from './dto/create-transaction.dto'; import { GetTransactionsDto } from './dto/get-transactions-filter.dto'; import { UpdateTransactionDto } from './dto/update-transaction.dto'; +import { Transaction } from './entities/transaction.entity'; +import { CookieAuthGuard } from 'src/acccount-manager/guards/cookie-auth.guard'; import { ReturnTransactionDto } from './dto/return-transaction.dto'; @ApiTags('transactions') @@ -24,6 +26,20 @@ export class TransactionsController { return this.transactionsService.getTransactions(getTransactionsDto); } + @UseGuards(CookieAuthGuard) + @Get('/inbox') + // returns trasnactions with latest messages + async userInbox( @Request() req: Request): Promise { + const user = req['user']; + const userOrgs = await user.organizations; + const org_id = userOrgs && userOrgs.length > 0 ? userOrgs[0].organizationId : false; + if (org_id) { + return this.transactionsService.find_by_org_with_latest_message(org_id); + } else { + return this.transactionsService.find_by_user_with_latest_message(user.id); + } + } + @Get('/:id') @ApiOperation({ summary: 'Fetch a transaction via ID.' }) getTransactionById(@Param('id') id: number): Promise { diff --git a/server/src/transactions/transactions.module.ts b/server/src/transactions/transactions.module.ts index e95e9bf9..b376eba0 100644 --- a/server/src/transactions/transactions.module.ts +++ b/server/src/transactions/transactions.module.ts @@ -3,9 +3,18 @@ import { TransactionsController } from './transactions.controller'; import { TransactionsService } from './transactions.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Transaction } from './entities/transaction.entity'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; @Module({ - imports: [TypeOrmModule.forFeature([Transaction])], + imports: [ + TypeOrmModule.forFeature([Transaction]), + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: '60s' }, + }), + ], controllers: [TransactionsController], providers: [TransactionsService], exports: [TransactionsService], diff --git a/server/src/transactions/transactions.service.ts b/server/src/transactions/transactions.service.ts index cdcc74a0..d4535465 100644 --- a/server/src/transactions/transactions.service.ts +++ b/server/src/transactions/transactions.service.ts @@ -22,6 +22,74 @@ export class TransactionsService { return this.transactionsRepository.find({ where: { ...getTransactionsDto } }); } + async find_by_user_with_latest_message(user_id: number): Promise { + // get an "inbox" of latest messages, one per transaction ( use this for citizen accounts) + + return this.transactionsRepository + .createQueryBuilder('transaction') + .distinctOn(['transaction.id']) + .leftJoinAndSelect('transaction.asset', 'asset') + .leftJoinAndSelect('transaction.messages', 'message') + .leftJoinAndSelect('transaction.donater_organization', 'donater_organization') + .leftJoinAndSelect('transaction.claimer', 'claimer') + .innerJoinAndSelect( + 'transaction.donater_user', + 'donater_user', + 'donater_user.id = :user_id', + { + user_id: user_id, + }, + ) + .orderBy('transaction.id', 'DESC') + .addOrderBy('message.created_date', 'DESC') + .getMany(); + } + + async find_by_org_with_latest_message(org_id: number): Promise { + // get an "inbox" of latest messages, one per transaction (use this for organization accounts) + return this.transactionsRepository + .createQueryBuilder('transaction') + .distinctOn(['transaction.id']) + .leftJoinAndSelect('transaction.asset', 'asset') + .leftJoinAndSelect('transaction.messages', 'message') + .leftJoinAndSelect('transaction.donater_user', 'donater_user') + .leftJoinAndSelect( + 'transaction.donater_organization', + 'donaterOrganization', + 'donaterOrganization.id = :org_id', + { org_id: org_id }, + ) + .leftJoinAndSelect('transaction.claimer', 'claimer', 'claimer.id = :claimer_id', { + claimer_id: org_id, + }) + .where('transaction.claimerId =:claimer_id', { claimer_id: org_id }) + .orWhere('transaction.donaterOrganizationId =:org_id', { + org_id: org_id, + }) + .orderBy('transaction.id', 'DESC') + .addOrderBy('message.created_date', 'DESC') + .getMany(); + } + + async getTransactionWithRelations( + id: number, + relations: any = { + messages: true, + donater_user: true, + donater_organization: true, + claimer: true, + }, + ): Promise { + const found = await this.transactionsRepository.findOne({ + relations: relations, + where: { id: id }, + }); + if (!found) { + throw new NotFoundException(); + } + return found; + } + async getTransactionById(id: number): Promise { const found = await this.transactionsRepository.findOneBy({ id }); if (!found) { diff --git a/server/src/user-org/entities/user-org.entity.ts b/server/src/user-org/entities/user-org.entity.ts index 46819f19..a5381dc3 100644 --- a/server/src/user-org/entities/user-org.entity.ts +++ b/server/src/user-org/entities/user-org.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Column, ManyToOne, CreateDateColumn, PrimaryGeneratedColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm'; import { Organization } from '../../organizations/entities/organization.entity'; import { User } from '../../acccount-manager/entities/user.entity'; @@ -19,6 +19,9 @@ export class UserOrganization { @ManyToOne(() => Organization, (org) => org.users, { eager: true }) organization!: Organization; + @Column({ nullable: true }) + organizationId: number; + @Column({ type: 'enum', enum: Role, @@ -26,7 +29,7 @@ export class UserOrganization { }) role: Role; - @ManyToOne(() => User, (user) => user.organizations, { eager: true }) + @ManyToOne(() => User, (user) => user.organizations) user!: User; @CreateDateColumn() diff --git a/server/test/stubs/messages.stub.ts b/server/test/stubs/messages.stub.ts index 325783ce..c92bbb7e 100644 --- a/server/test/stubs/messages.stub.ts +++ b/server/test/stubs/messages.stub.ts @@ -7,7 +7,10 @@ export const messageStub = (user?: User, transaction?: Transaction): Message => id: 1, text: 'fakeMessage', created_date: new Date(2021, 11, 6), - user: user || new User(), + sending_user: user || new User(), + sending_org: null, transaction: transaction || new Transaction(), + sendingOrgId: null, + sendingUserId: null, }; }; diff --git a/server/test/stubs/users.stub.ts b/server/test/stubs/users.stub.ts index 53ae3916..7e367463 100644 --- a/server/test/stubs/users.stub.ts +++ b/server/test/stubs/users.stub.ts @@ -7,9 +7,11 @@ import { UserOrganization } from '../../src/user-org/entities/user-org.entity'; export const userEntityStub = ( assets?: Asset[], - messages?: Message[], + sentMessages?: Message[], transactions?: Transaction[], organizations?: UserOrganization[], + receivedMessages?: Message[], + ): User => { return { id: 234545, @@ -21,8 +23,9 @@ export const userEntityStub = ( state: 'WA', zip_code: '98101', assets: assets, - messages, transactions, + receivedMessages, + sentMessages, organizations, email_verified: true, email_notification_opt_out: false, diff --git a/test b/test new file mode 100644 index 00000000..e69de29b