Merge branch 'master' into production

This commit is contained in:
Matt Lott 2015-10-06 13:15:24 -07:00
commit 0689e487dd
22 changed files with 519 additions and 84 deletions

View file

@ -875,6 +875,10 @@
play_time: "Play time:"
completed: "Completed:"
invite_students: "Invite students to join this class."
invite_link_header: "Link to join course"
invite_link_p_1: "Give this link to students you would like to have join the course."
invite_link_p_2: "Or have us email them directly:"
capacity_used: "Course slots used:"
enter_emails: "Enter student emails to invite, one per line"
send_invites: "Send Invites"
title: "Title"

View file

@ -51,7 +51,10 @@ class CocoModel extends Backbone.Model
@loading = false
@jqxhr = null
if jqxhr.status is 402
Backbone.Mediator.publish 'level:subscription-required', {}
if _.contains(jqxhr.responseText, 'be in a course')
Backbone.Mediator.publish 'level:course-membership-required', {}
else
Backbone.Mediator.publish 'level:subscription-required', {}
onLoaded: ->
@loaded = true

View file

@ -244,7 +244,7 @@ GeneralArticleSchema = c.object {
links: [{rel: 'db', href: '/db/article/{(original)}/version/{(majorVersion)}'}]
},
original: c.objectId(title: 'Original', description: 'A reference to the original Article.')#, format: 'hidden') # hidden?
majorVersion: {title: 'Major Version', description: 'Which major version of the Article is being used.', type: 'integer', minimum: 0}#, format: 'hidden'} # hidden?
majorVersion: {title: 'Major Version', description: 'Which major version of the Article is being used.', type: 'integer', minimum: 0} #, format: 'hidden'} # hidden?
LevelSchema = c.object {
title: 'Level'

View file

@ -171,3 +171,5 @@ module.exports =
hero: {type: 'object'}
'level:subscription-required': c.object {}
'level:course-membership-required': c.object {}

View file

@ -1,6 +1,6 @@
#course-details-view
.invite-emails
#invite-emails-textarea
width: 50%
.progress-cell

View file

@ -111,13 +111,13 @@
font-variant: small-caps
text-transform: none
.subscription-required
.subscription-required, .course-membership-required
display: none
margin-top: -160px
color: black
font-size: 24px
.start-subscription-button
.btn
width: 100%
margin: 0px auto
font-size: 40px

View file

@ -247,11 +247,25 @@ mixin progress-members-popup-started(i, level)
mixin invite-tab
p(data-i18n="courses.invite_students")
p TODO: Student unlock code
p TODO: Class capacity
textarea.invite-emails(rows=3, data-i18n="[placeholder]courses.enter_emails", placeholder="Enter student emails to invite, one per line")
div(style='margin-top:10px;')
button.btn.btn-success.btn-invite(data-i18n="courses.send_invites")
h3(data-i18n="courses.invite_link_header")
p(data-i18n="courses.invite_link_p_1")
.alert.alert-info
strong= document.location.origin + "/courses?_ppc=" + view.prepaid.get('code')
p(data-i18n="courses.invite_link_p_2")
.form
.form-group
textarea#invite-emails-textarea.form-control(
rows=3, data-i18n="[placeholder]courses.enter_emails", placeholder="Enter student emails to invite, one per line")
.form-group
button#invite-btn.btn.btn-success(data-i18n="courses.send_invites")
#invite-emails-sending-alert.alert.alert-info.hide(data-i18n="common.sending")
#invite-emails-success-alert.alert.alert-success.hide(data-i18n="play_level.done")
h3 Class Capacity
if view.prepaid.loaded
p
span.spr(data-i18n="courses.capacity_used")
span #{view.prepaid.get('redeemers').length} / #{view.prepaid.get('maxRedeemers')}.
mixin levels-tab
table.table.table-striped.table-condensed

View file

