Merge branch 'master' into production

This commit is contained in:
Nick Winter 2015-03-27 16:25:41 -07:00
commit 8be5278f98
17 changed files with 541 additions and 41 deletions

View file

@ -33,7 +33,8 @@ module.exports = class CocoRouter extends Backbone.Router
'admin/clas': go('admin/CLAsView')
'admin/employers': go('admin/EmployersListView')
'admin/files': go('admin/FilesView')
'admin/growth': go('admin/GrowthView')
'admin/analytics/users': go('admin/AnalyticsUsersView')
'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView')
'admin/level-sessions': go('admin/LevelSessionsView')
'admin/users': go('admin/UsersView')
'admin/base': go('admin/BaseView')

View file

@ -62,6 +62,9 @@ module.exports = class LevelLoader extends CocoClass
# Session Loading
loadSession: ->
if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered = {}
if @sessionID
url = "/db/level.session/#{@sessionID}"
else
@ -103,7 +106,6 @@ module.exports = class LevelLoader extends CocoClass
else if session is @opponentSession
@consolidateFlagHistory() if @session.loaded
return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered ?= {}
heroConfig = session.get('heroConfig')
heroConfig ?= me.get('heroConfig') if session is @session and not @headless
heroConfig ?= {}

View file

@ -25,7 +25,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
code: "コード"
admin: "管理" # Only shows up when you are an admin
home: "ホーム"
contribute: "貢献"
contribute: "コントリビュート"
legal: "規約"
about: "CoCoについて"
contact: "お問い合わせ"
@ -828,24 +828,24 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
polls:
priority: "プライオリティ"
# contribute:
# page_title: "Contributing"
# intro_blurb: "CodeCombat is 100% open source! Hundreds of dedicated players have helped us build the game into what it is today. Join us and write the next chapter in CodeCombat's quest to teach the world to code!"
contribute:
page_title: "コントリビュート"
intro_blurb: "CodeCombat は100%オープンソースです!何百もの熱心なプレイヤーが私たちがゲームを作るのを手伝っています。私たちと一緒に CodeCombat の次のチャプターを作って世界中のプレイヤーにプログラミングを教えましょう!"
# alert_account_message_intro: "Hey there!"
# alert_account_message: "To subscribe for class emails, you'll need to be logged in first."
# archmage_introduction: "One of the best parts about building games is they synthesize so many different things. Graphics, sound, real-time networking, social networking, and of course many of the more common aspects of programming, from low-level database management, and server administration to user facing design and interface building. There's a lot to do, and if you're an experienced programmer with a hankering to really dive into the nitty-gritty of CodeCombat, this class might be for you. We would love to have your help building the best programming game ever."
# class_attributes: "Class Attributes"
# archmage_attribute_1_pref: "Knowledge in "
# archmage_attribute_1_suf: ", or a desire to learn. Most of our code is in this language. If you're a fan of Ruby or Python, you'll feel right at home. It's JavaScript, but with a nicer syntax."
# archmage_attribute_2: "Some experience in programming and personal initiative. We'll help you get oriented, but we can't spend much time training you."
# how_to_join: "How To Join"
# join_desc_1: "Anyone can help out! Just check out our "
# join_desc_2: "to get started, and check the box below to mark yourself as a brave Archmage and get the latest news by email. Want to chat about what to do or how to get more deeply involved? "
# join_desc_3: ", or find us in our "
# join_desc_4: "and we'll go from there!"
# join_url_email: "Email us"
# join_url_hipchat: "public HipChat room"
# archmage_subscribe_desc: "Get emails on new coding opportunities and announcements."
archmage_introduction: "ゲームを作る上で一番重要なのは、たくさんの要素を合成することです。グラフィック、サウンド、リアルタイムネットワーキング、ソーシャルネットワーキング、一般的なプログラミング、ローレベルのデータベースマネジメント、管理画面のデザインやインターフェイスなど多岐に渡ります。やらなくてはいけないことはたくさんあります。もしあなたが経験豊富なプログラマであればアーキメイジになって CodeCombat のコアにコミットしましょう。ぜひとも私たちの最高のプログラミングゲームを手伝ってください。"
class_attributes: "クラスの属性"
archmage_attribute_1_pref: ""
archmage_attribute_1_suf: " の知識か、それを学ぶ欲求。ほとんどの私たちのコードはこの言語で書かれています。もしあなたが Ruby や Python のファンなら親しく感じるでしょう。JavaScript ですが、より素敵なシンタックスです。"
archmage_attribute_2: "プログラミングの経験や、自ら率先して行動すること。私たちは慣れるのをお手伝いしますが、あなたをトレーニングする時間はありません。"
how_to_join: "参加の方法"
join_desc_1: "誰でも"
join_desc_2: "からはじめることができます。また、下のチェックボックをオンにするとアークメイジと CodeCombat の最新情報がメールで届きます。さらに深く一翼を担いたいですか?"
join_desc_3: "をするか、私たちの"
join_desc_4: "で私たちに連絡してください!"
join_url_email: "メール"
join_url_hipchat: "公開の HipChat のルーム"
archmage_subscribe_desc: "コーディングの機会やアナウンスをメールで受け取る"
# artisan_introduction_pref: "We must construct additional levels! People be clamoring for more content, and we can only build so many ourselves. Right now your workstation is level one; our level editor is barely usable even by its creators, so be wary. If you have visions of campaigns spanning for-loops to"
# artisan_introduction_suf: ", then this class might be for you."
# artisan_attribute_1: "Any experience in building content like this would be nice, such as using Blizzard's level editors. But not required!"
@ -890,7 +890,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
# ambassador_subscribe_desc: "Get emails on support updates and multiplayer developments."
# changes_auto_save: "Changes are saved automatically when you toggle checkboxes."
# diligent_scribes: "Our Diligent Scribes:"
# powerful_archmages: "Our Powerful Archmages:"
powerful_archmages: "私たちの強力なアークメイジたち:"
# creative_artisans: "Our Creative Artisans:"
# brave_adventurers: "Our Brave Adventurers:"
# translating_diplomats: "Our Translating Diplomats:"

