From 0b8a0c8f6fe55b24e81c61e1d6ab245b7e401da0 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Tue, 17 Jun 2014 13:03:08 -0700 Subject: [PATCH] Added UserRemarks. --- app/locale/en.coffee | 3 + app/models/UserRemark.coffee | 6 ++ app/schemas/models/user_remark.coffee | 24 ++++++ app/styles/account/profile.sass | 5 ++ app/templates/account/profile.jade | 15 +++- app/templates/employers.jade | 9 ++- app/treema-ext.coffee | 2 + app/views/account/job_profile_view.coffee | 4 +- app/views/account/profile_view.coffee | 76 ++++++++++++++++++- app/views/employers_view.coffee | 13 +++- server/commons/mapping.coffee | 1 + server/users/remarks/UserRemark.coffee | 11 +++ .../users/remarks/user_remark_handler.coffee | 12 +++ server/users/user_handler.coffee | 19 ++++- 14 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 app/models/UserRemark.coffee create mode 100644 app/schemas/models/user_remark.coffee create mode 100644 server/users/remarks/UserRemark.coffee create mode 100644 server/users/remarks/user_remark_handler.coffee diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 4c734e41a..3ea886e85 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -282,6 +282,7 @@ education_description: "Description" education_description_help: "Highlight anything about this educational experience. (140 chars; optional)" our_notes: "Our Notes" + remarks: "Remarks" projects: "Projects" projects_header: "Add 3 projects" projects_header_2: "Projects (Top 3)" @@ -320,6 +321,7 @@ candidate_top_skills: "Top Skills" candidate_years_experience: "Yrs Exp" candidate_last_updated: "Last Updated" + candidate_who: "Who" featured_developers: "Featured Developers" other_developers: "Other Developers" inactive_developers: "Inactive Developers" @@ -882,6 +884,7 @@ document: "Document" sprite_sheet: "Sprite Sheet" candidate_sessions: "Candidate Sessions" + user_remark: "User Remark" delta: added: "Added" diff --git a/app/models/UserRemark.coffee b/app/models/UserRemark.coffee new file mode 100644 index 000000000..2bb37f42d --- /dev/null +++ b/app/models/UserRemark.coffee @@ -0,0 +1,6 @@ +CocoModel = require('./CocoModel') + +module.exports = class UserRemark extends CocoModel + @className: "UserRemark" + @schema: require 'schemas/models/user_remark' + urlRoot: "/db/user.remark" diff --git a/app/schemas/models/user_remark.coffee b/app/schemas/models/user_remark.coffee new file mode 100644 index 000000000..cd351c4a4 --- /dev/null +++ b/app/schemas/models/user_remark.coffee @@ -0,0 +1,24 @@ +c = require './../schemas' + +UserRemarkSchema = c.object { + title: "Remark" + description: "Remarks on a user, point of contact, tasks." +} + +_.extend UserRemarkSchema.properties, + user: c.objectId links: [{rel: 'extra', href: "/db/user/{($)}"}] + contact: c.objectId links: [{rel: 'extra', href: "/db/user/{($)}"}] + created: c.date title: 'Created', readOnly: true + history: c.array {title: 'History', description: 'Records of our interactions with the user.'}, + c.object {title: 'Record'}, {date: c.date(title: 'Date'), content: {title: 'Content', type: 'string', format: 'markdown'}} + tasks: c.array {title: 'Tasks', description: 'Task entries: when to email the contact about something.'}, + c.object {title: 'Task'}, {date: c.date(title: 'Date'), action: {title: 'Action', type: 'string'}} + + # denormalization + userName: { title: "Player Name", type: 'string' } + contactName: { title: "Contact Name", type: 'string' } # Not actually our usernames + + +c.extendBasicProperties UserRemarkSchema, 'user.remark' + +module.exports = UserRemarkSchema diff --git a/app/styles/account/profile.sass b/app/styles/account/profile.sass index 797bcad4d..05a06f6ee 100644 --- a/app/styles/account/profile.sass +++ b/app/styles/account/profile.sass @@ -193,6 +193,11 @@ width: 100% height: 100px + #remark-treema + background-color: white + border: 0 + padding-top: 0 + .right-column width: $side-width background-color: $sideBackground diff --git a/app/templates/account/profile.jade b/app/templates/account/profile.jade index 0b16838a3..639d14d84 100644 --- a/app/templates/account/profile.jade +++ b/app/templates/account/profile.jade @@ -169,6 +169,10 @@ block content button#contact-candidate.btn.btn-large.btn-inverse.flat-button span(data-i18n="account_profile.contact") Contact | #{profile.name.split(' ')[0]} + if me.isAdmin() + select#admin-contact.form-control + for contact in adminContacts + option(value=contact.id, selected=remark && remark.get('contact') == contact.id)= contact.name if !editing && sessions.length h3(data-i18n="account_profile.player_code") Player Code @@ -191,9 +195,12 @@ block content if editing && !profile.name h3.edit-label(data-i18n="account_profile.name_header") Fill in your name else if profile.name - h3= profile.name + h3= profile.name + (me.isAdmin() ? ' (' + user.get('name') + ')' : '') else - h3(data-i18n="account_profile.name_anonymous") Anonymous Developer + h3 + span(data-i18n="account_profile.name_anonymous") Anonymous Developer + if me.isAdmin() + span (#{user.get('name')}) form.editable-form .editable-icon.glyphicon.glyphicon-remove @@ -396,6 +403,10 @@ block content else div!= marked(notes) + if me.isAdmin() + h3(data-i18n="account_profile.remarks") Remarks + #remark-treema + .right-column.full-height-column .sub-column #projects-container.editable-section diff --git a/app/templates/employers.jade b/app/templates/employers.jade index 3125d7ea6..208c2bdf7 100644 --- a/app/templates/employers.jade +++ b/app/templates/employers.jade @@ -84,6 +84,8 @@ block content th(data-i18n="employers.candidate_top_skills") Top Skills th(data-i18n="employers.candidate_years_experience") Yrs Exp th(data-i18n="employers.candidate_last_updated") Last Updated + if me.isAdmin() + th(data-i18n="employers.candidate_who") Who if me.isAdmin() && area.id == 'inactive-candidates' th ✓? @@ -95,7 +97,10 @@ block content td if authorized img(src=candidate.getPhotoURL(50), alt=profile.name, title=profile.name, height=50) - p= profile.name + if profile.name + p= profile.name + else if me.isAdmin() + p (#{candidate.get('name')}) else img(src="/images/pages/contribute/archmage.png", alt="", title="Sign up as an employer to see our candidates", width=50) p Developer ##{index + 1 + (area.id == 'featured-candidates' ? 0 : featuredCandidates.length)} @@ -111,6 +116,8 @@ block content span td= profile.experience td(data-profile-age=(new Date() - new Date(profile.updated)) / 86400 / 1000)= moment(profile.updated).fromNow() + if me.isAdmin() + td= remarks[candidate.id] ? remarks[candidate.id].get('contactName') : '' if me.isAdmin() && area.id == 'inactive-candidates' if candidate.get('jobProfileApproved') td ✓ diff --git a/app/treema-ext.coffee b/app/treema-ext.coffee index 0a4b8d036..dcfd4dbed 100644 --- a/app/treema-ext.coffee +++ b/app/treema-ext.coffee @@ -6,6 +6,8 @@ locale = require 'locale/locale' class DateTimeTreema extends TreemaNode.nodeMap.string valueClass: 'treema-date-time' buildValueForDisplay: (el) -> el.text(moment(@data).format('llll')) + buildValueForEditing: (valEl) -> + @buildValueForEditingSimply valEl, null, 'date' class VersionTreema extends TreemaNode valueClass: 'treema-version' diff --git a/app/views/account/job_profile_view.coffee b/app/views/account/job_profile_view.coffee index c38bc2d5c..97b2371e4 100644 --- a/app/views/account/job_profile_view.coffee +++ b/app/views/account/job_profile_view.coffee @@ -19,10 +19,10 @@ module.exports = class JobProfileView extends CocoView buildJobProfileTreema: -> visibleSettings = @editableSettings.concat @readOnlySettings - data = _.pick (me.get('jobProfile') ? {}), (value, key) => key in visibleSettings + data = _.pick (me.get('jobProfile') ? {}), (value, key) -> key in visibleSettings data.name ?= (me.get('firstName') + ' ' + me.get('lastName')).trim() if me.get('firstName') schema = _.cloneDeep me.schema().properties.jobProfile - schema.properties = _.pick schema.properties, (value, key) => key in visibleSettings + schema.properties = _.pick schema.properties, (value, key) -> key in visibleSettings schema.required = _.intersection schema.required, visibleSettings for prop in @readOnlySettings schema.properties[prop].readOnly = true diff --git a/app/views/account/profile_view.coffee b/app/views/account/profile_view.coffee index 295269552..a4100808c 100644 --- a/app/views/account/profile_view.coffee +++ b/app/views/account/profile_view.coffee @@ -6,6 +6,7 @@ CocoCollection = require 'collections/CocoCollection' {me} = require 'lib/auth' JobProfileContactView = require 'views/modal/job_profile_contact_modal' JobProfileView = require 'views/account/job_profile_view' +UserRemark = require 'models/UserRemark' forms = require 'lib/forms' class LevelSessionsCollection extends CocoCollection @@ -14,6 +15,14 @@ class LevelSessionsCollection extends CocoCollection constructor: (@userID) -> super() +adminContacts = [ + {id: "", name: "Assign a Contact"} + {id: "512ef4805a67a8c507000001", name: "Nick"} + {id: "5162fab9c92b4c751e000274", name: "Scott"} + {id: "51eb2714fa058cb20d0006ef", name: "Michael"} + {id: "51538fdb812dd9af02000001", name: "George"} +] + module.exports = class ProfileView extends View id: "profile-view" template: template @@ -36,12 +45,14 @@ module.exports = class ProfileView extends View 'change .editable-profile .editable-array input': 'onEditArray' 'keyup .editable-profile .editable-array input': 'onEditArray' 'click .editable-profile a': 'onClickLinkWhileEditing' + 'change #admin-contact': 'onAdminContactChanged' constructor: (options, @userID) -> @userID ?= me.id @onJobProfileNotesChanged = _.debounce @onJobProfileNotesChanged, 1000 + @onRemarkChanged = _.debounce @onRemarkChanged, 1000 @authorizedWithLinkedIn = IN?.User?.isAuthorized() - @linkedInLoaded = Boolean(IN.parse) + @linkedInLoaded = Boolean(IN?.parse) @waitingForLinkedIn = false window.contractCallback = => @authorizedWithLinkedIn = IN?.User?.isAuthorized() @@ -70,6 +81,22 @@ module.exports = class ProfileView extends View else @user = User.getByID(@userID) @sessions = @supermodel.loadCollection(new LevelSessionsCollection(@userID), 'candidate_sessions').model + if me.isAdmin() + # Mimicking how the VictoryModal fetches LevelFeedback + @remark = new UserRemark() + @remark.setURL "/db/user/#{@userID}/remark" + @remark.fetch() + @listenToOnce @remark, 'sync', @onRemarkLoaded + @listenToOnce @remark, 'error', @onRemarkNotFound + + onRemarkLoaded: -> + @remark.setURL "/db/user.remark/#{@remark.id}" + @render() + + onRemarkNotFound: -> + @remark = new UserRemark() # hmm, why do we create a new one here? + @remark.set 'user', @userID + @remark.set 'userName', name if name = @user.get('name') onLinkedInLoaded: => @linkedinLoaded = true @@ -229,6 +256,8 @@ module.exports = class ProfileView extends View context.sessions.sort (a, b) -> (b.playtime ? 0) - (a.playtime ? 0) else context.sessions = [] + context.adminContacts = adminContacts + context.remark = @remark context afterRender: -> @@ -249,6 +278,31 @@ module.exports = class ProfileView extends View _.delay -> justSavedSection.removeClass "just-saved", duration: 1500, easing: 'easeOutQuad' , 500 + if me.isAdmin() + visibleSettings = ['history', 'tasks'] + data = _.pick (@remark.attributes), (value, key) -> key in visibleSettings + data.history ?= [] + data.tasks ?= [] + schema = _.cloneDeep @remark.schema() + schema.properties = _.pick schema.properties, (value, key) => key in visibleSettings + schema.required = _.intersection (schema.required ? []), visibleSettings + treemaOptions = + filePath: "db/user/#{@userID}" + schema: schema + data: data + aceUseWrapMode: true + callbacks: {change: @onRemarkChanged} + @remarkTreema = @$el.find('#remark-treema').treema treemaOptions + @remarkTreema.build() + @remarkTreema.open(3) + + onRemarkChanged: (e) => + return unless @remarkTreema.isValid() + for key in ['history', 'tasks'] + val = _.filter(@remarkTreema.get(key), (entry) -> entry?.content or entry?.action) + entry.date ?= (new Date()).toISOString() for entry in val if key is 'history' + @remark.set key, val + @saveRemark() initializeAutocomplete: (container) -> (container ? @$el).find('input[data-autocomplete]').each -> @@ -455,6 +509,26 @@ module.exports = class ProfileView extends View onClickLinkWhileEditing: (e) -> e.preventDefault() + onAdminContactChanged: (e) -> + newContact = @$el.find('#admin-contact').val() + newContactName = if newContact then _.find(adminContacts, id: newContact).name else '' + @remark.set 'contact', newContact + @remark.set 'contactName', newContactName + @saveRemark() + + saveRemark: -> + @remark.set 'user', @user.id + @remark.set 'userName', @user.get('name') + if errors = @remark.validate() + return console.error "UserRemark", @remark, "failed validation with errors:", errors + res = @remark.save() + res.error => + return if @destroyed + console.error "UserRemark", @remark, "failed to save with error:", res.responseText + res.success (model, response, options) => + return if @destroyed + console.log "Saved UserRemark", @remark, "with response", response + updateProgress: (highlightNext) -> return unless @user completed = 0 diff --git a/app/views/employers_view.coffee b/app/views/employers_view.coffee index 8a2c4e4ff..2b1186422 100644 --- a/app/views/employers_view.coffee +++ b/app/views/employers_view.coffee @@ -2,6 +2,7 @@ View = require 'views/kinds/RootView' template = require 'templates/employers' app = require 'application' User = require 'models/User' +UserRemark = require 'models/UserRemark' {me} = require 'lib/auth' CocoCollection = require 'collections/CocoCollection' EmployerSignupView = require 'views/modal/employer_signup_modal' @@ -10,6 +11,10 @@ class CandidatesCollection extends CocoCollection url: '/db/user/x/candidates' model: User +class UserRemarksCollection extends CocoCollection + url: '/db/user.remark?project=contact,contactName,user' + model: UserRemark + module.exports = class EmployersView extends View id: "employers-view" template: template @@ -37,6 +42,8 @@ module.exports = class EmployersView extends View ctx.inactiveCandidates = _.reject ctx.candidates, (c) -> c.get('jobProfile').active ctx.featuredCandidates = _.filter ctx.activeCandidates, (c) -> c.get('jobProfileApproved') ctx.otherCandidates = _.reject ctx.activeCandidates, (c) -> c.get('jobProfileApproved') + ctx.remarks = {} + ctx.remarks[remark.get('user')] = remark for remark in @remarks.models ctx.moment = moment ctx._ = _ ctx @@ -48,11 +55,13 @@ module.exports = class EmployersView extends View getCandidates: -> @candidates = new CandidatesCollection() @candidates.fetch() + @remarks = new UserRemarksCollection() + @remarks.fetch() # Re-render when we have fetched them, but don't wait and show a progress bar while loading. @listenToOnce @candidates, 'all', @renderCandidatesAndSetupScrolling + @listenToOnce @remarks, 'all', @renderCandidatesAndSetupScrolling renderCandidatesAndSetupScrolling: => - @render() $(".nano").nanoScroller() if window.history?.state?.lastViewedCandidateID @@ -179,7 +188,7 @@ module.exports = class EmployersView extends View "Last 4 weeks": (e, n, f, i, $r) -> days = parseFloat $($r.find('td')[i]).data('profile-age') days <= 28 - 7: + 8: "✓": filterSelectExactMatch "✗": filterSelectExactMatch diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 8e3188fa2..802b33790 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -8,6 +8,7 @@ module.exports.handlers = 'patch': 'patches/patch_handler' 'thang_type': 'levels/thangs/thang_type_handler' 'user': 'users/user_handler' + 'user_remark': 'users/remarks/user_remark_handler' 'achievement': 'achievements/achievement_handler' 'earned_achievement': 'achievements/earned_achievement_handler' diff --git a/server/users/remarks/UserRemark.coffee b/server/users/remarks/UserRemark.coffee new file mode 100644 index 000000000..974bc05f3 --- /dev/null +++ b/server/users/remarks/UserRemark.coffee @@ -0,0 +1,11 @@ +mongoose = require('mongoose') +plugins = require('../../plugins/plugins') +jsonschema = require('../../../app/schemas/models/user_remark') + +UserRemarkSchema = new mongoose.Schema({ + created: + type: Date + 'default': Date.now +}, {strict: false}) + +module.exports = UserRemark = mongoose.model('user.remark', UserRemarkSchema) diff --git a/server/users/remarks/user_remark_handler.coffee b/server/users/remarks/user_remark_handler.coffee new file mode 100644 index 000000000..8d89b7873 --- /dev/null +++ b/server/users/remarks/user_remark_handler.coffee @@ -0,0 +1,12 @@ +UserRemark = require('./UserRemark') +Handler = require('../../commons/Handler') + +class UserRemarkHandler extends Handler + modelClass: UserRemark + editableProperties: ['user', 'contact', 'history', 'tasks', 'userName', 'contactName'] + jsonSchema: require '../../../app/schemas/models/user_remark' + + hasAccess: (req) -> + req.user?.isAdmin() + +module.exports = new UserRemarkHandler() diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index 5ffa68c06..a9e0dc106 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -11,6 +11,7 @@ log = require 'winston' LevelSession = require('../levels/sessions/LevelSession') LevelSessionHandler = require '../levels/sessions/level_session_handler' EarnedAchievement = require '../achievements/EarnedAchievement' +UserRemark = require './remarks/UserRemark' serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset'] privateProperties = [ @@ -197,6 +198,7 @@ UserHandler = class UserHandler extends Handler return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank' return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements' return @trackActivity(req, res, args[0], args[2], args[3]) if args[1] is 'track' and args[2] + return @getRemark(req, res, args[0]) if args[1] is 'remark' return @sendNotFoundError(res) super(arguments...) @@ -313,7 +315,7 @@ UserHandler = class UserHandler extends Handler #query.jobProfileApproved = true unless req.user.isAdmin() # We split into featured and other now. query['jobProfile.active'] = true unless req.user.isAdmin() selection = 'jobProfile jobProfileApproved photoURL' - selection += ' email' if authorized + selection += ' email name' if authorized User.find(query).select(selection).exec (err, documents) => return @sendDatabaseError(res, err) if err candidates = (candidate for candidate in documents when @employerCanViewCandidate req.user, candidate.toObject()) @@ -321,7 +323,7 @@ UserHandler = class UserHandler extends Handler @sendSuccess(res, candidates) formatCandidate: (authorized, document) -> - fields = if authorized then ['jobProfile', 'jobProfileApproved', 'photoURL', '_id'] else ['jobProfile', 'jobProfileApproved'] + fields = if authorized then ['name', 'jobProfile', 'jobProfileApproved', 'photoURL', '_id'] else ['jobProfile', 'jobProfileApproved'] obj = _.pick document.toObject(), fields obj.photoURL ||= obj.jobProfile.photoURL if authorized subfields = ['country', 'city', 'lookingFor', 'jobTitle', 'skills', 'experience', 'updated', 'active'] @@ -363,4 +365,17 @@ UserHandler = class UserHandler extends Handler hash.update(user.get('_id') + '') hash.digest('hex') + getRemark: (req, res, userID) -> + return @sendUnauthorizedError(res) unless req.user.isAdmin() + query = user: userID + projection = null + if req.query.project + projection = {} + projection[field] = 1 for field in req.query.project.split(',') + UserRemark.findOne(query).select(projection).exec (err, remark) => + return @sendDatabaseError res, err if err + return @sendNotFoundError res unless remark? + @sendSuccess res, remark + + module.exports = new UserHandler()