diff --git a/app/controllers/dashboard.js b/app/controllers/dashboard.js new file mode 100644 index 0000000..5a894ea --- /dev/null +++ b/app/controllers/dashboard.js @@ -0,0 +1,58 @@ +// dashboard.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'dashboard'; + +const express = require('express'); + +const { SiteController } = require('../../lib/site-lib'); + +class DashboardController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService, session: sessionService } = dtp.services; + + const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); + + const router = express.Router(); + dtp.app.use('/dashboard', router); + + router.use(async (req, res, next) => { + res.locals.currentView = DTP_COMPONENT_NAME; + return next(); + }); + + router.get('/', + limiterService.create(limiterService.config.dashboard.getDashboardView), + authRequired, + this.getDashboardView.bind(this), + ); + } + + async getDashboardView (req, res, next) { + const { dashboard: dashboardService, link: linkService } = this.dtp.services; + try { + res.locals.userVisitStats = await dashboardService.getUserVisitStats(req.user); + res.locals.userCountryStats = await dashboardService.getUserCountryStats(req.user); + res.locals.userCityStats = await dashboardService.getUserCityStats(req.user); + res.locals.userLinks = await linkService.getForUser(req.user); + res.render('dashboard/view'); + } catch (error) { + this.log.error('failed to display dashboard', { userId: req.user._id, error }); + return next(error); + } + } +} + +module.exports = async (dtp) => { + let controller = new DashboardController(dtp); + return controller; +}; \ No newline at end of file diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index f46f445..e90b4dc 100644 --- a/app/models/lib/resource-stats.js +++ b/app/models/lib/resource-stats.js @@ -9,15 +9,11 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; module.exports.ResourceStats = new Schema({ + uniqueVisitCount: { type: Number, default: 0, required: true }, totalVisitCount: { type: Number, default: 0, required: true }, - upvoteCount: { type: Number, default: 0, required: true }, - downvoteCount: { type: Number, default: 0, required: true }, - commentCount: { type: Number, default: 0, required: true }, }); module.exports.ResourceStatsDefaults = { + uniqueVisitCount: 0, totalVisitCount: 0, - upvoteCount: 0, - downvoteCount: 0, - commentCount: 0, }; \ No newline at end of file diff --git a/app/models/resource-view.js b/app/models/resource-view.js index 05c4fa5..c9cb1d1 100644 --- a/app/models/resource-view.js +++ b/app/models/resource-view.js @@ -15,7 +15,7 @@ const ResourceViewSchema = new Schema({ resourceType: { type: String, enum: RESOURCE_TYPE_LIST, required: true }, resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' }, uniqueKey: { type: String, required: true, index: 1 }, - viewCount: { type: Number, default: 0, required: true }, + visitCount: { type: Number, default: 0, required: true }, }); ResourceViewSchema.index({ diff --git a/app/services/dashboard.js b/app/services/dashboard.js new file mode 100644 index 0000000..64ed80f --- /dev/null +++ b/app/services/dashboard.js @@ -0,0 +1,247 @@ +// dashboard.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Link = mongoose.model('Link'); +const LinkVisit = mongoose.model('LinkVisit'); + +const moment = require('moment'); + +const { SiteService } = require('../../lib/site-lib'); + +class DashboardService extends SiteService { + + static get CACHE_ENABLED ( ) { return process.env.LINKS_DASHBOARD_CACHE === 'enabled'; } + + constructor (dtp) { + super(dtp, module.exports); + } + + async getUserVisitStats (user) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:user:${user._id}:visit`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getUserStats', 'generating user visit stats report', { userId: user._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + const links = await Link.find({ user: user._id }).lean(); + const linkIds = links.map((link) => link._id); + + stats = await LinkVisit.aggregate([ + { + $match: { + link: { $in: linkIds }, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + year: { $year: '$created' }, + month: { $month: '$created' }, + day: { $dayOfMonth: '$created' }, + hour: { $hour: '$created' }, + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + date: { + $dateFromParts: { + year: '$_id.year', + month: '$_id.month', + day: '$_id.day', + hour: '$_id.hour', + }, + }, + count: '$count', + }, + }, + { + $sort: { date: 1 }, + }, + ]); + + stats = await Link.populate(stats, [ + { + path: 'link', + model: 'Link', + }, + ]); + + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } + + async getUserCountryStats (user) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:user:${user._id}:country`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getUserCountryStats', 'generating user country stats report', { userId: user._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + const links = await Link.find({ user: user._id }).lean(); + const linkIds = links.map((link) => link._id); + + stats = await LinkVisit.aggregate([ + { + $match: { + link: { $in: linkIds }, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + count: '$count', + }, + }, + { + $sort: { count: -1, country: 1 }, + }, + { + $limit: 10, + }, + ]); + + stats = await Link.populate(stats, [ + { + path: 'link', + model: 'Link', + }, + ]); + + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } + + async getUserCityStats (user) { + const { cache: cacheService } = this.dtp.services; + let stats; + + const cacheKey = `stats:user:${user._id}:city`; + if (DashboardService.CACHE_ENABLED) { + stats = await cacheService.getObject(cacheKey); + if (stats) { + return stats; + } + } + + this.log.info('getUserCityStats', 'generating user city stats report', { userId: user._id }); + + const END_DATE = new Date(); + const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); + + const links = await Link.find({ user: user._id }).lean(); + const linkIds = links.map((link) => link._id); + + stats = await LinkVisit.aggregate([ + { + $match: { + link: { $in: linkIds }, + $and: [ + { created: { $gt: START_DATE } }, + { created: { $lt: END_DATE } }, + ], + }, + }, + { + $group: { + _id: { + country: '$geoip.country', + region: '$geoip.region', + city: '$geoip.city', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: false, + country: '$_id.country', + region: '$_id.region', + city: '$_id.city', + count: '$count', + }, + }, + { + $sort: { count: -1, city: 1, region: 1, country: 1 }, + }, + { + $limit: 10, + }, + ]); + + stats = await Link.populate(stats, [ + { + path: 'link', + model: 'Link', + }, + ]); + + const response = { + start: START_DATE, + end: END_DATE, + stats, + }; + await cacheService.setObjectEx(cacheKey, 60 * 5, response); + return response; + } +} + +module.exports = { + slug: 'dashboard', + name: 'dashboard', + create: (dtp) => { return new DashboardService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/link.js b/app/services/link.js index 8b39c33..70d2e33 100644 --- a/app/services/link.js +++ b/app/services/link.js @@ -77,12 +77,20 @@ class LinkService extends SiteService { } async getForUser (user, pagination) { - const links = await Link - .find({ user: user._id }) - .sort({ order: 1 }) - .skip(pagination.skip) - .limit(pagination.cpp) - .lean(); + let links; + if (pagination) { + links = await Link + .find({ user: user._id }) + .sort({ order: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + } else { + links = await Link + .find({ user: user._id }) + .sort({ order: 1 }) + .lean(); + } return links; } @@ -108,7 +116,8 @@ class LinkService extends SiteService { /* * We geo-analyze (but do not store) the IP address. */ - const geo = geoip.lookup(req.ip); + const ipAddress = (req.ip === '127.0.0.1') ? '104.131.58.173' : req.ip; + const geo = geoip.lookup(ipAddress); if (geo) { visit.geoip = { country: geo.country, @@ -126,6 +135,8 @@ class LinkService extends SiteService { } await visit.save(); + + await Link.updateOne({ _id: link._id }, { $inc: { 'stats.totalVisitCount': 1 } }); } async setItemOrder (links) { diff --git a/app/services/resource.js b/app/services/resource.js index ef3b8a0..932999c 100644 --- a/app/services/resource.js +++ b/app/services/resource.js @@ -40,6 +40,7 @@ class ResourceService extends SiteService { uniqueKey += `:user:${req.user._id.toString()}`; } + const Model = mongoose.model(resourceType); const response = await ResourceView.updateOne( { created: CURRENT_DAY, @@ -48,17 +49,16 @@ class ResourceService extends SiteService { uniqueKey, }, { - $inc: { viewCount: 1 }, + $inc: { 'stats.visitCount': 1 }, }, { upsert: true }, ); - this.log.debug('resource view', { response }); + if (response.upsertedCount > 0) { - const Model = mongoose.model(resourceType); await Model.updateOne( { _id: resourceId }, { - $inc: { 'stats.totalViewCount': 1 }, + $inc: { 'stats.uniqueVisitCount': 1 }, }, ); } diff --git a/app/views/dashboard/view.pug b/app/views/dashboard/view.pug new file mode 100644 index 0000000..548f75e --- /dev/null +++ b/app/views/dashboard/view.pug @@ -0,0 +1,94 @@ +extends ../layouts/main +block content + + section.uk-section.uk-section-default + .uk-container.uk-container-expand + + .uk-margin + h1 #{site.name} Dashboard #[small(class="uk-visible@m") for #{user.displayName || user.username}] + + div(uk-grid).uk-grid-small + + //- + //- Main Content Column + //- + + div(class="uk-width-1-1 uk-width-2-3@m") + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + h4.uk-card-title Link Visits #[small.uk-text-muted past 7 days] + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-expand@m") + canvas(id="profile-stats", width="960", height="240") + div(class="uk-width-1-1 uk-width-auto@m") + .uk-margin + .uk-text-small start + .uk-text-bold= moment(userVisitStats.start).format('MMM DD, YYYY h:mm:ss a') + .uk-margin + .uk-text-small end + .uk-text-bold= moment(userVisitStats.end).format('MMM DD, YYYY h:mm:ss a') + + .uk-margin + .uk-card.uk-card-secondary.uk-card-small + .uk-card-header + h2.uk-card-title Link Analytics #[small.uk-text-muted all-time] + .uk-card-body + ul.uk-list + each link in userLinks + li + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-2-3 + .uk-text-lead + a(href=`/dashboard/link/${link._id}`).uk-text-truncate= link.label + .uk-width-1-6 + .uk-text-small.uk-text-muted unique + div= formatCount(link.stats.uniqueVisitCount) + .uk-width-1-6 + .uk-text-small.uk-text-muted total + div= formatCount(link.stats.totalVisitCount) + + //- + //- Sidebar + //- + + div(class="uk-width-1-1 uk-width-1-3@m") + + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + h5.uk-card-title Top Countries + table.uk-table.uk-table-small + thead + tr + th Country + th Visits + tbody + each entry in userCountryStats.stats + tr + td.uk-table-expand= entry.country ? entry.country : '--' + td= entry.count + + .uk-margin + .uk-card.uk-card-secondary.uk-card-small.uk-card-body + h5.uk-card-title Top Cities + table.uk-table.uk-table-small + thead + tr + th City + th(class="uk-visible@m") State + th Country + th Visits + tbody + each entry in userCityStats.stats + tr + td= entry.city ? entry.city : '--' + td(class="uk-visible@m")= entry.region ? entry.region : '--' + td= entry.country ? entry.country : '--' + td= entry.count + +block viewjs + script(src="/chart.js/chart.min.js") + script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js") + script. + window.addEventListener('dtp-load', ( ) => { + dtp.app.renderProfileStats('canvas#profile-stats', !{JSON.stringify(userVisitStats.stats)}); + }); \ No newline at end of file diff --git a/app/views/index-logged-in.pug b/app/views/index-logged-in.pug index f345eea..7f2507d 100644 --- a/app/views/index-logged-in.pug +++ b/app/views/index-logged-in.pug @@ -10,6 +10,9 @@ block content div(uk-grid).uk-grid-small .uk-width-expand h3.uk-heading-bullet.uk-margin-small Your links + .uk-width-auto + a(href='/dashboard').uk-button.dtp-button-default.uk-button-small + +renderButtonIcon('fa-tachometer-alt', 'Dashboard') .uk-width-auto button(type="button", uk-toggle={ target: '#link-editor' }).uk-button.dtp-button-primary.uk-button-small +renderButtonIcon('fa-plus', 'Add Link') diff --git a/app/views/link/components/list-item.pug b/app/views/link/components/list-item.pug index 64c74b5..4ba1ca8 100644 --- a/app/views/link/components/list-item.pug +++ b/app/views/link/components/list-item.pug @@ -4,8 +4,15 @@ mixin renderLinksListItem (link) .uk-width-auto span i.fas.fa-grip-lines + .uk-width-expand a(href= link.href).uk-button.dtp-button-primary.uk-button-small.uk-border-rounded= link.label + + .uk-width-auto + div(uk-grid).uk-grid-small.uk-flex-middle.uk-text-small.uk-text-muted + .uk-width-auto unique: #{formatCount(link.stats.uniqueVisitCount)} + .uk-width-auto total: #{formatCount(link.stats.totalVisitCount)} + .uk-width-auto button(type="button", uk-toggle={ target: `#link-editor-${link._id}` }).uk-button.dtp-button-default.uk-button-small span @@ -19,6 +26,7 @@ mixin renderLinksListItem (link) ).uk-button.dtp-button-danger.uk-button-small.uk-border-rounded span i.fas.fa-trash + div(id= `link-editor-${link._id}`, hidden).uk-margin .uk-card.uk-card-secondary.uk-card-small.uk-card-body - +renderLinkEditor(`#link-editor-${link._id}`, link) + +renderLinkEditor(`#link-editor-${link._id}`, link) \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index d6761fc..0f6a5bb 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -15,6 +15,13 @@ import Cropper from 'cropperjs'; import { EmojiButton } from '@joeattardi/emoji-button'; +const GRID_COLOR = '#a0a0a0'; +const GRID_TICK_COLOR = '#707070'; + +const AXIS_TICK_COLOR = '#c0c0c0'; + +const CHART_LINE_USER = 'rgb(0, 192, 0)'; + export default class DtpSiteApp extends DtpApp { constructor (user) { @@ -39,6 +46,8 @@ export default class DtpSiteApp extends DtpApp { if (this.chat.input) { this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); } + + this.charts = {/* will hold rendered charts */}; } async connect ( ) { @@ -564,6 +573,66 @@ export default class DtpSiteApp extends DtpApp { UIkit.modal.alert(`Failed to delete link: ${error.message}`); } } + + async renderProfileStats (selector, data) { + try { + const canvas = document.querySelector(selector); + const ctx = canvas.getContext('2d'); + this.charts.profileStats = new Chart(ctx, { + type: 'line', + data: { + labels: data.map((item) => new Date(item.date)), + datasets: [ + { + label: 'Link Visits', + data: data.map((item) => item.count), + borderColor: CHART_LINE_USER, + tension: 0.5, + }, + ], + }, + options: { + scales: { + yAxis: { + display: true, + ticks: { + color: AXIS_TICK_COLOR, + callback: (value) => { + return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0'); + }, + }, + grid: { + color: GRID_COLOR, + tickColor: GRID_TICK_COLOR, + }, + }, + + x: { + type: 'time', + }, + xAxis: { + display: false, + grid: { + color: GRID_COLOR, + tickColor: GRID_TICK_COLOR, + }, + }, + }, + plugins: { + title: { display: false }, + subtitle: { display: false }, + legend: { + display: true, + position: 'bottom', + }, + }, + }, + }); + } catch (error) { + this.log.error('renderProfileStats', 'failed to render profile stats', { error }); + UIkit.modal.alert(`Failed to render chart: ${error.message}`); + } + } } dtp.DtpSiteApp = DtpSiteApp; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index f36aacc..4ba7b60 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -50,12 +50,7 @@ module.exports = { * DashboardController */ dashboard: { - getEpisodeView: { - total: 15, - expire: ONE_MINUTE, - message: 'You are loading the dashboard episode view too quickly', - }, - getHome: { + getDashboardView: { total: 15, expire: ONE_MINUTE, message: 'You are loading the publisher dashboard too quickly', diff --git a/lib/site-platform.js b/lib/site-platform.js index b82877d..1afe1e6 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -209,6 +209,7 @@ module.exports.startWebServer = async (dtp) => { */ module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist'))); module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist'))); + module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist'))); module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free'))); module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min'))); diff --git a/package.json b/package.json index 8d11341..4821edb 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "argv": "^0.0.2", "bull": "^4.1.1", "chart.js": "^3.6.2", + "chartjs-adapter-moment": "^1.0.0", "compression": "^1.7.4", "connect-redis": "^6.0.0", "cookie-parser": "^1.4.6", diff --git a/yarn.lock b/yarn.lock index 484a3e3..85d5086 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2121,6 +2121,11 @@ chart.js@^3.6.2: resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.2.tgz#47342c551f688ffdda2cd53b534cb7e461ecec33" integrity sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg== +chartjs-adapter-moment@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.0.tgz#9174b1093c68bcfe285aff24f7388ad60d44e8f7" + integrity sha512-PqlerEvQcc5hZLQ/NQWgBxgVQ4TRdvkW3c/t+SUEQSj78ia3hgLkf2VZ2yGJtltNbEEFyYGm+cA6XXevodYvWA== + chokidar@^2.0.0: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"