View file

@ -941,13 +941,13 @@ module.exports = nativeDescription: "Português (Portugal)", englishDescription:
fight: "Lutar!"
watch_victory: "Vê a tua vitória"
defeat_the: "Derrota o"
# tournament_started: ", started"
tournament_started: ", começou"
tournament_ends: "O Torneio acaba"
tournament_ended: "O Torneio acabou"
tournament_rules: "Regras do Torneio"
tournament_blurb: "Escreve código, recolhe ouro, constrói exércitos, esmaga inimigos, ganha prémios e melhora a tua carreira no nosso torneio $40,000 Greed! Confere os detalhes"
tournament_blurb_criss_cross: "Ganha ofertas, constrói caminhos, supera os adversários, apanha gemas e melhore a tua carreira no nosso torneio Criss-Cross! Confere os detalhes"
# tournament_blurb_zero_sum: "Unleash your coding creativity in both gold gathering and battle tactics in this alpine mirror match between red sorcerer and blue sorcerer. The tournament began on Friday, March 27 and will run until Monday, April 6 at 5PM PDT. Compete for fun and glory! Check out the details"
tournament_blurb_zero_sum: "Liberta a tua criatividade de programação tanto na recolha de ouro como em táticas de combate nesta batalha-espelhada na montaha, entre o feiticeiro vermelho e o feiticeiro azul. O torneio começou na Sexta-feira, 27 de Março, e decorrerá até às 00:00 de Terça-feira, 7 de Abril. Compete por diversão e glória! Confere os detalhes"
tournament_blurb_blog: "no nosso blog"
rules: "Regras"
winners: "Vencedores"

View file

