Merge branch 'master' into production

This commit is contained in:
phoenixeliot 2016-07-18 10:42:54 -07:00
commit 96fa50afe4
37 changed files with 446 additions and 72 deletions

View file

@ -4,13 +4,18 @@ language: node_js
node_js:
- 5.1.1
env:
- CXX=g++-4.8
addons:
apt:
sources:
- mongodb-upstart
- ubuntu-toolchain-r-test
packages:
- mongodb-org-server
- g++-4.8
cache:
directories:

View file

@ -34,9 +34,10 @@ module.exports = class CocoRouter extends Backbone.Router
'admin/design-elements': go('admin/DesignElementsView')
'admin/files': go('admin/FilesView')
'admin/analytics': go('admin/AnalyticsView')
'admin/school-counts': go('admin/SchoolCountsView')
'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView')
'admin/level-sessions': go('admin/LevelSessionsView')
'admin/school-counts': go('admin/SchoolCountsView')
'admin/school-licenses': go('admin/SchoolLicensesView')
'admin/users': go('admin/UsersView')
'admin/base': go('admin/BaseView')
'admin/demo-requests': go('admin/DemoRequestsView')

View file

@ -961,7 +961,7 @@
manage_subscription: "Click here to manage your subscription."
new_password: "New Password"
new_password_verify: "Verify"
type_in_email: "Type in your email to confirm account deletion."
type_in_email: "Type in your email or username to confirm account deletion." # {change}
type_in_email_progress: "Type in your email to confirm deleting your progress."
type_in_password: "Also, type in your password."
email_subscriptions: "Email Subscriptions"
@ -1333,7 +1333,7 @@
update_account_title: "Your account needs attention!"
update_account_blurb: "Before you can access your classes, choose how you want to use this account."
update_account_current_type: "Current Account Type:"
update_account_account_email: "Account Email:"
update_account_account_email: "Account Email/Username:" # {change}
update_account_am_teacher: "I am a teacher"
update_account_keep_access: "Keep access to classes I've created"
update_account_teachers_can: "Teacher accounts can:"

View file

@ -438,6 +438,8 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi
tome_available_spells: "Доступные заклинания"
tome_your_skills: "Ваши навыки"
tome_current_method: "Текущий метод"
hints: "Советы"
hints_title: "Совет {{number}}"
code_saved: "Код сохранен"
skip_tutorial: "Пропуск (Esc)"
keyboard_shortcuts: "Горячие клавиши"

View file

@ -271,11 +271,11 @@ module.exports = class User extends CocoModel
window.location.reload()
@fetch(options)
signupWithPassword: (email, password, options={}) ->
signupWithPassword: (name, email, password, options={}) ->
options.url = _.result(@, 'url') + '/signup-with-password'
options.type = 'POST'
options.data ?= {}
_.extend(options.data, {email, password})
_.extend(options.data, {name, email, password})
jqxhr = @fetch(options)
jqxhr.then ->
window.tracker?.trackEvent 'Finished Signup', category: "Signup", label: 'CodeCombat'

View file

@ -0,0 +1,21 @@
#admin-school-licenses-view
table
td, th
padding: 0px
.range-container
position: relative
width: 100%
.range-background
position: absolute
height: 100%
left: 0px
top: 0px
background-color: green
opacity: 0.25
.range-dates
position: absolute
height: 100%
left: 0px
top: 0px

View file

@ -35,6 +35,9 @@
.help-block
margin: 0
.optional-help-block
font-style: italic
.form-container
width: 800px

View file

@ -10,7 +10,7 @@ else
.panel-body
.form
- var name = me.get('name') || '';
- var email = me.get('email');
- var email = me.get('email') || '';
- var admin = me.get('permissions', true).indexOf('admin') != -1;
- var godmode = me.get('permissions', true).indexOf('godmode') != -1;
.form-group
@ -71,11 +71,11 @@ else
.panel-body
.form#delete-account-form
.form-group
label.control-label(for="email1", data-i18n="account_settings.type_in_email")
input#email1.form-control(name="email", type="email")
label.control-label(for="delete-account-email-or-username", data-i18n="account_settings.type_in_email")
input#delete-account-email-or-username.form-control(name="emailOrUsername")
.form-group
label.control-label(for="password1", data-i18n="account_settings.type_in_password")
input#password1.form-control(name="password", type="password")
label.control-label(for="delete-account-password", data-i18n="account_settings.type_in_password")
input#delete-account-password.form-control(name="password", type="password")
button#delete-account-btn.btn.form-control.btn-primary(data-i18n="account_settings.delete_this_account")
.col-md-6

View file

@ -10,10 +10,10 @@ block content
.col-sm-1
button.btn.btn-primary.btn-large#enter-espionage-mode 007
label.control-label.col-sm-5(for="espionage-name-or-email")
em you are currently #{me.get('name')} at #{me.get('email')}
em you are currently #{me.get('name') || '(no username)'} at #{me.get('email') || '(no email)'}
if view.amActually
br
em but you are actually #{view.amActually.get('name')} at #{view.amActually.get('email')}
em but you are actually #{view.amActually.get('name') || '(no username)'} at #{view.amActually.get('email') || '(no email)'}
br
button#stop-spying-btn.btn.btn-xs Stop Spying
form#user-search-form.form-group
@ -47,6 +47,8 @@ block content
input.classroom-progress-class-code(type=text value="<class code>")
li
a(href="/admin/analytics") Dashboard
li
a(href="/admin/school-licenses") School Active Licenses
li
a(href="/admin/school-counts") School Counts
li