@ -7,19 +7,25 @@ block content
a.spl(href='mailto:team@codecombat.com') team@codecombat.com
br
if studentMode
+student-main
if state === 'enrolling'
.alert.alert-info Enrolling in course..
else
+teacher-main
.container-fluid
- var i = 0
while i < courses.length
.row
+course-block(courses[i], instances)
- i++
if i < courses.length
if state === 'unknown_error'
.alert.alert-danger.alert-dismissible= stateMessage
if studentMode
+student-main
else
+teacher-main
.container-fluid
- var i = 0
while i < courses.length
.row
+course-block(courses[i], instances)
- i++
if i < courses.length
+course-block(courses[i], instances)
- i++
mixin student-main
button.btn.btn-warning.btn-teacher(data-i18n="courses.teachers_click")
@ -68,9 +74,9 @@ mixin student-dialog(course)
.container-fluid
.row
.col-md-8
input.code-input(type='text', data-i18n="[placeholder]courses.enter_code1", placeholder="Enter unlock code")
input.code-input(type='text', data-course-id="#{course.id}", data-i18n="[placeholder]courses.enter_code1", placeholder="Enter unlock code")
.col-md-4
button.btn.btn-success.btn-enroll(data-i18n="courses.enroll")
button.btn.btn-success.btn-enroll(data-course-id="#{course.id}", data-i18n="courses.enroll")
mixin teacher-dialog(course)
.modal.continue-dialog(id="continueModal#{course.id}")
@ -104,7 +110,10 @@ mixin teacher-dialog(course)
div.or(data-i18n="courses.or")
.row.button-row.center
.col-md-12
button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}", data-i18n="courses.buy_course1")
if course.get('pricePerSeat') === 0
button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}") Start new class
else
button.btn.btn-success.btn-lg.btn-buy(data-course-id="#{course.id}", data-i18n="courses.buy_course1")
mixin course-block(course)
if studentMode

View file

@ -24,6 +24,10 @@
span(data-i18n="subscribe.subscription_required_to_play") You'll need a subscription to play this level.
button.start-subscription-button.btn.btn-lg.btn-warning(data-i18n="subscribe.subscribe") Subscribe
.course-membership-required
span(data-i18n="courses.course_membership_required_to_play") You'll need to join a course to play this level.
a.btn.btn-lg.btn-warning(data-i18n="courses.go_to_courses", href="/courses") Go To Courses
#tip-wrapper
strong.tip(data-i18n='play_level.tip_toggle_play') Toggle play/paused with Ctrl+P.
strong.tip(data-i18n='play_level.tip_scrub_shortcut') Ctrl+[ and Ctrl+] rewind and fast-forward.

View file

@ -129,6 +129,7 @@ module.exports = class CocoView extends Backbone.View
context.isIE = @isIE()
context.moment = moment
context.translate = $.i18n.t
context.view = @
context
afterRender: ->

View file

@ -7,6 +7,7 @@ RootView = require 'views/core/RootView'
template = require 'templates/courses/course-details'
User = require 'models/User'
utils = require 'core/utils'
Prepaid = require 'models/Prepaid'
module.exports = class CourseDetailsView extends RootView
id: 'course-details-view'
@ -22,6 +23,7 @@ module.exports = class CourseDetailsView extends RootView
'click .progress-level-cell': 'onClickProgressLevelCell'
'mouseenter .progress-level-cell': 'onMouseEnterPoint'
'mouseleave .progress-level-cell': 'onMouseLeavePoint'
'click #invite-btn': 'onClickInviteButton'
constructor: (options, @courseID, @courseInstanceID) ->
super options
@ -31,6 +33,7 @@ module.exports = class CourseDetailsView extends RootView
@memberSort = 'nameAsc'
@course = @supermodel.getModel(Course, @courseID) or new Course _id: @courseID
@listenTo @course, 'sync', @onCourseSync
@prepaid = new Prepaid()
if @course.loaded
@onCourseSync()
else
@ -55,6 +58,7 @@ module.exports = class CourseDetailsView extends RootView
context.sortedMembers = @sortedMembers ? []
context.userConceptStateMap = @userConceptStateMap ? {}
context.userLevelStateMap = @userLevelStateMap ? {}
context.document = document
context
onCourseSync: ->
@ -119,6 +123,16 @@ module.exports = class CourseDetailsView extends RootView
@members = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/members", model: User, comparator: 'nameLower' })
@listenToOnce @members, 'sync', @onMembersSync
@supermodel.loadCollection @members, 'members', cache: false
if @adminMode and prepaidID = @courseInstance.get('prepaidID')
@prepaid = @supermodel.getModel(Prepaid, prepaidID) or new Prepaid _id: prepaidID
@listenTo @prepaid, 'sync', @onPrepaidSync
if @prepaid.loaded
@onPrepaidSync()
else
@supermodel.loadModel @prepaid, 'prepaid'
@render?()
onPrepaidSync: ->
@render?()
onLevelSessionsSync: ->
@ -226,6 +240,26 @@ module.exports = class CourseDetailsView extends RootView
viewArgs: [{}, levelSlug]
}
onClickInviteButton: (e) ->
emails = @$('#invite-emails-textarea').val()
emails = emails.split('\n')
emails = _.filter((_.string.trim(email) for email in emails))
if not emails.length
return
url = @courseInstance.url() + '/invite_students'
@$('#invite-btn, #invite-emails-textarea').addClass('hide')
@$('#invite-emails-sending-alert').removeClass('hide')
$.ajax({
url: url
data: {emails: emails}
method: 'POST'
context: @
success: ->
@$('#invite-emails-sending-alert').addClass('hide')
@$('#invite-emails-success-alert').removeClass('hide')
})
onMouseEnterPoint: (e) ->
$('.progress-popup-container').hide()
container = $(e.target).find('.progress-popup-container').show()
@ -249,17 +283,17 @@ module.exports = class CourseDetailsView extends RootView
when "progressAsc"
@sortedMembers.sort (a, b) =>
for levelID, level of @campaign.get('levels')
if @userLevelStateMap[a][levelID] isnt 'complete' and @userLevelStateMap[b][levelID] is 'complete'
if @userLevelStateMap[a]?[levelID] isnt 'complete' and @userLevelStateMap[b]?[levelID] is 'complete'
return -1
else if @userLevelStateMap[a][levelID] is 'complete' and @userLevelStateMap[b][levelID] isnt 'complete'
else if @userLevelStateMap[a]?[levelID] is 'complete' and @userLevelStateMap[b]?[levelID] isnt 'complete'
return 1
0
when "progressDesc"
@sortedMembers.sort (a, b) =>
for levelID, level of @campaign.get('levels')
if @userLevelStateMap[a][levelID] isnt 'complete' and @userLevelStateMap[b][levelID] is 'complete'
if @userLevelStateMap[a]?[levelID] isnt 'complete' and @userLevelStateMap[b]?[levelID] is 'complete'
return 1
else if @userLevelStateMap[a][levelID] is 'complete' and @userLevelStateMap[b][levelID] isnt 'complete'
else if @userLevelStateMap[a]?[levelID] is 'complete' and @userLevelStateMap[b]?[levelID] isnt 'complete'
return -1
0
else