@ -941,13 +941,13 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi
fight: "В бой!"
watch_victory: "Наблюдать за победой"
defeat_the: "Победить"
# tournament_started: ", started"
tournament_started: ", начат"
tournament_ends: "Турнир заканчивается"
tournament_ended: "Турнир закончился"
tournament_rules: "Правила турнира"
tournament_blurb: "Пишите код, собирайте золото, стройте армию, крушите противников, получайте призы и улучшайте вашу карьеру в нашем \"$40,000 турнире жадности\"! Узнайте больше"
tournament_blurb_criss_cross: "Выигрывайте ставки, создавайте пути, перехитрите оппонентов, собирайте самоцветы и улучшайте вашу карьеру в нашем турнире Criss-Cross! Узнайте больше"
# tournament_blurb_zero_sum: "Unleash your coding creativity in both gold gathering and battle tactics in this alpine mirror match between red sorcerer and blue sorcerer. The tournament began on Friday, March 27 and will run until Monday, April 6 at 5PM PDT. Compete for fun and glory! Check out the details"
tournament_blurb_zero_sum: "Дайте волю своей программистской фантазии в собирании золота и боевой тактике в этом высокогорном зеркальном матче между красным волшебником и синим волшебником. Турнир начался в пятницу, 27 марта, и продолжится до 17.00 PDT понедельника, 6 апреля. Участвуйте для веселья и славы! Посмотрите детали"
tournament_blurb_blog: "в нашем блоге"
rules: "Правила"
winners: "Победители"

View file

@ -0,0 +1,50 @@
#admin-analytics-subscriptions-view
.big-stat
width: 25%
float: left
.total-count
color: green
.remaining-count
color: blue
.cancelled-count
color: red
.churn-count
color: orange
.count
font-size: 50pt
.description
font-size: 8pt
.line-graph-label
font-size: 10pt
font-weight: normal
.line-graph-container
height: 500px
width: 100%
// TODO: figure out why this is necessary
margin-bottom: 100px
.x.axis
font-size: 9pt
path
display: none
.y.axis
font-size: 9pt
path
display: none
.key-line
font-size: 9pt
.key-text
font-size: 9pt
.graph-point-info-container
display: none
position: absolute
padding: 10px
border: 1px solid black
z-index: 3
background-color: blanchedalmond
font-size: 10pt

View file

@ -299,7 +299,7 @@ kbd
&.btn-primary
border-image-source: url(/images/common/button-background-primary-disabled-border.png)
&.btn-success
border-image-source: url(/images/common/button-background-success-disabled-border.png)
border-image-source: url(/images/common/button-background-success-inactive-border.png)
&.btn-warning
border-image-source: url(/images/common/button-background-warning-disabled-border.png)
&.btn-danger
@ -357,7 +357,7 @@ html.no-borderimage
&.btn-primary
background-image: url(/images/common/button-background-primary-disabled.png)
&.btn-success
background-image: url(/images/common/button-background-success-disabled.png)
background-image: url(/images/common/button-background-success-inactive.png)
&.btn-warning
background-image: url(/images/common/button-background-warning-disabled.png)
&.btn-danger

View file

@ -225,6 +225,7 @@ $gameControlMargin: 30px
img
display: block
margin: 0px auto
max-width: 100%
.level-status
background: transparent url(/images/pages/play/level-info-status-spritesheet.png) no-repeat 0 0

View file

@ -44,8 +44,12 @@ block content
li
a(href="/admin/clas", data-i18n="admin.clas") CLAs
if me.isAdmin()
li
a(href="/admin/growth", data-i18n="admin.growth") Growth
li Analytics
ul
li
a(href="/admin/analytics/subscriptions") Subscriptions
li
a(href="/admin/analytics/users") Users (needs updating)
if me.isAdmin()
hr

View file