View file

@ -0,0 +1,39 @@
extends /templates/base-flat
//- DO NOT TRANSLATE
block content
if !me.isAdmin()
div You must be logged in as an admin to view this page.
else if !view.schools
h3 Loading...
else
h3 School Active Licenses
.small Max: total licenses
.small Used: licenses redeemed
.small Activity: level sessions created in last 30 days
table.table.table-condensed
thead
th School
th Max
th Used&nbsp;
th Activity
tr
td(style="height:26px;").range-container
each rangeKey in view.rangeKeys
span.range-background(style="left:#{rangeKey.startScale}%;width:#{rangeKey.width}%;background-color:#{rangeKey.color}")
span.range-dates(style="left:#{rangeKey.startScale}%;width:#{rangeKey.width}%;") #{rangeKey.name}
td(colspan=2)
each school in view.schools
each prepaid in school.prepaids
tr
td.range-container
span.range-background(style="left:#{prepaid.startScale}%;width:#{prepaid.rangeScale}%;")
span.range-dates(style="left:#{prepaid.startScale}%;")
span.spr #{prepaid.startDate.substring(0, 10)}
strong.spr #{school.name}
span #{prepaid.endDate.substring(0, 10)}
td #{prepaid.max}&nbsp;
td #{prepaid.used}
td= school.activity

View file

@ -36,6 +36,9 @@ form#basic-info-form.modal-body.basic-info
span(data-i18n="share_progress_modal.form_label")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#email-input(name="email" type="email")
if view.signupState.get('path') === 'student'
.help-block.optional-help-block.pull-right
span(data-i18n="signup.optional")
.col-xs-4.email-check
- var checkEmailState = view.state.get('checkEmailState');
if checkEmailState === 'checking'
@ -53,6 +56,7 @@ form#basic-info-form.modal-body.basic-info
span.text-forest.glyphicon.glyphicon-ok-circle
=" "
span(data-i18n="signup.email_good")
.form-group
.row
.col-xs-7.col-xs-offset-3
@ -74,6 +78,7 @@ form#basic-info-form.modal-body.basic-info
span.text-forest.glyphicon.glyphicon-ok-circle
=" "
span(data-i18n="signup.name_available")
.form-group
.row
.col-xs-7.col-xs-offset-3
@ -81,6 +86,7 @@ form#basic-info-form.modal-body.basic-info
span(data-i18n="general.password")
.col-xs-5.col-xs-offset-3
input.form-control.input-lg#password-input(name="password" type="password")
.form-group.checkbox.subscribe
.row
.col-xs-7.col-xs-offset-3

View file

@ -27,7 +27,7 @@ block content
if view.accountType
div #{view.accountType}
div
span.spr #{me.get('email')}
span.spr #{me.get('email') || me.get('name')}
span.not_you
span.spr(data-i18n="courses.not_you")
a.logout-btn(data-i18n="login.log_out", href="#")

View file

@ -4,7 +4,10 @@ block modal-header-content
.text-center
h1.modal-title(data-i18n="courses.remove_student1")
span.glyphicon.glyphicon-warning-sign.text-danger
p= view.user.get('name', true) + ' - ' + view.user.get('email')
p
span= view.user.get('name', true)
if view.user.get('email')
span= " — " + view.user.get('email')
h2(data-i18n="courses.are_you_sure")
block modal-body-content

View file

@ -87,16 +87,16 @@ module.exports = class AccountSettingsView extends CocoView
validateCredentialsForDestruction: ($form, onSuccess) ->
forms.clearFormAlerts($form)
enteredEmail = $form.find('input[type="email"]').val()
enteredPassword = $form.find('input[type="password"]').val()
if enteredEmail and enteredEmail is me.get('email')
enteredEmailOrUsername = $form.find('input[name="emailOrUsername"]').val()
enteredPassword = $form.find('input[name="password"]').val()
if enteredEmailOrUsername and enteredEmailOrUsername in [me.get('email'), me.get('name')]
isPasswordCorrect = false
toBeDelayed = true
$.ajax
url: '/auth/login'
type: 'POST'
data:
username: enteredEmail
username: enteredEmailOrUsername
password: enteredPassword
parse: true
error: (error) ->
@ -225,9 +225,16 @@ module.exports = class AccountSettingsView extends CocoView
return unless res
res.error =>
errors = JSON.parse(res.responseText)
forms.applyErrorsToForm(@$el, errors)
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
if res.responseJSON?.property
errors = res.responseJSON
forms.applyErrorsToForm(@$el, errors)
$('.nano').nanoScroller({scrollTo: @$el.find('.has-error')})
else
noty
text: res.responseText
type: 'error'
layout: 'topCenter'
timeout: 5000
@trigger 'save-user-error'
res.success (model, response, options) =>
@trigger 'save-user-success'

View file