View file

@ -26,6 +26,7 @@ module.exports = class CoursesView extends RootView
@courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance})
@listenToOnce @courseInstances, 'sync', @onCourseInstancesLoaded
@supermodel.loadCollection(@courseInstances, 'course_instances')
@courseEnroll(prepaidCode) if prepaidCode = utils.getQueryVariable('_ppc', false)
getRenderData: ->
context = super()
@ -33,6 +34,8 @@ module.exports = class CoursesView extends RootView
context.enrolledCourses = @enrolledCourses ? {}
context.instances = @courseInstances.models ? []
context.praise = @praise
context.state = @state
context.stateMessage = @stateMessage
context.studentMode = @studentMode
context
@ -70,7 +73,10 @@ module.exports = class CoursesView extends RootView
Backbone.Mediator.publish 'router:navigate', navigationEvent
onClickEnroll: (e) ->
alert('TODO: redeem course prepaid and navigate to correct course instance')
$('.continue-dialog').modal('hide')
courseID = $(e.target).data('course-id')
prepaidCode = $(".code-input[data-course-id=#{courseID}]").val()
@courseEnroll(prepaidCode)
onClickEnter: (e) ->
$('.continue-dialog').modal('hide')
@ -95,3 +101,32 @@ module.exports = class CoursesView extends RootView
viewArgs = [studentMode: false]
navigationEvent = route: route, viewClass: viewClass, viewArgs: viewArgs
Backbone.Mediator.publish 'router:navigate', navigationEvent
courseEnroll: (prepaidCode) ->
@state = 'enrolling'
@render?()
data = prepaidCode: prepaidCode
jqxhr = $.post('/db/course_instance/-/redeem_prepaid', data)
jqxhr.done (data, textStatus, jqXHR) =>
application.tracker?.trackEvent 'Redeemed course prepaid code', {prepaidCode: prepaidCode}
# TODO: handle fetch errors
me.fetch(cache: false).always =>
if data?.length > 0 && data[0].courseID && data[0]._id
courseID = data[0].courseID
courseInstanceID = data[0]._id
route = "/courses/#{courseID}/#{courseInstanceID}"
viewArgs = [{}, courseID, courseInstanceID]
Backbone.Mediator.publish 'router:navigate',
route: route
viewClass: 'views/courses/CourseDetailsView'
viewArgs: viewArgs
else
@state = 'unknown_error'
@stateMessage = "Database error."
@render?()
jqxhr.fail (xhr, textStatus, errorThrown) =>
console.error 'Got an error redeeming a course prepaid code:', textStatus, errorThrown
application.tracker?.trackEvent 'Failed to redeem course prepaid code', status: textStatus
@state = 'unknown_error'
@stateMessage = "#{xhr.status}: #{xhr.responseText}"
@render?()

View file