@ -0,0 +1,51 @@
extends /templates/base
block content
if !me.isAdmin()
div You must be logged in as an admin to view this page.
else
if total === 0
h1 Fetching subscriptions data...
else
div
.big-stat.total-count
div.description Total
div.count= total
.big-stat.remaining-count
div.description Remaining
div.count= total - cancelled
.big-stat.cancelled-count
div.description Cancelled
div.count= cancelled
.big-stat.churn-count
div.description Monthly Churn
div.count #{monthlyChurn.toFixed(2)}%
each graph in analytics.graphs
.line-graph-container
each line in graph.lines
each point in line.points
.graph-point-info-container(data-pointid="#{point.pointID}")
div(style='font-weight:bold;') #{point.day}
each value in point.values
div #{value}
div *Stripe APIs do not return information about inactive subs.
br
table.table.table-condensed.concepts-table
thead
tr
th Day
th Total
th Started
th Cancelled
tbody
each sub in subs
tr
td= sub.day
td= sub.total
td= sub.started
td= sub.cancelled

View file

@ -2,7 +2,7 @@ extends /templates/base
block content
h1(data-i18n="admin.growth_title") Growth
h1(data-i18n="admin.growth_title") Users
if me.isAdmin()
if crunchingData
h4 Crunching Data..

View file