@ -0,0 +1,72 @@
RootView = require 'views/core/RootView'
CocoCollection = require 'collections/CocoCollection'
Prepaid = require 'models/Prepaid'
TrialRequests = require 'collections/TrialRequests'
# TODO: year ranges hard-coded
module.exports = class SchoolLicensesView extends RootView
id: 'admin-school-licenses-view'
template: require 'templates/admin/school-licenses'
initialize: ->
return super() unless me.isAdmin()
@startDateRange = new Date()
@endDateRange = new Date()
@endDateRange.setUTCFullYear(@endDateRange.getUTCFullYear() + 2)
@supermodel.addRequestResource({
url: '/db/prepaid/-/active-schools'
method: 'GET'
success: ({prepaidActivityMap, schoolPrepaidsMap}) =>
@updateSchools(prepaidActivityMap, schoolPrepaidsMap)
}, 0).load()
super()
updateSchools: (prepaidActivityMap, schoolPrepaidsMap) ->
timeStart = @startDateRange.getTime()
time2017 = new Date('2017').getTime()
time2018 = new Date('2018').getTime()
timeEnd = @endDateRange.getTime()
rangeMilliseconds = timeEnd - timeStart
@rangeKeys = [
{name :'Today', color: 'blue', startScale: 0, width: Math.round((time2017 - timeStart) / rangeMilliseconds * 100)}
{name: '2017', color: 'red', startScale: Math.round((time2017 - timeStart) / rangeMilliseconds * 100), width: Math.round((time2018 - time2017) / rangeMilliseconds * 100)}
{name: '2018', color: 'yellow', startScale: Math.round((time2018 - timeStart) / rangeMilliseconds * 100), width: Math.round((timeEnd - time2018) / rangeMilliseconds * 100)}
]
@schools = []
for school, prepaids of schoolPrepaidsMap
activity = 0
schoolMax = 0
schoolUsed = 0
collapsedPrepaids = []
for prepaid in prepaids
activity += prepaidActivityMap[prepaid._id] ? 0
startDate = prepaid.startDate
endDate = prepaid.endDate
max = parseInt(prepaid.maxRedeemers)
used = parseInt(prepaid.redeemers?.length ? 0)
schoolMax += max
schoolUsed += used
foundIdenticalDates = false
for collapsedPrepaid in collapsedPrepaids
if collapsedPrepaid.startDate.substring(0, 10) is startDate.substring(0, 10) and collapsedPrepaid.endDate.substring(0, 10) is endDate.substring(0, 10)
collapsedPrepaid.max += parseInt(prepaid.maxRedeemers)
collapsedPrepaid.used += parseInt(prepaid.redeemers?.length ? 0)
foundIdenticalDates = true
break
unless foundIdenticalDates
collapsedPrepaids.push({startDate, endDate, max, used})
for collapsedPrepaid in collapsedPrepaids
collapsedPrepaid.startScale = Math.round((new Date(collapsedPrepaid.startDate).getTime() - @startDateRange.getTime()) / rangeMilliseconds * 100)
collapsedPrepaid.startScale = 0 if collapsedPrepaid.startScale < 0
collapsedPrepaid.rangeScale = Math.round((new Date(collapsedPrepaid.endDate).getTime() - new Date(collapsedPrepaid.startDate).getTime()) / rangeMilliseconds * 100)
collapsedPrepaid.rangeScale = 100 - collapsedPrepaid.startScale if collapsedPrepaid.rangeScale + collapsedPrepaid.startScale > 100
@schools.push {name: school, activity, max: schoolMax, used: schoolUsed, prepaids: collapsedPrepaids, startDate: collapsedPrepaids[0].startDate, endDate: collapsedPrepaids[0].endDate}
@schools.sort (a, b) ->
b.activity - a.activity or new Date(a.endDate).getTime() - new Date(b.endDate).getTime() or b.max - a.max or b.used - a.used or b.prepaids.length - a.prepaids.length or b.name.localeCompare(a.name)
# console.log @schools
@render()

View file

@ -64,7 +64,8 @@ module.exports = class BasicInfoView extends CocoView
checkEmail: ->
email = @$('[name="email"]').val()
if email is @state.get('checkEmailValue')
if @signupState.get('path') isnt 'student' and (not _.isEmpty(email) and email is @state.get('checkEmailValue'))
return @state.get('checkEmailPromise')
if not (email and forms.validateEmail(email))
@ -155,7 +156,7 @@ module.exports = class BasicInfoView extends CocoView
email: User.schema.properties.email
name: User.schema.properties.name
password: User.schema.properties.password
required: ['email', 'name', 'password'].concat (if @signupState.get('path') is 'student' then ['firstName', 'lastName'] else [])
required: ['name', 'password'].concat (if @signupState.get('path') is 'student' then ['firstName', 'lastName'] else ['email'])
onClickBackButton: -> @trigger 'nav-back'
@ -176,20 +177,20 @@ module.exports = class BasicInfoView extends CocoView
@checkEmail()
.then @checkName()
.then =>
if not (@state.get('checkEmailState') is 'available' and @state.get('checkNameState') is 'available')
if not (@state.get('checkEmailState') in ['available', 'standby'] and @state.get('checkNameState') is 'available')
throw AbortError
# update User
emails = _.assign({}, me.get('emails'))
emails.generalNews ?= {}
emails.generalNews.enabled = @$('#subscribe-input').is(':checked')
emails.generalNews.enabled = @$('#subscribe-input').is(':checked') and not _.isEmpty(@state.get('checkEmailValue'))
me.set('emails', emails)
unless _.isNaN(@signupState.get('birthday').getTime())
me.set('birthday', @signupState.get('birthday').toISOString())
me.set(_.omit(@signupState.get('ssoAttrs') or {}, 'email', 'facebookID', 'gplusID'))
me.set('name', @$('input[name="name"]').val())
jqxhr = me.save()
if not jqxhr
console.error(me.validationError)
@ -203,13 +204,15 @@ module.exports = class BasicInfoView extends CocoView
switch @signupState.get('ssoUsed')
when 'gplus'
{ email, gplusID } = @signupState.get('ssoAttrs')
jqxhr = me.signupWithGPlus(email, gplusID)
{ name } = forms.formToObject(@$el)
jqxhr = me.signupWithGPlus(name, email, gplusID)
when 'facebook'
{ email, facebookID } = @signupState.get('ssoAttrs')
jqxhr = me.signupWithFacebook(email, facebookID)
{ name } = forms.formToObject(@$el)
jqxhr = me.signupWithFacebook(name, email, facebookID)
else
{ email, password } = forms.formToObject(@$el)
jqxhr = me.signupWithPassword(email, password)
{ name, email, password } = forms.formToObject(@$el)
jqxhr = me.signupWithPassword(name, email, password)
return new Promise(jqxhr.then)