@ -15,6 +15,7 @@ module.exports = class LevelLoadingView extends CocoView
subscriptions:
'level:loaded': 'onLevelLoaded' # If Level loads after level loading view.
'level:subscription-required': 'onSubscriptionRequired' # If they'd need a subscription to start playing.
'level:course-membership-required': 'onCourseMembershipRequired' # If they'd need a subscription to start playing.
'subscribe-modal:subscribed': 'onSubscribed'
shortcuts:
@ -109,6 +110,10 @@ module.exports = class LevelLoadingView extends CocoView
@$el.find('.level-loading-goals, .tip, .load-progress').hide()
@$el.find('.subscription-required').show()
onCourseMembershipRequired: (e) ->
@$el.find('.level-loading-goals, .tip, .load-progress').hide()
@$el.find('.course-membership-required').show()
onClickStartSubscription: (e) ->
@openModalView new SubscribeModal()
window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'level loading', level: @level?.get('slug') or @options.level?.get('slug')

View file

@ -397,7 +397,7 @@ module.exports = class HeroVictoryModal extends ModalView
AudioPlayer.playSound name, 1
getNextLevelCampaign: ->
{'kithgard-gates': 'forest', 'kithgard-mastery': 'forest', 'siege-of-stonehold': 'desert', 'clash-of-clones': 'mountain'}[@level.get('slug')] or @level.get 'campaign' # Much easier to just keep this updated than to dynamically figure it out.
{'kithgard-gates': 'forest', 'kithgard-mastery': 'forest', 'siege-of-stonehold': 'desert', 'clash-of-clones': 'mountain', 'summits-gate': 'glacier'}[@level.get('slug')] or @level.get 'campaign' # Much easier to just keep this updated than to dynamically figure it out.
getNextLevelLink: (returnToCourse=false) ->
if @level.get('type', true) is 'course' and nextLevel = @level.get('nextLevel') and not returnToCourse

View file

@ -9,6 +9,7 @@ PrepaidHandler = require '../prepaids/prepaid_handler'
User = require '../users/User'
UserHandler = require '../users/user_handler'
utils = require '../../app/core/utils'
sendwithus = require '../sendwithus'
CourseInstanceHandler = class CourseInstanceHandler extends Handler
modelClass: CourseInstance
@ -31,6 +32,8 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
return @createAPI(req, res) if relationship is 'create'
return @getLevelSessionsAPI(req, res, args[0]) if args[1] is 'level_sessions'
return @getMembersAPI(req, res, args[0]) if args[1] is 'members'
return @inviteStudents(req, res, args[0]) if relationship is 'invite_students'
return @redeemPrepaidCodeAPI(req, res) if args[1] is 'redeem_prepaid'
super arguments...
createAPI: (req, res) ->
@ -101,4 +104,62 @@ CourseInstanceHandler = class CourseInstanceHandler extends Handler
cleandocs = (UserHandler.formatEntity(req, doc) for doc in users)
@sendSuccess(res, cleandocs)
inviteStudents: (req, res, courseInstanceID) ->
if not req.body.emails
return @sendBadInputError(res, 'Emails not included')
CourseInstance.findById courseInstanceID, (err, courseInstance) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless courseInstance
return @sendForbiddenError(res) unless @hasAccessToDocument(req, courseInstance)
Prepaid.findById courseInstance.get('prepaidID'), (err, prepaid) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless prepaid
return @sendForbiddenError(res) unless prepaid.get('maxRedeemers') > prepaid.get('redeemers').length
for email in req.body.emails
context =
email_id: sendwithus.templates.course_invite_email
recipient:
address: email
email_data:
class_name: courseInstance.get('name')
join_link: "https://codecombat.com/courses?_ppc=" + prepaid.get('code')
sendwithus.api.send context, _.noop
return @sendSuccess(res, {})
redeemPrepaidCodeAPI: (req, res) ->
return @sendUnauthorizedError(res) if not req.user? or req.user?.isAnonymous()
return @sendBadInputError(res) unless req.body?.prepaidCode
prepaidCode = req.body?.prepaidCode
Prepaid.find code: prepaidCode, (err, prepaids) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) if prepaids.length < 1
return @sendDatabaseError(res, "Multiple prepaid codes found for #{prepaidCode}") if prepaids.length > 1
prepaid = prepaids[0]
CourseInstance.find prepaidID: prepaid.get('_id'), (err, courseInstances) =>
return @sendDatabaseError(res, err) if err
return @sendForbiddenError(res) if prepaid.get('redeemers')?.length >= prepaid.get('maxRedeemers')
# Add to prepaid redeemers
query =
_id: prepaid.get('_id')
'redeemers.userID': { $ne: req.user.get('_id') }
$where: "this.redeemers.length < #{prepaid.get('maxRedeemers')}"
update = { $push: { redeemers : { date: new Date(), userID: req.user.get('_id') } }}
Prepaid.update query, update, (err, nMatched) =>
return @sendDatabaseError(res, err) if err
if nMatched is 0
@logError(req.user, "Course instance update prepaid lost race on maxRedeemers")
return @sendForbiddenError(res)
# Add to each course instance
makeAddMemberToCourseInstanceFn = (courseInstance) =>
(done) => courseInstance.update({$addToSet: { members: req.user.get('_id')}}, done)
tasks = (makeAddMemberToCourseInstanceFn(courseInstance) for courseInstance in courseInstances)
async.parallel tasks, (err, results) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, courseInstances)
module.exports = new CourseInstanceHandler()