@ -0,0 +1,323 @@
RootView = require 'views/core/RootView'
template = require 'templates/admin/analytics-subscriptions'
RealTimeCollection = require 'collections/RealTimeCollection'
# TODO: Add revenue line
# TODO: Add LTV line
# TODO: Graphing code copied/mangled from campaign editor level view. OMG, DRY.
require 'vendor/d3'
module.exports = class AnalyticsSubscriptionsView extends RootView
id: 'admin-analytics-subscriptions-view'
template: template
constructor: (options) ->
super options
if me.isAdmin()
@refreshData()
_.delay (=> @refreshData()), 30 * 60 * 1000
getRenderData: ->
context = super()
context.analytics = @analytics
context.subs = @subs ? []
context.total = @total ? 0
context.cancelled = @cancelled ? 0
context.monthlyChurn = @monthlyChurn ? 0.0
context
afterRender: ->
super()
@updateAnalyticsGraphs()
refreshData: ->
return unless me.isAdmin()
@analytics = graphs: []
@subs = []
@total = 0
@cancelled = 0
@monthlyChurn = 0.0
onSuccess = (subs) =>
subDayMap = {}
for sub in subs
startDay = sub.start.substring(0, 10)
subDayMap[startDay] ?= {}
subDayMap[startDay]['start'] ?= 0
subDayMap[startDay]['start']++
if cancelDay = sub?.cancel?.substring(0, 10)
subDayMap[cancelDay] ?= {}
subDayMap[cancelDay]['cancel'] ?= 0
subDayMap[cancelDay]['cancel']++
for day of subDayMap
@subs.push
day: day
started: subDayMap[day]['start']
cancelled: subDayMap[day]['cancel'] or 0
@subs.sort (a, b) -> a.day.localeCompare(b.day)
startedLastMonth = 0
for sub, i in @subs
@total += sub.started
@cancelled += sub.cancelled
sub.total = @total
startedLastMonth += sub.started if @subs.length - i < 31
@monthlyChurn = @cancelled / startedLastMonth * 100.0
@updateAnalyticsGraphData()
@render()
@supermodel.addRequestResource('subscriptions', {
url: '/db/subscription/-/subscriptions'
method: 'GET'
success: onSuccess
}, 0).load()
updateAnalyticsGraphData: ->
# console.log 'updateAnalyticsGraphData'
# Build graphs based on available @analytics data
# Currently only one graph
@analytics.graphs = [graphID: 'total-subs', lines: []]
return unless @subs?.length > 0
# TODO: Where should this metadata live?
# TODO: lineIDs assumed to be unique across graphs
totalSubsID = 'total-subs'
startedSubsID = 'started-subs'
cancelledSubsID = 'cancelled-subs'
lineMetadata = {}
lineMetadata[totalSubsID] =
description: 'Total Active Subscriptions'
color: 'green'
lineMetadata[startedSubsID] =
description: 'New Subscriptions'
color: 'blue'
lineMetadata[cancelledSubsID] =
description: 'Cancelled Subscriptions'
color: 'red'
days = (sub.day for sub in @subs)
if days.length > 0
currentIndex = 0
currentDay = days[currentIndex]
currentDate = new Date(currentDay + "T00:00:00.000Z")
lastDay = days[days.length - 1]
while currentDay isnt lastDay
days.splice currentIndex, 0, currentDay if days[currentIndex] isnt currentDay
currentIndex++
currentDate.setUTCDate(currentDate.getUTCDate() + 1)
currentDay = currentDate.toISOString().substr(0, 10)
## Totals
# Build line data
levelPoints = []
for sub, i in @subs
levelPoints.push
x: i
y: sub.total
day: sub.day
pointID: "#{totalSubsID}#{i}"
values: []
# Ensure points for each day
for day, i in days
if levelPoints.length <= i or levelPoints[i].day isnt day
prevY = if i > 0 then levelPoints[i - 1].y else 0.0
levelPoints.splice i, 0,
y: prevY
day: day
values: []
levelPoints[i].x = i
levelPoints[i].pointID = "#{totalSubsID}#{i}"
levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60
@analytics.graphs[0].lines.push
lineID: totalSubsID
enabled: true
points: levelPoints
description: lineMetadata[totalSubsID].description
lineColor: lineMetadata[totalSubsID].color
min: 0
max: d3.max(@subs, (d) -> d.total)
## Started
# Build line data
levelPoints = []
for sub, i in @subs
levelPoints.push
x: i
y: sub.started
day: sub.day
pointID: "#{startedSubsID}#{i}"
values: []
# Ensure points for each day
for day, i in days
if levelPoints.length <= i or levelPoints[i].day isnt day
prevY = if i > 0 then levelPoints[i - 1].y else 0.0
levelPoints.splice i, 0,
y: prevY
day: day
values: []
levelPoints[i].x = i
levelPoints[i].pointID = "#{startedSubsID}#{i}"
levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60
@analytics.graphs[0].lines.push
lineID: startedSubsID
enabled: true
points: levelPoints
description: lineMetadata[startedSubsID].description
lineColor: lineMetadata[startedSubsID].color
min: 0
max: d3.max(@subs, (d) -> d.started)
## Cancelled
# Build line data
levelPoints = []
for sub, i in @subs
levelPoints.push
x: i
y: sub.cancelled
day: sub.day
pointID: "#{cancelledSubsID}#{i}"
values: []
# Ensure points for each day
for day, i in days
if levelPoints.length <= i or levelPoints[i].day isnt day
prevY = if i > 0 then levelPoints[i - 1].y else 0.0
levelPoints.splice i, 0,
y: prevY
day: day
values: []
levelPoints[i].x = i
levelPoints[i].pointID = "#{cancelledSubsID}#{i}"
levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60
@analytics.graphs[0].lines.push
lineID: cancelledSubsID
enabled: true
points: levelPoints
description: lineMetadata[cancelledSubsID].description
lineColor: lineMetadata[cancelledSubsID].color
min: 0
max: d3.max(@subs, (d) -> d.started)
updateAnalyticsGraphs: ->
# Build d3 graphs
return unless @analytics?.graphs?.length > 0
containerSelector = '.line-graph-container'
# console.log 'updateAnalyticsGraphs', containerSelector, @analytics.graphs
margin = 20
keyHeight = 20
xAxisHeight = 20
yAxisWidth = 40
containerWidth = $(containerSelector).width()
containerHeight = $(containerSelector).height()
for graph in @analytics.graphs
graphLineCount = _.reduce graph.lines, ((sum, item) -> if item.enabled then sum + 1 else sum), 0
svg = d3.select(containerSelector).append("svg")
.attr("width", containerWidth)
.attr("height", containerHeight)
width = containerWidth - margin * 2 - yAxisWidth * graphLineCount
height = containerHeight - margin * 2 - xAxisHeight - keyHeight * graphLineCount
currentLine = 0
for line in graph.lines
continue unless line.enabled
xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)])
yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
# x-Axis and guideline once
if currentLine is 0
startDay = new Date(line.points[0].day)
endDay = new Date(line.points[line.points.length - 1].day)
xAxisRange = d3.time.scale()
.domain([startDay, endDay])
.range([0, width])
xAxis = d3.svg.axis()
.scale(xAxisRange)
svg.append("g")
.attr("class", "x axis")
.call(xAxis)
.selectAll("text")
.attr("dy", ".35em")
.attr("transform", "translate(" + (margin + yAxisWidth * (graphLineCount - 1)) + "," + (height + margin) + ")")
.style("text-anchor", "start")
# Horizontal guidelines
# svg.selectAll(".line")
# .data([10, 30, 50, 70, 90])
# .enter()
# .append("line")
# .attr("x1", margin + yAxisWidth * graphLineCount)
# .attr("y1", (d) -> margin + yRange(d))
# .attr("x2", margin + yAxisWidth * graphLineCount + width)
# .attr("y2", (d) -> margin + yRange(d))
# .attr("stroke", line.lineColor)
# .style("opacity", "0.5")
# y-Axis
yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
yAxis = d3.svg.axis()
.scale(yRange)
.orient("left")
svg.append("g")
.attr("class", "y axis")
.attr("transform", "translate(" + (margin + yAxisWidth * currentLine) + "," + margin + ")")
.style("color", line.lineColor)
.call(yAxis)
.selectAll("text")
.attr("y", 0)
.attr("x", 0)
.attr("fill", line.lineColor)
.style("text-anchor", "start")
# Key
svg.append("line")
.attr("x1", margin)
.attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
.attr("x2", margin + 40)
.attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
.attr("stroke", line.lineColor)
.attr("class", "key-line")
svg.append("text")
.attr("x", margin + 40 + 10)
.attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2)
.attr("fill", line.lineColor)
.attr("class", "key-text")
.text(line.description)
# Path and points
svg.selectAll(".circle")
.data(line.points)
.enter()
.append("circle")
.attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")")
.attr("cx", (d) -> xRange(d.x))
.attr("cy", (d) -> yRange(d.y))
.attr("r", 2)
.attr("fill", line.lineColor)
.attr("stroke-width", 1)
.attr("class", "graph-point")
.attr("data-pointid", (d) -> "#{line.lineID}#{d.x}")
d3line = d3.svg.line()
.x((d) -> xRange(d.x))
.y((d) -> yRange(d.y))
.interpolate("linear")
svg.append("path")
.attr("d", d3line(line.points))
.attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")")
.style("stroke-width", 1)
.style("stroke", line.lineColor)
.style("fill", "none")
currentLine++

