diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 5dee4e159..21a002e79 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -44,6 +44,10 @@ module.exports = class CocoRouter extends Backbone.Router 'beta': go('HomeView') 'cla': go('CLAView') + + 'clans': go('clans/ClansView') + 'clans/:clanID': go('clans/ClanDetailsView') + 'community': go('CommunityView') 'contribute': go('contribute/MainContributeView') diff --git a/app/models/Clan.coffee b/app/models/Clan.coffee new file mode 100644 index 000000000..23bcac753 --- /dev/null +++ b/app/models/Clan.coffee @@ -0,0 +1,7 @@ +CocoModel = require './CocoModel' +schema = require 'schemas/models/clan.schema' + +module.exports = class Clan extends CocoModel + @className: 'Clan' + @schema: schema + urlRoot: '/db/clan' diff --git a/app/schemas/models/clan.schema.coffee b/app/schemas/models/clan.schema.coffee new file mode 100644 index 000000000..a7d646821 --- /dev/null +++ b/app/schemas/models/clan.schema.coffee @@ -0,0 +1,18 @@ +c = require './../schemas' + +ClanSchema = c.object {title: 'Clan', required: ['name', 'type']} +c.extendNamedProperties ClanSchema # name first + +_.extend ClanSchema.properties, + description: {type: 'string'} + members: c.array {title: 'Members'}, c.objectId() + ownerID: c.objectId() + type: {type: 'string', 'enum': ['public']} + +c.extendBasicProperties ClanSchema, 'Clan' + +# Do we need these? +# c.extendSearchableProperties ClanSchema +# c.extendPermissionsProperties ClanSchema + +module.exports = ClanSchema diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 612e78350..510827657 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -306,6 +306,8 @@ _.extend UserSchema.properties, referrer: { type: 'string' } chinaVersion: { type: 'boolean' } + clans: c.array {}, c.objectId() + c.extendBasicProperties UserSchema, 'user' UserSchema.definitions = diff --git a/app/styles/clans/clan-details.sass b/app/styles/clans/clan-details.sass new file mode 100644 index 000000000..42e29f123 --- /dev/null +++ b/app/styles/clans/clan-details.sass @@ -0,0 +1,56 @@ +#clan-details-view + + .join-clan-link + width: 390px + + .join-link-prompt + font-weight: bold + + .stats-table + width: 240px + background: rgba(0, 0, 0, 0.0) + + $spriteSheetSize: 30px + + td.hero-icon-cell + width: 30px + + .player-hero-icon + background: transparent url(/images/pages/play/play-spritesheet.png) + background-size: cover + background-position: (-2 * $spriteSheetSize) 0 + display: inline-block + width: 30px + height: 30px + margin: 0px 2px + + .player-hero-icon + background-position: (-4 * $spriteSheetSize) 0 + + &.knight + background-position: (-5 * $spriteSheetSize) 0 + &.librarian + background-position: (-6 * $spriteSheetSize) 0 + &.ninja + background-position: (-7 * $spriteSheetSize) 0 + &.potion-master + background-position: (-8 * $spriteSheetSize) 0 + &.samurai + background-position: (-9 * $spriteSheetSize) 0 + &.trapper + background-position: (-10 * $spriteSheetSize) 0 + &.forest-archer + background-position: (-11 * $spriteSheetSize) 0 + &.sorcerer + background-position: (-12 * $spriteSheetSize) 0 + + td.code-language-cell + width: 30px + + span.code-language-cell + background: transparent url(/images/common/code_languages/javascript_small.png) + background-size: cover + display: inline-block + width: 30px + height: 30px + margin: 0px 2px diff --git a/app/styles/clans/clans.sass b/app/styles/clans/clans.sass new file mode 100644 index 000000000..55e7d501d --- /dev/null +++ b/app/styles/clans/clans.sass @@ -0,0 +1,8 @@ +#clans-view + color: black + + .clan-title + cursor: pointer + + .create-clan-description + width: 50% diff --git a/app/templates/base.jade b/app/templates/base.jade index 3a1687f1e..003471b53 100644 --- a/app/templates/base.jade +++ b/app/templates/base.jade @@ -8,11 +8,12 @@ block header a(href="/") span.glyphicon.glyphicon-home a(href="/about", data-i18n="nav.about") + a(href='/clans') Clans //a(href='/play/ladder', data-i18n="home.multiplayer").multiplayer-nav-link a(href='/community', data-i18n="nav.community") a(href='http://blog.codecombat.com/', data-i18n="nav.blog") a(href='http://discourse.codecombat.com/', data-i18n="nav.forum") - + if me.get('anonymous') === false span.dropdown button.btn.btn-sm.header-font.dropdown-toggle(href="#", data-toggle="dropdown") diff --git a/app/templates/clans/clan-details.jade b/app/templates/clans/clan-details.jade new file mode 100644 index 000000000..a23a67b96 --- /dev/null +++ b/app/templates/clans/clan-details.jade @@ -0,0 +1,74 @@ +extends /templates/base + +block content + + if clan + h1= clan.get('name') + if clan.get('description') + .clan-description + each line in clan.get('description').split('\n') + p= line + + h5 Summary + table.table.table-condensed.stats-table + if owner + tr + td + span.spr Chieftain + td + span.spr.player-hero-icon(data-memberid="#{clan.get('ownerID')}") + a(href="/user/#{clan.get('ownerID')}")= owner.get('name') + if stats.averageLevel + tr + td Average Level + td= stats.averageLevel + if stats.totalAchievements + tr + td Total Achievements + td= stats.totalAchievements + + p + if isOwner + button.btn.btn-xs.btn-warning.delete-clan-btn Delete Clan + else if isMember + button.btn.btn-xs.btn-warning.leave-clan-btn Leave Clan + else + button.btn.btn-lg.btn-success.join-clan-btn Join Clan + + div + span.spl.spr.join-link-prompt Invite: + input.join-clan-link(type="text", readonly, value="#{joinClanLink}") + .small *Invite players to this Clan by sending them this link. + + if members + h3 Heroes (#{members.length}) + table.table.table-striped.table-condensed + thead + tr + th + th + td Name + th Level + th Achievements + th Latest Achievement + th + tbody + each member in members + tr + td.hero-icon-cell + span.spr.player-hero-icon(data-memberid="#{member.id}") + td.code-language-cell + if memberLanguageMap && memberLanguageMap[member.id] + span.code-language-cell(style="background-image: url(/images/common/code_languages/#{memberLanguageMap[member.id]}_small.png)", title=memberLanguageMap[member.id]) + td + a(href="/user/#{member.id}")= member.get('name') || 'Anoner' + td= member.level() + td + if memberAchievementsMap && memberAchievementsMap[member.id] + | #{memberAchievementsMap[member.id].length} + td + if memberAchievementsMap && memberAchievementsMap[member.id] && memberAchievementsMap[member.id].length + span= memberAchievementsMap[member.id][0].get('achievementName') + td + if isOwner && member.id !== clan.get('ownerID') + button.btn.btn-xs.btn-warning.remove-member-btn(data-id="#{member.id}") Remove Hero diff --git a/app/templates/clans/clans.jade b/app/templates/clans/clans.jade new file mode 100644 index 000000000..5d87b4960 --- /dev/null +++ b/app/templates/clans/clans.jade @@ -0,0 +1,74 @@ +extends /templates/base + +block content + + p + input.create-clan-name(type='text' placeholder='New clan name') + p + textarea.create-clan-description(rows=2, placeholder='New clan description') + p + button.btn.btn-success.create-clan-btn Create New Clan + + div(role='tabpanel') + ul.nav.nav-tabs(role='tablist') + li.active(role='presentation') + a(href='#public-clans' aria-controls='public-clans', role='tab', data-toggle='tab') Public Clans + li(role='presentation') + a(href='#my-clans' aria-controls='my-clans', role='tab', data-toggle='tab') My Clans + + .tab-content + .tab-pane.active#public-clans(role='tabpanel') + table.table.table-striped.table-condensed + thead + tr + th Clan Name + th Heroes + th Chieftain + th + tbody + if publicClans.length + each clan in publicClans + tr + td + if clan.get('ownerID') === me.id + a(href="/clans/#{clan.id}", style='font-weight:bold')= clan.get('name') + else + a(href="/clans/#{clan.id}")= clan.get('name') + td= clan.get('members').length + td + if idNameMap && idNameMap[clan.get('ownerID')] + a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')] + else + a(href="/user/#{clan.get('ownerID')}") Anoner + td + if myClanIDs.indexOf(clan.id) < 0 + button.btn.btn-success.join-clan-btn(data-id="#{clan.id}") Join Clan + else if clan.get('ownerID') !== me.id + button.btn.btn-xs.btn-warning.leave-clan-btn(data-id="#{clan.id}") Leave Clan + + .tab-pane#my-clans(role='tabpanel') + table.table.table-striped.table-condensed + thead + tr + th Clan Name + th Heroes + th Chieftain + th + tbody + if myClans.length + each clan in myClans + tr + td + if clan.get('ownerID') === me.id + a(href="/clans/#{clan.id}", style='font-weight:bold')= clan.get('name') + else + a(href="/clans/#{clan.id}")= clan.get('name') + td= clan.get('members').length + td + if idNameMap && idNameMap[clan.get('ownerID')] + a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')] + else + a(href="/user/#{clan.get('ownerID')}") Anoner + td + if clan.get('ownerID') !== me.id + button.btn.btn-xs.btn-warning.leave-clan-btn(data-id="#{clan.id}") Leave Clan diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade index aecc72a03..813e622d5 100644 --- a/app/templates/play/campaign-view.jade +++ b/app/templates/play/campaign-view.jade @@ -112,6 +112,8 @@ else button#logout-button.btn.btn-illustrated.btn-warning(data-i18n="login.log_out") Log Out if me.isPremium() button.btn.btn-illustrated.btn-primary(data-i18n="nav.contact", data-toggle="coco-modal", data-target="core/ContactModal") Contact + span.spl + a.btn.btn-illustrated(href='/clans') Clans button.btn.btn-lg.btn-inverse.campaign-control-button#volume-button(data-i18n="[title]play.adjust_volume", title="Adjust volume") diff --git a/app/templates/user/main-user-view.jade b/app/templates/user/main-user-view.jade index e913f6db5..5ec9dd021 100644 --- a/app/templates/user/main-user-view.jade +++ b/app/templates/user/main-user-view.jade @@ -56,6 +56,33 @@ block append content a(href="/contribute#scribe" data-i18n="classes.scribe_title") Scribe .right-column + + .panel.panel-default + .panel-heading + h3.panel-title Clans + if (!clans) + .panel-body + p(data-i18n="common.loading") + else if (clans.length) + table.table + tr + th.col-xs-4 Name + th.col-xs-4 Chieftain + th.col-xs-4 Heroes + each clan in clans + tr + td + a(href="/clans/#{clan.id}")= clan.get('name') + td + if idNameMap && idNameMap[clan.get('ownerID')] + a(href="/user/#{clan.get('ownerID')}")= idNameMap[clan.get('ownerID')] + else + a(href="/user/#{clan.get('ownerID')}") Anoner + td= clan.get('members').length + else + .panel-body + p Not a member of any clans yet. + .panel.panel-default .panel-heading h3.panel-title(data-i18n="user.singleplayer_title") Singleplayer Levels @@ -141,4 +168,3 @@ block append content if earnedAchievements.length > 4 .panel-footer button.btn.btn-info.btn-sm.more-button(data-i18n="editor.more") - diff --git a/app/views/clans/ClanDetailsView.coffee b/app/views/clans/ClanDetailsView.coffee new file mode 100644 index 000000000..da3640f9b --- /dev/null +++ b/app/views/clans/ClanDetailsView.coffee @@ -0,0 +1,165 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +RootView = require 'views/core/RootView' +template = require 'templates/clans/clan-details' +CocoCollection = require 'collections/CocoCollection' +Clan = require 'models/Clan' +EarnedAchievement = require 'models/EarnedAchievement' +LevelSession = require 'models/LevelSession' +ThangType = require 'models/ThangType' +User = require 'models/User' + +# TODO: Message for clan not found +# TODO: join/leave mostly duped from clans view + +module.exports = class ClanDetailsView extends RootView + id: 'clan-details-view' + template: template + + events: + 'click .delete-clan-btn': 'onDeleteClan' + 'click .join-clan-btn': 'onJoinClan' + 'click .leave-clan-btn': 'onLeaveClan' + 'click .remove-member-btn': 'onRemoveMember' + + constructor: (options, @clanID) -> + super options + @initData() + + destroy: -> + @stopListening?() + + initData: -> + @stats = {} + + @clan = new Clan _id: @clanID + @members = new CocoCollection([], { url: "/db/clan/#{@clanID}/members", model: User, comparator:'slug' }) + @memberAchievements = new CocoCollection([], { url: "/db/clan/#{@clanID}/member_achievements", model: EarnedAchievement, comparator:'_id' }) + @memberSessions = new CocoCollection([], { url: "/db/clan/#{@clanID}/member_sessions", model: LevelSession, comparator:'_id' }) + + @listenTo me, 'sync', => @render?() + @listenTo @clan, 'sync', @onClanSync + @listenTo @members, 'sync', @onMembersSync + @listenTo @memberAchievements, 'sync', @onMemberAchievementsSync + @listenTo @memberSessions, 'sync', @onMemberSessionsSync + + @supermodel.loadModel @clan, 'clan', cache: false + @supermodel.loadCollection(@members, 'members', {cache: false}) + @supermodel.loadCollection(@memberAchievements, 'member_achievements', {cache: false}) + @supermodel.loadCollection(@memberSessions, 'member_sessions', {cache: false}) + + getRenderData: -> + context = super() + context.clan = @clan + if application.isProduction() + context.joinClanLink = "https://codecombat.com/clans/#{@clanID}" + else + context.joinClanLink = "http://localhost:3000/clans/#{@clanID}" + context.owner = @owner + context.memberAchievementsMap = @memberAchievementsMap + context.memberLanguageMap = @memberLanguageMap + context.members = @members?.models + context.isOwner = @clan.get('ownerID') is me.id + context.isMember = @clanID in (me.get('clans') ? []) + context.stats = @stats + context + + afterRender: -> + super() + @updateHeroIcons() + + refreshData: -> + me.fetch cache: false + @members.fetch cache: false + @memberAchievements.fetch cache: false + + updateHeroIcons: -> + return unless @members?.models? + for member in @members.models + continue unless hero = member.get('heroConfig')?.thangType + for slug, original of ThangType.heroes when original is hero + @$el.find(".player-hero-icon[data-memberID=#{member.id}]").removeClass('.player-hero-icon').addClass('player-hero-icon ' + slug) + + onClanSync: -> + unless @owner? + @owner = new User _id: @clan.get('ownerID') + @listenTo @owner, 'sync', => @render?() + @supermodel.loadModel @owner, 'owner', cache: false + @render?() + + onMembersSync: -> + @stats.averageLevel = Math.round(@members.reduce(((sum, member) -> sum + member.level()), 0) / @members.length) + @render?() + + onMemberAchievementsSync: -> + @stats.totalAchievements = @memberAchievements.models.length + @memberAchievementsMap = {} + for achievement in @memberAchievements.models + user = achievement.get('user') + @memberAchievementsMap[user] ?= [] + @memberAchievementsMap[user].push achievement + for user of @memberAchievementsMap + @memberAchievementsMap[user].sort (a, b) -> b.id.localeCompare(a.id) + @render?() + + onMemberSessionsSync: -> + @memberSessionMap = {} + for levelSession in @memberSessions.models + user = levelSession.get('creator') + @memberSessionMap[user] ?= [] + @memberSessionMap[user].push levelSession + @memberLanguageMap = {} + for user of @memberSessionMap + languageCounts = {} + for levelSession in @memberSessionMap[user] + language = levelSession.get('codeLanguage') or levelSession.get('submittedCodeLanguage') + languageCounts[language] = (languageCounts[language] or 0) + 1 if language + mostUsedCount = 0 + for language, count of languageCounts + if count > mostUsedCount + mostUsedCount = count + @memberLanguageMap[user] = language + @render?() + + onDeleteClan: (e) -> + return @openModalView(new AuthModal()) if me.isAnonymous() + options = + url: "/db/clan/#{@clanID}" + method: 'DELETE' + error: (model, response, options) => + console.error 'Error joining clan', response + success: (model, response, options) => + app.router.navigate "/clans" + window.location.reload() + @supermodel.addRequestResource( 'delete_clan', options).load() + + onJoinClan: (e) -> + return @openModalView(new AuthModal()) if me.isAnonymous() + options = + url: "/db/clan/#{@clanID}/join" + method: 'PUT' + error: (model, response, options) => + console.error 'Error joining clan', response + success: (model, response, options) => @refreshData() + @supermodel.addRequestResource( 'join_clan', options).load() + + onLeaveClan: (e) -> + options = + url: "/db/clan/#{@clanID}/leave" + method: 'PUT' + error: (model, response, options) => + console.error 'Error leaving clan', response + success: (model, response, options) => @refreshData() + @supermodel.addRequestResource( 'leave_clan', options).load() + + onRemoveMember: (e) -> + if memberID = $(e.target).data('id') + options = + url: "/db/clan/#{@clanID}/remove/#{memberID}" + method: 'PUT' + error: (model, response, options) => + console.error 'Error removing clan member', response + success: (model, response, options) => @refreshData() + @supermodel.addRequestResource( 'remove_member', options).load() + else + console.error "No member ID attached to remove button." diff --git a/app/views/clans/ClansView.coffee b/app/views/clans/ClansView.coffee new file mode 100644 index 000000000..5936ead1f --- /dev/null +++ b/app/views/clans/ClansView.coffee @@ -0,0 +1,110 @@ +app = require 'core/application' +AuthModal = require 'views/core/AuthModal' +RootView = require 'views/core/RootView' +template = require 'templates/clans/clans' +CocoCollection = require 'collections/CocoCollection' +Clan = require 'models/Clan' + +# TODO: Waiting for async messages +# TODO: Invalid clan name message +# TODO: Refresh data instead of page + +module.exports = class MainAdminView extends RootView + id: 'clans-view' + template: template + + events: + 'click .create-clan-btn': 'onClickCreateClan' + 'click .join-clan-btn': 'onJoinClan' + 'click .leave-clan-btn': 'onLeaveClan' + + constructor: (options) -> + super options + @initData() + + destroy: -> + @stopListening?() + + getRenderData: -> + context = super() + context.idNameMap = @idNameMap + context.publicClans = @publicClans.models + context.myClans = @myClans.models + context.myClanIDs = me.get('clans') ? [] + context + + initData: -> + @idNameMap = {} + + sortClanList = (a, b) -> + if a.get('members').length isnt b.get('members').length + if a.get('members').length < b.get('members').length then 1 else -1 + else + b.id.localeCompare(a.id) + @publicClans = new CocoCollection([], { url: '/db/clan/-/public', model: Clan, comparator: sortClanList }) + @listenTo @publicClans, 'sync', => + @refreshNames @publicClans.models + @render?() + @supermodel.loadCollection(@publicClans, 'public_clans', {cache: false}) + @myClans = new CocoCollection([], { url: "/db/user/#{me.id}/clans", model: Clan, comparator: sortClanList }) + @listenTo @myClans, 'sync', => + @refreshNames @myClans.models + @render?() + @supermodel.loadCollection(@myClans, 'my_clans', {cache: false}) + @listenTo me, 'sync', => @render?() + + refreshNames: (clans) -> + options = + url: '/db/user/-/names' + method: 'POST' + data: {ids: _.map(clans, (clan) -> clan.get('ownerID'))} + success: (models, response, options) => + @idNameMap[userID] = models[userID].name for userID of models + @render?() + @supermodel.addRequestResource('user_names', options, 0).load() + + onClickCreateClan: (e) -> + return @openModalView(new AuthModal()) if me.isAnonymous() + if name = $('.create-clan-name').val() + clan = new Clan() + clan.set 'type', 'public' + clan.set 'name', name + clan.set 'description', description if description = $('.create-clan-description').val() + clan.save {}, + error: (model, response, options) => + console.error 'Error saving clan', response.status + success: (model, response, options) => + app.router.navigate "/clans/#{model.id}" + window.location.reload() + else + console.log 'Invalid name' + + onJoinClan: (e) -> + return @openModalView(new AuthModal()) if me.isAnonymous() + if clanID = $(e.target).data('id') + options = + url: "/db/clan/#{clanID}/join" + method: 'PUT' + error: (model, response, options) => + console.error 'Error joining clan', response + success: (model, response, options) => + app.router.navigate "/clans/#{clanID}" + window.location.reload() + @supermodel.addRequestResource( 'join_clan', options).load() + else + console.error "No clan ID attached to join button." + + onLeaveClan: (e) -> + if clanID = $(e.target).data('id') + options = + url: "/db/clan/#{clanID}/leave" + method: 'PUT' + error: (model, response, options) => + console.error 'Error leaving clan', response + success: (model, response, options) => + me.fetch cache: false + @publicClans.fetch cache: false + @myClans.fetch cache: false + @supermodel.addRequestResource( 'leave_clan', options).load() + else + console.error "No clan ID attached to leave button." diff --git a/app/views/user/MainUserView.coffee b/app/views/user/MainUserView.coffee index fdf88a5bd..658fb3be4 100644 --- a/app/views/user/MainUserView.coffee +++ b/app/views/user/MainUserView.coffee @@ -3,6 +3,7 @@ CocoCollection = require 'collections/CocoCollection' LevelSession = require 'models/LevelSession' template = require 'templates/user/main-user-view' {me} = require 'core/auth' +Clan = require 'models/Clan' EarnedAchievementCollection = require 'collections/EarnedAchievementCollection' class LevelSessionsCollection extends CocoCollection @@ -22,6 +23,9 @@ module.exports = class MainUserView extends UserView constructor: (userID, options) -> super options + destroy: -> + @stopListening?() + getRenderData: -> context = super() if @levelSessions and @levelSessions.loaded @@ -47,6 +51,9 @@ module.exports = class MainUserView extends UserView context.favoriteLanguage = favoriteLanguage if @earnedAchievements and @earnedAchievements.loaded context.earnedAchievements = @earnedAchievements + if @clans and @clans.loaded + context.clans = @clans.models + context.idNameMap = @idNameMap context onLoaded: -> @@ -56,8 +63,30 @@ module.exports = class MainUserView extends UserView @earnedAchievements = new EarnedAchievementCollection @user.getSlugOrID() @supermodel.loadCollection @levelSessions, 'levelSessions', {cache: false} @supermodel.loadCollection @earnedAchievements, 'earnedAchievements', {cache: false} + sortClanList = (a, b) -> + if a.get('members').length isnt b.get('members').length + if a.get('members').length < b.get('members').length then 1 else -1 + else + b.id.localeCompare(a.id) + @idNameMap = {} + @clans = new CocoCollection([], { url: "/db/user/#{@userID}/clans", model: Clan, comparator: sortClanList }) + @listenTo @clans, 'sync', => + @refreshNameMap @clans?.models + @render?() + @supermodel.loadCollection(@clans, 'clans', {cache: false}) super() + refreshNameMap: (clans) -> + return unless clans? + options = + url: '/db/user/-/names' + method: 'POST' + data: {ids: _.map(clans, (clan) -> clan.get('ownerID'))} + success: (models, response, options) => + @idNameMap[userID] = models[userID].name for userID of models + @render?() + @supermodel.addRequestResource('user_names', options, 0).load() + onClickMoreButton: (e) -> panel = $(e.target).closest('.panel') panel.find('tr.hide').removeClass('hide') diff --git a/server/clans/Clan.coffee b/server/clans/Clan.coffee new file mode 100644 index 000000000..8749055d9 --- /dev/null +++ b/server/clans/Clan.coffee @@ -0,0 +1,32 @@ +mongoose = require 'mongoose' +log = require 'winston' +config = require '../../server_config' +plugins = require '../plugins/plugins' +User = require '../users/User' +jsonSchema = require '../../app/schemas/models/clan.schema' + +ClanSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:config.mongo.readpref} + +ClanSchema.pre 'save', (next) -> + User.update {_id: @get('ownerID')}, {$addToSet: {clans: @get('_id')}}, (err) => + if err + log.error err + return next(err) + next() + +ClanSchema.statics.privateProperties = [] +ClanSchema.statics.editableProperties = [ + 'description' + 'members' + 'name' + 'type' +] + +ClanSchema.plugin plugins.NamedPlugin + +# TODO: Do we need this? +# ClanSchema.plugin plugins.SearchablePlugin, {searchable: ['name']} + +ClanSchema.statics.jsonSchema = jsonSchema + +module.exports = Clan = mongoose.model 'clan', ClanSchema, 'clans' diff --git a/server/clans/clan_handler.coffee b/server/clans/clan_handler.coffee new file mode 100644 index 000000000..c19629c4e --- /dev/null +++ b/server/clans/clan_handler.coffee @@ -0,0 +1,148 @@ +async = require 'async' +mongoose = require 'mongoose' +Handler = require '../commons/Handler' +Clan = require './Clan' +EarnedAchievement = require '../achievements/EarnedAchievement' +EarnedAchievementHandler = require '../achievements/earned_achievement_handler' +LevelSession = require '../levels/sessions/LevelSession' +LevelSessionHandler = require '../levels/sessions/level_session_handler' +User = require '../users/User' +UserHandler = require '../users/user_handler' + +ClanHandler = class ClanHandler extends Handler + modelClass: Clan + jsonSchema: require '../../app/schemas/models/Clan.schema' + allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + + hasAccess: (req) -> + return true if req.method in ['GET'] + return true if req.user? and not req.user.isAnonymous() + false + + hasAccessToDocument: (req, document, method=null) -> + return false unless document? + method = (method or req.method).toLowerCase() + return true if req.user?.isAdmin() + return true if method is 'get' + return true if document.get('ownerID')?.equals req.user._id + false + + makeNewInstance: (req) -> + userName = req.user.get('name') ? 'Anoner' + instance = super(req) + instance.set 'ownerID', req.user._id + instance.set 'members', [req.user._id] + instance + + delete: (req, res, clanID) -> + @getDocumentForIdOrSlug clanID, (err, clan) => + return @sendDatabaseError res, err if err + return @sendNotFoundError res unless clan + return @sendForbiddenError res unless @hasAccessToDocument(req, clan) + memberIDs = clan.get('members') + Clan.remove {_id: clan.get('_id')}, (err) => + return @sendDatabaseError res, err if err + User.update {_id: {$in: memberIDs}}, {$pull: {clans: clan.get('_id')}}, {multi: true}, (err) => + return @sendDatabaseError(res, err) if err + @sendNoContent(res) + + getByRelationship: (req, res, args...) -> + return @joinClan(req, res, args[0]) if args[1] is 'join' + return @leaveClan(req, res, args[0]) if args[1] is 'leave' + return @getMemberAchievements(req, res, args[0]) if args[1] is 'member_achievements' + return @getMembers(req, res, args[0]) if args[1] is 'members' + return @getMemberSessions(req, res, args[0]) if args[1] is 'member_sessions' + return @getPublicClans(req, res) if args[1] is 'public' + return @removeMember(req, res, args[0], args[2]) if args.length is 3 and args[1] is 'remove' + super(arguments...) + + joinClan: (req, res, clanID) -> + return @sendForbiddenError(res) unless req.user? and not req.user.isAnonymous() + try + clanID = mongoose.Types.ObjectId(clanID) + catch err + return @sendNotFoundError(res, err) + Clan.update {_id: clanID}, {$addToSet: {members: req.user._id}}, (err) => + return @sendDatabaseError(res, err) if err + User.update {_id: req.user._id}, {$addToSet: {clans: clanID}}, (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res) + + leaveClan: (req, res, clanID) -> + return @sendForbiddenError(res) unless req.user? and not req.user.isAnonymous() + try + clanID = mongoose.Types.ObjectId(clanID) + catch err + return @sendNotFoundError(res, err) + Clan.findById clanID, (err, clan) => + return @sendDatabaseError(res, err) if err + return @sendDatabaseError(res, err) unless clan + return @sendForbiddenError(res) if clan.get('ownerID')?.equals req.user._id + Clan.update {_id: clanID}, {$pull: {members: req.user._id}}, (err) => + return @sendDatabaseError(res, err) if err + User.update {_id: req.user._id}, {$pull: {clans: clanID}}, (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res) + + getMemberAchievements: (req, res, clanID) -> + # TODO: add tests + Clan.findById clanID, (err, clan) => + return @sendDatabaseError(res, err) if err + return @sendDatabaseError(res, err) unless clan + memberIDs = _.map clan.get('members') ? [], (memberID) -> memberID.toHexString() + EarnedAchievement.find {user: {$in: memberIDs}}, (err, documents) => + return @sendDatabaseError(res, err) if err? + cleandocs = (EarnedAchievementHandler.formatEntity(req, doc) for doc in documents) + @sendSuccess(res, cleandocs) + + getMembers: (req, res, clanID) -> + # TODO: add tests + Clan.findById clanID, (err, clan) => + return @sendDatabaseError(res, err) if err + return @sendDatabaseError(res, err) unless clan + memberIDs = clan.get('members') ? [] + User.find {_id: {$in: memberIDs}}, (err, users) => + return @sendDatabaseError(res, err) if err + cleandocs = (UserHandler.formatEntity(req, doc) for doc in users) + @sendSuccess(res, cleandocs) + + getMemberSessions: (req, res, clanID) -> + # TODO: add tests + Clan.findById clanID, (err, clan) => + return @sendDatabaseError(res, err) if err + return @sendDatabaseError(res, err) unless clan + memberIDs = _.map clan.get('members') ? [], (memberID) -> memberID.toHexString() + LevelSession.find {creator: {$in: memberIDs}}, (err, documents) => + return @sendDatabaseError(res, err) if err? + cleandocs = (LevelSessionHandler.formatEntity(req, doc) for doc in documents) + @sendSuccess(res, cleandocs) + + getPublicClans: (req, res) -> + # Return 100 public clans, sorted by member count, created date + query = [{ $match : {type : 'public'} }] + query.push {$project : {_id: 1, name: 1, slug: 1, type: 1, description: 1, members: 1, memberCount: {$size: "$members"}, ownerID: 1}} + query.push {$sort: { memberCount: -1, _id: -1 }} + query.push {$limit: 100} + Clan.aggregate(query).exec (err, documents) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, documents) + + removeMember: (req, res, clanID, memberID) -> + return @sendForbiddenError(res) unless req.user? and not req.user.isAnonymous() + try + clanID = mongoose.Types.ObjectId(clanID) + memberID = mongoose.Types.ObjectId(memberID) + catch err + return @sendNotFoundError(res, err) + Clan.findById clanID, (err, clan) => + return @sendDatabaseError(res, err) if err + return @sendDatabaseError(res, err) unless clan + return @sendForbiddenError res unless @hasAccessToDocument(req, clan) + return @sendForbiddenError(res) if clan.get('ownerID').equals memberID + Clan.update {_id: clanID}, {$pull: {members: memberID}}, (err) => + return @sendDatabaseError(res, err) if err + User.update {_id: memberID}, {$pull: {clans: clanID}}, (err) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res) + +module.exports = new ClanHandler() diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 40a5e9a9e..7d31a0d7f 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -6,6 +6,7 @@ module.exports.handlers = # 'analytics_users_active': 'analytics/analytics_users_active_handler' 'article': 'articles/article_handler' 'campaign': 'campaigns/campaign_handler' + 'clan': 'clans/clan_handler' 'level': 'levels/level_handler' 'level_component': 'levels/components/level_component_handler' 'level_feedback': 'levels/feedbacks/level_feedback_handler' @@ -22,9 +23,9 @@ module.exports.handlers = 'achievement': 'achievements/achievement_handler' 'earned_achievement': 'achievements/earned_achievement_handler' 'poll': 'polls/poll_handler' - 'user_polls_record': 'polls/user_polls_record_handler' 'prepaid': 'prepaids/prepaid_handler' 'subscription': 'payments/subscription_handler' + 'user_polls_record': 'polls/user_polls_record_handler' module.exports.routes = [ diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index 0701b4063..646aa4652 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -10,6 +10,7 @@ async = require 'async' log = require 'winston' moment = require 'moment' AnalyticsLogEvent = require '../analytics/AnalyticsLogEvent' +Clan = require '../clans/Clan' LevelSession = require '../levels/sessions/LevelSession' LevelSessionHandler = require '../levels/sessions/level_session_handler' SubscriptionHandler = require '../payments/subscription_handler' @@ -262,6 +263,7 @@ UserHandler = class UserHandler extends Handler return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer' return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions' return @getCandidates(req, res) if args[1] is 'candidates' + return @getClans(req, res, args[0]) if args[1] is 'clans' return @getEmployers(req, res) if args[1] is 'employers' return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard' return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank' @@ -539,6 +541,14 @@ UserHandler = class UserHandler extends Handler candidates = (@formatCandidate(authorized, candidate) for candidate in candidates) @sendSuccess(res, candidates) + getClans: (req, res, userIDOrSlug) -> + @getDocumentForIdOrSlug userIDOrSlug, (err, user) => + return @sendNotFoundError(res) if not user + clanIDs = user.get('clans') ? [] + Clan.find {_id: {$in: clanIDs}}, (err, documents) => + return @sendDatabaseError(res, err) if err + @sendSuccess(res, documents) + formatCandidate: (authorized, document) -> fields = if authorized then ['name', 'jobProfile', 'jobProfileApproved', 'photoURL', '_id'] else ['_id','jobProfile', 'jobProfileApproved'] obj = _.pick document.toObject(), fields diff --git a/test/server/common.coffee b/test/server/common.coffee index eb594f712..1f2b35737 100644 --- a/test/server/common.coffee +++ b/test/server/common.coffee @@ -27,6 +27,7 @@ models_path = [ '../../server/analytics/AnalyticsUsersActive' '../../server/articles/Article' '../../server/campaigns/Campaign' + '../../server/clans/Clan' '../../server/levels/Level' '../../server/levels/components/LevelComponent' '../../server/levels/systems/LevelSystem' @@ -182,6 +183,10 @@ GLOBAL.loginUser = (user, done) -> form.append('username', user.get('email')) form.append('password', user.get('name')) +GLOBAL.logoutUser = (done) -> + request.post getURL('/auth/logout'), -> + done() + GLOBAL.dropGridFS = (done) -> if mongoose.connection.readyState is 2 mongoose.connection.once 'open', -> diff --git a/test/server/functional/clan.spec.coffee b/test/server/functional/clan.spec.coffee new file mode 100644 index 000000000..92f6d5522 --- /dev/null +++ b/test/server/functional/clan.spec.coffee @@ -0,0 +1,309 @@ +config = require '../../../server_config' +require '../common' +utils = require '../../../app/core/utils' # Must come after require /common +mongoose = require 'mongoose' + +describe 'Clans', -> + stripe = require('stripe')(config.stripe.secretKey) + clanURL = getURL('/db/clan') + + clanCount = 0 + createClanName = (name) -> name + clanCount++ + + createClan = (user, type, description, done) -> + name = createClanName 'myclan' + requestBody = + type: type + name: name + requestBody.description = description if description? + request.post {uri: clanURL, json: requestBody }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + expect(body.type).toEqual(type) + expect(body.name).toEqual(name) + expect(body.description).toEqual(description) if description? + expect(body.members?.length).toEqual(1) + expect(body.members?[0]).toEqual(user.id) + Clan.findById body._id, (err, clan) -> + expect(clan.get('type')).toEqual(type) + expect(clan.get('name')).toEqual(name) + expect(clan.get('description')).toEqual(description) if description? + expect(clan.get('members')?.length).toEqual(1) + expect(clan.get('members')?[0]).toEqual(user._id) + User.findById user.id, (err, user) -> + expect(err).toBeNull() + expect(user.get('clans')?.length).toBeGreaterThan(0) + expect(_.find user.get('clans'), (clanID) -> clan._id.equals clanID).toBeDefined() + done(clan) + + it 'Clear database users and clans', (done) -> + clearModels [User, Clan], (err) -> + throw err if err + done() + + it 'Create clan', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', 'test description', (clan) -> + done() + + it 'Anonymous create clan 401', (done) -> + logoutUser -> + requestBody = + type: 'public' + name: createClanName 'myclan' + request.post {uri: clanURL, json: requestBody }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(401) + done() + + it 'Create clan missing type 422', (done) -> + loginNewUser (user1) -> + requestBody = + name: createClanName 'myclan' + request.post {uri: clanURL, json: requestBody }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(422) + done() + + it 'Create clan missing name 422', (done) -> + loginNewUser (user1) -> + requestBody = + type: 'public' + request.post {uri: clanURL, json: requestBody }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(422) + done() + + it 'Get public clans', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + createClan user1, 'public', 'the second clan', (clan2) -> + request.get {uri: "#{clanURL}/-/public" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + expect(body.length).toBeGreaterThan(1) + done() + + it 'Get public clans anonymous', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + createClan user1, 'public', null, (clan2) -> + logoutUser -> + request.get {uri: "#{clanURL}/-/public" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + expect(body.length).toBeGreaterThan(1) + done() + + it 'Join clan', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(clan1.get('members')?.length).toEqual(2) + expect(_.find clan1.get('members'), (memberID) -> user2._id.equals memberID).toBeDefined() + User.findById user2.id, (err, user2) -> + expect(err).toBeNull() + expect(user2.get('clans')?.length).toBeGreaterThan(0) + expect(_.find user2.get('clans'), (clanID) -> clan1._id.equals clanID).toBeDefined() + done() + + it 'Join invalid clan 404', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/1234/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(404) + done() + + it 'Join clan anonymous 401', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + logoutUser -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(401) + done() + + it 'Join clan twice 200', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(_.find clan1.get('members'), (memberID) -> memberID.equals user2.id).toBeDefined() + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + done() + + it 'Leave clan', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', 'do not stay too long', (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + request.put {uri: "#{clanURL}/#{clan1.id}/leave" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(_.find clan1.get('members'), (memberID) -> memberID.equals user2.id).toBeUndefined() + User.findById user2.id, (err, user2) -> + expect(err).toBeNull() + expect(user2.get('clans').length).toEqual(0) + done() + + it 'Leave clan not member 200', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/leave" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(_.find clan1.get('members'), (memberID) -> memberID.equals user2.id).toBeUndefined() + done() + + it 'Leave owned clan 403', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + request.put {uri: "#{clanURL}/#{clan1.id}/leave" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(403) + done() + + it 'Remove member', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + loginUser user1, (user1) -> + request.put {uri: "#{clanURL}/#{clan1.id}/remove/#{user2.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(clan1.get('members').length).toEqual(1) + expect(clan1.get('members')[0]).toEqual(user1.get('_id')) + User.findById user2.id, (err, user2) -> + expect(err).toBeNull() + expect(user2.get('clans').length).toEqual(0) + done() + + it 'Remove non-member 200', (done) -> + loginNewUser (user2) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + request.put {uri: "#{clanURL}/#{clan1.id}/remove/#{user2.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + Clan.findById clan1.id, (err, clan1) -> + expect(err).toBeNull() + expect(clan1.get('members').length).toEqual(1) + expect(clan1.get('members')[0]).toEqual(user1.get('_id')) + done() + + it 'Remove invalid memberID 404', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + request.put {uri: "#{clanURL}/#{clan1.id}/remove/123" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(404) + done() + + it 'Remove member, not in clan 403', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + loginNewUser (user3) -> + request.put {uri: "#{clanURL}/#{clan1.id}/remove/#{user2.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(403) + done() + + it 'Remove member, not the owner 403', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + loginNewUser (user2) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + loginNewUser (user3) -> + request.put {uri: "#{clanURL}/#{clan1.id}/join" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(200) + request.put {uri: "#{clanURL}/#{clan1.id}/remove/#{user2.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(403) + done() + + it 'Remove member from owned clan 403', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan1) -> + request.put {uri: "#{clanURL}/#{clan1.id}/remove/#{user1.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(403) + done() + + it 'Delete clan', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan) -> + request.del {uri: "#{clanURL}/#{clan.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(204) + User.findById user1.id, (err, user1) -> + expect(err).toBeNull() + expect(user1.get('clans').length).toEqual(0) + done() + + it 'Delete clan anonymous 401', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan) -> + logoutUser -> + request.del {uri: "#{clanURL}/#{clan.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(401) + done() + + it 'Delete clan not owner 403', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan) -> + loginNewUser (user2) -> + request.del {uri: "#{clanURL}/#{clan.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(403) + done() + + it 'Delete clan no longer exists 404', (done) -> + loginNewUser (user1) -> + createClan user1, 'public', null, (clan) -> + request.del {uri: "#{clanURL}/#{clan.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(204) + request.del {uri: "#{clanURL}/#{clan.id}" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(404) + done() + + it 'Delete clan invalid ID 404', (done) -> + loginNewUser (user1) -> + request.del {uri: "#{clanURL}/1234" }, (err, res, body) -> + expect(err).toBeNull() + expect(res.statusCode).toBe(404) + done() diff --git a/test/server/functional/payment.spec.coffee b/test/server/functional/payment.spec.coffee index 6dcef8d62..a20adb714 100644 --- a/test/server/functional/payment.spec.coffee +++ b/test/server/functional/payment.spec.coffee @@ -46,7 +46,7 @@ describe '/db/payment', -> paymentCreated = body?._id expect(res.statusCode).toBe 201 User.findOne({name:'Joe'}).exec(err, (err, user) -> - expect(user.get('purchased').gems).toBe(5000) + expect(user.get('purchased')?.gems).toBe(5000) done() ) @@ -56,7 +56,7 @@ describe '/db/payment', -> expect(body._id is paymentCreated).toBe(true) expect(res.statusCode).toBe 200 User.findOne({name:'Joe'}).exec(err, (err, user) -> - expect(user.get('purchased').gems).toBe(5000) + expect(user.get('purchased')?.gems).toBe(5000) done() ) @@ -72,7 +72,7 @@ describe '/db/payment', -> expect(body._id is paymentCreated).toBe(false) expect(res.statusCode).toBe 201 User.findOne({name:'Joe'}).exec(err, (err, user) -> - expect(user.get('purchased').gems).toBe(16000) + expect(user.get('purchased')?.gems).toBe(16000) done() )