View file

@ -8,6 +8,9 @@ mongoose = require 'mongoose'
async = require 'async'
utils = require '../lib/utils'
log = require 'winston'
Campaign = require '../campaigns/Campaign'
Course = require '../courses/Course'
CourseInstance = require '../courses/CourseInstance'
LevelHandler = class LevelHandler extends Handler
modelClass: Level
@ -105,11 +108,26 @@ LevelHandler = class LevelHandler extends Handler
Session.findOne(sessionQuery).exec (err, doc) =>
return @sendDatabaseError(res, err) if err
return @sendSuccess(res, doc) if doc?
if level.get('type') is 'course'
return @makeOrRejectCourseLevelSession(req, res, level, sessionQuery)
requiresSubscription = level.get('requiresSubscription') or (req.user.get('chinaVersion') and level.get('campaign') and not (level.slug in ['dungeons-of-kithgard', 'gems-in-the-deep', 'shadow-guard', 'forgetful-gemsmith', 'signs-and-portents', 'true-names']))
canPlayAnyway = req.user.isPremium() or level.get 'adventurer'
return @sendPaymentRequiredError(res, err) if requiresSubscription and not canPlayAnyway
@createAndSaveNewSession sessionQuery, req, res
makeOrRejectCourseLevelSession: (req, res, level, sessionQuery) ->
CourseInstance.find {members: req.user.get('_id')}, (err, courseInstances) =>
courseIDs = (ci.get('courseID') for ci in courseInstances)
Course.find { _id: { $in: courseIDs }}, (err, courses) =>
campaignIDs = (c.get('campaignID') for c in courses)
Campaign.find { _id: { $in: campaignIDs }}, (err, campaigns) =>
levelOriginals = (_.keys(c.get('levels')) for c in campaigns)
levelOriginals = _.flatten(levelOriginals)
if level.get('original').toString() in levelOriginals
@createAndSaveNewSession(sessionQuery, req, res)
else
return @sendPaymentRequiredError(res, 'You must be in a course which includes this level to play it')
createAndSaveNewSession: (sessionQuery, req, res) =>
initVals = sessionQuery

View file

