diff --git a/client/package-lock.json b/client/package-lock.json index eec1c19..82bf3b1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -6400,6 +6400,16 @@ "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==" }, + "highcharts": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-8.2.2.tgz", + "integrity": "sha512-F63TXO7RxsvTcpO/KOubQZWualYpCMyCTuKtoWbt7KCsfQ3Kl7Fr6HEyyJdjkYl+XlnmnKlSRi9d3HjLK9Q0wg==" + }, + "highcharts-react-official": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/highcharts-react-official/-/highcharts-react-official-3.0.0.tgz", + "integrity": "sha512-VefJgDY2hkT9gfppsQGrRF2g5u8d9dtfHGcx2/xqiP+PkZXCqalw9xOeKVCRvJKTOh0coiDFwvVjOvB7KaGl4A==" + }, "history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", diff --git a/client/package.json b/client/package.json index 02e00fb..23d6297 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,8 @@ "@testing-library/user-event": "^7.1.2", "axios": "^0.20.0", "graphql": "^15.3.0", + "highcharts": "^8.2.2", + "highcharts-react-official": "^3.0.0", "jwt-decode": "^3.0.0", "prop-types": "^15.7.2", "react": "^16.13.1", diff --git a/client/src/App.js b/client/src/App.js index 312b71c..6ee7545 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -12,6 +12,7 @@ import { fetchIsDrawerOpen } from './redux/slices/drawer'; import { fetchLogin } from './redux/slices/auth'; import './App.css'; +import Dashboard from './components/dashboard'; const useStyles = makeStyles((theme) => ({ root: { @@ -39,12 +40,12 @@ function App() { const dispatch = useDispatch(); const snackbarState = useSelector((state) => state.snackbar); - + const token = useSelector((state) => state.auth.authToken); // Fetch the initial drawer open state useEffect(() => { dispatch(fetchIsDrawerOpen()); dispatch(fetchLogin()); - }, [dispatch]); + }, [dispatch, token]); return (
@@ -62,6 +63,9 @@ function App() { + + + {/* Auth related routes */} diff --git a/client/src/components/auth/Login.js b/client/src/components/auth/Login.js index 3343c0e..9ac844d 100644 --- a/client/src/components/auth/Login.js +++ b/client/src/components/auth/Login.js @@ -20,6 +20,7 @@ import axios from '../../utils/axios'; import loginQuery from '../../graphQl/queries/loginQuery'; import { setIsSnackbarOpen } from '../../redux/slices/snackbar'; import { setLogin } from '../../redux/slices/auth'; +import { fetchUrls } from '../../redux/slices/urls'; // Define styles for this component const useStyles = makeStyles((theme) => ({ @@ -108,6 +109,7 @@ function Login() { // Get the jwt and store in redux and local storage const authToken = response.data.data.login.token; dispatch(setLogin({ isLoggedIn: true, authToken })); + dispatch(fetchUrls({ token: authToken })); history.replace(prevPath, location.state); } catch (err) { diff --git a/client/src/components/dashboard/Graph/LineChart.js b/client/src/components/dashboard/Graph/LineChart.js new file mode 100644 index 0000000..b21dbf4 --- /dev/null +++ b/client/src/components/dashboard/Graph/LineChart.js @@ -0,0 +1,83 @@ +import React from 'react'; +import Highcharts from 'highcharts/highstock'; +import HighchartsReact from 'highcharts-react-official'; +import { useSelector } from 'react-redux'; + +/** + * Line Chart component + * This renders a line chart plotting the no of clicks made per day + */ + +// The component to render the line chart +function LineChart() { + // Retreive the selected Url + const selectedUrl = useSelector((state) => state.urls.selectedUrl); + const clicks = selectedUrl ? selectedUrl.clicks : []; + + // Group the Clicks according to date + const groups = clicks.reduce((groups, click) => { + // Retrieve the Date from clicked time + const date = click.time.split('T')[0]; + if (!groups[date]) { + groups[date] = []; + } + groups[date].push(click); + return groups; + }, {}); + // Generate an array according to the groups made + const groupArrays = Object.keys(groups).map((date) => { + return { + date, + clicks: groups[date], + }; + }); + + // Data to be passed to the chart + const options = { + chart: { + reflow: 'true', + BackgroundColor: '#fbfbfb', + plotBorderWidth: null, + plotShadow: false, + type: 'line', + }, + title: { + text: 'Clicks by Date ', + align: 'left', + }, + xAxis: { + title: { + text: 'Date', + }, + categories: groupArrays.map((groups) => groups.date), + }, + yAxis: { + title: { + text: 'Clicks', + }, + }, + series: [{ + name: 'Clicks', + data: groupArrays.map((groups) => groups.clicks.length), + }], + }; + + return ( +
+
+ + +
+
); +} + +export default LineChart; diff --git a/client/src/components/dashboard/Graph/PieChart.js b/client/src/components/dashboard/Graph/PieChart.js new file mode 100644 index 0000000..7d5c2f3 --- /dev/null +++ b/client/src/components/dashboard/Graph/PieChart.js @@ -0,0 +1,157 @@ +import HighchartsReact from 'highcharts-react-official'; +import Highcharts from 'highcharts/highstock'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +/** + * This is the Pie Chart component + * This has two variants + * 1. This plots the no of clicks made in the last month per location on a pieChart + * 2. This plots the no of clicks made in the last month per url on a pieChart + * @usage Just import it and pass a variant prop + * whose value should be `CLICKS_BY_LOCATION` for 1 else `CLICKS_BY_URLS` for 2 + * @example + */ + +export default function PieChart(props) { + const { variant } = props; + + const urls = useSelector((state) => state.urls.urls); + const selectedUrl = useSelector((state) => state.urls.selectedUrl); + const clicks = selectedUrl ? selectedUrl.clicks : []; + let options = null; + + // Check if the variant is one needing location + if (variant === 'CLICKS_BY_LOCATION') { + const groups = clicks.reduce((groups, click) => { + const location = click.location.city + ',' + click.location.state; + if (!groups[location]) { + groups[location] = []; + } + const date1 = new Date(); + const date2 = new Date(click.time.split('T')[0]); + const diffDays = Math.ceil((date1 - date2) / (1000 * 60 * 60 * 24)); + if (diffDays <= 31) { + groups[location].push(click); + } + return groups; + }, {}); + const groupArrays = Object.keys(groups).map((location) => { + return { + name: location, + y: groups[location].length, + }; + }); + options = { + chart: { + plotBackgroundColor: '#fbfbfb', + plotBorderWidth: null, + plotShadow: false, + type: 'pie', + }, + legend: { + layout: 'vertical', + align: 'right', + verticalAlign: 'middle', + }, + title: { + text: 'Clicks by Location', + align: 'left', + }, + tooltip: { + pointFormat: '{series.name}: {point.percentage:.1f}%', + }, + plotOptions: { + pie: { + dataLabels: { + enabled: true, + format: '{point.name}:{point.percentage:.1f} %', + }, + }, + }, + series: [{ + name: 'Clicks', + colorByPoint: true, + data: groupArrays, + }], + }; + } else if (variant === 'CLICKS_BY_URLS') { + const groups = urls.reduce((groups, url) => { + const clicks = url.clicks ? url.clicks : []; + const title = url.title ? url.title : url.shortUrl; + for (const click of clicks) { + const date1 = new Date(); + const date2 = new Date(click.time.split('T')[0]); + const diffDays = Math.ceil((date1 - date2) / (1000 * 60 * 60 * 24)); + if (!groups[title]) { + groups[title] = []; + } + if (diffDays <= 31) { + groups[title].push(click); + } + } + return groups; + }, {}); + const groupArrays = Object.keys(groups).map((title) => { + return { + name: title, + y: groups[title].length, + }; + }); + options = { + chart: { + plotBackgroundColor: '#fbfbfb', + plotBorderWidth: null, + plotShadow: false, + type: 'pie', + }, + legend: { + layout: 'vertical', + align: 'right', + verticalAlign: 'middle', + }, + title: { + text: 'Clicks last month', + align: 'left', + }, + tooltip: { + pointFormat: '{series.name}: {point.percentage:.1f}%', + }, + plotOptions: { + pie: { + dataLabels: { + enabled: true, + format: '{point.name}:{point.percentage:.1f} %', + }, + }, + }, + series: [{ + name: 'Clicks', + colorByPoint: true, + data: groupArrays, + }], + }; + } + return ( +
+
+ +
+
+ ); +} + +PieChart.propTypes = { + variant: PropTypes.string, +}; diff --git a/client/src/components/dashboard/RecentUrlcontainer.js b/client/src/components/dashboard/RecentUrlcontainer.js new file mode 100644 index 0000000..300513b --- /dev/null +++ b/client/src/components/dashboard/RecentUrlcontainer.js @@ -0,0 +1,61 @@ +import { + Card, + CardActions, + CardHeader, + makeStyles, + useMediaQuery, + useTheme, +} from '@material-ui/core'; +import React from 'react'; +import UrlList from './UrlList'; + +// Create styles for the component +const useStyles = makeStyles((theme) => ({ + container: { + display: 'flex', + flexGrow: 1, + flexBasis: '50%', + }, +})); + +// Url Container component +const UrlContainer = (props) => { + const theme = useTheme(); + const classes = useStyles(); + const mediaMinSm = useMediaQuery(theme.breakpoints.up('sm')); + + return ( +
+ + + +
+ +
+
+
+
); +}; + +export default UrlContainer; diff --git a/client/src/components/dashboard/UrlList/index.css b/client/src/components/dashboard/UrlList/index.css new file mode 100644 index 0000000..ad2de61 --- /dev/null +++ b/client/src/components/dashboard/UrlList/index.css @@ -0,0 +1,10 @@ +.item{ + cursor: 'pointer'; + height: '12%'; +} +.active{ + display: flex; + width: '100%'; + background-color: slategray; + color: white; +} \ No newline at end of file diff --git a/client/src/components/dashboard/UrlList/index.js b/client/src/components/dashboard/UrlList/index.js new file mode 100644 index 0000000..0aeecf8 --- /dev/null +++ b/client/src/components/dashboard/UrlList/index.js @@ -0,0 +1,157 @@ +import { makeStyles } from '@material-ui/core'; +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setIsSnackbarOpen } from '../../../redux/slices/snackbar'; +import { fetchUrls, setSelectedUrl } from '../../../redux/slices/urls'; +import './index.css'; + +// Create the styles for the components +const useStyles = makeStyles((theme) => ({ + list: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + }, +})); + +// Function to style and render an url item +const UrlItem = ({ url, onClick, active }) => { + // Extract and store the date of creation + const createdAt = new Date(url.createdAt).toDateString().substring(4); + /** Check if current url has been clicked or not + * if yes then then extract its length + * else store 0 + */ + const clicksLength = url.clicks ? url.clicks.length : 0; + + return ( +
+
+