View file

@ -1,5 +1,5 @@
RootView = require 'views/core/RootView'
template = require 'templates/admin/growth'
template = require 'templates/admin/analytics-users'
RealTimeCollection = require 'collections/RealTimeCollection'
require 'vendor/d3'
@ -16,8 +16,8 @@ require 'vendor/d3'
# TODO: aggregate recent data if missing?
#
module.exports = class GrowthView extends RootView
id: 'admin-growth-view'
module.exports = class AnalyticsUsersView extends RootView
id: 'admin-analytics-users-view'
template: template
height: 300
width: 1000
@ -55,7 +55,7 @@ module.exports = class GrowthView extends RootView
if me.isAdmin()
@createPerDayChart()
@createPerMonthChart()
createPerDayChart: ->
addedData = []
totalData = []
@ -76,7 +76,7 @@ module.exports = class GrowthView extends RootView
createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) ->
return unless data.length > 1
minVal = d3.min(data, (d) -> d.value)
maxVal = d3.max(data, (d) -> d.value)
@ -129,16 +129,16 @@ module.exports = class GrowthView extends RootView
.attr("cx", (d) -> d.x )
.attr("cy", (d) -> d.y )
.attr("r", "2px")
.attr("fill", "black")
.attr("fill", "black")
chart.selectAll(".text")
.data(points)
.enter()
.append("text")
.attr("dy", ".35em")
.attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)")
.text((d) ->
if d.id.length is 8
.text((d) ->
if d.id.length is 8
return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}"
else
return "#{parseInt(d.id[4..5])}/#{d.id[0..3]}"
@ -161,7 +161,7 @@ module.exports = class GrowthView extends RootView
.attr("cx", (d) -> d.x )
.attr("cy", (d) -> d.y )
.attr("r", "2px")
.attr("fill", "purple")
.attr("fill", "purple")
chart.selectAll('.line')
.data(sevenLinks)
@ -191,4 +191,3 @@ module.exports = class GrowthView extends RootView
.attr("y", (d) -> d.start.y - 6)
.attr("dy", ".35em")
.text((d) -> d.start.id)

View file

@ -29,7 +29,7 @@ getSubscriptions(null, function () {
});
function getSubscriptions(starting_after, done)
function getSubscriptions(starting_after, done)
{
var options = {limit: 100};
if (starting_after) options.starting_after = starting_after;

View file

@ -24,6 +24,7 @@ module.exports.handlers =
'poll': 'polls/poll_handler'
'user_polls_record': 'polls/user_polls_record_handler'
'prepaid': 'prepaids/prepaid_handler'
'subscription': 'payments/subscription_handler'
module.exports.routes =
[

View file

@ -2,6 +2,7 @@
# the stripe property in the user with what's being stored in Stripe.
async = require 'async'
config = require '../../server_config'
Handler = require '../commons/Handler'
discountHandler = require './discount_handler'
Prepaid = require '../prepaids/Prepaid'
@ -21,6 +22,71 @@ class SubscriptionHandler extends Handler
logSubscriptionError: (user, msg) ->
console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
getByRelationship: (req, res, args...) ->
return @getSubscriptions(req, res) if args[1] is 'subscriptions'
super(arguments...)
getSubscriptions: (req, res) ->
# Returns a list of active subscriptions
# TODO: does not handle customers with 11+ active subscriptions
# TODO: does not track sponsored subs, only basic
# TODO: does not return free subs
# TODO: add tests
# TODO: aggregate this data daily instead of providing it on demand
# TODO: take date range as input
return @sendForbiddenError(res) unless req.user and req.user.isAdmin()
# @subs ?= []
# return @sendSuccess(res, @subs) unless _.isEmpty(@subs)
@subs = []
customersProcessed = 0
nextBatch = (starting_after, done) =>
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.customers.list options, (err, customers) =>
return done(err) if err
customersProcessed += customers.data.length
for customer in customers.data
continue unless customer?.subscriptions?.data?.length > 0
for subscription in customer.subscriptions.data
continue unless subscription.plan.id is 'basic'
amount = subscription.plan.amount
if subscription?.discount?.coupon?
if subscription.discount.coupon.percent_off
amount = amount * (100 - subscription.discount.coupon.percent_off) / 100;
else if subscription.discount.coupon.amount_off
amount -= subscription.discount.coupon.amount_off
else if customer.discount?.coupon?
if customer.discount.coupon.percent_off
amount = amount * (100 - customer.discount.coupon.percent_off) / 100
else if customer.discount.coupon.amount_off
amount -= customer.discount.coupon.amount_off
continue unless amount > 0
sub = start: new Date(subscription.start * 1000)
if subscription.cancel_at_period_end
sub.cancel = new Date(subscription.canceled_at * 1000)
sub.end = new Date(sub.current_period_end * 1000)
@subs.push(sub)
# Can't fetch all the test Stripe data
if customers.has_more and (config.isProduction or customersProcessed < 500)
return nextBatch(customers.data[customers.data.length - 1].id, done)
else
return done()
nextBatch null, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @subs)
subscribeUser: (req, user, done) ->
if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
return done({res: 'You must be signed in to subscribe.', code: 403})

View file

@ -419,6 +419,8 @@ describe 'Subscriptions', ->
options = customer: body.stripe.customerID, limit: 100
stripe.invoices.list options, (err, invoices) ->
expect(err).toBeNull()
expect(invoices).not.toBeNull()
return done(updatedUser) unless invoices?
expect(invoices.has_more).toEqual(false)
makeWebhookCall = (invoice) ->
(callback) ->