@ -196,7 +196,7 @@ class SubscriptionHandler extends Handler
@sendSuccess(res, user)
subscribeWithPrepaidCode: (req, res) ->
return @sendForbiddenError(res) unless req.user?
return @sendUnauthorizedError(res) unless req.user?
return @sendBadInputError(res,"You must provide a valid prepaid code") unless req.body?.ppc
# Check if code exists and has room for more redeemers
@ -206,14 +206,30 @@ class SubscriptionHandler extends Handler
return @sendDatabaseError(res, err)
unless prepaid
@logSubscriptionError(req.user, "Could not find prepaid code #{req.body.ppc}")
return @sendForbiddenError(res)
return @sendNotFoundError(res, "Prepaid not found")
oldRedeemers = prepaid.get('redeemers') ? []
return @sendForbiddenError(res) if oldRedeemers.length >= prepaid.get('maxRedeemers')
return @sendError(res, 403, "Too many redeemers") if oldRedeemers.length >= prepaid.get('maxRedeemers')
months = parseInt(prepaid.get('properties')?.months)
return @sendForbiddenError(res) if isNaN(months) or months < 1
return @sendBadInputError(res, "Bad months") if isNaN(months) or months < 1
for redeemer in oldRedeemers
return @sendForbiddenError(res) if redeemer.userID.equals(req.user._id)
return @sendError(res, 403, "User already redeemed") if redeemer.userID.equals(req.user._id)
@redeemPrepaidCode(req, res, months)
redeemPrepaidCode: (req, res, months) =>
return @sendUnauthorizedError(res) unless req.user?
return @sendForbiddenError(res) unless req.body?.ppc
return @sendForbiddenError(res) if isNaN(months) or months < 1
newRedeemerPush = { $push: { redeemers : { date: new Date(), userID: req.user._id } }}
Prepaid.update { 'code': req.body.ppc, 'redeemers.userID': { $ne: req.user._id }, '$where': 'this.redeemers.length < this.maxRedeemers'}, newRedeemerPush, (err, num, info) =>
if err
@logSubscriptionError(req.user, "Subscribe with Prepaid Code update: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
return @sendError(res, 403, "Can't add user to prepaid redeemers") if num isnt 1
customerID = req.user.get('stripe')?.customerID
subscriptionID = req.user.get('stripe')?.subscriptionID
@ -224,51 +240,32 @@ class SubscriptionHandler extends Handler
if err
@logSubscriptionError(user, "Redeem Prepaid Code Stripe cancel subscription error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
@redeemPrepaidCode(req, res, oldRedeemers, months, stripeSubscriptionPeriodEndDate)
redeemPrepaidCode: (req, res, oldRedeemers, months, startDate=null) =>
return @sendForbiddenError(res) unless req.user?
return @sendForbiddenError(res) unless req.body?.ppc
return @sendForbiddenError(res) unless oldRedeemers
return @sendForbiddenError(res) if isNaN(months) or months < 1
# Add terminal subscription to User, extending existing subscriptions
# TODO: refactor this into some form useable by both this and purchaseYearSale
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
endDate = new moment()
if stripeSubscriptionPeriodEndDate
endDate = new moment(stripeSubscriptionPeriodEndDate)
else if _.isString(stripeInfo.free) and new moment().isBefore(new moment(stripeInfo.free))
endDate = new moment(stripeInfo.free)
newRedeemerPush = { $push: { redeemers : { date: new Date().toISOString(), userID: req.user._id } }}
endDate = endDate.add(months, 'months')
stripeInfo.free = endDate.toISOString().substring(0, 10)
req.user.set('stripe', stripeInfo)
# Only update the prepaid document if the length of the redeemers array hasn't changed in the db.
# This will probably fail if redeemers isn't defined. new terminal_subscriptions created should be sure to set the redeemers array
# TODO: find a better way?
Prepaid.update { 'code': req.body.ppc, 'redeemers': { $size: oldRedeemers.length }}, newRedeemerPush, (err, num, info) =>
if err
@logSubscriptionError(req.user, "Subscribe with Prepaid Code update: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
# Add gems to User
purchased = _.clone(req.user.get('purchased'))
purchased ?= {}
purchased.gems ?= 0
purchased.gems += subscriptions.basic.gems * months
req.user.set('purchased', purchased)
return @sendNotFoundError(res, "Error while updating prepaid redeemer") if num isnt 1
# Add terminal subscription to User, extending existing subscriptions
# TODO: refactor this into some form useable by both this and purchaseYearSale
stripeInfo = _.cloneDeep(req.user.get('stripe') ? {})
endDate = new moment()
if startDate
endDate = new moment(startDate)
else if _.isString(stripeInfo.free) and new moment().isBefore(new moment(stripeInfo.free))
endDate = new moment(stripeInfo.free)
endDate = endDate.add(months, 'months')
stripeInfo.free = endDate.toISOString().substring(0, 10)
req.user.set('stripe', stripeInfo)
# Add gems to User
purchased = _.clone(req.user.get('purchased'))
purchased ?= {}
purchased.gems ?= 0
purchased.gems += subscriptions.basic.gems * months
req.user.set('purchased', purchased)
req.user.save (err, user) =>
if err
@logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
@sendSuccess(res, user)
req.user.save (err, user) =>
if err
@logSubscriptionError(req.user, "User save error: #{JSON.stringify(err)}")
return @sendDatabaseError(res, err)
@sendSuccess(res, user)
subscribeUser: (req, user, done) ->
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()

View file

@ -19,7 +19,7 @@ PrepaidHandler = class PrepaidHandler extends Handler
console.warn "Prepaid Error: [#{user.get('slug')} (#{user._id})] '#{msg}'"
hasAccess: (req) ->
req.user?.isAdmin()
req.method is 'GET' || req.user?.isAdmin()
getByRelationship: (req, res, args...) ->
relationship = args[1]

View file

@ -22,3 +22,5 @@ module.exports.templates =
generic_email: 'tem_JhRnQ4pvTS4KdQjYoZdbei'
plain_text_email: 'tem_85UvKDCCNPXsFckERTig6Y'
next_steps_email: 'tem_RDHhTG5inXQi8pthyqWr5D'
course_invite_email: 'tem_u6D2EFWYC5Ptk38bSykjsU'

View file

@ -6,7 +6,8 @@ stripe = require('stripe')(config.stripe.secretKey)
# TODO: add permissiosn tests
describe 'CourseInstance', ->
courseInstanceURL = getURL('/db/course_instance/-/create')
courseInstanceCreateURL = getURL('/db/course_instance/-/create')
courseInstanceRedeemURL = getURL('/db/course_instance/-/redeem_prepaid')
userURL = getURL('/db/user')
createCourseInstances = (user, courseID, seats, token, done) ->
@ -17,7 +18,7 @@ describe 'CourseInstance', ->
seats: seats
stripe:
token: token
request.post {uri: courseInstanceURL, json: requestBody }, (err, res) ->
request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(201)
CourseInstance.find {name: name}, (err, courseInstances) ->
@ -81,7 +82,7 @@ describe 'CourseInstance', ->
requestBody =
courseID: course.get('_id')
name: createName('course instance ')
request.post {uri: courseInstanceURL, json: requestBody }, (err, res) ->
request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(422)
done()
@ -145,7 +146,7 @@ describe 'CourseInstance', ->
courseID: course.get('_id')
name: createName('course instance ')
seats: 1
request.post {uri: courseInstanceURL, json: requestBody }, (err, res) ->
request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(422)
done()
@ -163,7 +164,7 @@ describe 'CourseInstance', ->
courseID: course.get('_id')
name: createName('course instance ')
seats: -1
request.post {uri: courseInstanceURL, json: requestBody }, (err, res) ->
request.post {uri: courseInstanceCreateURL, json: requestBody }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(422)
done()
@ -196,3 +197,132 @@ describe 'CourseInstance', ->
expect(prepaid.get('maxRedeemers')).toEqual(50)
expect(prepaid.get('properties')?.courseIDs?.length).toEqual(courses.length)
done()
describe 'Invite to course', ->
it 'takes a list of emails and sends invites', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
createCourse 0, (err, course) ->
expect(err).toBeNull()
return done(err) if err
createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
expect(err).toBeNull()
return done(err) if err
expect(courseInstances.length).toEqual(1)
inviteStudentsURL = getURL("/db/course_instance/#{courseInstances[0]._id}/invite_students")
requestBody = {
emails: ['test@test.com']
}
request.post { uri: inviteStudentsURL, json: requestBody }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
done()
describe 'Redeem prepaid code', ->
it 'Redeem prepaid code an instance of max 2', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
createCourse 0, (err, course) ->
expect(err).toBeNull()
return done(err) if err
createCourseInstances user1, course.get('_id'), 2, token.id, (err, courseInstances) ->
expect(err).toBeNull()
return done(err) if err
expect(courseInstances.length).toEqual(1)
Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
expect(err).toBeNull()
return done(err) if err
loginNewUser (user2) ->
request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
# Check prepaid
Prepaid.findById prepaid.id, (err, prepaid) ->
expect(err).toBeNull()
return done(err) if err
expect(prepaid.get('redeemers')?.length).toEqual(1)
expect(prepaid.get('redeemers')[0].date).toBeLessThan(new Date())
expect(prepaid.get('redeemers')[0].userID).toEqual(user2.get('_id'))
# Check course instance
CourseInstance.findById courseInstances[0].id, (err, courseInstance) ->
expect(err).toBeNull()
return done(err) if err
members = courseInstance.get('members')
expect(members?.length).toEqual(2)
# TODO: must be a better way to check membership
usersFound = 0
for memberID in members
usersFound++ if memberID.equals(user1.get('_id'))
usersFound++ if memberID.equals(user2.get('_id'))
expect(usersFound).toEqual(2)
done()
it 'Redeem full prepaid code on instance of max 1', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user1) ->
createCourse 0, (err, course) ->
expect(err).toBeNull()
return done(err) if err
createCourseInstances user1, course.get('_id'), 1, token.id, (err, courseInstances) ->
expect(err).toBeNull()
return done(err) if err
expect(courseInstances.length).toEqual(1)
Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
expect(err).toBeNull()
return done(err) if err
loginNewUser (user2) ->
request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(200)
loginNewUser (user3) ->
request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toBe(403)
done()
it 'Redeem 50 count course prepaid codes 51 times, in parallel', (done) ->
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
seatCount = 50
loginNewUser (user1) ->
createCourse 0, (err, course) ->
expect(err).toBeNull()
return done(err) if err
createCourseInstances user1, course.get('_id'), seatCount, token.id, (err, courseInstances) ->
expect(err).toBeNull()
return done(err) if err
expect(courseInstances.length).toEqual(1)
Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
expect(err).toBeNull()
return done(err) if err
forbiddenResults = 0
makeRedeemCall = ->
(callback) ->
loginNewUser (user2) ->
request.post {uri: courseInstanceRedeemURL, json: {prepaidCode: prepaid.get('code')} }, (err, res) ->
expect(err).toBeNull()
if res.statusCode is 403
forbiddenResults++
else
expect(res.statusCode).toBe(200)
callback err
tasks = (makeRedeemCall() for i in [1..seatCount+1])
async.parallel tasks, (err, results) ->
expect(err?).toEqual(false)
expect(forbiddenResults).toEqual(1)
Prepaid.findById courseInstances[0].get('prepaidID'), (err, prepaid) ->
expect(err).toBeNull()
return done(err) if err
expect(prepaid.get('redeemers')?.length).toEqual(prepaid.get('maxRedeemers'))
done()

View file

@ -29,3 +29,75 @@ describe 'Level', ->
body = JSON.parse(body)
expect(body.type).toBeDefined()
done()
describe 'GET /db/level/<id>/session', ->
describe 'when level is a course level', ->
levelID = null
it 'sets up a course instance', (done) ->
clearModels [Campaign, Course, CourseInstance, Level, User], (err) ->
loginAdmin (admin) ->
url = getURL('/db/level')
body =
name: 'Course Level'
type: 'course'
permissions: simplePermissions
request.post {uri: url, json: body }, (err, res, level) ->
levelID = level._id
url = getURL('/db/campaign')
body =
name: 'Course Campaign'
levels: {}
body.levels[level.original] = { 'original': level.original }
request.post { uri: url, json: body }, (err, res, campaign) ->
course = new Course({
name: 'Test Course'
campaignID: ObjectId(campaign._id)
})
course.save (err) ->
expect(err).toBeNull()
loginJoe (joe) ->
courseInstance = new CourseInstance({
name: 'Course Instance'
members: [
joe.get('_id')
]
courseID: ObjectId(course.id)
})
courseInstance.save (err) ->
expect(err).toBeNull()
done()
it 'creates a new session if the user is in a course with that level', (done) ->
loginJoe (joe) ->
url = getURL("/db/level/#{levelID}/session")
request.get { uri: url }, (err, res, body) ->
expect(res.statusCode).toBe(200)
done()
it 'does not create a new session if the user is not in a course with that level', (done) ->
loginSam (sam) ->
url = getURL("/db/level/#{levelID}/session")
request.get { uri: url }, (err, res, body) ->
expect(res.statusCode).toBe(402)
done()

View file

@ -2,6 +2,7 @@ require '../common'
config = require '../../../server_config'
moment = require 'moment'
{findStripeSubscription} = require '../../../server/lib/utils'
async = require 'async'
describe '/db/prepaid', ->
prepaidURL = getURL('/db/prepaid')
@ -320,7 +321,6 @@ describe '/db/prepaid', ->
expect(payments[0].get('amount')).toEqual(8991)
done()
it 'Anonymous cant redeem a prepaid code', (done) ->
logoutUser () ->
subscribeWithPrepaid joeCode, (err, res) ->
@ -332,7 +332,7 @@ describe '/db/prepaid', ->
loginJoe (joe) ->
subscribeWithPrepaid 'abc123', (err, res) ->
expect(err).toBeNull()
expect(res.statusCode).toEqual(403)
expect(res.statusCode).toEqual(404)
done()
it 'User cant redeem empty code', (done) ->
@ -463,4 +463,48 @@ describe '/db/prepaid', ->
expect(err).toBeNull()
expect(res.statusCode).not.toEqual(200)
done()
# TODO: add a bunch of parallel tests trying to redeem a code with a high maxRedeemers (50?) to see what happens
it 'Test a bunch of people trying to redeem at once', (done) ->
doRedeem = (userX, code, testnum, retry, fnDone) =>
loginUser userX, () =>
endDate = new moment().add(3, 'months').toISOString().substring(0, 10)
subscribeWithPrepaid code, (err, res, result) ->
if err
return fnDone(err)
expect(err).toBeNull()
expect(result).toBeDefined()
if result.stripe
expect(result.stripe).toBeDefined()
expect(result.stripe.free).toEqual(endDate)
expect(result?.purchased?.gems).toEqual(10500)
return fnDone(null, {status: "ok", msg: "Redeemed " + retry})
else
return fnDone(null, {status: 'error', msg: "Redeem attempt Error #{result} (#{userX.id})" + retry })
redeemPrepaidFn = (code, testnum) =>
(fnDone) =>
loginNewUser (user1) =>
doRedeem(user1, code, testnum, 0, fnDone)
stripe.tokens.create {
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
loginNewUser (user) =>
codeRedeemers = 50
codeMonths = 3
redeemers = 51
purchasePrepaid 'terminal_subscription', months: codeMonths, codeRedeemers, token.id, (err, res, prepaid) ->
expect(err).toBeNull()
expect(prepaid).toBeDefined()
expect(prepaid.code).toBeDefined()
tasks = (redeemPrepaidFn(prepaid.code, i) for i in [0...redeemers])
async.parallel tasks, (err, results) =>
redeemed = 0
error = 0
for result in results
redeemed += 1 if result.status is 'ok'
error += 1 if result.status is 'error'
expect(redeemed).toEqual(codeRedeemers)
expect(error).toEqual(redeemers - codeRedeemers)
done()