View file

@ -373,7 +373,7 @@ module.exports = class TeacherClassView extends RootView
coursePlaytimesString += "0,"
else
coursePlaytimesString += "#{moment.duration(coursePlaytime.playtime, 'seconds').humanize()},"
csvContent += "#{student.get('name')},#{student.get('email')},#{playtimeString},#{coursePlaytimesString}\"#{conceptsString}\"\n"
csvContent += "#{student.get('name')},#{student.get('email') or ''},#{playtimeString},#{coursePlaytimesString}\"#{conceptsString}\"\n"
csvContent = csvContent.substring(0, csvContent.length - 1)
encodedUri = encodeURI(csvContent)
window.open(encodedUri)

View file

@ -81,6 +81,7 @@ grabUser = (session, callback) ->
totalEmailsSent = 0
emailUserInitialRecruiting = (user, callback) ->
return callback null, false if not user.email
#return callback null, false if user.emails?.anyNotes?.enabled is false # TODO: later, uncomment to obey also 'anyNotes' when that's untangled
return callback null, false if user.emails?.recruitNotes?.enabled is false
return callback null, false if user.email in alreadyEmailed
@ -129,6 +130,7 @@ grabEmail = (winner, callback) ->
callback null, winner
emailUserTournamentResults = (winner, callback) ->
return callback null, false if not winner.email
return callback null, false if DEBUGGING and (winner.team is 'humans' or totalEmailsSent > 1)
++totalEmailsSent
name = winner.name

View file

