Browse Source

analytics: user profile visit stats

- refactored LinkVisit to ResourceVisit
- centralized all unique/total view counting into resource service
- added visit counting for user public profile
- added graph for public profile visits to Pro Dashboard
master
Rob Colbert 2 years ago
parent
commit
c1449a16ac
  1. 7
      app/controllers/dashboard.js
  2. 5
      app/controllers/home.js
  3. 12
      app/controllers/link.js
  4. 9
      app/models/lib/geo-types.js
  5. 35
      app/models/link-visit.js
  6. 29
      app/models/resource-visit.js
  7. 7
      app/models/user.js
  8. 173
      app/services/dashboard.js
  9. 41
      app/services/link.js
  10. 59
      app/services/resource.js
  11. 29
      app/views/dashboard/view.pug
  12. 12
      client/js/site-app.js
  13. 17
      data/patches/link-visit-port.js
  14. 20
      data/patches/user-stats.js

7
app/controllers/dashboard.js

@ -78,11 +78,12 @@ class DashboardController extends SiteController {
async getDashboardView (req, res, next) { async getDashboardView (req, res, next) {
const { dashboard: dashboardService, link: linkService } = this.dtp.services; const { dashboard: dashboardService, link: linkService } = this.dtp.services;
try { try {
res.locals.userVisitStats = await dashboardService.getUserVisitStats(req.user); res.locals.profileVisitStats = await dashboardService.getResourceVisitStats('User', req.user._id);
res.locals.userCountryStats = await dashboardService.getUserCountryStats(req.user); res.locals.linkVisitStats = await dashboardService.getUserVisitStats(req.user);
res.locals.userCityStats = await dashboardService.getUserCityStats(req.user);
res.locals.userLinks = await linkService.getForUser(req.user); res.locals.userLinks = await linkService.getForUser(req.user);
res.locals.linkCountryStats = await dashboardService.getUserCountryStats(req.user);
res.locals.linkCityStats = await dashboardService.getUserCityStats(req.user);
res.render('dashboard/view'); res.render('dashboard/view');
} catch (error) { } catch (error) {

5
app/controllers/home.js

@ -54,7 +54,7 @@ class HomeController extends SiteController {
} }
async getPublicProfile (req, res, next) { async getPublicProfile (req, res, next) {
const { link: linkService } = this.dtp.services; const { link: linkService, user: userService } = this.dtp.services;
try { try {
this.log.debug('profile request', { url: req.url }); this.log.debug('profile request', { url: req.url });
if (!res.locals.userProfile) { if (!res.locals.userProfile) {
@ -63,6 +63,9 @@ class HomeController extends SiteController {
res.locals.currentView = 'public-profile'; res.locals.currentView = 'public-profile';
res.locals.pagination = this.getPaginationParameters(req, 20); res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.links = await linkService.getForUser(res.locals.userProfile, res.locals.pagination); res.locals.links = await linkService.getForUser(res.locals.userProfile, res.locals.pagination);
await userService.recordProfileView(res.locals.userProfile, req);
res.render('profile/home'); res.render('profile/home');
} catch (error) { } catch (error) {
this.log.error('failed to display landing page', { error }); this.log.error('failed to display landing page', { error });

12
app/controllers/link.js

@ -66,19 +66,11 @@ class LinkController extends SiteController {
} }
async postCreateLinkVisit (req, res, next) { async postCreateLinkVisit (req, res, next) {
const { link: linkService, resource: resourceService } = this.dtp.services; const { resource: resourceService } = this.dtp.services;
try { try {
this.log.info('recording link visit', { link: res.locals.link._id, ip: req.ip }); this.log.info('recording link visit', { link: res.locals.link._id, ip: req.ip });
/* await resourceService.recordView(req, 'Link', res.locals.link._id);
* Do these jobs in parallel so the total work gets done faster
*/
const jobs = [
linkService.recordVisit(res.locals.link, req),
resourceService.recordView(req, 'Link', res.locals.link._id),
];
await Promise.all(jobs);
res.redirect(res.locals.link.href); // off you go! res.redirect(res.locals.link.href); // off you go!
} catch (error) { } catch (error) {

9
app/models/lib/geo-types.js

@ -10,4 +10,13 @@ const Schema = mongoose.Schema;
module.exports.GeoPoint = new Schema({ module.exports.GeoPoint = new Schema({
type: { type: String, enum: ['Point'], default: 'Point', required: true }, type: { type: String, enum: ['Point'], default: 'Point', required: true },
coordinates: { type: [Number], required: true }, coordinates: { type: [Number], required: true },
});
module.exports.GeoIp = new Schema({
country: { type: String },
region: { type: String },
eu: { type: String },
timezone: { type: String },
city: { type: String },
location: { type: module.exports.GeoPoint },
}); });

35
app/models/link-visit.js

@ -1,35 +0,0 @@
// link-visit.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { GeoPoint } = require('./lib/geo-types');
const LinkVisitSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' },
link: { type: Schema.ObjectId, required: true, index: 1, ref: 'Link' },
user: { type: Schema.ObjectId, index: 1, ref: 'User' },
geoip: {
country: { type: String },
region: { type: String },
eu: { type: String },
timezone: { type: String },
city: { type: String },
location: { type: GeoPoint },
},
});
LinkVisitSchema.index({
user: 1,
}, {
partialFilterExpression: {
user: { $exists: true },
},
name: 'link_visits_for_user',
});
module.exports = mongoose.model('LinkVisit', LinkVisitSchema);

29
app/models/resource-visit.js

@ -0,0 +1,29 @@
// resource-visit.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const { GeoIp } = require('./lib/geo-types');
const ResourceVisitSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' },
resourceType: { type: String, enum: ['Link', 'User'], required: true },
resource: { type: Schema.ObjectId, required: true, index: 1, ref: 'Link' },
user: { type: Schema.ObjectId, index: 1, ref: 'User' },
geoip: { type: GeoIp },
});
ResourceVisitSchema.index({
user: 1,
}, {
partialFilterExpression: {
user: { $exists: true },
},
name: 'resource_visits_for_user',
});
module.exports = mongoose.model('ResourceVisit', ResourceVisitSchema);

7
app/models/user.js

@ -8,6 +8,8 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
const { ResourceStats, ResourceStatsDefaults } = require('./lib/resource-stats');
const UserFlagsSchema = new Schema({ const UserFlagsSchema = new Schema({
isAdmin: { type: Boolean, default: false, required: true }, isAdmin: { type: Boolean, default: false, required: true },
isModerator: { type: Boolean, default: false, required: true }, isModerator: { type: Boolean, default: false, required: true },
@ -34,10 +36,7 @@ const UserSchema = new Schema({
}, },
flags: { type: UserFlagsSchema, select: false }, flags: { type: UserFlagsSchema, select: false },
permissions: { type: UserPermissionsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false },
stats: { stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
profileViewCountTotal: { type: Number, default: 0, required: true },
profileViewCountUnique: { type: Number, default: 0, required: true },
}
}); });
module.exports = mongoose.model('User', UserSchema); module.exports = mongoose.model('User', UserSchema);

173
app/services/dashboard.js

@ -7,7 +7,7 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const Link = mongoose.model('Link'); const Link = mongoose.model('Link');
const LinkVisit = mongoose.model('LinkVisit'); const ResourceVisit = mongoose.model('ResourceVisit');
const moment = require('moment'); const moment = require('moment');
@ -21,16 +21,18 @@ class DashboardService extends SiteService {
super(dtp, module.exports); super(dtp, module.exports);
} }
/* async getResourceVisitStats (resourceType, resourceId) {
* if (!resourceId) {
* USER VISIT STATS throw new Error('Invalid resource');
* }
*/
async getUserVisitStats (user) { // this will throw if not a valid ObjectId (or able to become one)
resourceId = mongoose.Types.ObjectId(resourceId);
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:user:${user._id}:visit`; const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:visit`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -38,18 +40,15 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getUserVisitStats', 'generating user visit stats report', { userId: user._id }); this.log.info('generating resource visit stats report', { resourceId });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
const links = await Link.find({ user: user._id }).lean(); stats = await ResourceVisit.aggregate([
const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([
{ {
$match: { $match: {
link: { $in: linkIds }, resource: resourceId,
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },
@ -86,25 +85,25 @@ class DashboardService extends SiteService {
}, },
]); ]);
const response = { const response = { start: START_DATE, end: END_DATE, stats };
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response); await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response; return response;
} }
/* /*
* *
* LINK VISIT STATS * RESOURCE COUNTRY STATS
* *
*/ */
async getLinkVisitStats (link) { async getResourceCountryStats (resourceType, resourceId) {
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:link:${link._id}:visit`; // this will throw if not a valid ObjectId (or able to become one)
resourceId = mongoose.Types.ObjectId(resourceId);
const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:country`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -112,15 +111,15 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getLinkVisitStats', 'generating link visit stats report', { linkId: link._id }); this.log.info('generating resource country stats report', { resourceId });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
stats = await LinkVisit.aggregate([ stats = await ResourceVisit.aggregate([
{ {
$match: { $match: {
link: link._id, resource: resourceId,
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },
@ -130,10 +129,7 @@ class DashboardService extends SiteService {
{ {
$group: { $group: {
_id: { _id: {
year: { $year: '$created' }, country: '$geoip.country',
month: { $month: '$created' },
day: { $dayOfMonth: '$created' },
hour: { $hour: '$created' },
}, },
count: { $sum: 1 }, count: { $sum: 1 },
}, },
@ -141,41 +137,37 @@ class DashboardService extends SiteService {
{ {
$project: { $project: {
_id: false, _id: false,
date: { country: '$_id.country',
$dateFromParts: {
year: '$_id.year',
month: '$_id.month',
day: '$_id.day',
hour: '$_id.hour',
},
},
count: '$count', count: '$count',
}, },
}, },
{ {
$sort: { date: 1 }, $sort: { count: -1, country: 1 },
},
{
$limit: 10,
}, },
]); ]);
const response = { const response = { start: START_DATE, end: END_DATE, stats };
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response); await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response; return response;
} }
/* /*
* *
* USER COUNTRY STATS * RESOURCE CITY STATS
* *
*/ */
async getUserCountryStats (user) { async getResourceCityStats (resourceType, resourceId) {
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:user:${user._id}:country`; // this will throw if not a valid ObjectId (or able to become one)
resourceId = mongoose.Types.ObjectId(resourceId);
const cacheKey = `stats:${resourceType.toLowerCase()}:${resourceId.toString()}:city`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -183,18 +175,15 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getUserCountryStats', 'generating user country stats report', { userId: user._id }); this.log.info('generating resource city stats report', { resourceId });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
const links = await Link.find({ user: user._id }).lean(); stats = await ResourceVisit.aggregate([
const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([
{ {
$match: { $match: {
link: { $in: linkIds }, resource: resourceId,
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },
@ -205,6 +194,8 @@ class DashboardService extends SiteService {
$group: { $group: {
_id: { _id: {
country: '$geoip.country', country: '$geoip.country',
region: '$geoip.region',
city: '$geoip.city',
}, },
count: { $sum: 1 }, count: { $sum: 1 },
}, },
@ -213,36 +204,35 @@ class DashboardService extends SiteService {
$project: { $project: {
_id: false, _id: false,
country: '$_id.country', country: '$_id.country',
region: '$_id.region',
city: '$_id.city',
count: '$count', count: '$count',
}, },
}, },
{ {
$sort: { count: -1, country: 1 }, $sort: { count: -1, city: 1, region: 1, country: 1 },
}, },
{ {
$limit: 10, $limit: 10,
}, },
]); ]);
const response = { const response = { start: START_DATE, end: END_DATE, stats };
start: START_DATE,
end: END_DATE,
stats,
};
await cacheService.setObjectEx(cacheKey, 60 * 5, response); await cacheService.setObjectEx(cacheKey, 60 * 5, response);
return response; return response;
} }
/* /*
* *
* LINK COUNTRY STATS * USER VISIT STATS
* *
*/ */
async getLinkCountryStats (link) { async getUserVisitStats (user) {
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:link:${link._id}:country`; const cacheKey = `stats:user-links:${user._id}:visit`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -250,15 +240,18 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getLinkCountryStats', 'generating link country stats report', { linkId: link._id }); this.log.info('generating user visit stats report', { userId: user._id });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
stats = await LinkVisit.aggregate([ const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id);
stats = await ResourceVisit.aggregate([
{ {
$match: { $match: {
link: link._id, resource: { $in: linkIds },
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },
@ -268,7 +261,10 @@ class DashboardService extends SiteService {
{ {
$group: { $group: {
_id: { _id: {
country: '$geoip.country', year: { $year: '$created' },
month: { $month: '$created' },
day: { $dayOfMonth: '$created' },
hour: { $hour: '$created' },
}, },
count: { $sum: 1 }, count: { $sum: 1 },
}, },
@ -276,15 +272,19 @@ class DashboardService extends SiteService {
{ {
$project: { $project: {
_id: false, _id: false,
country: '$_id.country', date: {
$dateFromParts: {
year: '$_id.year',
month: '$_id.month',
day: '$_id.day',
hour: '$_id.hour',
},
},
count: '$count', count: '$count',
}, },
}, },
{ {
$sort: { count: -1, country: 1 }, $sort: { date: 1 },
},
{
$limit: 10,
}, },
]); ]);
@ -299,14 +299,14 @@ class DashboardService extends SiteService {
/* /*
* *
* USER CITY STATS * USER COUNTRY STATS
* *
*/ */
async getUserCityStats (user) { async getUserCountryStats (user) {
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:user:${user._id}:city`; const cacheKey = `stats:user-links:${user._id}:country`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -314,7 +314,7 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getUserCityStats', 'generating user city stats report', { userId: user._id }); this.log.info('generating user country stats report', { userId: user._id });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
@ -322,10 +322,10 @@ class DashboardService extends SiteService {
const links = await Link.find({ user: user._id }).lean(); const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id); const linkIds = links.map((link) => link._id);
stats = await LinkVisit.aggregate([ stats = await ResourceVisit.aggregate([
{ {
$match: { $match: {
link: { $in: linkIds }, resource: { $in: linkIds },
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },
@ -336,8 +336,6 @@ class DashboardService extends SiteService {
$group: { $group: {
_id: { _id: {
country: '$geoip.country', country: '$geoip.country',
region: '$geoip.region',
city: '$geoip.city',
}, },
count: { $sum: 1 }, count: { $sum: 1 },
}, },
@ -346,13 +344,11 @@ class DashboardService extends SiteService {
$project: { $project: {
_id: false, _id: false,
country: '$_id.country', country: '$_id.country',
region: '$_id.region',
city: '$_id.city',
count: '$count', count: '$count',
}, },
}, },
{ {
$sort: { count: -1, city: 1, region: 1, country: 1 }, $sort: { count: -1, country: 1 },
}, },
{ {
$limit: 10, $limit: 10,
@ -370,14 +366,14 @@ class DashboardService extends SiteService {
/* /*
* *
* LINK CITY STATS * USER CITY STATS
* *
*/ */
async getLinkCityStats (link) { async getUserCityStats (user) {
const { cache: cacheService } = this.dtp.services; const { cache: cacheService } = this.dtp.services;
let stats; let stats;
const cacheKey = `stats:link:${link._id}:city`; const cacheKey = `stats:user-links:${user._id}:city`;
if (DashboardService.CACHE_ENABLED) { if (DashboardService.CACHE_ENABLED) {
stats = await cacheService.getObject(cacheKey); stats = await cacheService.getObject(cacheKey);
if (stats) { if (stats) {
@ -385,15 +381,18 @@ class DashboardService extends SiteService {
} }
} }
this.log.info('getLinkCityStats', 'generating link city stats report', { linkId: link._id }); this.log.info('generating user city stats report', { userId: user._id });
const END_DATE = new Date(); const END_DATE = new Date();
const START_DATE = moment(END_DATE).subtract(7, 'day').toDate(); const START_DATE = moment(END_DATE).subtract(7, 'day').toDate();
stats = await LinkVisit.aggregate([ const links = await Link.find({ user: user._id }).lean();
const linkIds = links.map((link) => link._id);
stats = await ResourceVisit.aggregate([
{ {
$match: { $match: {
link: link._id, resource: { $in: linkIds },
$and: [ $and: [
{ created: { $gt: START_DATE } }, { created: { $gt: START_DATE } },
{ created: { $lt: END_DATE } }, { created: { $lt: END_DATE } },

41
app/services/link.js

@ -10,7 +10,6 @@ const mongoose = require('mongoose');
const pug = require('pug'); const pug = require('pug');
const Link = mongoose.model('Link'); const Link = mongoose.model('Link');
const LinkVisit = mongoose.model('LinkVisit');
const geoip = require('geoip-lite'); const geoip = require('geoip-lite');
const striptags = require('striptags'); const striptags = require('striptags');
@ -141,50 +140,10 @@ class LinkService extends SiteService {
} }
async remove (link) { async remove (link) {
this.log.debug('removing link visit records', { link: link._id });
await LinkVisit.deleteMany({ link: link._id });
const { resource: resourceService } = this.dtp.services; const { resource: resourceService } = this.dtp.services;
await resourceService.remove('Link', link._id); await resourceService.remove('Link', link._id);
} }
async recordVisit (link, req) {
const NOW = new Date();
const visit = new LinkVisit();
visit.created = NOW;
visit.link = link._id;
if (req.user) {
visit.user = req.user._id;
}
/*
* We geo-analyze (but do not store) the IP address.
*/
const ipAddress = req.ip;
const geo = geoip.lookup(ipAddress);
if (geo) {
visit.geoip = {
country: geo.country,
region: geo.region,
eu: geo.eu,
timezone: geo.timezone,
city: geo.city,
};
if (Array.isArray(geo.ll) && (geo.ll.length === 2)) {
visit.geoip.location = {
type: 'Point',
coordinates: geo.ll,
};
}
}
await visit.save();
await Link.updateOne({ _id: link._id }, { $inc: { 'stats.totalVisitCount': 1 } });
}
async setItemOrder (links) { async setItemOrder (links) {
for await (const link of links) { for await (const link of links) {
this.log.debug('set link order', { link }); this.log.debug('set link order', { link });

59
app/services/resource.js

@ -6,9 +6,12 @@
const { SiteService } = require('../../lib/site-lib'); const { SiteService } = require('../../lib/site-lib');
const geoip = require('geoip-lite');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const ResourceView = mongoose.model('ResourceView'); const ResourceView = mongoose.model('ResourceView');
const ResourceVisit = mongoose.model('ResourceVisit');
class ResourceService extends SiteService { class ResourceService extends SiteService {
@ -32,7 +35,11 @@ class ResourceService extends SiteService {
* a view is being tracked. * a view is being tracked.
*/ */
async recordView (req, resourceType, resourceId) { async recordView (req, resourceType, resourceId) {
const CURRENT_DAY = new Date(); const Model = mongoose.model(resourceType);
const modelUpdate = { $inc: { } };
const NOW = new Date();
const CURRENT_DAY = new Date(NOW);
CURRENT_DAY.setHours(0, 0, 0, 0); CURRENT_DAY.setHours(0, 0, 0, 0);
let uniqueKey = req.ip.toString().trim().toLowerCase(); let uniqueKey = req.ip.toString().trim().toLowerCase();
@ -40,7 +47,6 @@ class ResourceService extends SiteService {
uniqueKey += `:user:${req.user._id.toString()}`; uniqueKey += `:user:${req.user._id.toString()}`;
} }
const Model = mongoose.model(resourceType);
const response = await ResourceView.updateOne( const response = await ResourceView.updateOne(
{ {
created: CURRENT_DAY, created: CURRENT_DAY,
@ -55,19 +61,56 @@ class ResourceService extends SiteService {
); );
if (response.upsertedCount > 0) { if (response.upsertedCount > 0) {
await Model.updateOne( modelUpdate.$inc['stats.uniqueViewCount'] = 1;
{ _id: resourceId },
{
$inc: { 'stats.uniqueVisitCount': 1 },
},
);
} }
/*
* Record the ResourceVisit
*/
const visit = new ResourceVisit();
visit.created = NOW;
visit.resourceType = resourceType;
visit.resource = resourceId;
if (req.user) {
visit.user = req.user._id;
}
/*
* We geo-analyze (but do not store) the IP address.
*/
const ipAddress = req.ip;
const geo = geoip.lookup(ipAddress);
if (geo) {
visit.geoip = {
country: geo.country,
region: geo.region,
eu: geo.eu,
timezone: geo.timezone,
city: geo.city,
};
if (Array.isArray(geo.ll) && (geo.ll.length === 2)) {
visit.geoip.location = {
type: 'Point',
coordinates: geo.ll,
};
}
}
await visit.save();
modelUpdate.$inc['stats.totalVisitCount'] = 1;
await Model.updateOne({ _id: resourceId }, modelUpdate);
} }
async remove (resourceType, resource) { async remove (resourceType, resource) {
this.log.debug('removing resource view records', { resourceType, resourceId: resource._id }); this.log.debug('removing resource view records', { resourceType, resourceId: resource._id });
await ResourceView.deleteMany({ resource: resource._id }); await ResourceView.deleteMany({ resource: resource._id });
this.log.debug('removing resource visit records', { resourceType, resourceId: resource._id });
await ResourceVisit.deleteMany({ resource: resource._id });
this.log.debug('removing resource', { resourceType, resourceId: resource._id }); this.log.debug('removing resource', { resourceType, resourceId: resource._id });
const Model = mongoose.model(resourceType); const Model = mongoose.model(resourceType);
await Model.deleteOne({ _id: resource._id }); await Model.deleteOne({ _id: resource._id });

29
app/views/dashboard/view.pug

@ -16,15 +16,27 @@ block content
div(class="uk-width-1-1 uk-width-2-3@m") div(class="uk-width-1-1 uk-width-2-3@m")
.uk-margin .uk-margin
.uk-card.uk-card-secondary.uk-card-small.uk-card-body .uk-card.uk-card-secondary.uk-card-small.uk-card-body
h4.uk-card-title Link Visits #[small.uk-text-muted past 7 days] h4.uk-card-title Profile Visits #[small.uk-text-muted past 7 days]
canvas(id="profile-stats").visit-graph canvas(id="profile-stats").visit-graph
div(class="uk-visible@m", uk-grid).uk-flex-between.uk-text-small div(class="uk-visible@m", uk-grid).uk-flex-between.uk-text-small
.uk-width-auto .uk-width-auto
.uk-margin .uk-margin
div= moment(userVisitStats.start).format('MMM DD, YYYY h:mm:ss a') div= moment(profileVisitStats.start).format('MMM DD, YYYY h:mm:ss a')
.uk-width-auto
.uk-margin
div= moment(profileVisitStats.end).format('MMM DD, YYYY h:mm:ss a')
.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]
canvas(id="link-stats").visit-graph
div(class="uk-visible@m", uk-grid).uk-flex-between.uk-text-small
.uk-width-auto
.uk-margin
div= moment(linkVisitStats.start).format('MMM DD, YYYY h:mm:ss a')
.uk-width-auto .uk-width-auto
.uk-margin .uk-margin
div= moment(userVisitStats.end).format('MMM DD, YYYY h:mm:ss a') div= moment(linkVisitStats.end).format('MMM DD, YYYY h:mm:ss a')
.uk-margin .uk-margin
.uk-card.uk-card-secondary.uk-card-small .uk-card.uk-card-secondary.uk-card-small
@ -60,7 +72,7 @@ block content
th Country th Country
th Visits th Visits
tbody tbody
each entry in userCountryStats.stats each entry in linkCountryStats.stats
tr tr
td.uk-table-expand= entry.country ? entry.country : '--' td.uk-table-expand= entry.country ? entry.country : '--'
td= entry.count td= entry.count
@ -76,7 +88,7 @@ block content
th Country th Country
th Visits th Visits
tbody tbody
each entry in userCityStats.stats each entry in linkCityStats.stats
tr tr
td= entry.city ? entry.city : '--' td= entry.city ? entry.city : '--'
td(class="uk-visible@m")= entry.region ? entry.region : '--' td(class="uk-visible@m")= entry.region ? entry.region : '--'
@ -88,5 +100,10 @@ block viewjs
script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js") script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js")
script. script.
window.addEventListener('dtp-load', ( ) => { window.addEventListener('dtp-load', ( ) => {
dtp.app.renderProfileStats('canvas#profile-stats', !{JSON.stringify(userVisitStats.stats)}); const data = {
profileVisitStats: !{JSON.stringify(profileVisitStats.stats)},
linkVisitStats: !{JSON.stringify(linkVisitStats.stats)},
};
dtp.app.renderStatsGraph('canvas#profile-stats', 'Profile Visits', data.profileVisitStats);
dtp.app.renderStatsGraph('canvas#link-stats', 'Link Visits', data.linkVisitStats);
}); });

12
client/js/site-app.js

@ -574,7 +574,7 @@ export default class DtpSiteApp extends DtpApp {
} }
} }
async renderProfileStats (selector, data) { async renderStatsGraph (selector, title, data) {
try { try {
const canvas = document.querySelector(selector); const canvas = document.querySelector(selector);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@ -584,7 +584,7 @@ export default class DtpSiteApp extends DtpApp {
labels: data.map((item) => new Date(item.date)), labels: data.map((item) => new Date(item.date)),
datasets: [ datasets: [
{ {
label: 'Link Visits', label: title,
data: data.map((item) => item.count), data: data.map((item) => item.count),
borderColor: CHART_LINE_USER, borderColor: CHART_LINE_USER,
tension: 0.5, tension: 0.5,
@ -621,10 +621,7 @@ export default class DtpSiteApp extends DtpApp {
plugins: { plugins: {
title: { display: false }, title: { display: false },
subtitle: { display: false }, subtitle: { display: false },
legend: { legend: { display: false },
display: true,
position: 'bottom',
},
}, },
maintainAspectRatio: true, maintainAspectRatio: true,
aspectRatio: 16.0 / 9.0, aspectRatio: 16.0 / 9.0,
@ -639,12 +636,11 @@ export default class DtpSiteApp extends DtpApp {
} else { } else {
chart.config.options.aspectRatio = 16.0 / 16.0; chart.config.options.aspectRatio = 16.0 / 16.0;
} }
this.log.debug('renderProfileStats', 'chart resizing', { aspect: chart.config.options.aspectRatio });
}, },
}, },
}); });
} catch (error) { } catch (error) {
this.log.error('renderProfileStats', 'failed to render profile stats', { error }); this.log.error('renderStatsGraph', 'failed to render stats graph', { title, error });
UIkit.modal.alert(`Failed to render chart: ${error.message}`); UIkit.modal.alert(`Failed to render chart: ${error.message}`);
} }
} }

17
data/patches/link-visit-port.js

@ -0,0 +1,17 @@
'use strict';
/* globals db */
const cursor = db.linkvisits.find({ });
while (cursor.hasNext()) {
const linkVisit = cursor.next();
print(`porting link visit: ${linkVisit._id.toString()}`);
db.resourcevisits.insertOne({
_id: linkVisit._id,
created: linkVisit.created,
resourceType: 'Link',
resource: linkVisit.link,
user: linkVisit.user,
geoip: linkVisit.geoip,
});
}

20
data/patches/user-stats.js

@ -0,0 +1,20 @@
'use strict';
/* globals db */
const cursor = db.users.find({ });
while (cursor.hasNext()) {
const user = cursor.next();
print(`updating user: ${user.username}`);
db.users.updateOne(
{ _id: user._id },
{
$set: {
stats: {
uniqueVisitCount: 0,
totalVisitCount: 0,
},
},
},
);
}
Loading…
Cancel
Save