This document contains technical details about how the Purdue Ironhacks platform works.
The app is powered by Bloggify—a modular and flexible platform for building modular applications. Since Bloggify is a Node.js platform, you have to install Node.js on your machine. The data is stored in a MongoDB database.
To run this application, you will have to have Node.js and MongoDB installed on your machine.
Then you can proceed to the next steps:
- Clone the repo either from GitHub:
git clone https://github.com/RCODI/purdue-ironhacks.git
Note: This is not recommended because the code pushed to Heroku is a little bit different than the version we push to GitHub. When deploying the app on Heroku, we bundle the files before pushing them, therefore the Heroku version will store the production bundles, while the GitHub version doesn't store any bundles at all.
There are two reasons why we bundle the files before deployment:
-
Heroku is going to complain if we do it on their servers because it's a very resource-consuming process (it's using RAM memory, eventually exceeding it).
Doing it on our machines is much better because we have enough RAM.
-
The boot time of the app will be much faster (because no bundles have to be generated on Heroku).
-
Open the folder:
cd purdue-ironhacks
- Install the dependencies:
npm install
Start MongoDB:
mongod
# or
sudo mongod
Before starting the app, you will have to create a file named .env
, containing:
GITHUB_CLIENT=...
GITHUB_SECRET=...
SENDGRID_KEY=...
MONGODB_URI=mongodb://localhost/purdue_ironhacks
You can get the GitHub keys after creating a GitHub application. Do not share these with anyone.
Start the app in dev mode:
npm run start:dev
Make yourself an admin, by passing YOUR GitHub username (the username of the account you use to sign in for the first time):
ADMIN_USERNAME=<your-github-username> npm run start:dev
For example:
ADMIN_USERNAME=hackpurdue npm run start:dev
Note: The very first start takes up to 30 seconds because there is no existing cache. After the cache is created, the next application starts will be much faster (1-3 seconds).
When deployed to Heroku, the application url is https://<app-name>.herokuapp.com
(unless it's using a custom domain).
Note: When using a free dyno, it's working fine, but with some limitations:
- it's slower
- it's going to sleep if it's innactive for a certain period of time.
- it has bandwidth limits, but pretty liberal
The app configuration is stored in the bloggify.js
file.
-
Make sure that the
heroku
remote exists (rungit remote -v
for that). If it doesn't exist, run:heroku git:remote ironhackplatform
-
Commit all the changes and then run the following command:
npm run deploy
There is one forum for Purdue, and three forums for Bogota, and three forums for Platzi. In each hack type we can extend the number of forums from the admin interface.
The posts and discussions from one forum are not visible to the users from the other forums.
+--------------------+-----------+-----------+
| Purdue | Bogota | Plazi |
+--------------------+-----------+-----------+
| 0 | 0 | 1 | 2 | 0 | 1 | 2 |
+--------------------+-----------+-----------+
| | | | | | | |
...
Being a Bloggify application, the application configuration is kept in a file: bloggify.js
. This contains (see the inline comments):
"use strict";
const conf = require("bloggify-config");
// Set the right MongoDB URI (depending on the environment).
const DB_URI = process.env.MONGODB_URI
if (!DB_URI) {
console.error(">>>> Please provide the MongoDB URI. Set the MONGODB_URI environment variable.");
}
module.exports = conf({
// Application metadata
title: "IronHacks"
, description: "Hack for inovation and join the open data movement."
// The production domain
, domain: "http://www.ironhacks.com"
// Core plugins (which are initialized before the others)
, corePlugins: [
"bloggify-mongoose"
]
// Application plugins
, plugins: [
"bloggify-sendgrid"
, "bloggify-custom-assets"
, "bloggify-github-login"
]
// The application router
, router: "bloggify-flexible-router"
// We do not have a blog page, so we do not need a Bloggify viewer at all
, viewer: null
// Plugins configuration
, config: {
// Custom application assets
"bloggify-custom-assets": {
styles: [
"app/assets/stylesheets/index.css"
]
, server: [
"app/server/index.js"
]
}
// The application router
, "bloggify-flexible-router": {
controllers_dir: "app/controllers"
, routes_dir: "app/routes"
, error_pages: {
404: "404.ajs"
, 500: "500.ajs"
, bad_csrf: "422.ajs"
}
}
// Login with GitHub
, "bloggify-github-login": {
githubClient: process.env.GITHUB_CLIENT
, githubSecret: process.env.GITHUB_SECRET
}
// Connect to the MongoDB database
, "bloggify-mongoose": {
db: DB_URI
, models_dir: "app/models"
}
// Send emails
, "bloggify-sendgrid": {
key: process.env.SENDGRID_KEY
}
}
}, {
cms_methods: false
, server: {
session: {
storeOptions: {
url: DB_URI
}
}
}
});
The way how this Bloggify application is structured is explained below.
The app
directory contains the application files
The application routes (urls) are:
GET /
GET /404
GET /500
GET/POST /admin
GET /countdown
GET/POST /logout
GET/POST /new
GET/POST /register
GET /login
GET /scores
GET /search
GET /quizzes
GET/POST /posts/topicId-_slug/
POST /posts/topicId-_slug/comments
POST /posts/topicId-_slug/delete
GET/POST /posts/topicId-_slug/edit
POST /posts/topicId-_slug/toggle-vote
GET/POST /users/_user/edit
GET /users/_user
The GET
method means that we fetch information from the server, while the POST
means we post information to the server side.
The routes may have associated controllers which are located in the app/controllers
directory.
All the quizzes created on the Qualtrics platform have a snippet of JavaScript which stores the user data in the response (as known as Embedded data).
We store the user_email
and the user_id
which are sent in the url.
The code in the Qualtrics quizzes is set in the last block, last question (which may happen to be an empty question, used for tracking):
TODO
This approach is being used for all the quizzes: the sign up survey and the other technical quizzes.
When the user is not authenticated, the main page displays two buttons: a login button and a Sign up button.
When the login button is clicked, the /login
url is opened.
The login url redirects to the GitHub authentication workflow (OAuth2) where the user should accept access of our GitHub app in their GitHub account.
IronHack Platform GitHub.com
----------------- ----------
/login ----------------------------------------> Do you want to accept access of the app?
(the message won't appear if accept was already granted)
|
*------------ GitHub App: IronHack
|
___________/ \___________
/ \
Yes No
/login-callback <-------------------/ |
/ <------------------------------------------------/
The difference between the Sign in and Sign up buttons is that for the sign in, we assume that the user already has an account, therefore we make the redirection to GitHub APIs and authenticate them with their GitHub account. In case they do not have an account on the site, they will have to choose the hack type after the GitHub authentication.
In Sign up case we will assume that the user doesn't have an account yet, therefore we ask them to choose the hack type and only then the GitHub authentication is made. Still, if an existing user clicks the Sign up button, they will log in into their account anyways.
This is the authentication workflow:
In the controllers/register.js
file, we create a new user or authenticate an existing user.
For the first-time users, we display the selection of the hack type: Purdue, Bogota and Platzi, and then redirect to the survey.
At this moment, we start a session on the server side, but we don't write any data in the users' database yet.
After the user completes the survey, they are redirected back, and the user account is created.
To redirect the users to the survey, we send the Location
header from the server to the client. Additional data is added in the url parameters to track the user:
email
: The email of the useruser_id
: The user idredirect_to
: The redirection url where they will arrive after answering the survey.
Using a JavaScript snippet in the survey page we store the email and the user id in the survey answer and we detect when the survey is done and redirect the users back to the app.
When they finish the survey, they are redirected to the main app and the account is created.
When creating a user account, we do not assign a hack_id
to the user but we wait until the contest is started.
The hack_id
is a number between 0
and 2
based on which we create multiple forums inside of the same hack type.
The range can be configured in the admin dashboard.
For Purdue, we have one forum (the hack_id
will be always 0
) and for Bogota
and Platzi
, we have three forums for each (the hack_id
can be 0
, 1
or 2
).
+--------------------+-------------+--------------+
| Purdue | Bogota | Plazi |
+--------------------+-------------+--------------+
| 0 | 0 | 1 | 2 | 0 | 1 | 2 |
+--------------------+-------------+--------------+
| | | | | | | |
...
If the user is already registered, they are authenticated based on the existing data.
The hack_id
values are assigned either when the user signs up (if the contest is already started) or when the contest starts.
The default value for hack_id
is null
. This is changed automatically when the contest starts.
The algorithm which assigns the hack ids is designed to create groups of an equal number of users. Specifically, the user will join in the hack id with the fewest users at that moment.
The database query is: find how many users we have in each hack id, for a given hack type. Then, join the current user in the hack id with the fewest users.
function generateGetHackId(hType, name) {
return cb => {
User.model.aggregate([{
$match: {
"profile.hack_id": { $ne: null },
"profile.hack_type": name
}
}, {
$group: {
_id: "$profile.hack_id",
total: { $sum: 1 }
}
}], (err, docs) => {
if (err) { return cb(0); }
const ids = Array(hType.subforums_count + 1).fill(0);
docs.forEach(c => {
ids[c._id] = c.total;
});
let minId = 0;
let min = ids[minId];
ids.forEach((count, index) => {
if (count < min) {
minId = index;
min = ids[minId];
}
});
cb(minId);
});
};
}
forEach(HACK_TYPES, (c, name) => {
c.getHackId = generateGetHackId(c, name);
});
The function which assigns the hack id values to the users is in the HackTypes
controller (app/controllers/HackTypes.js
).
This function receives as input a hack type object and groups the users inside of the hack type.
const assignHackIdsToUsers = hType => {
const usersCursor = User.model.find({
"profile.hack_id": null,
"profile.hack_type": hType.name
}).cursor();
usersCursor.on("data", cDoc => {
usersCursor.pause();
hType.getHackId(uHackId => {
User.update({
_id: cDoc._id
}, {
profile: {
hack_id: uHackId
}
}, (err, data) => {
if (err) { Bloggify.log(err); }
usersCursor.resume();
});
});
});
usersCursor.on("error", err => {
Bloggify.log(err);
});
usersCursor.on("end", cDoc => {
Bloggify.log(`Grouped the studends from ${hType.name}.`);
});
};
The function above is called when the countdown finishes, being triggered by a scheduler:
const setScheduleForHackType = name => {
if (name.name) {
name = name.name;
}
let hackTypeObj = HACK_TYPES[name];
if (hackTypeObj.startSchedule) {
hackTypeObj.startSchedule.cancel();
}
hackTypeObj.startSchedule = schedule.scheduleJob(hackTypeObj.start_date, () => {
assignHackIdsToUsers(hackTypeObj);
});
};
Or, it may be triggered when we make changes in the admin dashboard, changing the start of the contest.
if (new Date() > thisHackType.start_date) {
if (thisHackType.startSchedule) {
thisHackType.startSchedule.cancel();
}
assignHackIdsToUsers(thisHackType);
} else {
setScheduleForHackType(thisHackType);
}
To catch the save
event, we add a hook using the addHook
method defined
by the bloggify-mongoose
plugin.
Settings.model.addHook("post", "save", update);
The quizzes page displays the quizzes that can be taken by the user. The user may answer the same quiz multiple times.
In the view file (app/routes/quizzes.ajs
) we have the part which renders the links to each quiz:
<% include("../views/header", { title: "Quizzes" }) %>
<% include("../views/container/start") %>
<h1>Quizzes</h1>
<% quizzes.forEach(function (quiz) { %>
<a class="btn" href="<%= quiz.url %>"><%= quiz.label %></a>
<% }); %>
<% include("../views/container/end") %>
<% include("../views/footer") %>
The data associated with this view is storred in the controller (app/controllers/quizzes.js
)–see below. The user can click the generated link which contains information about the user (the email address and the user id)–which are storred in the Qualtrics quiz responses as embedded data, and also the redirect url.
When the user finishes the quiz, they are redirected back the application, on the /quizzes
page and the application marks the quiz complete internally. Even the quiz was completed, the user can take it again.
const Bloggify = require("bloggify")
, Session = require("./Session")
, User = require("./User")
, findValue = require("find-value")
;
// Define the quizzes list
const quizzes = [
["d3.js", "https://purdue.qualtrics.com/jfe/form/SV_71xEzp5vQ7rC817", "d3"]
, ["HTML & CSS", "https://purdue.qualtrics.com/jfe/form/SV_do6Sc9VJsAMmOih", "html_css"]
, ["JavaScript & jQuery", "https://purdue.qualtrics.com/jfe/form/SV_b8zyxX8wozQfNul", "javascript_jquery"]
];
// Map the quizzes labels to the data
const validQuizzes = {};
quizzes.forEach(c => {
validQuizzes[c[2]] = c;
});
module.exports = (lien, cb) => {
const user = Session.getUser(lien);
if (!user) { return lien.redirect("/"); }
// Set the quiz complete
const completed = lien.query.markComplete;
if (completed && validQuizzes[completed]) {
return User.update({
_id: user._id
}, {
profile: {
surveys: {
[completed]: {
ended_at: new Date()
}
}
}
}, (err, _user) => {
lien.redirect("/quizzes");
})
}
// Generate the redirect links
const completedSurveys = findValue(user, "profile.surveys") || {};
const userQuizzes = quizzes.map(c => {
const redirectTo = encodeURIComponent(`${Bloggify.options.metadata.domain}/quizzes?markComplete=${c[2]}`);
return {
label: c[0]
, url: `${c[1]}?redirect_to=${redirectTo}&user_email=${user.email}&user_id=${user._id}`
, is_complete: !!completedSurveys[c[2]]
};
});
// Send the quizzes array to the view
cb(null, {
quizzes: userQuizzes
});
};
On the search page (/search
) we can search for content which appears either in the post data or in the comments.
The view associated with this page is storred in the app/routes/search.ajs
and it looks like this:
<% include("../views/header", { title: "Search" }) %>
<% include("../views/container/start") %>
<h1>Search</h1>
<div class="search-form-wrapper">
<% include("../views/search-form") %>
</div>
<% if (f("results")) { %>
<p class="search-results-text">Search results for <em>‘<%= lien.query.search %>’</em></p>
<div class="search-results">
<% if (results.length) { %>
<% results.forEach(function (cResult) { %>
<div class="seach-result-item">
<h2>
<a href="<%= cResult.url %>">
<%= cResult.title %>
</a>
</h2>
</div>
<% }) %>
<% } else { %>
<div class="no-search-results">
There are no results. Maybe try a different query.
</div>
<% } %>
</div>
<% } %>
<% include("../views/container/end") %>
<% include("../views/footer") %>
This file requires the search-form
which appears in the app/views/search-form.ajs
file, representing the search form itself:
<form>
<input type="text" name="search" value="<%= lien.query.search || "" %>" placeholder="Search for something..." />
</form>
When the user submits the query, the ?search=<query>
querystring parameter is added in the url, triggering the search functionality in the controller (located in app/controllers/search.js
). To increase the search results accuracy we used the internal MongoDB text search indexes like this: we created text indexes for the topic title and content and comment content, using the text: true
in the model configuration:
app/models/Topic.js
:
module.exports = {
...
title: {
type: String,
text: true
},
...
body: {
type: String,
text: true
},
...
};
app/models/Comment.js
:
module.exports = {
...
body: {
type: String,
text: true
},
...
};
Note: The admin users will see the search results from all the forums, while the simple users will see the search results from the forum they belong to.
The controller which takes care of searching looks like this:
const Session = require("./Session")
, Topic = require("./Topic")
, Comment = require("./Comment")
;
module.exports = (lien, cb) => {
const user = Session.getUser(lien);
if (!user) {
return lien.redirect("/");
}
const isAdmin = Session.isAdmin(user);
// Perform the search query
if (lien.query.search) {
// Use the $text index to search
const filters = {
$text: {
$search: lien.query.search
}
};
let results = {};
// Search in the topics and comments
Promise.all([
Topic.model.find(filters)
, Comment.model.find(filters)
]).then(data => {
results.topics = data[0];
results.comments = data[1].map(c => c.toObject());
return Promise.all(results.comments.map(cComment => {
return Topic.model.findOne({ _id: cComment.topic });
}));
}).then(topics => {
let uniqueTopics = {};
results.topics.concat(topics).forEach(c => {
if (!c) { return; }
// Let the admin see all the posts/comments in all the forums
if (!isAdmin) {
if (c.metadata.hack_type !== user.profile.hack_type ||
c.metadata.hack_id !== user.profile.hack_id) {
return;
}
}
uniqueTopics[c._id] = c;
c.url = Topic.getUrl(c);
});
cb(null, { results: Object.keys(uniqueTopics).map(k => uniqueTopics[k]) });
}).catch(e => {
cb(e);
});
} else {
cb();
}
};
For authenticated users, we display the posts on the first page, ordered by the date, but the sticky posts are always the first ones. Only the admin users can make create sticky posts (or edit a post and make it sticky).
Here, the users from a specific forum can see and upvote the posts from the same forum. They can click on the post urls and post comments.
The single post pages are accessible by authenticated users only. They display the post title, body, votes and comments.
In case somebody comments, the comments are updated in real-time, the votes too.
When a user opens a topic page, we collect stats about that event:
actor
: the user id who clicked the buttontopic_id
: the topic idphase
: the phase of the projectcreated_at
: the timestamp
By accessing the /new
endpoint, one can post a topic in their forum. They have to write the title and the topic content.
The topic content can be styled with Markdown specific styles (bold, italic etc).
We display the scores of the users, on the /scores
page. The users see the anonymous name of the users in the table. The displayed items in the table are shuffled each time.
If in the admin interface the scores are not provided, the scores collumns will not appear in the scores. In a similar way it happens for the urls: if we don't enter the urls, the urls collumns will not appear in the scores page.
When the user clicks on the View scores button, we collect stats:
actor
: the user id who clicked the buttonhacker_id
: the user from the table which was clickedphase
: the phase of the projectcreated_at
: the timestamp
Similar things happen when one clicks the Project url or the GitHub repository url. We know what was clicked and who did it.
In the scores controller (the controllers/scores.js
file) a query to fetch the users from a certain forum is made. Then we get the active scores and urls for the current phase of the contest and create an array which is passed to the scores view.
To keep the users semi-anonymous, we change the usernames into Hacker {1-...}
. Then the users array is shuffled.
This is the code snippet which fetches the users, modifies the usernames and shuffles the array.
User.model.find({
"profile.hack_type": user.profile.hack_type,
"profile.hack_id": user.profile.hack_iand usls for the current phase of the contest and create an array which is passed to the scores view.
}, (err, users) => {
if (err) { return cb(err); }
Settings.get((err, options) => {
if (err) { return cb(err); }
const phase = options.settings.hack_types[user.profile.hack_type].phase;
users = users.map((u, i) => {
u = u.toObject();
u.username = `Hacker ${i + 1}`;
const phaseObj = Object(u.profile[phase]);
return {
_id: u._id,
username: u.username,
score_technical: phaseObj.score_technical,
score_info_viz: phaseObj.score_info_viz,
score_novelty: phaseObj.score_novelty,
score_total: phaseObj.score_total,
project_url: phaseObj.project_url,
github_repo_url: phaseObj.github_repo_url
};
});
shuffle(users);
cb(null, {
users: users,
phase: phase
});
});
});
The shuffle
function is a basic algorithm of shuffling the elements from a given array:
function shuffle(array) {
var currentIndex = array.length, temporaryValue, randomIndex;
// While there remain elements to shuffle...
while (0 !== currentIndex) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
}
An admin can access additional functionality (such as deleting and editing any post).
They have access to the dashboard (/admin
) where they can make other users admins.
If nobody is admin (say there are no users), we can make somebody an admin (even if
they don't exist yet in the database) by assiging the GitHub username of an eventual
user to the environment variable called ADMIN_USERNAME
.
The ADMIN_USERNAME
environment variable represents the GitHub username of the user
which should be an admin (this user cannot be a simple user anymore, nobody being
able to remove the admin rights from them). When they are going to log in,
they will be authenticated as admin.
To set the ADMIN_USERNAME
variable, there are multiple ways, but the easiest ones are:
-
If the application runs in a Heroku environment, the variable can be set from the browser interface:
https://dashboard.heroku.com/apps/<app-name>/settings
, by clicking theReveal Config Vars
button or in the command line using:heroku config:set ADMIN_USERNAME=hackpurdue
Note: after setting an enviroment variable on Heroku (either from the command line) or from the browser interface, the application will be restarted automatically.
-
When running locally, the environment variable can be set when starting the app:
ADMIN_USERNAME=hackpurdue npm run start:dev
In the admin interface, the admin can:
- Change the Phase.
- Download the CSV stats.
- Set the start dates for each hack type.
- See all the users and update the scores for each and eventually make them admins.
In case another user is made admin, they should log out (if they are authenticated) and log in back.
For simple users, the database queries include the author
, being the current authenticated user.
When the user has admin permissions, we do not append anymore the author
the queries, therefore making the queries more liberal, giving more power to the admin.
For instance, when deleting a post, the user will create the following query:
delete the post with _id
=... and author=...
Therefore, if the user tries to delete another post, having the id, that post will not be found because it is not created by the authenticated user.
Tho, if the user is an admin, the query is simpler, lacking the author
field (we want to give them the power to delete any post):
*delete the post with _id=...
*
This is happening in the app/controllers/posts/_topicId-_slug/delete.js
controller:
const Topic = require("../../Topic")
, Session = require("../../Session")
;
exports.post = (lien, cb) => {
const user = Session.getUser(lien);
if (!user) {
return lien.next();
}
const filters = {
_id: lien.params.topicId
};
if (!Session.isAdmin(user)) {
filters.author = user._id;
}
Topic.remove(filters, (err, count) => {
if (err) { return lien.apiError(err); }
lien.redirect("/");
})
};
In the routes
folder, we have the page templates which are linked to the controllers from the controllers
folder.
routes/
├── 404.ajs
├── 500.ajs
├── admin.ajs
├── countdown.ajs
├── index.ajs
├── logout.ajs
├── new.ajs
├── posts
│ ├── index.ajs
│ └── _topicId-_slug
│ ├── comments.ajs
│ ├── delete.ajs
│ ├── edit.ajs
│ ├── index.ajs
│ └── toggle-vote.ajs
├── quizzes.ajs
├── register.ajs
├── scores.ajs
├── search.ajs
└── users
└── _user
├── edit.ajs
└── index.ajs
The _
character marks a dynamic route (such as a topic id/slug, or user).
The controllers are:
controllers/
├── admin.js
├── Comment.js
├── countdown.js
├── HackTypes.js
├── index.js
├── login.js
├── logout.js
├── new.js
├── posts
│ └── _topicId-_slug
│ ├── comments.js
│ ├── delete.js
│ ├── edit.js
│ ├── index.js
│ └── toggle-vote.js
├── quizzes.js
├── register.js
├── scores.js
├── search.js
├── Session.js
├── Settings.js
├── Stats.js
├── Topic.js
├── User.js
└── users
└── _user
├── edit.js
└── index.js
In this application we use the following main modules:
To connect with the database the bloggify-mongoose
module was used.
The database models are stored in the app/models
directory:
models/
├── Comment.js
├── Settings.js
├── Stats.js
├── Topic.js
└── User.js
To see the raw database collections and documents, we can connect directly using the MongoDB CLI:
mongo ....mlab.com:63758/heroku_... -u <dbuser> -p <dbpassword>
or we can see that in the browser:
-
Open the application Overview page
https://dashboard.heroku.com/apps/ironhackplatform
-
Clik the
mLab MongoDB
. This will redirect to an url like this:https://www.mlab.com/databases/heroku_...
-
On this page, we can see the collections and eventually the documents and edit them.
The models we interact with are:
Stores the comments in the comments
collection.
module.exports = {
author: "string",
body: {
type: String,
text: true
},
created_at: "date",
topic: "string",
votes: ["string"]
};
Stores the application settings.
module.exports = {
settings: "object"
};
Used to store the stats we collect.
module.exports = {
actor: "string",
metadata: "object",
event: "string",
created_at: "date"
};
In this collection we save the user events. See below what stats we collect.
Stores the topics in the topics
collection.
module.exports = {
author: "string",
title: {
type: String,
text: true
},
slug: "string",
body: {
type: String,
text: true
},
created_at: "date",
votes: ["string"],
sticky: "boolean",
metadata: "object"
};
Stores the users in the users
collection.
module.exports = {
username: "string",
email: "string",
password: "string",
profile: "object",
role: "string"
};
We collect stats when the user:
- clicks the View scores button
- opens a topic page
The stats are collected by making HTTP requests to the server, using the
fetch
technology (it's a new browser API, similar to XHRHttpRequest
).
The code snippets that take care of this is located in app/assets/javascripts/util/index.js
:
...
/**
* post
* Posts the data to the server.
*
* @name post
* @function
* @param {String} url The endpoint url.
* @param {Object} data The post data.
* @returns {Promise} The `fetch` promise.
*/
, post (url, data) {
data._csrf = data._csrf || _pageData.csrfToken;
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "same-origin",
body: JSON.stringify(data)
});
}
/**
* getJSON
* Fetches from the server JSON data.
*
* @name getJSON
* @function
* @param {String} url The endpoint url.
* @returns {Promise} The `fetch` promise.
*/
, getJSON (url) {
return fetch(url, {
credentials: "same-origin"
}).then(c => c.json())
}
...
That's the low-level side of sending/receiving any data to/from the server using
fetch
. Note: some browsers don't have the fetch
technology, therefore we use
a polyfill created by GitHub to ensure the function is there.
We collect three types of stats:
-
view-topic
Emitted when the user opens a topic.
Metadata:
topic_id
: The topic id.topic_author
: The user id of the topic author.
Code snippet:
util.post("/api/stats", { event: "view-topic", metadata: { topic_id: topic._id, topic_author: topic.author._id } });
-
score-click
Emitted when the user clicks the View scores button.
Metadata:
hacker_id
: The user that was clicked.
Code snippet:
util.post("/api/stats", { event: "score-click", metadata: { hacker_id: this.props.hacker._id } });
-
Clicks on the urls.
The following events are emitted:
click-project-url
: When clicking the project url.click-github-repo-url
: When clicking the GitHub repository url.
Metadata:
hacker_id
: The hacker id from the table.url
: The clicked url.
Code snippet:
util.post("/api/stats", { event: e.target.dataset.event, metadata: { hacker_id: this.props.hacker._id, url: e.target.href } });
The stats functionality on the server:
On the server, we create a custom endpoint at /api/stats
which expects POST
data. We do not collect any stats from unauthenticated users.
Along with the metadata we receive from the client side (see above) we add in the stat object the following information:
actor
: The current authenticated user id.event
: The event name.user_agent
: The user agent: this contains device and browser information.phase
: The phase of the contest.
The actor
is the authenticated user id, and it will always be appended in the
event object because we know there is an authenticated user.
After we build the stats object, we call the Stats.record
which will record
the event in the database. The record
method is not anything more than just
a create query, after appending the created_at
field in the event object.
Bloggify.server.addPage("/api/stats", "post", lien => {
const user = Session.getUser(lien);
if (!user) {
return lien.next();
}
const ev = {
actor: user._id,
event: lien.data.event,
metadata: lien.data.metadata || {}
};
ev.metadata.user_agent = lien.header("user-agent");
Settings.get((err, settings) => {
if (settings) {
ev.metadata.phase = settings.settings.hack_types[user.profile.hack_type].phase;
}
Stats.record(ev, (err, data) => {
if (err) {
return lien.apiError(err);
}
lien.apiMsg("success");
});
});
});
We used bloggify-github-login
to handle the GitHub authentication.
By providing the GitHub application credentials, this module handles the OAuth2 workflow.
{
"githubClient": "45...7",
"githubSecret": "1f...2"
}
To send emails, we use bloggify-sendgrid
.
{
"key": "SG.SmlHGA...ylY"
}
The notifications.js
file takes care of sending the emails using this module. We send emails when:
-
Somebody creates a new topic
Emails are sent to all the users from the forum the author belongs to, except to the author.
-
Somebody posts a comment
Emails are sent to previous people involved in the conversation.