@ -52,6 +52,36 @@ var courses =
duration: NumberInt(5),
free: false,
screenshot: "/images/pages/courses/105_info.png"
},
{
name: "CS: Game Development 1",
slug: "game-dev-1",
campaignID: ObjectId("5789236960deed1f00ec2ab8"),
description: "Learn to create your owns games which you can share with your friends.",
duration: NumberInt(5),
free: false,
//screenshot: "/images/pages/courses/105_info.png",
adminOnly: true
},
{
name: "CS: Web Development 1",
slug: "web-dev-1",
campaignID: ObjectId("578913f2c8871ac2326fa3e4"),
description: "Learn the basics of web development in this introductory HTML & CSS course.",
duration: NumberInt(5),
free: false,
//screenshot: "/images/pages/courses/105_info.png",
adminOnly: true
},
{
name: "CS: Web Development 2",
slug: "web-dev-2",
campaignID: ObjectId("57891570c8871ac2326fa3f8"),
description: "Learn more advanced web development, including scripting to make interactive webpages.",
duration: NumberInt(5),
free: false,
//screenshot: "/images/pages/courses/105_info.png",
adminOnly: true
}
];
@ -62,7 +92,7 @@ for (var i = 0; i < courses.length; i++) {
if (cursor.hasNext()) {
var doc = cursor.next();
for (var levelID in doc.levels) {
for (var j = 0; j < doc.levels[levelID].concepts.length; j++) {
for (var j = 0; j < (doc.levels[levelID].concepts || []).length; j++) {
concepts[doc.levels[levelID].concepts[j]] = true;
}
}

View file

@ -82,6 +82,8 @@ module.exports = class Handler
sendBadInputError: (res, message) -> errors.badInput(res, message)
sendPaymentRequiredError: (res, message) -> errors.paymentRequired(res, message)
sendDatabaseError: (res, err) ->
if err instanceof errors.NetworkError
return res.status(err.code).send(err.toJSON())
return @sendError(res, err.code, err.response) if err?.response and err?.code
log.error "Database error, #{err}"
errors.serverError(res, 'Database error, ' + err)
@ -467,6 +469,7 @@ module.exports = class Handler
@notifyWatcherOfChange editor, watcher, changedDocument, editPath
notifyWatcherOfChange: (editor, watcher, changedDocument, editPath) ->
return if not watcher.get('email')
context =
email_id: sendwithus.templates.change_made_notify_watcher
recipient:

View file

@ -100,7 +100,7 @@ errorResponseSchema = {
}
errorProps = _.keys(errorResponseSchema.properties)
class NetworkError
class NetworkError extends Error
code: 0
constructor: (@message, options) ->

View file

@ -98,7 +98,8 @@ PatchHandler = class PatchHandler extends Handler
@sendPatchCreatedEmail req.user, watcher, doc, doc.targetLoaded, docLink
sendPatchCreatedEmail: (patchCreator, watcher, patch, target, docLink) ->
# return if watcher._id is patchCreator._id
return if not watcher.get('email')
# return if watcher._id is patchCreator._id
context =
email_id: sendwithus.templates.patch_created
recipient:

View file

@ -270,6 +270,9 @@ class SubscriptionHandler extends Handler
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
return done({res: 'You must be signed in to subscribe.', code: 403})
if not req.user.get('email')
return done({res: 'Your account needs an email address to subscribe.', code: 403})
token = req.body.stripe.token
prepaidCode = req.body.stripe.prepaidCode
customerID = user.get('stripe')?.customerID

View file

@ -110,9 +110,9 @@ UserHandler = class UserHandler extends Handler
# Name setting
(req, user, callback) ->
return callback(null, req, user) unless req.body.name
return callback(null, req, user) unless req.body.name?
nameLower = req.body.name?.toLowerCase()
return callback(null, req, user) unless nameLower
return callback(null, req, user) unless nameLower?
return callback(null, req, user) if user.get 'anonymous' # anonymous users can have any name
return callback(null, req, user) if nameLower is user.get('nameLower')
User.findOne({nameLower: nameLower, anonymous: false}).exec (err, otherUser) ->

View file

@ -115,7 +115,7 @@ module.exports =
_type: "lead"
lead_id: leadID
assigned_to: userID
text: "Call #{teacherEmail}"
text: "Call license inquiry #{teacherEmail}"
is_complete: false
options =
uri: "https://#{apiKey}:X@app.close.io/api/v1/task/"

View file

@ -1,9 +1,11 @@
wrap = require 'co-express'
errors = require '../commons/errors'
database = require '../commons/database'
Prepaid = require '../models/Prepaid'
User = require '../models/User'
mongoose = require 'mongoose'
LevelSession = require '../models/LevelSession'
Prepaid = require '../models/Prepaid'
TrialRequest = require '../models/TrialRequest'
User = require '../models/User'
cutoffDate = new Date(2015,11,11)
cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'0000000000000000')
@ -11,14 +13,14 @@ cutoffID = mongoose.Types.ObjectId(Math.floor(cutoffDate/1000).toString(16)+'000
module.exports =
logError: (user, msg) ->
console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'"
post: wrap (req, res) ->
validTypes = ['course']
unless req.body.type in validTypes
throw new errors.UnprocessableEntity("type must be on of: #{validTypes}.")
# TODO: deprecate or refactor other prepaid types
if req.body.creator
user = yield User.search(req.body.creator)
if not user
@ -32,16 +34,16 @@ module.exports =
database.validateDoc(prepaid)
yield prepaid.save()
res.status(201).send(prepaid.toObject())
redeem: wrap (req, res) ->
if not req.user?.isTeacher()
throw new errors.Forbidden('Must be a teacher to use licenses')
prepaid = yield database.getDocFromHandle(req, Prepaid)
if not prepaid
throw new errors.NotFound('Prepaid not found.')
if prepaid._id.getTimestamp().getTime() < cutoffDate.getTime()
throw new errors.Forbidden('Cannot redeem from prepaids older than November 11, 2015')
unless prepaid.get('creator').equals(req.user._id)
@ -61,7 +63,7 @@ module.exports =
return res.status(200).send(prepaid.toObject({req: req}))
if user.isTeacher()
throw new errors.Forbidden('Teachers may not be enrolled')
query =
_id: prepaid._id
'redeemers.userID': { $ne: user._id }
@ -71,7 +73,7 @@ module.exports =
if result.nModified is 0
@logError(req.user, "POST prepaid redeemer lost race on maxRedeemers")
throw new errors.Forbidden('This prepaid is exhausted')
update = {
$set: {
coursePrepaid: {
@ -84,7 +86,7 @@ module.exports =
if not user.get('role')
update.$set.role = 'student'
yield user.update(update)
# return prepaid with new redeemer added locally
redeemers = _.clone(prepaid.get('redeemers') or [])
redeemers.push({ date: new Date(), userID: user._id })
@ -94,12 +96,12 @@ module.exports =
fetchByCreator: wrap (req, res, next) ->
creator = req.query.creator
return next() if not creator
unless req.user.isAdmin() or creator is req.user.id
throw new errors.Forbidden('Must be logged in as given creator')
unless database.isID(creator)
throw new errors.UnprocessableEntity('Invalid creator')
q = {
_id: { $gt: cutoffID }
creator: mongoose.Types.ObjectId(creator)
@ -108,3 +110,43 @@ module.exports =
prepaids = yield Prepaid.find(q)
res.send((prepaid.toObject({req: req}) for prepaid in prepaids))
fetchActiveSchools: wrap (req, res) ->
unless req.user.isAdmin() or creator is req.user.id
throw new errors.Forbidden('Must be logged in as given creator')
prepaids = yield Prepaid.find({type: 'course'}, {creator: 1, properties: 1, startDate: 1, endDate: 1, maxRedeemers: 1, redeemers: 1}).lean()
userPrepaidsMap = {}
today = new Date()
userIDs = []
redeemerIDs = []
redeemerPrepaidMap = {}
for prepaid in prepaids
continue if new Date(prepaid.endDate ? prepaid.properties?.endDate ? '2000') < today
continue if new Date(prepaid.endDate) < new Date(prepaid.startDate)
userPrepaidsMap[prepaid.creator.valueOf()] ?= []
userPrepaidsMap[prepaid.creator.valueOf()].push(prepaid)
userIDs.push prepaid.creator
for redeemer in prepaid.redeemers ? []
redeemerIDs.push redeemer.userID + ""
redeemerPrepaidMap[redeemer.userID + ""] = prepaid._id.valueOf()
# Find recently created level sessions for redeemers
lastMonth = new Date()
lastMonth.setUTCDate(lastMonth.getUTCDate() - 30)
levelSessions = yield LevelSession.find({$and: [{created: {$gte: lastMonth}}, {creator: {$in: redeemerIDs}}]}, {creator: 1}).lean()
prepaidActivityMap = {}
for levelSession in levelSessions
prepaidActivityMap[redeemerPrepaidMap[levelSession.creator.valueOf()]] ?= 0
prepaidActivityMap[redeemerPrepaidMap[levelSession.creator.valueOf()]]++
trialRequests = yield TrialRequest.find({$and: [{type: 'course'}, {applicant: {$in: userIDs}}]}, {applicant: 1, properties: 1}).lean()
schoolPrepaidsMap = {}
for trialRequest in trialRequests
school = trialRequest.properties?.organization ? trialRequest.properties?.school
continue unless school
if userPrepaidsMap[trialRequest.applicant.valueOf()]?.length > 0
schoolPrepaidsMap[school] ?= []
for prepaid in userPrepaidsMap[trialRequest.applicant.valueOf()]
schoolPrepaidsMap[school].push prepaid
res.send({prepaidActivityMap, schoolPrepaidsMap})

View file

@ -89,6 +89,8 @@ module.exports =
timestamp = (new Date).getTime()
if not user
throw new errors.NotFound('User not found')
if not user.get('email')
throw new errors.UnprocessableEntity('User must have an email address to receive a verification email')
context =
email_id: sendwithus.templates.verify_email
recipient:
@ -127,14 +129,18 @@ module.exports =
unless req.user.isAnonymous()
throw new errors.Forbidden('You are already signed in.')
{ password, email } = req.body
unless _.all([password, email])
throw new errors.UnprocessableEntity('Requires password and email')
{ name, email, password } = req.body
unless password
throw new errors.UnprocessableEntity('Requires password')
unless name or email
throw new errors.UnprocessableEntity('Requires username or email')
if yield User.findByEmail(email)
if not _.isEmpty(email) and yield User.findByEmail(email)
throw new errors.Conflict('Email already taken')
if not _.isEmpty(name) and yield User.findByName(name)
throw new errors.Conflict('Name already taken')
req.user.set({ password, email, anonymous: false })
req.user.set({ name, email, password, anonymous: false })
yield module.exports.finishSignup(req, res)
signupWithFacebook: wrap (req, res) ->

View file

@ -105,6 +105,7 @@ module.exports =
if watchers.length
User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) ->
for watcher in watchers
continue if not watcher.get('email')
context =
email_id: sendwithus.templates.change_made_notify_watcher
recipient:

View file

@ -122,6 +122,10 @@ UserSchema.statics.findByEmail = (email, done=_.noop) ->
emailLower = email.toLowerCase()
User.findOne({emailLower: emailLower}).exec(done)
UserSchema.statics.findByName = (name, done=_.noop) ->
nameLower = name.toLowerCase()
User.findOne({nameLower: nameLower}).exec(done)
emailNameMap =
generalNews: 'announcement'
adventurerNews: 'tester'
@ -267,6 +271,7 @@ UserSchema.statics.unconflictName = unconflictName = (name, done) ->
unconflictName name + suffix, done
UserSchema.methods.sendWelcomeEmail = ->
return if not @get('email')
{ welcome_email_student, welcome_email_user } = sendwithus.templates
timestamp = (new Date).getTime()
data =
@ -345,14 +350,25 @@ UserSchema.methods.saveActiveUser = (event, done=null) ->
UserSchema.pre('save', (next) ->
if _.isNaN(@get('purchased')?.gems)
return next(new errors.InternalServerError('Attempting to save NaN to user'))
return next(new errors.InternalServerError('Attempting to save NaN to user'))
Classroom = require './Classroom'
if @isTeacher() and not @wasTeacher
Classroom.update({members: @_id}, {$pull: {members: @_id}}, {multi: true}).exec (err, res) ->
if email = @get('email')
@set('emailLower', email.toLowerCase())
else
@set('email', undefined)
@set('emailLower', undefined)
if name = @get('name')
@set('nameLower', name.toLowerCase())
else
@set('name', undefined)
@set('nameLower', undefined)
unless email or name or @get('anonymous') or @get('deleted')
return next(new errors.UnprocessableEntity('User needs a username or email address'))
pwd = @get('password')
if @get('password')
@set('passwordHash', User.hashPassword(pwd))

View file

@ -26,7 +26,7 @@ module.exports = createNewTask = (req, res) ->
validatePermissions = (req, sessionID, callback) ->
return callback 'You are unauthorized to submit that game to the simulator.' unless req.user?.get('email')
return callback 'You are unauthorized to submit that game to the simulator.' if (not req.user) or req.user.isAnonymous()
return callback null if req.user?.isAdmin()
findParameters = _id: sessionID

View file

@ -26,7 +26,7 @@ module.exports = dispatchTaskToConsumer = (req, res) ->
checkSimulationPermissions = (req, cb) ->
if req.user?.get('email')
if req.user and not req.user.isAnonymous()
cb null
else
cb 'You need to be logged in to simulate games'

View file

@ -104,6 +104,7 @@ module.exports.setup = (app) ->
app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword)
app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
app.get('/db/prepaid/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools)
app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)
app.post('/db/prepaid/:handle/redeemers', mw.prepaids.redeem)

View file

@ -471,6 +471,7 @@ taskReminderAlreadySentThisWeekFilter = (task, cb) ->
sendUserRemarkTaskEmail = (task, cb) ->
mailTaskName = @mailTaskName
User.findOne("_id":task.contact).select("email").lean().exec (err, contact) ->
return if not contact.email
if err? then return cb err
User.findOne("_id":task.user).select("jobProfile.name").lean().exec (err, user) ->
if err? then return cb err
@ -567,6 +568,7 @@ handleLadderUpdate = (req, res) ->
sendLadderUpdateEmail = (session, now, daysAgo) ->
User.findOne({_id: session.creator}).select('name email firstName lastName emailSubscriptions emails preferredLanguage').exec (err, user) ->
return if not user.get('email')
if err
log.error "Couldn't find user for #{session.creator} from session #{session._id}"
return
@ -686,13 +688,14 @@ handleNextSteps = (req, res) ->
log.info "Found #{results.length} next-steps users to email updates about for #{daysAgo} day(s) ago." if DEBUGGING
sendNextStepsEmail result, now, daysAgo for result in results
sendNextStepsEmail = (user, now, daysAgo) ->
module.exports.sendNextStepsEmail = sendNextStepsEmail = (user, now, daysAgo) ->
return log.info "Not sending next steps email to user with no email address" if not user.get('email')
unless user.isEmailSubscriptionEnabled('generalNews') and user.isEmailSubscriptionEnabled('anyNotes')
log.info "Not sending email to #{user.get('email')} #{user.get('name')} because they only want emails about #{JSON.stringify(user.get('emails'))}" if DEBUGGING
return
LevelSession.find({creator: user.get('_id') + ''}).select('levelName levelID changed state.complete playtime').lean().exec (err, sessions) ->
return log.error "Couldn't find sessions for #{user.get('email')}: #{err}" if err
return log.error "Couldn't find sessions for #{user.get('email')} #{user.get('name')}: #{err}" if err
complete = (s for s in sessions when s.state?.complete)
incomplete = (s for s in sessions when not s.state?.complete)
return if complete.length < 2
@ -704,7 +707,7 @@ sendNextStepsEmail = (user, now, daysAgo) ->
nextLevel = null
err = null
do (err, nextLevel) ->
return log.error "Couldn't find next level for #{user.get('email')}: #{err}" if err
return log.error "Couldn't find next level for #{user.get('email')} #{user.get('name')}: #{err}" if err
name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name')
name = 'Hero' if not name or name in ['Anoner', 'Anonymous']
#secretLevel = switch user.get('testGroupNumber') % 8

View file

@ -1,7 +1,10 @@
require '../common'
utils = require '../utils'
mail = require '../../../server/routes/mail'
sendwithus = require '../../../server/sendwithus'
User = require '../../../server/models/User'
request = require '../request'
LevelSession = require '../../../server/models/LevelSession'
testPost =
data:
@ -37,3 +40,30 @@ describe 'handleUnsubscribe', ->
expect(u.isEmailSubscriptionEnabled('ambassadorNews')).toBeFalsy()
expect(u.isEmailSubscriptionEnabled('artisanNews')).toBeFalsy()
done()
# This can be re-enabled on demand to test it, but for some async reason this
# crashes jasmine soon afterward.
describe 'sendNextStepsEmail', ->
xit 'Sends the email', utils.wrap (done) ->
user = yield utils.initUser({generalNews: {enabled: true}, anyNotes: {enabled: true}})
expect(user.id).toBeDefined()
yield new LevelSession({
creator: user.id
permissions: simplePermissions
level: original: 'dungeon-arena'
state: complete: true
}).save()
yield new LevelSession({
creator: user.id
permissions: simplePermissions
level: original: 'dungeon-arena-2'
state: complete: true
}).save()
spyOn(sendwithus.api, 'send').and.callFake (options, cb) ->
expect(options.recipient.address).toBe(user.get('email'))
cb()
done()
mail.sendNextStepsEmail(user, new Date, 5)
.pend('Breaks other tests — must be run alone')

View file

@ -1,7 +1,8 @@
async = require 'async'
config = require '../../../server_config'
require '../common'
utils = require '../../../app/core/utils' # Must come after require /common
appUtils = require '../../../app/core/utils' # Must come after require /common
utils = require '../utils'
mongoose = require 'mongoose'
TRAVIS = process.env.COCO_TRAVIS_TEST
nockUtils = require '../nock-utils'
@ -113,6 +114,13 @@ describe '/db/user, editing stripe property', ->
request.put {uri: userURL, json: body, headers: headers}, (err, res, body) ->
expect(res.statusCode).toBe 403
done()
it 'denies username-only users trying to subscribe', utils.wrap (done) ->
user = yield utils.initUser({ email: undefined, })
yield utils.loginUser(user)
[res, body] = yield request.putAsync(getURL("/db/user/#{user.id}"), { headers, json: { stripe: { planID: 'basic', token: '12345' } } })
expect(res.statusCode).toBe(403)
done()
#- shared data between tests
joeData = null
@ -327,7 +335,7 @@ describe 'Subscriptions', ->
return done() unless subscription?
expect(subscription.plan.amount).toEqual(1)
expect(subscription.customer).toEqual(sponsorCustomerID)
expect(subscription.quantity).toEqual(utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?))
expect(subscription.quantity).toEqual(appUtils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?))
# Verify sponsor payment
# May be greater than expected amount due to multiple subscribes and unsubscribes
@ -336,7 +344,7 @@ describe 'Subscriptions', ->
recipient: mongoose.Types.ObjectId(sponsorUserID)
"stripe.customerID": sponsorCustomerID
"stripe.subscriptionID": sponsorStripe.sponsorSubscriptionID
expectedAmount = utils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)
expectedAmount = appUtils.getSponsoredSubsAmount(subPrice, numSponsored, sponsorStripe.subscriptionID?)
Payment.find paymentQuery, (err, payments) ->
expect(err).toBeNull()
expect(payments).not.toBeNull()
@ -1192,7 +1200,7 @@ describe 'Subscriptions', ->
for invoice in invoices.data
line = invoice.lines.data[0]
if line.type is 'invoiceitem' and line.proration
totalAmount = utils.getSponsoredSubsAmount(subPrice, 2, false)
totalAmount = appUtils.getSponsoredSubsAmount(subPrice, 2, false)
expect(invoice.total).toBeLessThan(totalAmount)
expect(invoice.total).toEqual(totalAmount - subPrice)
Payment.findOne "stripe.invoiceID": invoice.id, (err, payment) ->

View file

@ -234,6 +234,14 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
expect(body.role).toBe('advisor')
done()
it 'returns 422 if both email and name would be unset for a registered user', utils.wrap (done) ->
user = yield utils.initUser()
yield utils.loginUser(user)
[res, body] = yield request.putAsync { uri: getURL('/db/user/'+user.id), json: { email: '', name: '' }}
expect(body.code).toBe(422)
expect(body.message).toEqual('User needs a username or email address')
done()
describe 'PUT /db/user/-/become-student', ->
beforeEach utils.wrap (done) ->
@url = getURL('/db/user/-/become-student')
@ -697,6 +705,50 @@ describe 'POST /db/user/:handle/signup-with-password', ->
expect(sendwithus.api.send).toHaveBeenCalled()
done()
it 'signs up the user with just a name and password', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
name = 'someusername'
json = { name, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('name')).toBe(name)
expect(updatedUser.get('nameLower')).toBe(name.toLowerCase())
expect(updatedUser.get('slug')).toBe(name.toLowerCase())
expect(updatedUser.get('passwordHash')).toBeDefined()
expect(updatedUser.get('email')).toBeUndefined()
expect(updatedUser.get('emailLower')).toBeUndefined()
done()
it 'signs up the user with a username, email, and password', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
name = 'someusername'
email = 'user@example.com'
json = { name, email, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(200)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('name')).toBe(name)
expect(updatedUser.get('nameLower')).toBe(name.toLowerCase())
expect(updatedUser.get('slug')).toBe(name.toLowerCase())
expect(updatedUser.get('email')).toBe(email)
expect(updatedUser.get('emailLower')).toBe(email.toLowerCase())
expect(updatedUser.get('passwordHash')).toBeDefined()
done()
it 'returns 422 if neither username or email were provided', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
json = { password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(422)
updatedUser = yield User.findById(user.id)
expect(updatedUser.get('anonymous')).toBe(true)
expect(updatedUser.get('passwordHash')).toBeUndefined()
done()
it 'returns 409 if there is already a user with the given email', utils.wrap (done) ->
email = 'some@email.com'
initialUser = yield utils.initUser({email})
@ -707,6 +759,17 @@ describe 'POST /db/user/:handle/signup-with-password', ->
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(409)
done()
it 'returns 409 if there is already a user with the given username', utils.wrap (done) ->
name = 'someusername'
initialUser = yield utils.initUser({name})
expect(initialUser.get('nameLower')).toBeDefined()
user = yield utils.becomeAnonymous()
url = getURL("/db/user/#{user.id}/signup-with-password")
json = { name, password: '12345' }
[res, body] = yield request.postAsync({url, json})
expect(res.statusCode).toBe(409)
done()
it 'disassociates the user from their trial request if the trial request email and signup email do not match', utils.wrap (done) ->
user = yield utils.becomeAnonymous()
@ -739,7 +802,7 @@ describe 'POST /db/user/:handle/signup-with-facebook', ->
facebookID = '12345'
facebookEmail = 'some@email.com'
validFacebookResponse = new Promise((resolve) -> resolve({
validFacebookResponse = new Promise((resolve) -> resolve({
id: facebookID,
email: facebookEmail,
first_name: 'Some',
@ -753,12 +816,12 @@ describe 'POST /db/user/:handle/signup-with-facebook', ->
verified: true
}))
invalidFacebookResponse = new Promise((resolve) -> resolve({
invalidFacebookResponse = new Promise((resolve) -> resolve({
error: {
message: 'Invalid OAuth access token.',
type: 'OAuthException',
code: 190,
fbtrace_id: 'EC4dEdeKHBH'
fbtrace_id: 'EC4dEdeKHBH'
}
}))

View file

@ -36,7 +36,8 @@ module.exports = mw =
options = {}
options = _.extend({
permissions: []
email: 'user'+_.uniqueId()+'@gmail.com'
name: 'Name Nameyname '+_.uniqueId()
email: 'user'+_.uniqueId()+'@example.com'
password: 'password'
anonymous: false
}, options)
@ -49,7 +50,7 @@ module.exports = mw =
done = options
options = {}
form = {
username: user.get('email')
username: user.get('email') or user.get('name')
password: 'password'
}
(options.request or request).post mw.getURL('/auth/login'), { form: form }, (err, res) ->
@ -89,7 +90,7 @@ module.exports = mw =
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
data = _.extend({}, {
name: _.uniqueId('Level ')
permissions: [{target: mw.lastLogin.id, access: 'owner'}]
}, data)