{createdAt}

+
+
+

{url.title}

+
+
+

{url.shortUrl}

+
+
+

{clicksLength}

+
+
+ ); +}; + +const UrlList = (props) => { + const [selected, setSelected] = useState(); + const classes = useStyles(); + const dispatch = useDispatch(); + const token = useSelector((state) => state.auth.authToken); + const urls = useSelector((state) => state.urls.urls); + const error = useSelector((state) => state.urls.error); + + /** Function to dispatch the fetch Urls reducer + * which updates the url list current component is using if neccessary + */ + const fetchingUrls = useCallback(() => { + // fetch the urls only if user is logged in + if (token) { + dispatch(fetchUrls({ token })); + } + }, [dispatch, token]); + // Slice out the five most recent Urls + + useEffect(() => { + fetchingUrls(); + }, + [fetchingUrls]); + + // If there is some error in fetching urls then notify the user accordingly + if (error) { + dispatch(setIsSnackbarOpen({ + status: 'open', + message: 'Some Unknown Error occured in fetching urls, Please Refresh', + severity: 'error', + })); + } + return ( +
+ +
+

Date

+
+
+

Title

+
+
+

Short Url

+
+
+

Clicks

+
+
+
+ {urls.map((url, index) => { + return { + dispatch(setSelectedUrl({ selectedUrl: url })); + return setSelected(index); + }}/>; + })} +
+
+ ); +}; + +UrlItem.propTypes = { + url: PropTypes.object, + onClick: PropTypes.func, + active: PropTypes.bool, +}; + +export default UrlList; diff --git a/client/src/components/dashboard/index.js b/client/src/components/dashboard/index.js new file mode 100644 index 0000000..1353ba5 --- /dev/null +++ b/client/src/components/dashboard/index.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { + CssBaseline, + makeStyles, +} from '@material-ui/core'; +import UrlContainer from './RecentUrlcontainer'; +import PieChart from './Graph/PieChart'; +import LineChart from './Graph/LineChart'; + +// Create styles for this component +const useStyles = makeStyles((theme) => ({ + root: { + margin: '10px', + }, + container: { + 'display': 'flex', + 'height': '100%', + 'width': '100%', + }, + leftContainer: { + display: 'flex', + flexDirection: 'column', + margin: '10px', + flexGrow: 3, + }, + rightContainer: { + display: 'flex', + flexDirection: 'column', + padding: '10px', + alignItems: 'center', + flexGrow: 2, + }, +})); + +// Function to render the Dashboard component +export default function Dashboard() { + const classes = useStyles(); + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ ); +}; diff --git a/client/src/graphQl/queries/getUrlsQuery.js b/client/src/graphQl/queries/getUrlsQuery.js new file mode 100644 index 0000000..ab08fe3 --- /dev/null +++ b/client/src/graphQl/queries/getUrlsQuery.js @@ -0,0 +1,27 @@ + +const getUrlsQuery = () => { + const graphqlQuery = { + query: `query{ + getUrls{ + urls{ + owner + _id + title + longUrl + shortUrl + createdAt + updatedAt + clicks{ + ip + time + location{ + city + state + } + } + } + } +}` }; + return graphqlQuery; +}; +export default getUrlsQuery; diff --git a/client/src/graphQl/queries/loginQuery.js b/client/src/graphQl/queries/loginQuery.js index 2a39ac4..b9fa0a2 100644 --- a/client/src/graphQl/queries/loginQuery.js +++ b/client/src/graphQl/queries/loginQuery.js @@ -1,4 +1,4 @@ -const loginQuery=(authData)=>{ +const loginQuery = (authData) => { const graphqlQuery = { query: ` query{ diff --git a/client/src/redux/actionTypes.js b/client/src/redux/actionTypes.js index 695696f..d98e987 100644 --- a/client/src/redux/actionTypes.js +++ b/client/src/redux/actionTypes.js @@ -2,3 +2,5 @@ export const SET_IS_DRAWER_OPEN = 'SET_IS_DRAWER_OPEN'; export const FETCH_IS_DRAWER_OPEN = 'FETCH_IS_DRAWER_OPEN'; export const LOGIN = 'LOGIN'; export const FETCH_LOGIN = 'FETCH_LOGIN'; +export const FETCH_URLS = 'FETCH_URLS'; +export const SET_SELECTED_URL = 'SET_SELECTED_URL'; diff --git a/client/src/redux/rootReducer.js b/client/src/redux/rootReducer.js index 26bc3b5..1bc22b3 100644 --- a/client/src/redux/rootReducer.js +++ b/client/src/redux/rootReducer.js @@ -2,9 +2,10 @@ import { combineReducers } from 'redux'; import { drawerSlice } from './slices/drawer'; import { authSlice } from './slices/auth'; import { snackbarSlice } from './slices/snackbar'; - +import { urlSlice } from './slices/urls'; export default combineReducers({ [drawerSlice.name]: drawerSlice.reducer, [authSlice.name]: authSlice.reducer, [snackbarSlice.name]: snackbarSlice.reducer, + [urlSlice.name]: urlSlice.reducer, }); diff --git a/client/src/redux/slices/urls.js b/client/src/redux/slices/urls.js new file mode 100644 index 0000000..9a23b0c --- /dev/null +++ b/client/src/redux/slices/urls.js @@ -0,0 +1,47 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import getUrlsQuery from '../../graphQl/queries/getUrlsQuery'; +import axios from '../../utils/axios'; + +import { FETCH_URLS, SET_SELECTED_URL } from '../actionTypes'; + +export const fetchUrls = createAsyncThunk(FETCH_URLS, + async (payload) => { + try { + const { token } = payload; + const graphqlQuery = getUrlsQuery(); + const response = await axios.post('/', graphqlQuery, { + headers: { + Authorization: token, + }, + }); + const urls = response.data.data.getUrls.urls; + + return { urls }; + } catch (err) { + return { error: true }; + } + }); + +export const setSelectedUrl = createAsyncThunk(SET_SELECTED_URL, + async (payload) => { + const { selectedUrl } = payload; + localStorage.setItem('selectedUrlId', selectedUrl._id); + return { + selectedUrl, + }; + }); + +export const urlSlice = createSlice({ + name: 'urls', + initialState: { urls: [], selectedUrl: null, error: false }, + extraReducers: { + [fetchUrls.fulfilled]: (state, action) => ({ + ...state, + ...action.payload, + }), + [setSelectedUrl.fulfilled]: (state, action) => ({ + ...state, + ...action.payload, + }), + }, +}); diff --git a/package.json b/package.json index 5698e01..49bbb25 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "install": "concurrently --kill-others-on-fail \"cd ./client && npm install\" \"cd ./server && npm install\" \"cd ./redirection-server && npm install\"", "start-client": "cd ./client && npm start", "start-server": "cd ./server && npm start", - "start-redirection-server":"cd ./redirection-server && npm start", + "start-redirection-server": "cd ./redirection-server && npm start", "dev": "concurrently --kill-others-on-fail \"npm run start-server\" \"npm run start-client\" \"npm run start-redirection-server\"", "start": "npm run start-server", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/redirection-server/middlewares/redirect.js b/redirection-server/middlewares/redirect.js index 0e53c16..4d5132e 100644 --- a/redirection-server/middlewares/redirect.js +++ b/redirection-server/middlewares/redirect.js @@ -38,19 +38,21 @@ const redirect = async (req, res) => { * after every redirection request */ - const ip = (req.headers['x-forwarded-for'] || '').split(',').pop().trim() || + let ip = (req.headers['x-forwarded-for'] || '').split(',').pop().trim() || req.connection.remoteAddress || req.socket.remoteAddress || req.connection.socket.remoteAddress; + if (ip === '::1') { + ip = '49.37.5.225'; + } const time = new Date().toISOString(); const apiEndpoint = BASE_URL + ip + '?access_key=' + key; const location = await axios.get(apiEndpoint); const click = new Click({ - location: location.data, + location: { city: location.data.city, state: location.data.region_name }, time, ip, }); - await click.save(); return Url.updateOne({ shortUrl: hash }, { $push: { clicks: click, diff --git a/redirection-server/models/Url.js b/redirection-server/models/Url.js index 91996ca..23c0f2c 100644 --- a/redirection-server/models/Url.js +++ b/redirection-server/models/Url.js @@ -12,9 +12,25 @@ const urlSchema = new Schema( type: String, required: true, }, + title: { + type: String, + }, + description: { + type: String, + }, clicks: [{ - type: mongoose.Types.ObjectId, - ref: 'Click', + location: { + type: 'Object', + required: true, + }, + time: { + type: String, + required: true, + }, + ip: { + type: String, + required: true, + }, }], owner: { type: mongoose.Types.ObjectId, diff --git a/redirection-server/package-lock.json b/redirection-server/package-lock.json index 7e039ac..3886437 100644 --- a/redirection-server/package-lock.json +++ b/redirection-server/package-lock.json @@ -1015,8 +1015,7 @@ "follow-redirects": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==", - "dev": true + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" }, "forwarded": { "version": "0.1.2", diff --git a/server/graphQl/resolvers.js b/server/graphQl/resolvers.js index c0df23e..9c2e6b0 100644 --- a/server/graphQl/resolvers.js +++ b/server/graphQl/resolvers.js @@ -4,6 +4,12 @@ const urlShortener = require('../utils/urlShortener'); const validator = require('../validator'); const User = require('../models/User'); const Url = require('../models/Url'); +const { GraphQLDateTime } = require('graphql-iso-date'); +const { ObjectID } = require('mongodb'); + +const customScalarResolver = { + Date: GraphQLDateTime, +}; // Sign in resolver const signUp = async ({ UserInput }) => { @@ -194,6 +200,29 @@ const addDetails = async ({ title, description, shortUrl, updatedShortUrl }, req }; } }; + +// Resolver to fetch all the urls belonging to the current user +const getUrls = async ({ }, request) => { + // Get the user id from the request + const { userId } = request; + // If user is not logged in then ask him to login first + if (!userId) { + const error = new Error('Please Login'); + error.code = 401; + throw error; + } + const urls = await Url.aggregate([{ + $match: { + owner: new ObjectID(userId), + }, + }, { + $sort: { 'createdAt': -1 }, + }], + ); + // Retrieve all the urls owned by the user + return { urls }; +}; + module.exports = { - signUp, login, shortenUrl, addDetails, + signUp, login, shortenUrl, addDetails, getUrls, customScalarResolver, }; diff --git a/server/graphQl/schema.js b/server/graphQl/schema.js index 366dd21..e686586 100644 --- a/server/graphQl/schema.js +++ b/server/graphQl/schema.js @@ -1,34 +1,37 @@ const { buildSchema } = require('graphql'); module.exports = buildSchema(` + scalar Date type Url{ - _id : ID! - ownerid : ID! + _id : String! + owner : String! longUrl : String! shortUrl : String! - expirydate : String! - createdAt : String! - readwriteaccess : Access! + expirydate : Date! + title : String + description : String + createdAt : Date! + updatedAt : Date! + readaccess : [User] + writeaccess : [User] clicks : [Click!] } type User{ - _id : ID! + _id : String! name : String! email : String! password : String! - createdAt : String! - updatedAt : String! - urls : [Url!] + createdAt : Date! + updatedAt : Date! } - - type Click{ - email : String! - clickedAt : String! - location : String! + type location{ + city : String + state : String } - type Access{ - email : String! - permissions : [String!]! + type Click{ + ip : String! + time : Date! + location : location! } type AuthToken{ token : String! @@ -41,21 +44,20 @@ module.exports = buildSchema(` email : String! password : String! } - type PostData{ - urls : [Url!]! - totalurls : Int! - } type RootQuery{ login(email : String! , password : String!) : AuthToken! - urls : PostData! + getUrls: urls! } type shortUrl{ - _id : ID!, + _id : String!, longUrl : String!, shortUrl : String!, title : String, description : String } + type urls{ + urls : [Url!] + } type RootMutation{ signUp(UserInput : UserInputData) : GenericMessage! shortenUrl(longUrl : String!) : shortUrl! diff --git a/server/models/Url.js b/server/models/Url.js index 91996ca..23c0f2c 100644 --- a/server/models/Url.js +++ b/server/models/Url.js @@ -12,9 +12,25 @@ const urlSchema = new Schema( type: String, required: true, }, + title: { + type: String, + }, + description: { + type: String, + }, clicks: [{ - type: mongoose.Types.ObjectId, - ref: 'Click', + location: { + type: 'Object', + required: true, + }, + time: { + type: String, + required: true, + }, + ip: { + type: String, + required: true, + }, }], owner: { type: mongoose.Types.ObjectId, diff --git a/server/package-lock.json b/server/package-lock.json index cc21b20..efb8020 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1380,6 +1380,11 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz", "integrity": "sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w==" }, + "graphql-iso-date": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz", + "integrity": "sha512-AwFGIuYMJQXOEAgRlJlFL4H1ncFM8n8XmoVDTNypNOZyQ8LFDG2ppMFlsS862BSTCDcSUfHp8PD3/uJhv7t59Q==" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", diff --git a/server/package.json b/server/package.json index 1a99aae..45f19a7 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "express-graphql": "^0.11.0", "express-validator": "^6.6.1", "graphql": "^15.3.0", + "graphql-iso-date": "^3.6.1", "jsonwebtoken": "^8.5.1", "jwt-decode": "^3.0.0", "mongodb": "^3.6.2",