diff --git a/common/models/block.js b/common/models/block.js new file mode 100644 index 0000000000..fb8498cd53 --- /dev/null +++ b/common/models/block.js @@ -0,0 +1,12 @@ +import { Observable } from 'rx'; + +export default function(Block) { + Block.on('dataSourceAttached', () => { + Block.findOne$ = + Observable.fromNodeCallback(Block.findOne, Block); + Block.findById$ = + Observable.fromNodeCallback(Block.findById, Block); + Block.find$ = + Observable.fromNodeCallback(Block.find, Block); + }); +} diff --git a/common/models/block.json b/common/models/block.json new file mode 100644 index 0000000000..222fe0c8a6 --- /dev/null +++ b/common/models/block.json @@ -0,0 +1,49 @@ +{ + "name": "block", + "base": "PersistedModel", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": { + "superBlock": { + "type": "string", + "required": true, + "description": "The super block that this block belongs too" + }, + "order": { + "type": "number", + "required": true, + "description": "the order in which this block appears" + }, + "name": { + "type": "string", + "required": true, + "description": "The name of this block derived from the title, suitable for regex search" + }, + "superOrder": { + "type": "number", + "required": true + }, + "dashedName": { + "type": "string", + "required": true, + "description": "Generated from the title to be URL friendly" + }, + "title": { + "type": "string", + "required": true, + "description": "The title of this block, suitable for display" + } + }, + "validations": [], + "relations": { + "challenges": { + "type": "hasMany", + "model": "challenge", + "foreignKey": "blockId" + } + }, + "acls": [], + "methods": {} +} diff --git a/seed/index.js b/seed/index.js index bea266a763..b5f4e11dab 100644 --- a/seed/index.js +++ b/seed/index.js @@ -5,19 +5,34 @@ var adler32 = require('adler32'); var Rx = require('rx'), _ = require('lodash'), + utils = require('../server/utils'), getChallenges = require('./getChallenges'), app = require('../server/server'); +var dasherize = utils.dasherize; +var nameify = utils.nameify; +var Observable = Rx.Observable; var Challenge = app.models.Challenge; -var destroy = Rx.Observable.fromNodeCallback(Challenge.destroyAll, Challenge); -var create = Rx.Observable.fromNodeCallback(Challenge.create, Challenge); -destroy() - .flatMap(function() { return Rx.Observable.from(getChallenges()); }) +var destroyChallenges = + Observable.fromNodeCallback(Challenge.destroyAll, Challenge); +var createChallenges = + Observable.fromNodeCallback(Challenge.create, Challenge); + +var Block = app.models.Block; +var destroyBlocks = Observable.fromNodeCallback(Block.destroyAll, Block); +var createBlocks = Observable.fromNodeCallback(Block.create, Block); + +Observable.combineLatest( + destroyChallenges(), + destroyBlocks() +) + .last() + .flatMap(function() { return Observable.from(getChallenges()); }) .flatMap(function(challengeSpec) { var order = challengeSpec.order; - var block = challengeSpec.name; + var blockName = challengeSpec.name; var superBlock = challengeSpec.superBlock; var superOrder = challengeSpec.superOrder; var isBeta = !!challengeSpec.isBeta; @@ -25,48 +40,62 @@ destroy() var fileName = challengeSpec.fileName; var helpRoom = challengeSpec.helpRoom || 'Help'; - console.log('parsed %s successfully', block); + console.log('parsed %s successfully', blockName); // challenge file has no challenges... if (challengeSpec.challenges.length === 0) { - return Rx.Observable.just([{ block: 'empty ' + block }]); + return Rx.Observable.just([{ block: 'empty ' + blockName }]); } - var challenges = challengeSpec.challenges - .map(function(challenge, index) { - challenge.name = challenge.title.replace(/[^a-zA-Z0-9\s]/g, ''); + var block = { + title: blockName, + name: nameify(blockName), + dashedName: dasherize(blockName), + superOrder: superOrder, + superBlock: superBlock, + order: order + }; - challenge.dashedName = challenge.name - .toLowerCase() - .replace(/\:/g, '') - .replace(/\s/g, '-'); + return createBlocks(block) + .map(block => { + console.log('successfully created %s block', block.name); - challenge.checksum = adler32.sum( - Buffer(challenge.title + - JSON.stringify(challenge.description) + - JSON.stringify(challenge.challengeSeed) + - JSON.stringify(challenge.tests))); + return challengeSpec.challenges + .map(function(challenge, index) { + challenge.name = nameify(challenge.title); - challenge.fileName = fileName; - challenge.helpRoom = helpRoom; - challenge.order = order; - challenge.suborder = index + 1; - challenge.block = block; - challenge.isBeta = challenge.isBeta || isBeta; - challenge.isComingSoon = challenge.isComingSoon || isComingSoon; - challenge.time = challengeSpec.time; - challenge.superOrder = superOrder; - challenge.superBlock = superBlock - .split('-') - .map(function(word) { - return _.capitalize(word); - }) - .join(' '); + challenge.dashedName = dasherize(challenge.name); - return challenge; - }); + challenge.checksum = adler32.sum( + Buffer( + challenge.title + + JSON.stringify(challenge.description) + + JSON.stringify(challenge.challengeSeed) + + JSON.stringify(challenge.tests) + ) + ); - return create(challenges); + challenge.fileName = fileName; + challenge.helpRoom = helpRoom; + challenge.order = order; + challenge.suborder = index + 1; + challenge.block = blockName; + challenge.blockId = block.id; + challenge.isBeta = challenge.isBeta || isBeta; + challenge.isComingSoon = challenge.isComingSoon || isComingSoon; + challenge.time = challengeSpec.time; + challenge.superOrder = superOrder; + challenge.superBlock = superBlock + .split('-') + .map(function(word) { + return _.capitalize(word); + }) + .join(' '); + + return challenge; + }); + }) + .flatMap(challenges => createChallenges(challenges)); }) .subscribe( function(challenges) { diff --git a/server/boot/a-services.js b/server/boot/a-services.js index 9c2607c7b9..8c318d8543 100644 --- a/server/boot/a-services.js +++ b/server/boot/a-services.js @@ -2,14 +2,17 @@ import Fetchr from 'fetchr'; import getHikesService from '../services/hikes'; import getJobServices from '../services/job'; import getUserServices from '../services/user'; +import getMapServices from '../services/map'; export default function bootServices(app) { const hikesService = getHikesService(app); const jobServices = getJobServices(app); const userServices = getUserServices(app); + const mapServices = getMapServices(app); Fetchr.registerFetcher(hikesService); Fetchr.registerFetcher(jobServices); Fetchr.registerFetcher(userServices); + Fetchr.registerFetcher(mapServices); app.use('/services', Fetchr.middleware()); } diff --git a/server/model-config.json b/server/model-config.json index 60800cf81b..2bbec7e0a5 100644 --- a/server/model-config.json +++ b/server/model-config.json @@ -70,5 +70,9 @@ "flyer": { "dataSource": "db", "public": true + }, + "block": { + "dataSource": "db", + "public": true } } diff --git a/server/services/hikes.js b/server/services/hikes.js index a6117c8a1d..ef5263a990 100644 --- a/server/services/hikes.js +++ b/server/services/hikes.js @@ -1,5 +1,4 @@ import debugFactory from 'debug'; -import assign from 'object.assign'; const debug = debugFactory('fcc:services:hikes'); @@ -19,9 +18,7 @@ export default function hikesService(app) { debug('dashedName', dashedName); if (dashedName) { - assign(query.where, { - dashedName: { like: dashedName, options: 'i' } - }); + query.where.dashedName = { like: dashedName, options: 'i' }; } debug('query', query); Challenge.find(query, (err, hikes) => { diff --git a/server/services/map.js b/server/services/map.js new file mode 100644 index 0000000000..c8781c7617 --- /dev/null +++ b/server/services/map.js @@ -0,0 +1,84 @@ +import { Observable } from 'rx'; +import { Schema, valuesOf, arrayOf, normalize } from 'normalizr'; +import { nameify, dasherize } from '../utils'; + +const challenge = new Schema('challenge', { idAttribute: 'dashedName' }); +const block = new Schema('block', { idAttribute: 'dashedName' }); +const superBlock = new Schema('superBlock', { idAttribute: 'dashedName' }); + +block.define({ + challenges: arrayOf(challenge) +}); + +superBlock.define({ + blocks: arrayOf(block) +}); + +const mapSchema = valuesOf(superBlock); + +/* + * interface ChallengeMap { + * result: [superBlockDashedName: String] + * entities: { + * superBlock: { + * [superBlockDashedName: String]: { + * blocks: [blockDashedName: String] + * } + * }, + * block: { + * [blockDashedName: String]: { + * challenges: [challengeDashedName: String] + * } + * }, + * challenge: { + * [challengeDashedName: String]: Challenge + * } + * } + * } + */ +function cachedMap(Block) { + const query = { + include: 'challenges', + order: ['superOrder ASC', 'order ASC'] + }; + return Block.find$(query) + .flatMap(blocks => Observable.from(blocks.map(block => block.toJSON()))) + .reduce((map, block) => { + if (map[block.superBlock]) { + map[block.superBlock].blocks.push(block); + } else { + map[block.superBlock] = { + title: block.superBlock, + order: block.superOrder, + name: nameify(block.superBlock), + dashedName: dasherize(block.superBlock), + blocks: [block] + }; + } + return map; + }, {}) + .map(map => normalize(map, mapSchema)) + .map(map => { + const result = Object.keys(map.result).reduce((result, supName) => { + const index = map.entities.superBlock[supName].order; + result[index] = supName; + return result; + }, []); + return { + ...map, + result + }; + }) + .shareReplay(); +} + +export default function mapService(app) { + const Block = app.models.Block; + const challengeMap$ = cachedMap(Block); + return { + name: 'map', + read: (req, resource, params, config, cb) => { + return challengeMap$.subscribe(map => cb(null, map), cb); + } + }; +} diff --git a/server/utils/index.js b/server/utils/index.js index 9efdc7ae9d..8f3a80c66d 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -12,7 +12,14 @@ module.exports = { return ('' + name) .toLowerCase() .replace(/\s/g, '-') - .replace(/[^a-z0-9\-\.]/gi, ''); + .replace(/[^a-z0-9\-\.]/gi, '') + .replace(/\:/g, ''); + }, + + nameify: function nameify(str) { + return ('' + str) + .replace(/[^a-zA-Z0-9\s]/g, '') + .replace(/\:/g, ''); }, unDasherize: function unDasherize(name) {