From b82f1304542c1ab148bf8ce1f22dfa231682b6dc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 6 Dec 2016 14:44:15 -0500 Subject: [PATCH 01/18] Adds a new closedAt time for assets to prevent new comments from being created --- models/asset.js | 15 +++++++ models/setting.js | 10 +++++ routes/api/comments/index.js | 15 ++++++- tests/routes/api/comments/index.js | 63 ++++++++++++++++++++++++++---- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/models/asset.js b/models/asset.js index a0619576f..7b0717674 100644 --- a/models/asset.js +++ b/models/asset.js @@ -29,6 +29,14 @@ const AssetSchema = new Schema({ type: Schema.Types.Mixed, default: null }, + closedAt: { + type: Date, + default: null + }, + closedMessage: { + type: String, + default: null + }, title: String, description: String, image: String, @@ -60,6 +68,13 @@ AssetSchema.index({ background: true }); +/** + * Returns true if the asset is closed, false else. + */ +AssetSchema.virtual('isClosed').get(function() { + return this.closedAt && this.closedAt.getTime() <= new Date().getTime(); +}); + /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). diff --git a/models/setting.js b/models/setting.js index 22b2b103f..5d71b8c97 100644 --- a/models/setting.js +++ b/models/setting.js @@ -28,6 +28,16 @@ const SettingSchema = new Schema({ type: String, default: '' }, + closedTimeout: { + type: Number, + + // Two weeks default expiry. + default: 60 * 60 * 24 * 7 * 2 + }, + closedMessage: { + type: String, + default: '' + }, wordlist: [String] }, { timestamps: { diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index b82c1faf3..748f64f5b 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -80,7 +80,20 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { status = Promise.resolve('rejected'); } else { status = Asset - .rectifySettings(Asset.findById(asset_id)) + .rectifySettings(Asset.findById(asset_id).then((asset) => { + if (!asset) { + return Promise.reject(new Error('asset referenced is not found')); + } + + // Check to see if the asset has closed commenting... + if (asset.isClosed) { + + // They have, ensure that we send back an error. + return Promise.reject(new Error(`asset has commenting closed because: ${asset.closedMessage}`)); + } + + return asset; + })) // Return `premod` if pre-moderation is enabled and an empty "new" status // in the event that it is not in pre-moderation mode. diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bf7a06ff6..70912eac7 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -19,6 +19,14 @@ const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { + // Ensure that the settings are always available. + beforeEach(() => Promise.all([ + Setting.init(settings), + wordlist.insert([ + 'bad words' + ]) + ])); + describe('#get', () => { const comments = [{ body: 'comment 10', @@ -75,11 +83,7 @@ describe('/api/v1/comments', () => { return Action.create(actions); }), - User.createLocalUsers(users), - wordlist.insert([ - 'bad words' - ]), - Setting.init(settings) + User.createLocalUsers(users) ]); }); @@ -142,11 +146,19 @@ describe('/api/v1/comments', () => { describe('#post', () => { + let asset_id; + + beforeEach(() => Asset.findOrCreateByUrl('https://coralproject.net/section/article-is-the-best').then((asset) => { + + // Update the asset id. + asset_id = asset.id; + })); + it('should create a comment', () => { return chai.request(app) .post('/api/v1/comments') .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); @@ -157,7 +169,7 @@ describe('/api/v1/comments', () => { return chai.request(app) .post('/api/v1/comments') .set(passport.inject({roles: []})) - .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); @@ -188,6 +200,43 @@ describe('/api/v1/comments', () => { expect(res.body.status[0]).to.have.property('type', 'premod'); }); }); + + it('shouldn\'t create a comment when the asset has expired commenting', () => { + return Asset.create({ + closedAt: new Date().setDate(0), + closedMessage: 'tests said expired!' + }) + .then((asset) => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); + }) + .then((res) => { + expect(res).to.have.status(500); + }) + .catch((err) => { + expect(err.response.body).to.not.be.null; + expect(err.response.body).to.have.property('message'); + expect(err.response.body.message).to.contain('tests said expired!'); + }); + }); + + it('should create a comment when the asset has not expired yet', () => { + return Asset.create({ + closedAt: new Date().setDate(32), + closedMessage: 'tests said expired!' + }) + .then((asset) => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); + }) + .then((res) => { + expect(res).to.have.status(201); + }); + }); }); }); From fb392422eae88c96be5016c1e8d26bcd21a53e30 Mon Sep 17 00:00:00 2001 From: Dan Zajdband Date: Thu, 8 Dec 2016 12:44:02 -0500 Subject: [PATCH 02/18] Added textarea for closed message on admin --- .../src/containers/Configure/Configure.js | 16 ++++++++++++++++ client/coral-admin/src/translations.json | 8 ++++++-- client/coral-embed-stream/src/CommentStream.js | 4 ++-- client/coral-framework/actions/config.js | 1 + client/coral-framework/reducers/config.js | 3 ++- models/setting.js | 2 +- 6 files changed, 28 insertions(+), 6 deletions(-) diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index a9e2f3ef2..e171fc27d 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -28,6 +28,7 @@ class Configure extends React.Component { // InfoBox has two settings. Enable or not and the content of it if it is enable. this.updateInfoBoxEnable = this.updateInfoBoxEnable.bind(this); this.updateInfoBoxContent = this.updateInfoBoxContent.bind(this); + this.updateClosedMessage = this.updateClosedMessage.bind(this); this.saveSettings = this.saveSettings.bind(this); } @@ -51,6 +52,11 @@ class Configure extends React.Component { this.props.dispatch(updateSettings({infoBoxContent})); } + updateClosedMessage (event) { + const closedMessage = event.target.value; + this.props.dispatch(updateSettings({closedMessage})); + } + saveSettings () { this.props.dispatch(saveSettingsToServer()); } @@ -78,6 +84,16 @@ class Configure extends React.Component {

+ + + {lang.t('configure.closed-comments-desc')} + + + @@ -114,7 +114,7 @@ class CommentStream extends Component { author={user} /> - :

Comments are closed for this thread.

+ :

{this.props.config.closedMessage}

} {!loggedIn && } { diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/config.js index 6dc880434..e004fa1eb 100644 --- a/client/coral-framework/actions/config.js +++ b/client/coral-framework/actions/config.js @@ -5,6 +5,7 @@ import coralApi from '../helpers/response'; * Action name constants */ +export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; export const OPEN_COMMENTS = 'OPEN_COMMENTS'; export const CLOSE_COMMENTS = 'CLOSE_COMMENTS'; export const ADD_ITEM = 'ADD_ITEM'; diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js index 9620f5b97..7fbccaa49 100644 --- a/client/coral-framework/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -5,7 +5,8 @@ import * as actions from '../actions/config'; const initialState = Map({ features: Map({}), - status: 'open' + status: 'open', + closedMessage: '' }); export default (state = initialState, action) => { diff --git a/models/setting.js b/models/setting.js index 5d71b8c97..2414381fa 100644 --- a/models/setting.js +++ b/models/setting.js @@ -99,7 +99,7 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { * @param {Object} settings the source settings object * @return {Object} the filtered settings object */ -SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); +SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent', 'closedMessage']); /** * This is run once when the app starts to ensure settings are populated. From d52ff11f417455f9f2060646f44c0e214b833ebc Mon Sep 17 00:00:00 2001 From: gaba Date: Thu, 8 Dec 2016 09:02:45 -1000 Subject: [PATCH 03/18] Architecture Diagram. --- architecture.png | Bin 0 -> 62004 bytes architecture.xml | 1 + 2 files changed, 1 insertion(+) create mode 100644 architecture.png create mode 100644 architecture.xml diff --git a/architecture.png b/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..0096ccd41af9621933ca8620344f06d3ede4bba0 GIT binary patch literal 62004 zcmZsC1yq$=xAq26S{gPj4bsxxA&nrNo9-0pkZv((X#u5?ly0Ou1WD=chJSI#{f?gR zzYYdNk+t7dbIvE`8?K@xgN{Ok0)ar#pUXay{4=9%RW!pH26g}~aqJGfE&vJq>&{D=< zD1~^Zf~MOWtcW0jdx0u-M|p=@x6S!VN-Kw7%Yl*iB=UVVh5kX*8b)WEFXB%xtJ?0L z2WhR7M^gkm$>&nO?8&0OghV~Raum$>{WRFK%7OBA8DTN1`~{Hl#Az zNSZDstP=?Mb!6qyg`efjRXoe5cUc?>OT{R6P79P6*Aa$35>Ra8^fQfT)2M3`?AB6Y zXkjD1B;H(0%U6Escd%iv8<{6qQdIEO00q7#lSn=vTc_)pBEU^F}m!p&B$C^ zXrir0QmK-O8d;YRWfQ(eP;`#cuiU_|EPP8>$D5S5?(g(jM2W?wr^HAnd2=oa*6iN=thdKdfPK#lt0)c6?i&_O7ft&QYq^NH(U1g=NchR4E17TdxuKT zu3qMkq`~#iNy>zzlpr4kzHj8&9D5ZQv5mW)D{RK&8d_v@EQ?MZ|9l>+B7jKlxOP!E z%I2|t-aM6sPtYm#=$RaHYk5uH7(4pNyWo(nK%LYfiJJ2#a-7I~r8=)hazfL$1aWpT zPW*e~&gurx=!>(VA3S4+wrb?`Cwm+ewMBLgzTz+sEd5BwvL}M=IqOxxQ#vppaVKnZ zV|Ey0l*BoiZ*PP&EAhvesJw zDK8`Xs$7t5Kz&h4HurP>O`}oC_{$T96N%j?!lKc!2cM~85>zSJ)RlL0R0qr^A^s7j ze!H9w)zP0M_?+;Cu@vk^oOM{)((_x?_D0;!QwD!j<3VlhL#m&Cv{4XxJ)x}^Xr}x0 z1%oE-Q}n2l0HN#+Bi+E2`S{=A0R&y!vNM5%0&NxysuZ|CyPQhLtl6TIj#^?8$ouSr zdtTV`*QgcbaB2{6b5t$IwuSM<;@!x7%)MDWA<7hM_EOU)74T_4<-Ja-X}=Wx5|;{l znOZz|_W>KBLESHXD#FpfF8EurmGWJ~inkTP%=Heob!qwBgDSR0?ShN2{ThagX*)C0 z5_PkpG*awuWBa_GzxQ-D22oQO+ZBY-)h^QrRo(8aS|2sJJWjpZ*UnTk?h_uu+TsD31B?f9s8V@AuRc++#n)Ji(Z=Rdsy{%q9FeMbupJR zSRcK`U7W7%RqihhDYHl>1B@Z%>Je_8kse)fw#)^iH*5A?u^;98pdn$59*B0$Q~_s0 zFK?n5DaJO(BL3d{5`SGtSaNy}=d3HX z$qlnRuck;MUd7IDn=1p7?ZP+*u!;R_QoGnd_cbS(371*cGm?g+ zqmr)tl4Oy&&AWD_UX(^@ZGrdcns4H|On-ykb=PR%c;Ha0^VJLjc>;MZDW>jju>A=+ zv47?2Zr zmz|jIN8>nZnoFO*I43uW=UDR|xb-dW?dZD@oZ0diaB3R#nIBX`c}n>N{$F1U58*%8 z59NKwg~P)9*OzRRB~F+>(%)b1!T#-GnxzG5|MjckSUdk33jVtU^DXoM#qHJ8e?RRB zdzR3DPXG@do64Z2SV>A0LGrIJ5k3`vGN$7}xK?W2eO zB=@ba_8tbr5AV>E#Qzaw0z!|mKnWq1<3*e(Kr~Xuy5Su$#nTn&h5L=GvWNn~g7~`S zu#Vcpt#6Ro2S(WbHgF`>S2s!GoocAf7*!5yGw|+6&FBzkQW6m% z#w}a;MI`_4kQhEKo;wqLNM9-8FI@Kvd3gU3$n8T>u^NnKE=U#cbh`GAAEd??*K-$> z>NS4Lcq&JOnvC7nOXTJ)h<%wyTlb{zE{l@p_}}N~{ZZ5j7WC+f2ciRaE<8_d2`#A# zw%>UqDskrlseX`=IpDBxp~xdo47r1I?4~7KkIqPhA}u)0@Z32-648uGm<7MEvBN~r zMr%HIwV|zjMe(1DlY!*?tfyv$lP!y8Z-Oq8TOvoM2^-GR%Y$Nf$ToqFYZUmJs4=+&!ynTYIUUZu{pS4Krwv1mt%-mk10q6<-BZwh>~@DdUWzrcGGi4~H}E)v|7$D1)k-~CNo!~2uT z_Cx`1k0QaI}cK_6zF~kOKYlm5j}Tn znzYEO`orJ!;pXDl+NtHR2^MDvwR*foMV$<$2!B1}n>(~P=iLMDx#!8Rf`LExv3U>~ zmquD)Ips$;=Xt~>ejQYdry-Qsxq9>N6;6ODyXT#&`iB#!Q z4p`6|e?BH$UR)U_65_Tm^hAkgFji?x(-C5c5{#ykRi3%v6?#2Oj#k2n%GTBC4BiaY ztGrLp#l3T8p=gbPI1lV&gkcv63`lU#d+w44>7UDNXouDO9d~h+j#t?PBe#<*2icfW zxICp2+axev7|xJLK)fwd1RAKl<4X?anMfXViL;_hk#{MO9j~g`;?hZPuStr@4F9M~ z3>#=nnv_s&=W^4Ca!kYJ(epdxSR3GCEEMrnqG#&<(E7&f!*bRWjt-=*_KT63ID6f^ ztW%^~$40apDq;@F0o&L;)Z#lz0l2j_@&`nXf6~2ssHjs~92Vr~gN(C`Ck}AYHbglJ zaOnkJ!fW;U%%evZpyOkJf76GD{?^lP>aXm>ZKJMVk}NbAH%q zP`buX?_@=Xc_JzVrkX4xn@8Kif+!S^FIBWkh43R7BAycUr&2XM4c)n1Ie%uCscTNY z_&6-?jU>hU-dei#js-z_sDjBaZFccS6WgVai>j-$KfadUu1*Do`Pj#8kaBcjJtUJV z*|=b^k5y5hDT&-T$jab2dPSd1@4r2BJRqRe5b>w>k>h}XOY}gGs=lL}7wmukB~iIG z70y>)v2Bp)^n&D~11Z|bS3pr2St4wmFhk~zbUq_2=;HBeS!18!BF~Iq%oJxbDmE3T zB_ASfj~XW4Z^ANihuf7^o+I3soNso}UlWxbYa~iL-FiM;sY?X;Qn>9q3N}?=FdGJ5 z2790Hda!YpfQuZIUpn{V=vm+G&Q-?SAA5wEYk@9LZAtqCDNoHpq=!eBBP@ch-nPD7 z{vq-@f^3x;Z;V`u)=xQ>q=QY~36@hM1dG6WZzvYZfJ^OoeQqKuLGUN1>|uf>vN46d z_F>aVyC7ZNkT2vJj~5Wjj-Kxsl@~Xe^A15CQ|L2K#8;Lp7pd*ReWth*v&$vKaEv=N zv!sxS-}KSK9-*s$G+ApqdhZ$*l!i|GX4I=y(uvlTmBonR9IXj?Fui+_{YagCATBNP z+9X#i-<)98JWZ){h}|>!{Ck5Xn8DC`ZXQjO|H9j(E^*~Y`E;{BtVNj(3SLGq3Fyfn9B3YRw~2Jc=^GAnna% zwoD8Y^o{wd16yj$1?RGz_YrF7(rH+G*lXd)v4a0xcTvpyl!}RrE)-%j+-)(1*YODF zO%h2z32LJ~Ef$Aqc&DIpF07n0-K63A(bptDvE}!f3TPrrzpz)WRQo&}rK1w1nfEA+ zWzo^)=T=%AEfo3OjYZ1b_X7J%IPhL<2@Q8kPmF+7b;ZR$4xRmToi zqkR+0VHCn5VsduXnyvGNGX>U6me)YGqF%TskLxzyd zUeqQb4X!0E42(u=SzeDEi3i4?xvBK+6MK2;^zEm-37cB`oQS-Hc9xmh*V=ADv_?YE z)q0T3M|JGHFUfF}hIJ&#=IP-M`>5Rq;_OIi(aew+BjhRjhcG^BTY_8-Jjzc^o(VlfI>LdLcedNyrbr8{wK%ecRK=ld$7O0yVRW^Mm%vM5Mmyu9 zG+fzb>J`BimeY7}l!V;OaO5Iwn$t$}IOT3ze4{gS^i1ga zX;U`+qVR$~6)mDO_nN)uHOXim3a z#Ujcf972jHqdjNRL$QA#_W~N6OncZgi-f0p4ioC0ozXMb+tx`FyVqfCp_w(UDMLxI zEvNK4i5#MBbs1HWHodp6Jh7ph9d6pZZnt_qU-c_JTJNXWtTo z)5xC5mw&=F6X6a~9v&4diSzLFNrQ5&(%1Wbr0*cqgu-UPIVy`!99y6Th&@hN=lyy2gl{Po12@0l9H07 zfq{YSmlvO%B_t#`&Mj`ah(@ z`#mK4*(EhKwID7Lkh)&?)LY&!u59(*a3=-e{hh7EvQ>kRyPM2pZ zed0e^u&e&%mg{$SJIwQ|ox^oj?eOrh)OF)U{nc(AT|A3E5djv~ft{Qj3I@*-!`G+6 zqN1>NM0_!cKRhi!ln#@eTtrw%=q9|;=W6vaDSw4Rf3je9rwa`uBdW{VK!-eQgX>ar zzypRaU%o^YcKeCGUwf33h6dje9@klwNha)=2x}k=zf7L~#5B{QcQ&Uq(JhP4xpP#>{m6|N>pLtNP zt5KvA%%GM_eXti56%~G=6m+t?@TTxAs~ySE#G7<^&m*7uZ z(|2@~+Kt&bZg;~j>zTN6DIvq5j)<6w5{rM%2mFQU2_`YIKm#~pjJ`y6s&bpjV*CnA zJDe15yVmw_oYAJs&4MwtJf#DoFzt!cjEM*;l~jwMgvEnaQBP>&H=C$Dyn|xYBbLym z%S9i8>&`3gDS>gmx^3)qXUb`zb@X%_6?!9Mi}gSZm8(8c-sZTq5~(|s8`@zCmy zy=o5^dg&1Oe2cozDLzNeqDNsoY3=Q(k3EdZS?8A@`U(%HD_1`H8Rd`jWX&0}(NlSYLh}+a4QJ{~7w!o>nClYJ5gzm-!OS>zM;zp6DX_ z{3*{z0xop})9!j~PigEOdl+lJlI~2>(6XI)V0FMe;(~KZUK=izi>-LL0Riok1CkBG zs@^O20$x_59H?<-_d=5gDv!jVdp7TpCcUZq=xe_j8eiL#>d$IZyvc~mt28f;{>pURs8^<^+tZ5;o+pPXVlx=W;0)b=$H|Nc-<~hG5fTzsU};{iNM4%`ftq6=dBo^X zY)kg;4l-p_r)95(yms--KQa=F>h7VVkiS?}730i6khvxF3ZPw+%S5fFep(i1h#rbR zkJH(=m5(a^`}2iuBRCML$7_;hE&Z=ziyj-MBlwGlq!Cu-QU0gE0eNBp3Kz2HH<0Zr z;JUt`g--6fd2==|)8u=jWM*or0NF3Zz!)zHT@8aGKPW|MHrM6Fk*L_MB0SEMvR$x^Y^e2YfH!Zbszsh^a z$4n!c)%6!q*+b?;MMc%EH2v{zUlfya)tZFY5hCPuDp+PWQ-%KH#}5S#+0(0!hh@mT zl+wJWAzPu-K=PBMTJV z2rRXB$C^_BQrY~bSNH4td&RNZOG}hksHX%1q+b4sxMl}r_2`(G+l!M)!;*!POzn<1 za(-9Ki9+pfG1PQ)qq|I2rYcVTh$mYy5m>aIqmJ{&925Q5(eGLr0b)iz!o|&s7LutM}L&6uyee+=DZ0yjUf6 z-5d+|ppJz*H*8j(dVu4q9_2`1{WAz@dF3z??JDy{6kAzp8ajv5toJ9G;|cmzOT$&W zeVD{IHoD*3x@5==)bH((k6r9;wksR#g_S8<-Zvh4sh{&pjWKfXJ>KKXY=)KN^=fiE zJ;Q?xW@eYFV*LT_SoDdaGhkA_^nM2VH%^Wp41F&iR?j-i_lVqGOX$YAefb!i z*Me=%1o15!c#!bSuJ-92ylizjhoNNwHLU&p@(O%A9Iw4Dgse?I&ZG&ldP5`V6In~4 z$9r_~E~*m6-&R+DBC`BOFL5o8+HOnxuQQT`cXD#7GxWPTk*|Bbb~e`X@^o)L)@|qK zXHq`r-zzye)Y3;EDu$};TI5@(sHlj8LqjJAdwRapgA&Wb&(AMJOG`_~!ZLv5)!W_O zE#R~yX|JQBqhM)iX)Y<*9#B|Vs0dO(ebyT*WI^o-hscr$EE)e~Sdf#K9JO;$V%lFj zVS^NPz2|o)aJd{SV&3SW5)abfatN`#?5{Et#B6K#SCUS_Ur_E86%^_j`S@x#%o6nD zXTQEIC^hd-?ier9B}g#vWLR1{*Y{prnfJTv*lW6rDblTm^(S*#6=;?WC_aDw0~EBr zLmWD#KE!<|tS`77(SwX7S9SN-cn_;gM6s9g$@rH)*98GN;OXMxqMzNb?_3pbc60HV z^}%m)w7;3to$kzZIWngW=&01vQBW9*nwy&k0yyxgaXCf+X8(O77Zx0vt$wyW9az$M ziGqsHB&*-#>yrfPEKOG|dAe2aqFV)FiO||0tleg4tn9BxG6p0jCQe?R?s&}~Z;U35 zVjiym#3DJGtN0#+l%Ep?A_eJv2qBAzit^5pio{>^xjYW9-(q=mR8vjD?}}7kcrdPM zsHi9}F97(VlflG!fS1>+qF3QqljmQm{(25hKxA8M>nRvR&zbmcB2sr@XQn#a<7hPr zWTx1z_VzJwpzC+d4=_wcuJ_x_ifi6zPI)vk8Pr4H(<;jS{AMxOR8@~ZH?!s<1#4f6 zmwfP-x;0M?O8k}eJis%J`>m*l5x;e_#tSsF6;rsO&rD?}_7~fF)xWC$pu>0i@s0*6 z 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«uhUF|d*8-#_RM9#|D+3mNT315y>ozBHh%C^Ho?&JD`xqmG|6P} z7sLhr4*ZuMi5s~^ggxP-U^^%(f4Ia))T-U$EW;T47;w%Q=aQ2<>xPr)m#^XaT?(cxb9hKy1!c1dgORFN+xeFN_=ayONmxpeDCDcif;(qP9OaE+jb@G@DEOF%%tbI8Lv4GlUgz#$ei)YN@v!L0cd z$P5#hk_@kaR(KY)uSqal&;TGtemvZi5Emy?kp}S~*owrN5A-0+KUIOFprut^roMLd z`@l*5J!fTe#;U3+{K0y_MN-Uln0sixq<=D79|F|D7nDPJfiTUEAagIL2g(wn>h1)nVAq4P+o_+lSoQuAu6VE*lVsME?RL9Ody8PV^#vehnHcn~|{lrhx>X zi0Xu}*s61GZY%PzQCJZHwlGM;caJ|SGbuhVJ5IiPKi4I<%xz37O6N?^J&Exu0j;XF zHxK`$i}MM%8))hJJqR6tmn)%jkLrw5qQ$EGfZT`vL)$gSUjex;fxl$^o2R$#sJ_Tu zR@%+;RbQU;Z7$sUI{Kx?J!>4$h;1&v&zhjE6h$2Q3;d5sH#b4A(+f6UlJPxgWXBbQ zc7FbzzI&|turw6xrG=!Vq_Pkb)5MaR!@p_#GFtHQ>xI6$T3!qy_Ty)|qwBN)30l}( z+E_x;9l$%>_kqMYYe0C!PI2g(=N)}(ZR%@EBuGch%YXiIQjbx&V6g^HFYLlxu6u9VD*C7}vZheK6#wwUgSy@k<_y;cR<=>2pJw!d> zFr9UQ?+opgJ8E#sc|W@7w;Ql4lR#zofWx&9>@p4wIyIy+0nnf(?hubX4<{zio#w?L zelD5hKfvC0@Sg@vvB~-0hUuw*5wQ!p}W86hD*2|B3Y6C?D7aDW+Ig`(|^1IcPw$l2dDdMhw%hH4}^$qlM#ourk-Ise)D^# zkOYXk3pds(we=($R`~b9%QJA>%@=7duT4}x`3bhYviwoqUW3Qnq=J~@TuCkuDhd(w z8N%wis}Gz6DrDCVLOiATU0t1>^KIc&c@BYJjSa#D0S*J;44K{yCk-LseA*0&85{+H zOE~}ym0RWCzkg62t0KjJmn`I`N#ct(H)}U^gWswcNU^0Sq7aptG0_np%qfk*AR`#9 zy?z3Jl+ukqo}KBK(;6zeeZXr3Ef!p3$rH<&bqTibJ#PMwtIW zWKr7^qgAE{lPU;>yb8$Y^tt@jzHlmBz_u0GeBS$0pJQ9dK2I=73T%`}R zzMPvdqBfV|e7`~dvYHR7XY!LzmG;_>uVeghkL`5yTbgmWBtt!|D(gP(vb0n)(?U8m zO4u_3glQK0tb~G?21XUFqCmj=XQ22L`Tz#*`WqU4YY_RUd_Vo?D;$cNhVd|h<=L2- ztv7?RYFeECuKw@All?6w8DW6TvlqsL5zaH5aD4p97_?#7s5_4|FozTH^Nd$&t9rDZ z|9Ytw$5C#PrM7uK^k190A0=txp*>J7rY&dId=gFh9!{#u8nC9+MS3-71Tp9G$2Gow z(uMvD0|%ka4bW|&pY%9~H{2(nCmX*F-idvB)ortLp`oFkC^XZ|Y?(?mRjF%)>@GBUBKEd0z<+NhjyJM+qFT;>D~hDEMmBAb?t-LekpJt~mZ_;u z#TT$?;^)I3KXMXa7%PhOLcy(&jNqs#%f##4&?EL$4+8JXj|9-3K5@~FsSiir&>`1~ z=!A?rkDk6hWept{oyAj`_zanJxVbfrnRwjwhFg`QyhTm|q{DM};?h#<1{>e(eg)b$ z3votFfKF@qrvAj8QVJNuRk62LhO@FHZ2az&W@?}36U1uOT?v*#<{GA&cVbSO!e9OX zV(+n;h`X2wmniUTciLX;zpSH-8?Z(xd3l`a%kc}f){wo{8(Tr8!0_1o(O^6@MjtU7 zH7s{gyb`*gV6gz?)vvGT{1KhR;*>c*=J;Ux*yypXPiQa{4nb4}L?1XdVcttA>G$Bd zyPzaocm)Og!dPjfF=4n_3X1;(V`)m|>IqVcGqXnnf;fjYKwEkjalt%FJ`X*bRqH=i zTS7q@shMFUD*haExnZ|Oo}*^D(a;Guu1T#WD(D&!`bJju+H}nI4f4H>R!DK4e3<0|OLI(&NX0lB%R+0HQMF$V(Ln_qN zKE4=Ehc>z~HJKc8dTw#xqh9x(^#Kt6EMPKD5M8+m!E^6m0F(rsuyMQq?7&IiOCwt?c^XAu_ zRMHojk10i4wH0eIVsrb-K!i9_Yna_U1ikdVb~{qTl~iTokB#c;9uWnwVZ?Sb!*56^&L8ZR8_pv>a4B@Yssb(DngaDs}VA z4Q+9Q3v~{iRO4Vk#pg>+qyS;oy_ch*XZF(n`};HN@^F49UP~X7GI?WQ)U!LJx|^qR z@blFsxLMy>x#l~|3Eq2pgmg&FBXpmboQpucyhKV8w7l54yZsy1nO!T*=o~hU#<1xF z-|UbTzt*YI1IA#z6v>;csjRvSTGM)uxK0ucVi*Fj4sUta09-(ls>Yio%)bKK(8)=0 z1t={amcKg##R=6X}D z;eq_j^`~&Ld6ef9A2_t0`w22Hwx zm|n+H`5Sy@@0;)5#U$8ow8=j1v>|aB%)3?|{n=J2;1U)a+SmxLFg=@*rjQ^c@~QtU z!9WM4a2{`?bO6fxbXOlighM6@qnDl40ZJ-982MyF1>LlzpR|!A?8Gv+Q1^+s$*y?9 zO3MB9l89pM#U!ho*U~eTqQ2^t?YoioYQFt}Q^yzbeiu&?1zREhK>qV0{E9-)nNz>> zr-|~@>N-6iI91cY&)`HtWU7b6B5f-0EtCXC6WC$>*>M3j=N<|NWzO`GarBU?@g07Csql% zwJ5}}4K0g`uK}68&v%W!q0Wa+9j4ouudl~r0?3pu%-FH*CutK({Niu1{{#EXPCO3L z_SA!kV<7a2lha|qmR1jhi~oB&WHe{s>kjhv zMoxE^WyKj>L5Ye8@w@9dWyBu3-!0Zf#nKUl3u74Q`*?;Uxa>)NZTOpKK6MuiPI=^P zt3hCiUP;mp=W``epYtDQQEO~;Z?LkmrA0*=_r$IE5jr>Ul3h|lT$VG7%kN~NJbxl5 zT%;rgIVs4+D0#HxHsY2VOS6ogl3RyLzJhL7QGg_lpt=el=3*)~L%)6HFJE#<$ z=jP2q*h5Y(3A?y7^}dW~&EcbxoxDd2vJqv(*tWi?0d84wlT7JP5k{5!+ImPL+5B+N z($Et&;q!|3cA?3*41X%_Pjx~wx!8e_aV}e84>&DF0a30s%9ZPF#1EHwZSE|jo8}(V zBY0DZ7kgJi%tleyrGeO2^acdh+RB@zFNHeT2k)R-=}c?*3A1^*B1$Moa`BcEWOVtT zW0X`o$sd9*N^gu{pI4DGmo@=!wuU$_kNEcj?8J0uNm3#O4=G-~dUaMqWgah!*nI_a z=|e@t#pQCh!MNH39wnz>n!wi@a=eO-2>}XL*8T~2X}T`5)P0}=W#7+1XFrsG#wQwhUD9)7+Uzkyh-DPxx3kTnRHO5reiPs{LEUS8Gg&M^Zc~U{H$P> z#}t$NSk*DZzE|g0exLf-6c4+aSUhv;j}W#Pzb6!Ri5f+{6j~H6=i_FBH+JXEsC-(Q zIW4@4bO;zt%tszts$m+b756c`RFCd9-U5@@TXflWcvw3%J9z*F1BxxKyr-3QO`wNi zA;L~HTc*fZQ})%Dn{RHEpdl zej>lJTo)?IQb{^64iwZb)?wXeaXrE(PP|{w^RV}c8fX1fG8@*YAtGLgy2>fg+V&*A z?O3L7u3=Psbg(iL|5>x|bwk_)>O=hADcXsbVy6-|j_c)Zy-oZo*pdYrD?8=LOdFu~Gib4+k zgogcU6B5%gq9tzIG|4Gq6{Gv}TVuw{C>K-V3&Ag?ueiL*{>At)zx_$ocb{0@+4Yl| zbMM0~#6pyB8;fFzC|k+s7^{l2V`)T_bXG28O_-=KJobU_a6!6NM?_z7*|Xe^;WLs| ztA-=!mLhsiAoPdRjG;K$HP@7(`lQ7}%N>sM7b^Wx*Ev@ij^rTiU0UdAFt4>+i1&6( zux6WL-kA>@w%P||3-;Q|5vQpWsZGrDdX%nY;1v@eyRU9MxFXJP3|~kdUt@oaqL4>x z?(x2t6fXJjjA{E`34EhMI-pyZ)DwO2$asDR?3J6-vh?6$g)g&3&miDb*0#u$a^Pj*_@2jEVqV*c?>9c{@~bVEeOud+2PzmQ-lqe zao69h*2*rtqf);rzjFNcv_)}9K7r8}3QnRo5T}}N4nZ;8KuUdCUS56{ujXaGy%q*k z`$tz-*JH55y`zLMLW>vS_=)NTn)~>wl&6O}r7y53ypi{myWT)w45H)lb|E_EXyF+FHodLt{yTsCW$M zblUm3SSVi_#Oa>r6;B~Epy8pQRqIXRR3jN{IV_|vb0L{quj;^7zsMqRxBZ1l$bS|!^qwCz+N`{tG_v-ma>HVH8e6X+RsXOiQxv~&Zv;`VG zu%orrpXVm3NJa4*pi1K-snQ4-_oMME_wr?>^iRg!03%LXlQx|yh1@1M1p{x6rfliL6_Y7f3=P6B~-a@C%jmKUwfwGVF6rA`=HG&r?gA{;B{ zR=tzA1%bn^sf*dHyvJz_=g%rK@H)yV-2y_}`A8L@17BeXImyfQh;lsQe^5*b;jw^av`_>`G@3>jCyT)K4X)a%f0h3J(0g^QvuG=2fN z=2X?>nEp%e4F6D%Hos3XM@USiF*J;(xv*B;&(d<)Clr*10fLAz&%VBT9$4r#NJ6ko zXPb6m3h0WwJDFqP!cwZ+* zKpgi_fXVb2<`VhdTi+6NUN5kPPo~`Xi!|teN7Z%-z zv|393wqE0A>H+I)*LyzoUXYp^1?lwHFW$2;x{G73nGjh!=!h%jp+gxQCCJ{eS~V+_ z(J+XWUeb%R#>y#38@ieI8f+4{yG=WkBey9*Xgt?YFHKiW;5WeGzg9lIm=1%1Q=N#=S`wn!0;{U4+pc3W*YXGI%64KM2`w%upv^}*eryRKHZIh$qY+wp5^XFK4-L$%Ver@&IH2B1Hry(?4w;x zQ0^ESlU;=7rEv+2@{^&D6hS@WY5n^kK@uCGLPFduR#ncV=G%s&9IbJ`6 z4!@eh+GQ%oC8wT3ZO{uM#miboZmj$f^jtY5e1;5?RrO3--1r{8uy0OQ)55uL9LjD3 zCVKzQqx|cf=CeT;#Xx%oUah?4515k`c`>T3!BK{eL$m^MW^fqiMY9 zaoxC-l;8P{Le;tkrfC@{6{5LKBqVs1;>db|nzxFpo`_f6Iizq!zrBuEVSxy4v2Fct zSC~c8cC{=Fm#>_0_ReV1>y4FQ&cqAQXGQOP7;fr)!E#?7+6of!l@A~t-DHuvnimLi zuAVc(`2wy5fiA-_<@3)-2uYMlG(Gkq3c5;7U45F%(KkP?h<`>A-HN9O=N_=(M{B0X z>t&7wRSO6R>a;AqFBJa<-s8H;*Jk2{Og$~7Sy!)D^I9}9ROnONr>2g!2>mI|AZ=J| zPTS+qAv#Y&7?y3x&S^mBSnMemyFgT=;pX?{hJtq5aJ1zoEj?-ZlUM3k%q6N%3%EVB ztI*vEkP~(eF+DH;Odu^4hTBXV^^59xPOOj8Qk_zJv@dIvxR74Id7ho-9K1St@s`qc4l~2Bjy3-7UWztl z7c)0gc-Mhxq=<_(3!3leE=r%%K3GniG0c`sc;BM-76q+~4dClZBa<6%P6$$Mi5-Ok zVYX1&PAAJwWeSMRalR6bw`aB5t|J+t6d>PlTyG>`G`Zr2uCqccP07} zGfDUN@^Uix5Fi}AA=E zqI;LDA6Ga0LS<@YSZYE|_ZpvwQS_Qo3d`FvTJNul@ltH%YmLe&ocQ+O{< z#V9hg^fM+3Hklt9&rlR_8TIWm7!fs75EC z@ACbJ%;QS{41e#xV1M~3`Uz3MY;*NHEsa2inW7}Gu<58zq(tFp*1@%l@|W7~f(z3I zx}K||-rng~k}_O?E4`s6J+3nCYdQ7=9r1unzwsEp>tLV~9v# z`Q;aAO*;YZ8MX;J8FI_r;)Rz!Z(ZIO z_zGewq|igkX4pBUg?`mAH5k9leI!K!p&n+^RG9#1$>RgZUa#hVeM3Lj*k~>{_QAH z$JoFdEdy;SaruGSre*zA+3Q2~X{b+G_+_roLKs%t;Foc{BOuUV3XIU|plTZxJWD3l zaF*Yi!67*%K&Qk&yn}rjXkX(|8Lfqp7Z(li86K7@e|QaPDc|+1=ULXNw-Kc2BpzT` zi71D9J_Z98UiVJ0&e`#-nncg|IFf8yQTEAdmOsaq%xPK5qqk6dBu6I^vMa_p? z;K&&C-+!{#^J6%DV)+cg>sRo^w9K`QRt;o=qY%`t1n0PMtf_$oNK=jRK7TMJ^3#z6 z;1$e<$N+4b+=X-HkF_+IT|blt#lr?;G_rX0KW{OEID8Sh9yISpc{izgBN6Ttp_ph0 zH4?&R5XJpFu#^onBWKyI)rk)R`&yM-p>J!=L|6A8~A~1$UL9s9YW}4EueEC0~ya9|2 z{aiQD#k%yI*jM#7H4;MpZ6FGJzxq#0fC;2}WFjRm7N>tNx}@6WVflf!GNCB1`BUez zDA7-%kMj6sT?gfIQL&77wfT9?9~Inr%IEwsL>>C^Xadc6P`4tu|rPbiZo7b!1E@)pY4D=9W{msv+v%S4{AGkPyEme>`Vzb8}Fc(F#qZ5&?7z@9E zh*#SBY}KGzh*fQOa}e{flUh46Oh+Y17e(1OrW66WW(F@E;)kSp(Ty#;%cd$DI0-Dg z28BsGl-D^sZ}vsiTGN40jRth&q~;e+K4D<^kaK&s390%7=Ny1e{-3J2S03K3Eoz#W zlqV6a*^}8^5)deq?ZTIVDHp!3&KMQUSvS%+Q$5fe*SU$-oaqjqj9=2t&-a@+gIM*k z@)*SqPspFwvmp!*;_7pCD>ebXQAMLQWKEG4wwD zjIi#*7qR6Mp_&C7F%k+rw--(na;6%B&S_ z&Ar5zBV8WIpdud3r%MjoIkj^M_g*7hFF^9U#SyNGHx1`7VjS`6G<>QetT& zBQsXi7B(HK$AbSkTm4+rId9)cN4bg9>%N<_b^fQm3R&%*zv7_6eQ zNM`j@rv>#e^4Ol1IrTHSq@w6F4Z~YJK7le5*VO(wCY4|DBsEl|r+T%_{1lNPR)!~? zbegSNYe9vN-{RUUS=XSyFXRO=H~Bc?{Vq~abjp!BrU0*TS?^+R&l154hSWQqw$E8m zCiIjwab-@%=TKhPD2a&v=>bUWFDfaM31SB4tVY?BmT|;g%f!dbhi4k$plsASo$^-% zO}S7_;%oNL&$BB1Awej~oLY^0aYl{I#L7_b^3}vs|Gwq_L(?XPj+@MA@Yja_c~aZZ zMv|ED10LsOrGqp@jA>D>j@bq2sJxc++a0yuJmD4b%@m=6S5F9HuJUp9gl6DLe5@ms z9wS7&j#64lL+xi*l6tccJtDQve_eZUYpJw~X>X6}?j&Ymh_@=^zB8zmgfcuG1S;St z&3%Z&%@~&=@0aQ#?=Z}Bdqi9D7Z_rv8I8W_p}`M3KcKfvw!&4IVHt~iZyi`5Pi10O zt!+e@-Wb)0LoBkB1#}XmdW-Mq=~0w^d15Y{o|6x`b@w3tF=tZ9;wlpTnS)=`slN^d z!tjc=2=dy3G_yuO_LphmY0Az}a%+9B8Q#vsKP0iaAbB3kn3=OZqXr)oK#=3NAw}e< z^RRFUP1)`9uMgw=F*qoq%7;;`y5iF-DCFM--In;)@=z91cy+S095vM#+dil9kc9tz z`m6%$6p>PU`D~l|$rqH|Ds@%8FFH7oK7`2ynL6%@@a4Qzg#`E3bscx0#AIwXKs=rAb z3ML3hi>M`1PDbhA1$crX4ZB|zF>RLJ2(|bO{%)q6s*Ye@2!($C6v z94`8B$>kh&PN*T=(mHNkVj&Dpa_a$Lc*4V4HmqHbup_b;h6Q>K79h?sgO08F%GL*Zm6_ zpYk_~ih1{EK$R(mn0iKIlvU0`lpM;PV|`$ndsI)6G8`nY*B;C%*X#vIOU= zO_l=Vf2)=`q=+gcq@ff>XoJ6qk&uMON65Ay&$8yJ9n46Gh{jo7q`0LLNJz#+&rzaV z1G&=03}Q>sX9P&yxDc4UnpH8#kwqQ}c%KO)#tV_O9yJ~ON(cEH$6cCO188IuUqqK0 zYi_Lr($4X`2VQz)rlnVx^d}`@RN!%A=t!Hino)E4iVeI5Gk*UIKjxmS3VRNSx-u9} z8c;7?&VaPF9*AVUtfj{&FG4p^cwKLj1e-uO)&7hayC`?j*uEJn=zd){}Q@0{;F>pRa{J+)fd|NZ}s_kG>hb=^Uf|E$^# z;s>o8uxeG>T5r$7k3Ikfd&W< zanFbJaLgt-A0lA;WEpOf4|w{*I#>g$fI7(G`z*s(M|}xlse#{(t4I(LHT)}>9eXB3 zD2z#twj4w|x@Vff#7xlVTnrXz9`gZOra0}V$jXHs)si3`} zdd_9qagZADh>G^5J8$ach9Xvc&S6s=q&s=E^6m?PN^8^yQW*x;Z!Gtr;F{i4c2KtU zk|crDuDnd4qrVJl-^b^CVfUYFZK`|L)e#gaf925wu6zZhTOz!Ts@Pip;Hje>w zkUb>Jg#jWrRjuXS$dK#yZG(w9e-*^;8HVC=&0Auc;O#1=8X&vn+Av$=1A)O@NSf>r}{lf3sM2__U*S+_<$;kxAhS6yM7^YB}aJ&~3gp(K-R zia2H?KED6_2T;EKeX+TZGi(tO5){-1Ez?4mt02=Rk)!;1b588<*b|jkRP+esgs|Z; zSjx?@JQR1hLRFH%3o{x|IxPoG)o`PrYW^MZRVOyp99pFMCB%smQnr2uPA45^s%DWK z(&@T1wbYMwo{`yMHBO#LG-)Zz3UWw0OXNSg;ie;$UT)W+BXInm{XA0lNTJzKe6}s5 zHlZ7%CvGbi+n{32k98(3<$Si2{X8Y!=pf!H;Vy=s-oSJB8E#msx!~8HLFa+8!r$wH zHk9!Ao*FQZQ{dX65sJ#Pb9^hhr4xCKW17Z%sMI!c?afkQX3sc|tb1JK&7U91MGsBn z&L&G3id{S<>scW`{D(-92!Hg7R8K#$0;!~3FMP!sEOr#q>847cY z9ox2CJUTHW!R_Z5=`5$2nKXx$eyx4Bh&p-dmd0;rt9)r#_s<1k@oK+KTS)Eibs4IC zsRAvj0fM||$lq-ujZki8k>qzegGy8GK1PNSucs;}xx)A(5|iTyJ-r?3uB%e;yrdjB zSFY4RFo;2udPC&TYs%F2R=~-V+K^USh9Jfj*v#bSu4AWxyDA-NA2JEtRcCQe*D5P{ zLi}Dl(PBOMS3QAErkS)2rh2LP?S;W~s7;(GE48$i`Wh=brKUzwIUBb}$~0s1U-Jx1QyPAf zGD;pUJTDisj^Ncki}dIxQ2y-82GE4VD>dFMOkRcX+CjQq;u+`TNPf=s>;5`<0?EuS zC!&QE2Z&S#XgU&JJnavdXwp^00=$M56?t&@Pn;CSY1?+{d56 z4qpBlKkkpMx#f@I#$S(ca%B(*<3YIZx6ZuORqKyX;N8IZgRd?HlfrSN_XV?|Pz{}v zKz~|JOpc}Rgu{YV=X)f*=C)jrijzcqijva7gtgttlz%7S!jDcawIL-Nhg$WN{%T3C z_!EYr2kqSC66FZNlADs?Z(i;$yr1@-HkEZ?t&xQ7NzwAvi-f90f?cWldV15r>yu9e zQPZe*L29nYt4U2PBT2?0j%DuL{Eg;7|GbDzWr|&HvSkn)+sairq{!1#6yYEPcZU1m zK>9^Gfu_pqft4>FqS~~G;TNo*1`@Bz8q+iF0(rA{8?w(clQd*8iJILMT}Xegj8*(F zleod;b)sI%1*i35&kHs5qkVQy8CxcHnFKTvJxSV(7mt)%Lj-h0O$ zcnMrqUx|NwOORbf!6?>@X6x8N5F~_+`Z(xEjQ989E+;b*Dpn^A(n&^pQPx#X+4N-S zGs-PvZ&>sXz)u#((fI(d=s98Q*U~g}TvQfF(Jm>v)J~f)6oAD67he}oaDFv^4F54! zNW{=%4S6b*!fz@m*qcOs(IEZjjV52N3Hs*xxSp!cbI#RiHfMc)y5|`VD2W1xe4c<= zC=uW*$remIWz*GjbwYEvA3ARatEokCVs>zdJYXj?3!*qrWXpcEHuqQCpsWYiE3M=U z%CQZ%*PECU<7pn#qV8q#93^y8rg0}-rGUcl0otTZy2t7!bGR_qMzDl(B}yxayqqTd z+Ke>GLr4n;Ze^`IsV~VWD@X}8EtCe{e1#Z_y~oUQo9ql<|rur@_M#6k+sa-IWwDyI5*S_k&pCI`JYhg*u zoBdp#k`pN(VNp3$VeafgQ3xkc?E&)JZF+k8FSH+C75^yuDj-RuhAR3&apQOe7y2w` zyHE3hPGL#4-n>Gz@2OVxIe7B9JFF_N8i43M2QKmwclfUfL-7BbfMQuo=r7?&&R|Ba7fsjK^8-ul<6ez{oz$%rc`)Z&%c zl}aRC6VvzjY`UUUx}Rj#W?6RX2&9jTYws)EWJ7jbX6CI1P2S7dE=uFqY+}Mq!RK4D zJMHm(L)|HrUfT*p+HT$$P>V>H94in)D(m{}il8kgy=ksuWXLhOXNqhF5VM_hPecxZ zE1#oRunV@x{)80UQbWiP{r1i9*grkhksUyp_Y)%u;hi}5J&4?Nnt-{tHX&M+*i9~FpZZ3hai zRHtuYIKOq6V5_gFsKs{vuGlI{*iTS6nyx?A{+!?C0WUhy|IWvzioASH*%#wgb>|44 z5)q+yojglKU5hp0D`E4XTx*tOJz7`ILM_uLxsjj3{jWc@L05lC<$J6~D9(rhe-lnZCpXHQ4vDe^ofJzg6rDaQQJB!WieG zCa#KU_6RjNGN8~EF~X312Da|zv8|(1cI3Y=v^T-^@_#FMZi23{e7UCjCkhFY zpkNc73Q1M@deHJg4OH_^eX2G6hVEyG@iHT6EO_~$HW{Du47SPTF5D^<3!7}oi@;F2 z3G@MnLm;t}wHR}D_=Z7@7wtjkD=G>`rrzHywx2>p<6yd?Q8qfHNh9m6_@Z#TPX*+N z!44!&L0W1qm(`G^sjvzcO1UWF`tcBgg37@1(c7p{P+@9AH?QZMqN3}^Fc~v?&zqzG zZ`eNFPhf6iR7=sX>T!BtlTXsY>lmi z#)UGAIpHRtVu`CEx$Qsk;v-~u9xT^fr(BU)IoI0aEDy6{&n;SNG&vuwFf-!iGb@;EgeTC zfErBF?pF_hS8kN@`0f#Q5XzavTPfWk~sifs*}#^b4S9-UUa@H|8n_i2@2* zYI(BHH|s@%@r#z~Y>?pSKfZQi9VXjV>^GY&OLecvxVlQfSdsqM9>dEQeViZ51wZtG zj$k*0J9fT9^K5GBYIl%fCji#*D-Rcs#=GTFfk>^W&;8f>WgG5+$9Bt9UuDO%)MlS| zlm?#J}_Z>n>Bou6G~r1I^#MI_ih?+-I*dVj0?_2_7Y)wprJxnR*V@{;Dtmh^YYJ!>0SGv<0#VethcS*LATTf~)O{oX z6q?cQ(}soiz{Ksfff6qfCW9OMSp8>(U=s*LJ+gi~eSY4gk$3hn-De#*1-dkXM*ULP zjbP|d$etz&i*pE#8d!Z8`BCYFF_K;ZQi0GX;GEY;j>(5Z^M zYHea<#4Z14BOHMc#sB{_%RT2eZW#e|{`N|s=Ly&qFT-!5EfSV30XdqzL9v*g0=pvp zXjgbHhDBk+9&1L!w^+4+HA01Oiltm9z1yLbnEeH0d_ADwdb$o|DkQGqY_T;lNmI5u zKXnPZO(fymVFw^KM4d3-)gI74YZ-`cwH`CAk-tG3aVW&kpPupH!FDaKM`-KNY~hba z>*u^=ynEl{L~9ir?oD&z|FmZL8AOXtV_jA3uVb83v$Ma*3JR9G>g(&rfi%X=5QJlS zK=_>l1?S5gP+*M!5RvvJU-Nzs{H+nvZ#eSuKXItVjj;;XPHjiWLWs;YV3qJ(yl4eY z9-1y+4b1^UEz2xk=z4m3+QYwvmYV`U&7td5%WQkxn5gAT z_pf=Xaj{UZGYAH=5hgIsDM`!7*u8w^%A^fMM48XwAh1!Hlfq;OV&!8xNfTZ&|lM4nbY;v@lUr zRCI(fP=h}Qz^?>Yr8RkZc{T9+jSHvHc=)kAaQ&r2+Tl#3q@~qW!IbWT6Oj${xJ|P# zGO96AF#%hB=Uspef;!CVNdtrD?b3nwTKmobr^=r$WHnjJafY)TH~ z@N0X{Lw}HnESk4Rizc?2I7VFbX9W)j#hkx6ADn{xi z4r>|eFpIA)?lQ$KHzi+Cx?+fNXCnAqX-Q(1c^i4VmsB2CW&7)?dW?4Dl4RQlTyPc2 z!z{wF3MHt;rep!nfJfMAG<-NKp+V**B{Cc&jo;XoEd#0iubzKxsuB>_iNY|U$MwHA zw}ynQ30_O*VYABz9Ey5rDWSc0DUO?d$mN#7H(5C^tAR&(betdV7UIf8Kb+5*#8|Nu zV{`VkN;;Q(VndW+3_Va9t{qayg~ReI`*xYvs?1#I%Em2BeHQn1ZXOyu9_ty*p{TJmCjRPz;7O5!@1F-!$bT%lRd#3;13 zQ^k6c6VD97cZ>P;t7CTImnwCfvq`T;P+|S2^QwZ?W#gBLum+b^Oky=2;)bd5QOxGr zrkZ4^#q*#={za|Ytcf5^F@<&WCy-#}xX<0a;rgu;+d04X@^M+Gu`h5aX6l<4Ed{aR zs&mAcdVc=!5swHGg3qQ(62`LqMOY9umq@TY-XCO$`QQ_T?mw@iqVPD_>ZC45&T=Oy zZ7rp04&o>P?6Ux84v0xKISO0uD=u0F5_&nw4*W(xEGGM<;KN275a8|ZfylsF9c3oB zi$s&1`krM+yt{s9^-HnbzAt8I&w(>%Sz{~`J1qgz9h7}+wW%8-xcqK(R3X8m<^D^l ze8)$APm}iG*Hy;!=boCmi7g__eve{lM?e|5%{gfwP5D$jk$9l{DW>8CpWg5}Z?+|7 zJ)$6(BU~R%&Sz65SU*Ngt^JZYUThDGf7s_VuwMw3`SEB`HuewgG9?Lexhg*9WV3NV zo-7`b_w&^UAMb_1nDYa_r-@=Gqbj(pj>m@d!E-nL32XKRxN=03f%El{#JfUS!Z@44 zXe^vhOudQ5N*({$wy`CL3x;n|BL8bC1@h!5J?}eX^@$%WWj**B*s~P$Klc0N-&DYC zFu`->E{FKqIKbL7a6l;!g?#XFD(t==`aNKSXrc3V-ZK9W!!jvGZ{MyE-*k^yt{gUOGxrH6-q zqod7z57~X#>hD!;<)Dn9>0DTZZX}14Ad`nDCu|*Roj^c0tD+x_`mz$xVcDW3(zp8k_xti8Dj5^KX(B!=mUHf*p}6gFq4Mt}gaRrJp*Q|a4*Sr)kr_Kr#Rzl% z`VpFki|Q+!ch?kLC7;^2k(gZ>p(HZGc^@CysV!dSzvESLn^g>k!tXpMT{0d#ecr_v zLTYR9M2*r-J&6C`+7S~?>v?Oah_79(rUTzK!l1g;B%S#6%48+g))yH7W# zs5LtFGVcPVD<2GO7xs|y7hu$$;1?2{d}AGqs_~%wNEyBeEsGuII`P$jiMlUf5bz z%`uJO+=JNHaO5cEixBal`+eq!VrOo=O%6Ugrlhc-(}jCMQ^f>@SLXR$*A!kZCoV+C zuPhCP5)pM)kBIu?8YGGHf8M+8cp`}G&MmOG>PQL&V8hh#effOHeF>oPLdwai>*$;Nz&p;z_TP_aA=4H zeA!26@C~Y}suS?b9Q1cg@arr&Ir$L$DrKOjFWl68{Tbr4*4fL75H)tdA1T3+XBV^y z@5`ZmY9g)-Ff_}0n41DvirT97^*A9K434; zJ)z;)lg^3o)p;++Jj4LN?Wz!nH#-vh0O=rp)D~hs1F#BKK3BFJb0^Y~P~Gp+^Rgen zm50f)>Uo?VlmNufoiwwE$n`TVAR$lVM1%8_qYEPVKJ)qE`P&tnY*n2a*fW63_tEn@ z{n~`XBxBRK7Or1`uwt8M_S`-zzqHiRdYpq8M!P9o#gl0v0wwP{41N?_ZD?X*k_nc+ zkC1rX>ej7WZ$U0L2i7@G%Fr((_Tf3#D~&)yJqwza z(#+$kt@We|tG^>8eGAiRPPv_zfDpFumO%8N3jFxfAVOx6af02p43e^!*T5-?IF3QI z0_kw`5J4|eQ&Ve&E?x`<>zD&*l=A7Z%A6@?S5;-_@cXm^YY!goRA1{l_p8um{aH+2 z)fS#lBmcRuTLB}q;m+0PQZZ5>C$nIOiaVqKNo&0`= zDZ3x3@k2r4hEMFdnNmI#1CqgJSvvw@B&WD@QH?DzYEkybb$>|)jFP`El#;g&U}des zK#pGpai~QjENI6yC|;O@bB~sCp`sjA=64;Viw-F2SFG`oh~Fxg_BHb#u(jz9fbCk3 zFw^$n5^#gGs_6ol;p}H+Wr?cV7vh4ruy;=^vHxKQvV`09oKH?yc1#s6p*L`4KENhL z+QkS!Ho8i0k4QDr5L<>LVgQ_W6gTC;nIu893!rUl5vG}%V|?3iU$r_Z@b8pw{Do4c zZBN7EPq4(934__JB{^_p7UzSS5CdxI$<6 zb-pYm9QUdf-^Wff70X6VDy6~#tJd0BYHvq|#qsot0QS-HNa^iifZtIw8J_{J`VGY6 z&QZ{Gm@=OzgF*S>$j!dpdQCV@2&C}*f4~vIq&G{3hUIq_bcYOT0-fx(ihV5Z&*Ek! z;m9L+^QTXGE^w)2wgz=~OAzWePq0?w)2~Np0CD2$gU}&aP}%T1LF-hfQ(8CG$}aHi z+}dE_45MKi9&nj{ca3hcMe`zFC?~kZlt><3dQ_3g6H5fgue5kd^?=R+Xdt5#+_432 zS()$Mhok#JKiZ6$11S`tomK*r2tifaDT4$YRPUu<3pt3A*8?H}B({$HalPv6PSU1>fEiJyNUx)^&2s!BC}P_br#O2uJ3?t2(w!_Y@#9(_rP-;sI%nciFLz;mb~bTN4g zDUmmuOlV%GA{>;#aLnG5|CZ%tV&gMntRvPqXnTr4$dvug&P+R4hI}#shx05mGc##R zmBLB4(-#1A+w`q~JS6@Fgn$IK16j!!maERjQpia~Y4mlhu;NKcuYYM-Ss5pEB7OG- zO71?JTsouGPi}lxJG{~JRdYtnsXyl|d~VQrDXH5zxIgsm=Rr!bS_X0mMmGo-kKcGA z!^3s1d9dUbaB7=tSMoE0azj_*}Q2urcdSt?m)e{fP=nt z0+U1w8W;7reN<9q!}Y%GQeYN7I|HHT;`*I?Uw)&x#XZw;(=#fAEYxj5>aDldb2jq% z;G4cJ``-fF&V#I}Vc0!40o!Ug+m%%77p-7z&A!BdIvMl^_i4teZ*7Z~NPJNA{Q3O_ z@a!q{fz}jWnXYwRf1oZrdO1Vf;V*~^$hCnF zC%}14qBFd(J&*_}cM48(f8cx$kE7uakVi!a{<@-|7FbARJnhdyCl|#TYC46fY z>cMH?M)&L?P|#n%CANyiX0kTjeU@n`ZlK!M#c{cA57GQ`-=k+j+RsL|;h`8?s996g z9t^?zOGqRtnSk>+CxodtS6S1>6`oa4`IqHR`z1_e3?LOOA!l92H|D|kXKR??xZg_6 zPUJE1J}@wqx&_@5p6;F{;oJY65D#Y0i%%d}S~~=vxulrtR!?AcS3m=vKQ&;7Y1>V= zkY}GC`&>PnCiz?*Gw(gpiBE81@y%W4RGb6qM}&R+l~3xBCGIocJekSa9Nu_I<%deX zp33)_PAyEa2==u)b8P38|N2rR#fI10dom=JRnJ;9Q1Sqf8}Z-w)wlo$gi>wEKPi9irQM;W=IlpbI_PAp7otmAEm?e?O=SI6_f4FiSK= zqM(@?g*YiB!z8)B>L5Yn*DMOr4AwyJlFBHqlOHqicBMMiOd<82-3PXwb#(yCkc6Rxt1#H#p>er`Mq)y^4?}&-~`b96}UTgIHUA9_O z&x}6-vGz)@m;Sp%ws~mcBvm+g<+XYaaO0?eHna<$ks5DAaBv4u(*{8%8~kAK^m)Vu z>e3JrAZ>|b(irpL);!L^>?WpE}{-!5xKrDIUGhmx9sF)=?eQDrK$#gHrO`oR* z^R%`W^S8I{fhdWX%l@YGK#$pH8X#VCSNe#FHrSSgv51QYvG>=J{A5hvPuID0b#{Lh zAT;m;GdNp87d%0opV5`Ol81tbm+8l5k3=8%?(^$%szGK_n1FG&Th?8lorH%)QPh}Br1+BpP9dpzURxr@&i>DRUbz9d8{7FloTM(ufG>$TMD)O5RuM!KZlzwuaME=Xffa=ew^FzVyUntx- z-?@uP1H}k2^#VhDokaNqKN-k{U_Xj1H0x>qK)Jt^_5We-5AJ^sU^hK z$vz9q%29aFOCQNr&jTkF;Yip+XXMsH$Rh*o9BUvT(*jN^!ud`E;GSHQ7J%v-Zrq&3 zjbJ&>sW}5cJr?|1Q)RpsI`7FJ@Tb?WLaBjUGzIuw+EDeA|9NO13#x6B0`BilBwO4H zTDe+D5hJuw&mI_sDZm(VUcp;BE@F;K(Tm$)xX>W$aw~w#{d@*>P4^%BbM=xwL=^Ik^ zeH(dB1@?G*Yvk|mPq<|E;?^Uvb3t~Ut{u^J9IBMel49J&&{s4{Y=-N2 z%% z>m=qrUQMtR62whUEu|#HA(%wpNe;x~w`p-7`bt1V2J)6!)=B zD8b1dk^M()`Cz`;KZGBhZm2|aUpt%8H7dJ>M#0YEU{&>Zt9*g(u2|hWm~G>zO&CUR z%=l}&=FGg(LJp|H+~Cz2<6EwjtNW)U??i5uI|)2_edeWz5Rs;t&!Yc`BlUlMj=Sxn zS50rE&uF-Eoh5>`Z@}WTQ!yCq@KYwxKxHqsbJDZW0L*DDzcNq0@NE8s<+|fY=)0UP zp#J;^a|JoMBDINNwDQeyRSu1x%i-?%O3Lj=OKvS-WbXqf49UW$>G03VYl4}5eE}Fd zs{JG!TA@ht^gw?TwnN`qd0%$>>Ir@Kd;fmTk$Q&`VRICb^POOHV%}>h>8ehvDVr?D z{5m`{q1*+xVoJ#%)1{{B-7HEpKD6CtM2MG1nOY;}$ZsN1ejXyu=$cj%D+c;7?y>-K z*9!sUMf+rbiGby7ZSe;3y=3-E04(2oSntG_yOcw)cSRuIg1oFBTG`|WDx}3|B z8MXD>tvfGBYvyRON8a$ze$Eb;d;_aX{|if&Sq&^0T?&Jb8k7X5vR@37l$fd#gle7T zijmTE`~j&LA`Eo^NW>Fz_xIo-B8aq#VCt&nzIQA)nD2775aJ?-xK^Aqy`g44?f%c^ z>Hd!Me{!^Wp75_z>Xx~TZ8PwZ6uag}g}@-)I6hL^n%G?1F2`l|A!SLzK!4UsJn}x@ zS3n<=;E)}+7m^1BYylXHSHp#;0gluc`>ffANnvged*dHE`@hoq|I7nqy?z<6;M%^P zCSS1H+jvb(gJMhlh%#cGr9O`hX1(Mu+pa?( zuWA#O*TUz%eIq|QnRy>~0>T(kHN)~8g2$h^!W#NuokyVw)mc2}rJYWNyd4(^#?;8$ z#y|1vPtrhsQKi)FHdGrejX!Prd$`p?qekjHgw^`uaGiXhp?TDxPkkXzLa^#J&Deap zygugG zto`T5WQiZv{X9kZUn@ro-8?|6ZbGJ;FI}Wozq9dLl}Wep#V`gQs~POH*&zPUx*Mxl zKI!p4*Bt>bSH$84h~VdeOCUMm+ASdcXbmN(h(~J9m%(~qmC*Yy=a!ZkPa7NOI_j~y(?#Jg9MdCzc)g?` zRs~Iit{N5KC%cH1EDtki+jq@N{0Xz~9{9@uv@i$dlQVPCKM(>ic)0Q)b%-o*hUpTT z>L1yh@h4|flf8Nw8OzN%5p2Kaza~HfCg4S+v8~4xjCHa1H^8s9_%|q9+N0$I2=RztH#XlhceDJ52 zvwONFyjRW_$}{g_gNuTl{C&j6ACQ)7^&qL~Y0}$U2h)5bq!e^ND`9`>^Q#wKXQz!} zfPQ`-t$FOu3Co5?MEVbl>pufj!36^(1#UjAkqp-S!0v5=zBU6WVs2~$G@t2-JAi!A zyX_WBwcaRcsp>Hj5D03JlE(6L3uCMFsvS>deG+%<$mCxfHz)grz@889k0WNh9}0|1 zEJpt*DWNOa0~R^GL-O*;s!V%(=(JP~ar{Gu+rTT)PU2Gj z&U8JCbKC`?9W6v!?Wv?tJk7e|yENFI#oq)aw?dJ&uw}&CmEjAJ&ZFOgNej(orFr)=ZI)c_4hu}q;1|^=Mti(iCnL!j&$`4%y z2H`8*Kt@UhF1b2D1Uz8ca4+cZ?=iKPB`Ydp#SUrGZ;~KCjAEo#N;AL10<5%{V+%%) z=WD;dRV%XRCbEwRCagY5p!i%7ocq%d{{_^ZlNcvcZ>E&gBpTI;SZP<&J(t=Ds_Ih^ z5j$RxZQT8lW#`e9?-&2;PwIEzeAii60LQgLuw#<4%cC;Sc4pA&&5dRkKs>u-EJN+D zDn&U)Y?ZyKe++=*dnkq8nhdwEx~mYx_=KT7`de?@$3eN)=#XX4OFJB!*W3Pe;zhyH z^_iP1OC*u`Z1-#2dx^^-{6^l`SW;qME5xti%yXp2*BRkG@bijaVtB(PX_U{DD>sSC zThc?bLN@|Yt_TjNtAw5q?z~4#f-g0QZ7bYFOj@}vB{jxlWgr#Y0T?S0H2Fic-rPorBurB$Qy-&W}2&35gppcOY80ctL!;LX@;oXjbL7 zjn_Q%9me>(mkLfM^^(DraZbHx#4ZdOzf10AKV0EtMKLo?Sg15%Fe}!Drv8OtVf)C32qOL;?JT-e!ww5--W`l4Ux@jgmW}NI9vDFK!yG~5%Ikx?vEpwg;8i$`-FiyAS ze0|Dy{My2?<{$0Tm7#?G(;4AK?)^!OI^^Pn1L_-m7etpc_jb~+@;W$6Ih}Zn*`nxq z!XZlA(P*#vmMtLj5awuPrLi&=_fQLc4GbSN!bBH}6 zrfFa{X9kgo_RJ6mGH_EoP6prS{^z^*lBZ{woF?%sD8GijmmbIUoh7samHMq$(0Cc_ zU|o8WibJp2_ROC03NMrYYVBCh`$r7^wwLZ&e~`Y%?$L(nn9F@lqrXy*`67HO4lxE! zzirNgH-d$Ey6^=C5IYh$;i4BtH@O*@LDkqiI7CPxmi< ze}VZcm>U05;@y+jOaM-)57%|w4r;z1jpPisJ8&Gt_+nx(`IUVO!!+dtlazTwDL&ZP z@|UV=Xt|B^oZl%YsjOF!LR!H?42n4a*qfw5o`DXQ7Q)cBY8Fu)H4fo)d-JwdtU!Dt zY-L@Y!}Y_1K9n#ToJz>+)>47KQ~OEa2UG6JPV60N>lA+12TyESvJHKp*VB@Sd3t=`=A>)Xsno z^_@fyscWJ@ZgxvdM1!(06O5T3eye;dknWKUcb?iuT=+gLt*dut12i!95#JsuQ%XJ^ z{fii1SE0hX$`U-Yr8H2vlo<9icE378c`^uYKB~kj5YbYVoTA3JI3!W`ZD&*xE^WPh z@{5Wp)-l2?F^KvtbuJH+)gO;Q-NqYJzoW->#BEiFT7SjxOQMeLm~#YeD{7p9y%ae~fSje{6X^8R{6*G*^vQ!g^G@Cqv))`!X` znWED*TL;~omncfc;ZcZfb{*si%;hIlqUySQLMp?TG{yNEbcL$Z)rUL@Tlrti>>U`; z`(3ylBo^4}B3N~`wY@2H$!@8YfGC$4S=;qLdL(;?;3N*9O?DKsdQh@aj|*T}i3*)wOUpN+wK zumP_mGsDJQx3tF9ihNUHCp6wHQcg^`g*4kE^)_KQ>FK#hrRuYis!23N<@T4oOZOIy zB;TMSRQ3aYXx6N(J%IJK_GDi3B&@IOPm#U`yoBw2EY^okN)^=jAWW6`hYDKEI#coo zV{6b{`&pwq=n#aOd*Pa)+?i(-GgQ9i+GZM)e+GL2v&z$I?o$1IO> zvZX7fJP2*fqZv@yk#?S=O{Ou{74&G`5-=+C@$4q$*Duz(7xzZ{aR_!p;=>>*`)bx= zVN=jS?^$#TgJR0L){-90kP4sRyQKY%9k=+~`z&r%k?NEa9V1g|d$kf*r7Tw`Wf@?F zIYUgJOqX(7&*ggIP6w>Nt;~4tuPn)zz>iyVEHV-jpKBQ@VhIu&KUoSA#4g>;39D*o zdZR-0RPRgF4K*bh*K2Gw@Vl6oe!z)+O|=!Li+Qv1v;V+8M17u%QH=S!34>~k4Uu|V zAqX7OMtc8p>&~8em{^qk`Wx!Un1?MDhhOYJ4h6(2$e=7ORwTlLisZNmta*`7R8?Eq z3m-2(H9Y_IpU*71k6!SAl%`dp{R^Q%Iw}awoHqp+q*GiD zH5h+L`6r-=b?f|+IjA7z(5UG4G1>CQ_A6@i=MmMbXl|gyKgiZ;VNmUIhD%l|IZ6=o zKZ8v(nP5~SplI$}A>RhzS2$rnN(bhj>A|0!1w4*O*Is8GnA2zODQOcjWyp9HCMj%& zk&B4MsKQmNexH_(rWE4OjDXRp_)wwmNhlPNiai7XNP~@AX_tv4gsDZ7PdI_LWWmN0 zh0jktgWkMn$(zL0>PzGJ+E3@aM`q%S+`i1ONjJiaInlAfytNkOW5y5+zUJoA-3^u= zNjz2h;divOMr+BbW9p54|4aq0j0M7ol35jvejs{`a?9}S7CTfE%OJ^|{ob#oC~q_F zcJV2#Kbb=7&{J13Kk}0r(wmf2Q!}Rl9EnpXHldr^Y0a=n$lUIyV8p=tK}_;k@W z)4zty=PMkG##v$yCXq;JH$_!V)_288l7m)*@QtaOTENyyeY+m?83!1{n7XSI3bJ$1 z)JACOgjYG(Znw@!=Y0XkULnN!jFV4Mc<(i_kE5+rMf19ZM+TmMmDC!KkqWx_$!Tcy z?8=@pEBCb#SFx|-caj#Qv{cNq55ApbBr)_>N@MocY2bTf{QKW;mnP@^DEHD)A|pC0>QPxOW7 z{);q<);;xtIq`VyTXsD&J$Hk*`!-p6(!F|C#_nwI2ai3B%-CLe|IL3pdwY4J@8>Uu zVfs*a#Vglvcw%z;qV!}sL6ePi6KBSB*Kyruy8ZZ7wdOxAdu(ME@r#M;{h#kR%lAz( zRB4Kq7Es<>{TukRr9O~GB=ejSjbB(^Q+K@}(4j=O(` zO?S&US>0?OTgZ4i#=qyVOmnN&nM-o&$F&5#{R8Sz{??bIRh$})`(XYP>2Z7Rg#xa| zWx~LrM`=qKhi?_h-)lb$l#kBy9y>IaZdAvw-&V!A_sZG5B6Xdl@s7cl&Xl{ik{b?Q zx7f#?TXLw)uiV+yCQY}uf$`SPKD)ZF?d&1zwGa<2J*|nKKMqXPDs>*Ay1{yY2Hl8N6;bhjh{a z3_b1w6ce7f%ile__T2e$KaZMw5t|JCUEh9%+#QUSmq0?XlT90MV_6!BSe*wa!Tpt+ zLuyW4UWLJTt0L|TK5hBEp!Ix0+R1J!17M>fyN4QPp@-AWXRm)?Wr99+ZN&|NkmMH^ z7Yocwkv+k7lwI|eqoH&j%D(y|?o~{k=u&!(sO^1g6K%4%$8Sd#v+1-=T=kS(x%^?H=<`m+4GhQ2&v)LLKMPyGM%iJQ0#XP#)UEq52=C;`PSCch-dhycx8qWW>F)+)|&+i0+alS#wTZyne#jo$~ znPy}BG(_kHrEk?j1=twn19S$1Iu~;Xu9{TP--j|P3TyyK(FqpoBNwGBBGai2Z8cpc zY53K$J`4Oa^N&=Q6TV!kex}2c%X9hO*57t&-HI%du}r~U$FDgF6W5gp32nK(foASD z_|n6UuKCN;%cq(z0?4s*huk3K6Yj1LA55Hk0Tkzb|3Fl9u-pOn$Y?YI6qv3x&`5yKyPc)6oz?FT;2x;|7pek7j8C+ z`plLyU!r{n)NF0RzRYHE3c!g};A9v7>4yn74Al@ctuP{-17Cf;3>x3HPsJUfj}^^w zA3?Rg3pnt39b(YKBK2kikdGUV@73cd&D&Vjx=6=sFlkv)7-9FjXId!&NLqduMhjXb zq#xVPz~Ps6Lv#X_@S;=DYpm}O_@Tz_-O!I&NmInEQh^-`tv;6l8k~m!D-9vBTZ6%` zGq^qe$U&TV1@@0sGflrV1w>^Y&~-g55uNDU#*<;Fr7^u-YMIB%hiVHEAOuHs;cu^>`6G@jSWx6{_qNd*F(04MrGaIVo!&NI||Qf;V7 zg^y?zV)8Et%V@d_sWMY@q7^oBdY}mSHyEJL8$IdAOMHNfm}c9Sq}p*U{cy}jerqNi8s?)a8ZbPIwRu!2NHpjI%T_%+xa<+ zp$sJKEN$@$%~vg7-Cj>)l5i|CONTNunVlMi`8C!kK@`k;1EJq$_?))VX1rzKiLuO^ zI@doo*TV?v!=A&ocS3u~pN0ko2rJ}UJIT&7t5+uA-}%&5b6VdID39~N&oL|veM%r7 zew`?fo12{jO-2?K*RJt$J5q&wRspSdvW4IprkF9*^hD~}nMX*w+}+rRv|LeTVpMPG zh+HO7Rs^qi6o2sK`av_b@a5jx!so8jpRQ9>6~U^jny8t!Qs}=83>ABr^zP>&v1xE^ zJiv3QcLN0nu~4CTGxcot+=f@so9U{Ol#Y!5atwm(MuObj>AoJ zRe|Wm+vi>YbeH;DdG((C1t&zaG{tRnr8AfJiA`Jet_KmenEGv!3aRbM57l_D$7kAd zAbNXHE)k{_4T2`)RxaSon&VD!+fc_|pPM}Y@{8q7*^eY2f;-T0aQ{Xahq8&T z6^-c|5pO}hhf0)#D_XNOMvqjEk;2-8&M%)RF(JX&u1){h+?+k#4%IrU9cMCB36QwgF4WV1ah(ZDMx1d{(*ZdI4%$ z31Tci2#@yMN~&Ae1?hx5(!~!u8e80YtOT+6S(MB#FW7m32Jsw^LtVd2!#BIH&e zQ)Qjft~y`GV}HMBmBYtsO- zmZa;X5kwB$U>RUaj8Zn`r}_$&`3InTh`jt9cV1K^#q9rS@7m*;ZrivtHB#*-)s%*9 zVH8GAA)~d1c^n>!TB^xIrP7&@SyQ7KlhTnj>qJk5P6|0bits9)nmmXhI*1V^JlD_r z-}}7(zW=@Z{Py2IyYJt9xbEw^ukZJJbIcv_W*1!zY_S`hQyIAp780Cv?d z`+Uazy3RFTR9{_#$;+5Yw5O>@mZR^ig*~lO#SAz<+`ePFqgY20n!nN_-E4^eLE^@n zd;mXTp=@k!BGP26W4$X~sI&K@qE68_o%Znu#eX=EIb$_{L|EZ%;U(d-QXj1~^hM)SJ}3g<(}o zk9XNj%o7C&fh0gXsi`B?&?}FQ&)$UUu!3M7!+-mFqyn@Yn9!)7zY@*W`A3|xke9?h zm26{YDOJ|AM9gDU9;CwkRVY;Rbz;y}qPd z>4Nr}V32zLe54&zP`?Fe3&MrG3$N+PML=;PRj25A*&O7HlTA2O)qz=#^S~)v?8n## zgh59@#pKl3SGq#}>l)O?b!UERdEhazlyDmUgM!T5uTu6EZUiWUgP@^b14R7htautN ziiJDs3u-*gHkkQ*b&=qzok31c#W+v(G#Zu`Y{}y!tX}dBmZAq()U!C3@LE76Qs6TL zvyw}K!(TFAcC=G?-H+@Kl zW99^9z*L%=Gmjnn5k;LzRdZNWI# zA~1hP;%eW7Y+r#+AJqI^ElN;1TDh}hOuN#>coCURw_ z6qmoUx?T-Aq`^T>U!t^y8yprY!}&xX*uN3r$})4fZg zu~;u1fPUJgEC_8dOX(UZ8eUKu*AI1n`?3IS?paq`8|@#LQa^&r!@G3Y@n=-3@%}`( zqKwI;g{C>r+JWTZo_;&9fN^nE{OrE=#i~sPzGipyRA=NtHFRaB*n1qOgCkuvrBY#r zd81u08~p)`MX~R9L5bfL?8n|H)?j%WaQm*(ks`ydnfxym#n|80Z;Rf(*ojo{6#3}| zYTaA+t*xK7r|vY#qxOL;3s`qQp}_8#$G_$|Z_bD2=sKJvnY>J1Y+#;`@MXRvu0N9@)rf z_N{GkkM_51suH~8`UoG_adZco$u#N#>%M}0=wMqU(SoxY-n0*lIyZsjO7_H$kz663 z6C_?h3Rc?-4gEvynm6WF5R{sj_z;SZY*ESxLM zQVZ3?blGk;yVVMe*3c-tefiq?PTI-Y%AzDgF5e5~z{w$C(rp2qq6&t)m74lBfW=~=)$4}9*WSEnuu?6U z_m^|s^H46a1E`3MWktaN4Nk6sYV8LAT3$%Z_okm)*Cu`H>_Y9lJpmDQcU{{mEeXC6 zo8gHX8}1ei5KqR>q@}e0vaTlxabtRT)b2r5Iv{4%6CILoYF>72dBpH8sBBmUF$}UZ z3ZchJSr;sKr|8-bITMFY2*L|$Tg7!JIb_SA<;9mcG8yK3R4~?Ch!577$4AfFR)*w> zpp^pBsOsvYm=k5k;%O3BP{kC1?2NOSwY7B#Slg2HbnQaWJe5J1p3`$VaU)#I0j7L& zbL+dpgGT)=YJfFPLp4rX;b6>Aj!&Ed^^xQDQA3KUBxX;cYeL*5yNq0G!z3j1#jirU z+DpYdVUneY*(F2tSRO!(jfevKLVx;RIQs4`Sh{qxDGuvb`{G5R8I?Nd2jPv>ON~X52;Bnlyz8JK9p+G z^>Mfz+;u(v+Asfj0}6hMw|T)P*`}5i(#$QuKm7toAmz8u?~dA-nwpw8I5_kG2=op7 zo1Rc4YFmcIJnU5{^y|B95&3?zp01mtEFHO!!XS3}zj_KL*F81M_9S>e)axCz=_NJ? zvll0JC;A4`?<^L7q|02x!^77-Yv0<~t6ZMQl!JI2AHHMTEbvP;X9e@hB+htE#_SU= zdL;lUFJK*Z}`;5}m#O>A-%734I#8k1V;O zGK%{wkk3FZ8a!`3xaL~lTFzOKroCpc!_!Z;`cG#oW_#0q(#19v__(e6g?`6%8}Dx! C7Pt`r literal 0 HcmV?d00001 diff --git a/architecture.xml b/architecture.xml new file mode 100644 index 000000000..ce5d82675 --- /dev/null +++ b/architecture.xml @@ -0,0 +1 @@ +7Vpbc6M2FP41nrYPmwFksP1oO0k7nU0nbTpt91EGBWsjEBUicfrrVwIJkCVnnQbbWbd5cODogvR956YDI7DMNj8yWKxvaILIKPCSzQhcjoJgMvPErxQ8N4IwmDSClOGkEfmd4A7/g5RQjUsrnKDS6MgpJRwXpjCmeY5ibsggY/TJ7HZPifnUAqbIEtzFkNjSP3HC1410Gnqd/CeE07V+su+plhWMH1JGq1w9bxSA+/qvac6gnkv1L9cwoU89EbgagSWjlDdX2WaJiIRWw9aMu97R2q6boZzvM2AcNSMeIamQXnK9MP6swRAjBO7iZiFWW0hhTGglJlg8rTFHdwWMpfBJaIKQrXlGxJ0vLtXUiHG02bk+v921UCZEM8TZs+iiBkRaI7QiNXdPHSmBhn7dJ2SmOkKlCGk7cweGuFB4uLEBDigSoSbqljK+pinNIbnqpIuaeyRn8Eww0Abzv6T4IlR3n3RLLhbWa5K3n9QEJYeMz6VG17DDssSxFl9j0k6eJ3YnIex1+Yw4f1aWBitOhajbwUdKC9Wv5Iw+oCUllNV7Bl7917Zoe+gIttgUkNGKxQozhaJYcYpUr0BpnYTzRS1giECOH02rfAun4ewQnPo9Rr2LSWiQalB6Ab4RUreo+Tcsh96RSI38w5MamKRe+JHJa9i23yKGxRYQ+4ZMeAC2m0hyBLYDK2LNi0II7hAToWYURESsaJHgR3GZysvvfxHpyShYyn1uCobK8uJz+YPuKB7X62sp0l4hzoGWFfV2hjgwMUOcr7OMXpDzx64gN/Xejub46/H/8BC06ZOCALggmLogGA8AQTQ+vPt4KSTUruUcvYQ/doT+I3kJ36XYlmuYJxnORzJHvha/8hjDxDJovss52BNI1/NOHImvs2PtSIKpbUWBw4qiIYwoPK0RTc7WiMITGlFoGdGSMii7XGUrlJTvQMvB1BErDqXl/sxOPirx1MC7ZfQRC/dxCkginQBrw/ftY7LvARsSMAAk7XI7SG4gJiohw2LXpwAkMACZOFTEVTYYAo8QnNYRnqsf1DWDk5wwA7tM9jNdCVv3fq2QkDgOHQ8Vel/HjOlWjr2v35wMYBTAEUgIFpv5TmJ4J/ZuISK2xU0QTB3LaS4t517o6pYIEpzmUqfF/PIUvpAg4RiSuWrIcJLUZudCfQeuryla7nOcmTiQDoZAOjqE+7GLlufhYt7gOtTQW4rFjC31wdZhHmxz2rg0Naqj9fUTNX7QmqjWj3Y/+5UADlI+M1WmDVKOOvf5qJMZscDMjljNsfQIEWts13H+f3lxKFLDI5EK7OPHNaO127jKE3kKIVWK89KVjzAEY9HxmqGk2uyblugXfk0sTmi1qlt8Q1f8HZF02DeB2+e+YHzEc994YgGvikVC0VT5KIKZ3HW+Kgt3rWhJs6zKMX/e1fer1aYbmMO0q3HvSV6VkXksF9plQh/hCpFbWuK62AUuV5RzmokORDYs2pfYPUtSr7HtbIrT4ckGZvDzgc01OBTVUztV7RFnUYKyVX3q/49RAhxn6kNRotPnHiW/oQTbpRYNbwI5LAW8u3L8gb9R2Ko2gMiGZjazofH9AbDRzxo4zlvlhnOM5aH+tOgEpdXQUTajeUovdzuNY2u1afCBs6joeic3iFoHFjyG8+3hE/1dya+lFrJg8EGd/eeiB0H3vGvdjqr3TdpkTyMbPpS1espZ/HGxsWfRVfDfIXkQ/+YslnzEvGrElximDGa9oNA8zlzCC+H+xNv7Q7Rc+JJCUqzhK7dxTnUcMwux3Xore2MVWebl7Td/zRG++64SXH0B \ No newline at end of file From 74bef9dc945ee32dd791c28001eb85a42e28f443 Mon Sep 17 00:00:00 2001 From: gaba Date: Thu, 8 Dec 2016 09:13:34 -1000 Subject: [PATCH 04/18] Clean the text. --- architecture.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/architecture.xml b/architecture.xml index ce5d82675..ce2b09a19 100644 --- a/architecture.xml +++ b/architecture.xml @@ -1 +1 @@ -7Vpbc6M2FP41nrYPmwFksP1oO0k7nU0nbTpt91EGBWsjEBUicfrrVwIJkCVnnQbbWbd5cODogvR956YDI7DMNj8yWKxvaILIKPCSzQhcjoJgMvPErxQ8N4IwmDSClOGkEfmd4A7/g5RQjUsrnKDS6MgpJRwXpjCmeY5ibsggY/TJ7HZPifnUAqbIEtzFkNjSP3HC1410Gnqd/CeE07V+su+plhWMH1JGq1w9bxSA+/qvac6gnkv1L9cwoU89EbgagSWjlDdX2WaJiIRWw9aMu97R2q6boZzvM2AcNSMeIamQXnK9MP6swRAjBO7iZiFWW0hhTGglJlg8rTFHdwWMpfBJaIKQrXlGxJ0vLtXUiHG02bk+v921UCZEM8TZs+iiBkRaI7QiNXdPHSmBhn7dJ2SmOkKlCGk7cweGuFB4uLEBDigSoSbqljK+pinNIbnqpIuaeyRn8Eww0Abzv6T4IlR3n3RLLhbWa5K3n9QEJYeMz6VG17DDssSxFl9j0k6eJ3YnIex1+Yw4f1aWBitOhajbwUdKC9Wv5Iw+oCUllNV7Bl7917Zoe+gIttgUkNGKxQozhaJYcYpUr0BpnYTzRS1giECOH02rfAun4ewQnPo9Rr2LSWiQalB6Ab4RUreo+Tcsh96RSI38w5MamKRe+JHJa9i23yKGxRYQ+4ZMeAC2m0hyBLYDK2LNi0II7hAToWYURESsaJHgR3GZysvvfxHpyShYyn1uCobK8uJz+YPuKB7X62sp0l4hzoGWFfV2hjgwMUOcr7OMXpDzx64gN/Xejub46/H/8BC06ZOCALggmLogGA8AQTQ+vPt4KSTUruUcvYQ/doT+I3kJ36XYlmuYJxnORzJHvha/8hjDxDJovss52BNI1/NOHImvs2PtSIKpbUWBw4qiIYwoPK0RTc7WiMITGlFoGdGSMii7XGUrlJTvQMvB1BErDqXl/sxOPirx1MC7ZfQRC/dxCkginQBrw/ftY7LvARsSMAAk7XI7SG4gJiohw2LXpwAkMACZOFTEVTYYAo8QnNYRnqsf1DWDk5wwA7tM9jNdCVv3fq2QkDgOHQ8Vel/HjOlWjr2v35wMYBTAEUgIFpv5TmJ4J/ZuISK2xU0QTB3LaS4t517o6pYIEpzmUqfF/PIUvpAg4RiSuWrIcJLUZudCfQeuryla7nOcmTiQDoZAOjqE+7GLlufhYt7gOtTQW4rFjC31wdZhHmxz2rg0Naqj9fUTNX7QmqjWj3Y/+5UADlI+M1WmDVKOOvf5qJMZscDMjljNsfQIEWts13H+f3lxKFLDI5EK7OPHNaO127jKE3kKIVWK89KVjzAEY9HxmqGk2uyblugXfk0sTmi1qlt8Q1f8HZF02DeB2+e+YHzEc994YgGvikVC0VT5KIKZ3HW+Kgt3rWhJs6zKMX/e1fer1aYbmMO0q3HvSV6VkXksF9plQh/hCpFbWuK62AUuV5RzmokORDYs2pfYPUtSr7HtbIrT4ckGZvDzgc01OBTVUztV7RFnUYKyVX3q/49RAhxn6kNRotPnHiW/oQTbpRYNbwI5LAW8u3L8gb9R2Ko2gMiGZjazofH9AbDRzxo4zlvlhnOM5aH+tOgEpdXQUTajeUovdzuNY2u1afCBs6joeic3iFoHFjyG8+3hE/1dya+lFrJg8EGd/eeiB0H3vGvdjqr3TdpkTyMbPpS1espZ/HGxsWfRVfDfIXkQ/+YslnzEvGrElximDGa9oNA8zlzCC+H+xNv7Q7Rc+JJCUqzhK7dxTnUcMwux3Xore2MVWebl7Td/zRG++64SXH0B \ No newline at end of file +7Vpbc6M2FP41nrYP6wFksP1oO0k7nd1O2nTa7qMMCtZGICpE4vTXVwIJkCVnnQbbWU/z4MDRBen7zk0HRmCVbX9ksNh8ogkio8BLtiNwNQqC6dwTv1Lw3AjCYNoIUoaTRuR3gjv8D1JCNS6tcIJKoyOnlHBcmMKY5jmKuSGDjNEns9s9JeZTC5giS3AXQ2JL/8QJ3zTSWeh18p8QTjf6yb6nWtYwfkgZrXL1vFEA7uu/pjmDei7Vv9zAhD71ROB6BFaMUt5cZdsVIhJaDVsz7mZPa7tuhnJ+yIBJ1Ix4hKRCesn1wvizBkOMELiLm6VYbSGFMaGVmGD5tMEc3RUwlsInoQlCtuEZEXe+uFRTI8bRdu/6/HbXQpkQzRBnz6KLGhBpjdCK1Nw9daQEGvpNn5C56giVIqTtzB0Y4kLh4cYGOKBIhJqoW8r4hqY0h+S6ky5r7pGcwTPBQFvM/5LicajuPuuWXCys1yRvP6sJSg4ZX0iNrmGHZYljLb7BpJ08T+xOQtjr8gVx/qwsDVacClG3g4+UFqpfyRl9QCtKKKv3DLz6r23R9tARbLEpIKMVixVmCkWx4hSpXoHSOgnni1rAEIEcP5pW+RZOw/kxOPV7jHrjaWiQalA6Bt8IqTvU/BeWQ+9EpEb+8UkNTFLHfmTyGrbtt4hhsQXEviETHoDtJpKcgO3AiliLohCCO8REqBkFERErWib4UVym8vL7X0R6MgpWcp/bgqGyHH8pf9AdxeN6fS1FOijEOdCyot7eEAemZojzdZbRC3L+xBXkZt7b0Zx8Pf4fH4I2fVIQABcEMxcEkwEgiCbHdx8vhYTatVyil/AnjtB/Ii/huxTbcg2LJMP5SObIN+JXHmOYWAbN9zkHewLpet6JI/F1dqwdSTCzrShwWFE0hBGF5zWi6cUaUXhGIwotI1pRBmWX62yNkvIdaDmYOWLFsbTcn9vJRyWeGni3jD5i4T7OAUmkE2Bt+L59TPY9YEMCBoCkXW4HySeIiUrIsNj1OQAJDECmDhVxlQ2GwCME53WEl+oHdc3gLCfMwC6T/UzXwta9XyskJI5Dx0OF3tcxY7aTYx/qN6cDGAVwBBKCxWa+kxjeib1biIhtcRMEU8dymkvLuRe6uiOCBKe51GkxvzyFLyVIOIZkoRoynCS12blQ34Pra4qWhxxnpg6kgyGQjo7hfuyi5WW4mDe4DjX0lmIxY0t9sHOYB7ucNi5Njepoff1EjR+0Jqr1o93PYSWAo5TPTJVpg5Sjzn056mRGLDC3I1ZzLD1BxJrYdZz/X14ci9TwRKQC+/hxw2jtNq7zRJ5CSJXivHTlIwzBWHS8YSiptoemJfqFXxOLE1qt6xbf0BV/TyQd9k3g7rkvmJzw3DeZWsCrYpFQNFU+imAmd52vy8JdK1rRLKtyzJ/39f1qtekTzGHa1bgPJK/KyCKWC+0yoY9wjcgtLXFd7AJXa8o5zUQHIhuW7UvsniWp19h2NsXpDtntO21pd4V+GyKVA+epEg+gEMAMkD6w9QEcSx1mdjrbI9eiDWXrujLwrmkbnhLgOHcfixKdYvco+Q0l2C7HaHgTyGEp4N13Dhj4O4adigSIbGjmcxsa3x8AG/2sgXMBqyRxifE+1J8fnaH8GjpKazRP6dV+p3FqrTYNPnAWHl3v7QZR68CCx3C+PXyivyv5RdVSFhU+qPrAQvQg6J53rbuR975JrexpZMOHslZPOYs/Kbb2LLpS/jskD+LfgsWSj5hXjfgKw5TBrBcUmseZS3ghJTjz9v4QLWNfUkiKDXzlNi6p1mNmIbZbb2VvrDTL3L39LrA55nffXoLrfwE= \ No newline at end of file From 7c4208fa163c17086ddcff0dc411109f38f0ac7a Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Thu, 8 Dec 2016 10:04:10 -1000 Subject: [PATCH 05/18] Update config notes --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0f217d89c..848dd989a 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,19 @@ Runs Talk. The Talk application requires specific configuration options to be available inside the environment in order to run, those variables are listed here: -- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to - secure cookies -- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook - Login enabled app. -- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your - Facebook Login enabled app. -- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to +secure cookies. +`TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook +Login enabled app. +`TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your +Facebook Login enabled app. +`TALK_ROOT_URL` (*required*) - root url of the installed application externally +available in the format: `://` without the path. +`TALK_SMTP_PROVIDER` (*required*) - SMTP provider name. +`TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. +`TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. +`TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. +`TALK_SMTP_PORT` (*required*) - SMTP port. ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` From 1c5dffc94cd975d4b082db9b8ac71c44b0cf22ae Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Thu, 8 Dec 2016 10:05:38 -1000 Subject: [PATCH 06/18] Add product links --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 848dd989a..6589cddb9 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ A commenting platform from The Coral Project. [https://coralproject.net](https:/ ## Contributing to Talk +### Product Roadmap +You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625 + +You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk + ### Local Dependencies Node Mongo From 3d35183e6781b15cc081b5dcc1b13258e43f087f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 8 Dec 2016 15:56:52 -0500 Subject: [PATCH 07/18] Add payload filtering and status_history updates --- app.js | 4 +- middleware/payload-filter.js | 73 ++++++++++++++++++++++++++++++ models/asset.js | 8 +--- models/comment.js | 61 +++++++++++++------------ models/setting.js | 48 ++++++++++++++++---- models/user.js | 22 ++++----- routes/api/comments/index.js | 2 +- routes/api/index.js | 4 ++ routes/api/stream/index.js | 17 +++++-- routes/api/user/index.js | 47 ++++++++----------- services/scraper.js | 4 +- tests/models/comment.js | 44 ++++++++---------- tests/models/setting.js | 14 ++++++ tests/routes/api/comments/index.js | 27 ++++++----- tests/routes/api/queue/index.js | 6 +-- tests/routes/api/stream/index.js | 19 ++++++-- 16 files changed, 265 insertions(+), 135 deletions(-) create mode 100644 middleware/payload-filter.js diff --git a/app.js b/app.js index dd58e9883..fe392932b 100644 --- a/app.js +++ b/app.js @@ -94,7 +94,9 @@ app.use((req, res, next) => { // returning a status code that makes sense. app.use('/api', (err, req, res, next) => { if (err !== ErrNotFound) { - console.error(err); + if (app.get('env') !== 'test') { + console.error(err); + } } res.status(err.status || 500); diff --git a/middleware/payload-filter.js b/middleware/payload-filter.js new file mode 100644 index 000000000..8ce3d39d5 --- /dev/null +++ b/middleware/payload-filter.js @@ -0,0 +1,73 @@ +// The maximum depth to recurse into nested objects checking for mongoose +// objects. +const maxRecursion = 3; + +/** + * Middleware to wrap the `res.json` function to ensure that we can filter the + * payload response first based on user and role. + */ +module.exports = (req, res, next) => { + /** + * Updates the original document based on filtering out for roles. + * @param {Mixed} o original object to be modified + * @param {Integer} l current level of depth in the first object + * @return {Mixed} (possibly) modified object + */ + const wrap = (o, d = 0) => { + if (d > maxRecursion) { + return o; + } + + // If this is an array, we need to walk over all the object's elements. + if (Array.isArray(o)) { + + // Map each of the array elements. + return o.map((ob) => wrap(ob, d + 1)); + + } else if (o && o.constructor && o.constructor.name === 'model') { + + // The object here is definitly a mongoose model. + + // Check to see if it has a `filterForUser` method. + if (typeof o.filterForUser === 'function') { + + // The object here actually has the `filterForUser` function, so filter + // the object! + o = o.filterForUser(req.user); + } + + } else if (typeof o === 'object') { + + // Itterate over the props, find only properties owned by the object. + for (let prop in o) { + + // If and only if the object owns the property do we actually pull the + // property out. + if (typeof o.hasOwnProperty === 'function' && o.hasOwnProperty(prop)) { + + // Wrap the property with one more layer down. + o[prop] = wrap(o[prop], d + 1); + } + } + + } + + return o; + }; + + // Save a reference to the original json function. + const json = res.json; + + // Override the original json function. + res.json = (payload) => { + + // Restore the old pointer. + res.json = json; + + // Send it down the pipe after we've filtered it. + res.json(wrap(payload)); + }; + + // Now that we've overridden the `res.json`, let's hand it down. + next(); +}; diff --git a/models/asset.js b/models/asset.js index a0619576f..0cdc77d7b 100644 --- a/models/asset.js +++ b/models/asset.js @@ -36,11 +36,7 @@ const AssetSchema = new Schema({ subsection: String, author: String, publication_date: Date, - modified_date: Date, - status: { - type: String, - default: 'open' - } + modified_date: Date }, { versionKey: false, timestamps: { @@ -85,7 +81,7 @@ AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ // If the asset exists and has settings then return the merged object. if (asset && asset.settings) { - return Object.assign({}, settings, asset.settings); + settings.merge(asset.settings); } return settings; diff --git a/models/comment.js b/models/comment.js index af9aa4cb1..53e8bbfb0 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,5 +1,6 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const _ = require('lodash'); const uuid = require('uuid'); const Action = require('./action'); @@ -45,7 +46,7 @@ const CommentSchema = new Schema({ }, asset_id: String, author_id: String, - status: [StatusSchema], + status_history: [StatusSchema], parent_id: String }, { timestamps: { @@ -59,22 +60,7 @@ const CommentSchema = new Schema({ * output. */ CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.hide = '_id status'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * toJSON overrides to remove fields from the json - * output. - */ -CommentSchema.options.toJSON = {}; +CommentSchema.options.toJSON.virtuals = true; CommentSchema.options.toJSON.hide = '_id'; CommentSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { @@ -86,14 +72,31 @@ CommentSchema.options.toJSON.transform = (doc, ret, options) => { return ret; }; +/** + * Filters the object for the given user only allowing those with the allowed + * roles/permissions to access particular parameters. + */ +CommentSchema.method('filterForUser', function(user = false) { + if (!user || !user.roles.includes('admin')) { + return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']); + } + + return this.toJSON(); +}); + /** * Sets up a virtual getter function on a comment such that when you try and * access the `comment.last_status` it returns the last status in the array - * of status's on the comment, or `null` if there was no status. + * of status's on the comment, or `null` if there was no status_history. */ -CommentSchema.virtual('last_status').get(function() { - if (this.status && this.status.length > 0) { - return this.status[this.status.length - 1].type; +CommentSchema.virtual('status').get(function() { + + // Here we are taking advantage of the fact that when documents are inserted + // for the new status on a comment that they are always appended to the end + // of the list in the order that they are inserted, hence, the last status + // is always the most recent. + if (this.status_history && this.status_history.length > 0) { + return this.status_history[this.status_history.length - 1].type; } return null; @@ -123,7 +126,7 @@ CommentSchema.statics.publicCreate = (comment) => { body, asset_id, parent_id, - status: status ? [{ + status_history: status ? [{ type: status, created_at: new Date() }] : [], @@ -157,7 +160,7 @@ CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ */ CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ asset_id, - 'status.type': 'accepted' + 'status_history.type': 'accepted' }); /** @@ -169,10 +172,10 @@ CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ asset_id, $or: [ { - 'status.type': 'accepted' + 'status_history.type': 'accepted' }, { - status: { + status_history: { $size: 0 } } @@ -202,7 +205,7 @@ CommentSchema.statics.findIdsByActionType = (action_type) => Action .then((actions) => actions.map(a => a.item_id)); /** - * Find comments by their status. + * Find comments by their status_history. * @param {String} status the status of the comment to search for * @return {Promise} */ @@ -210,9 +213,9 @@ CommentSchema.statics.findByStatus = (status = false) => { let q = {}; if (status) { - q['status.type'] = status; + q['status_history.type'] = status; } else { - q.status = {$size: 0}; + q.status_history = {$size: 0}; } return Comment.find(q); @@ -269,7 +272,7 @@ CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { */ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { $push: { - status: { + status_history: { type: status, created_at: new Date(), assigned_by diff --git a/models/setting.js b/models/setting.js index 22b2b103f..86dc761f3 100644 --- a/models/setting.js +++ b/models/setting.js @@ -36,6 +36,42 @@ const SettingSchema = new Schema({ } }); +/** + * toJSON provides settings overrides to this object's serialization methods. + */ +SettingSchema.options.toJSON = {}; +SettingSchema.options.toJSON.virtuals = true; + +/** + * Merges two settings objects. + */ +SettingSchema.method('merge', function(src) { + SettingSchema.eachPath((path) => { + + // Exclude internal fields... + if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) { + return; + } + + // If the source object contains the path, shallow copy it. + if (path in src) { + this[path] = src[path]; + } + }); +}); + +/** + * Filters the object for the given user only allowing those with the allowed + * roles/permissions to access particular parameters. + */ +SettingSchema.method('filterForUser', function(user = false) { + if (!user || !user.roles.includes('admin')) { + return _.pick(this.toJSON(), ['moderation', 'infoBoxEnable', 'infoBoxContent']); + } + + return this.toJSON(); +}); + /** * The Mongo Mongoose object. */ @@ -62,7 +98,9 @@ const EXPIRY_TIME = 60 * 2; * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector)); +SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => { + return Setting.findOne(selector); +}).then((setting) => new Setting(setting)); /** * This will update the settings object with whatever you pass in @@ -83,14 +121,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { .then(() => settings); }); -/** - * Filters the document to ensure that the resulting document is indeed ready - * for non authenticated users. - * @param {Object} settings the source settings object - * @return {Object} the filtered settings object - */ -SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); - /** * This is run once when the app starts to ensure settings are populated. * @return {Promise} null initialize the global settings object diff --git a/models/user.js b/models/user.js index 336486a54..10a3a5e05 100644 --- a/models/user.js +++ b/models/user.js @@ -1,5 +1,6 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); +const _ = require('lodash'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); @@ -105,7 +106,8 @@ UserSchema.index({ * output. */ UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = '_id password profiles roles disabled'; +UserSchema.options.toJSON.hide = '_id password'; +UserSchema.options.toJSON.virtuals = true; UserSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { options.hide.split(' ').forEach((prop) => { @@ -117,20 +119,16 @@ UserSchema.options.toJSON.transform = (doc, ret, options) => { }; /** - * toObject overrides to remove the password field from the toObject - * output. + * Filters the object for the given user only allowing those with the allowed + * roles/permissions to access particular parameters. */ -UserSchema.options.toObject = {}; -UserSchema.options.toObject.hide = 'password'; -UserSchema.options.toObject.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); +UserSchema.method('filterForUser', function(user = false) { + if (!user || !user.roles.includes('admin')) { + return _.pick(this.toJSON(), ['id', 'displayName', 'settings', 'created_at', 'updated_at']); } - return ret; -}; + return this.toJSON(); +}); // Create the User model. const UserModel = mongoose.model('User', UserSchema); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index b82c1faf3..00908e8e8 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -97,7 +97,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { .then((comment) => { // The comment was created! Send back the created comment. - res.status(201).send(comment); + res.status(201).json(comment); }) .catch((err) => { next(err); diff --git a/routes/api/index.js b/routes/api/index.js index 9b4f0432b..2c36e86bb 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,8 +1,12 @@ const express = require('express'); const authorization = require('../../middleware/authorization'); +const payloadFilter = require('../../middleware/payload-filter'); const router = express.Router(); +// Filter all content going down the pipe based on user roles. +router.use(payloadFilter); + router.use('/asset', authorization.needed('admin'), require('./asset')); router.use('/settings', authorization.needed('admin'), require('./settings')); router.use('/queue', authorization.needed('admin'), require('./queue')); diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 26947be09..8d70adbb4 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -1,21 +1,32 @@ const express = require('express'); const _ = require('lodash'); const scraper = require('../../../services/scraper'); +const url = require('url'); const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const Asset = require('../../../models/asset'); const Setting = require('../../../models/setting'); +const ErrInvalidAssetURL = new Error('asset_url is invalid'); +ErrInvalidAssetURL.status = 400; const router = express.Router(); router.get('/', (req, res, next) => { + let asset_url = decodeURIComponent(req.query.asset_url); + + // Verify that the asset_url is parsable. + let parsed_asset_url = url.parse(asset_url); + if (!parsed_asset_url.protocol) { + return next(ErrInvalidAssetURL); + } + // Get the asset_id for this url (or create it if it doesn't exist) Promise.all([ // Find or create the asset by url. - Asset.findOrCreateByUrl(decodeURIComponent(req.query.asset_url)) + Asset.findOrCreateByUrl(asset_url) // Add the found asset to the scraper if it's not already scraped. .then((asset) => { @@ -34,7 +45,7 @@ router.get('/', (req, res, next) => { // Merge the asset specific settings with the returned settings object in // the event that the asset that was returned also had settings. if (asset && asset.settings) { - settings = Object.assign({}, settings, asset.settings); + settings.merge(asset.settings); } // Fetch the appropriate comments stream. @@ -98,7 +109,7 @@ router.get('/', (req, res, next) => { comments, users, actions, - settings: Setting.public(settings) + settings }); }) .catch(error => { diff --git a/routes/api/user/index.js b/routes/api/user/index.js index 1765809ad..bd83563dc 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -26,26 +26,15 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { .limit(limit), User.count() ]) - .then(([data, count]) => { - const users = data.map((user) => { - const {id, displayName, created_at} = user; - return { - id, - displayName, - created_at, - profiles: user.toObject().profiles, - roles: user.toObject().roles - }; - }); + .then(([result, count]) => { res.json({ - result: users, + result, limit: Number(limit), count, page: Number(page), - totalPages: Math.ceil(count / limit) + totalPages: Math.ceil(count / (limit === 0 ? 1 : limit)) }); - }) .catch(next); }); @@ -53,8 +42,8 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { User .addRoleToUser(req.params.user_id, req.body.role) - .then(role => { - res.send(role); + .then(() => { + res.status(204).end(); }) .catch(next); }); @@ -65,13 +54,17 @@ router.post('/', (req, res, next) => { User .createLocalUser(email, password, displayName) .then(user => { - res.status(201).send(user); + + res.status(201).json(user); }) .catch(err => { next(err); }); }); +const ErrPasswordTooShort = new Error('password must be at least 8 characters'); +ErrPasswordTooShort.status = 400; + /** * expects 2 fields in the body of the request * 1) the token that was in the url of the email link {String} @@ -81,7 +74,7 @@ router.post('/update-password', (req, res, next) => { const {token, password} = req.body; if (!password || password.length < 8) { - return res.status(400).send('Password must be at least 8 characters'); + return next(ErrPasswordTooShort); } User.verifyPasswordResetToken(token) @@ -93,7 +86,8 @@ router.post('/update-password', (req, res, next) => { }) .catch(error => { console.error(error); - res.status(401).send('Not Authorized'); + + next(authorization.ErrNotAuthorized); }); }); @@ -133,10 +127,8 @@ router.post('/request-password-reset', (req, res, next) => { // if we fail on missing emails, it would reveal if people are registered or not. res.status(204).end(); }) - .catch(error => { - const errorMsg = typeof error === 'string' ? error : error.message; - - res.status(500).json({error: errorMsg}); + .catch((err) => { + next(err); }); }); @@ -150,10 +142,11 @@ router.put('/:user_id/bio', (req, res, next) => { User .addBio(user_id, bio) - .then(user => res.status(200).send(user)) - .catch(error => { - const errorMsg = typeof error === 'string' ? error : error.message; - res.status(500).json({error: errorMsg}); + .then(user => { + res.json(user); + }) + .catch((err) => { + next(err); }); }); diff --git a/services/scraper.js b/services/scraper.js index 922ef77bc..10e0e4aa8 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -23,7 +23,7 @@ const scraper = { title: `Scrape for asset ${asset.id}`, asset_id: asset.id }) - .attempts(10) + .attempts(3) .delay(1000) .backoff({type: 'exponential'}) .save((err) => { @@ -106,7 +106,7 @@ const scraper = { // Handle errors that occur. .catch((err) => { - console.error(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err); + debug(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err); done(err); }); diff --git a/tests/models/comment.js b/tests/models/comment.js index a9c2f4787..3db7d67a2 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -11,14 +11,14 @@ describe('models.Comment', () => { const comments = [{ body: 'comment 10', asset_id: '123', - status: [], + status_history: [], parent_id: '', author_id: '123', id: '1' }, { body: 'comment 20', asset_id: '123', - status: [{ + status_history: [{ type: 'accepted' }], parent_id: '', @@ -27,14 +27,14 @@ describe('models.Comment', () => { }, { body: 'comment 30', asset_id: '456', - status: [], + status_history: [], parent_id: '', author_id: '456', id: '3' }, { body: 'comment 40', asset_id: '123', - status: [{ + status_history: [{ type: 'rejected' }], parent_id: '', @@ -43,7 +43,7 @@ describe('models.Comment', () => { }, { body: 'comment 50', asset_id: '1234', - status: [{ + status_history: [{ type: 'premod' }], parent_id: '', @@ -52,7 +52,7 @@ describe('models.Comment', () => { }, { body: 'comment 60', asset_id: '1234', - status: [{ + status_history: [{ type: 'premod' }], parent_id: '', @@ -99,8 +99,7 @@ describe('models.Comment', () => { expect(c).to.not.be.null; expect(c.id).to.not.be.null; expect(c.id).to.be.uuid; - expect(c.status).to.have.length(1); - expect(c.status[0]).to.have.property('type', 'accepted'); + expect(c.status).to.be.equal('accepted'); }); }); @@ -116,17 +115,15 @@ describe('models.Comment', () => { }]).then(([c1, c2, c3]) => { expect(c1).to.not.be.null; expect(c1.id).to.be.uuid; - expect(c1.status).to.have.length(1); - expect(c1.status[0]).to.have.property('type', 'accepted'); + expect(c1.status).to.be.equal('accepted'); expect(c2).to.not.be.null; expect(c2.id).to.be.uuid; - expect(c2.status).to.have.length(0); + expect(c2.status).to.be.null; expect(c3).to.not.be.null; expect(c3.id).to.be.uuid; - expect(c3.status).to.have.length(1); - expect(c3.status[0]).to.have.property('type', 'rejected'); + expect(c3.status).to.be.equal('rejected'); }); }); @@ -220,17 +217,16 @@ describe('models.Comment', () => { return Comment.findById(comment_id) .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(0); + expect(c.status).to.be.null; return Comment.pushStatus(comment_id, 'rejected', '123'); }) .then(() => Comment.findById(comment_id)) .then((c) => { expect(c).to.have.property('status'); - expect(c.status).to.have.length(1); - expect(c.status[0]).to.have.property('type', 'rejected'); - expect(c.status[0]).to.have.property('assigned_by', '123'); + expect(c.status_history).to.have.length(1); + expect(c.status_history[0]).to.have.property('type', 'rejected'); + expect(c.status_history[0]).to.have.property('assigned_by', '123'); }); }); @@ -238,13 +234,13 @@ describe('models.Comment', () => { return Comment.pushStatus(comments[1].id, 'rejected', '123') .then(() => Comment.findById(comments[1].id)) .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(2); - expect(c.status[0]).to.have.property('type', 'accepted'); - expect(c.status[0]).to.have.property('assigned_by', null); + expect(c).to.have.property('status_history'); + expect(c.status_history).to.have.length(2); + expect(c.status_history[0]).to.have.property('type', 'accepted'); + expect(c.status_history[0]).to.have.property('assigned_by', null); - expect(c.status[1]).to.have.property('type', 'rejected'); - expect(c.status[1]).to.have.property('assigned_by', '123'); + expect(c.status_history[1]).to.have.property('type', 'rejected'); + expect(c.status_history[1]).to.have.property('assigned_by', '123'); }); }); diff --git a/tests/models/setting.js b/tests/models/setting.js index 3e77297fc..fbd5bf77f 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -39,4 +39,18 @@ describe('models.Setting', () => { }); }); }); + + describe('#merge', () => { + it('should merge a settings object and it\'s overrides', () => { + return Setting + .retrieve() + .then((settings) => { + let ovrSett = {moderation: 'post'}; + + settings.merge(ovrSett); + + expect(settings).to.have.property('moderation', 'post'); + }); + }); + }); }); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bf7a06ff6..988320e34 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -19,6 +19,11 @@ const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { + beforeEach(() => Promise.all([ + wordlist.insert(['bad words']), + Setting.init(settings) + ])); + describe('#get', () => { const comments = [{ body: 'comment 10', @@ -32,13 +37,13 @@ describe('/api/v1/comments', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }, { body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; @@ -75,11 +80,7 @@ describe('/api/v1/comments', () => { return Action.create(actions); }), - User.createLocalUsers(users), - wordlist.insert([ - 'bad words' - ]), - Setting.init(settings) + User.createLocalUsers(users) ]); }); @@ -161,8 +162,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'rejected'); + expect(res.body).to.have.property('status', 'rejected'); }); }); @@ -184,8 +184,7 @@ describe('/api/v1/comments', () => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'premod'); + expect(res.body).to.have.property('status', 'premod'); }); }); }); @@ -301,20 +300,20 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: [] + status_history: [] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index d64423013..ce55836ab 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -21,7 +21,7 @@ describe('/api/v1/queue', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: [{ + status_history: [{ type: 'rejected' }] }, { @@ -29,14 +29,14 @@ describe('/api/v1/queue', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ + status_history: [{ type: 'premod' }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: [{ + status_history: [{ type: 'accepted' }] }]; diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index a128938ba..67088e37e 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -25,7 +25,7 @@ describe('/api/v1/stream', () => { body: 'comment 10', author_id: '', parent_id: '', - status: [{ + status_history: [{ type: 'accepted' }] }, { @@ -33,21 +33,21 @@ describe('/api/v1/stream', () => { body: 'comment 20', author_id: '', parent_id: '', - status: [] + status_history: [] }, { id: 'uio', body: 'comment 30', asset_id: 'asset', author_id: '456', parent_id: '', - status: [{ + status_history: [{ type: 'accepted' }] }, { id: 'hij', body: 'comment 40', asset_id: '456', - status: [{ + status_history: [{ type: 'rejected' }] }]; @@ -116,6 +116,16 @@ describe('/api/v1/stream', () => { }); }); + it('should reject requests without a scheme in the asset_url', () => { + return chai.request(app) + .get('/api/v1/stream') + .query({asset_url: 'test.com'}) + .catch((err) => { + expect(err).to.have.status(400); + expect(err.response.body.message).to.contain('asset_url is invalid'); + }); + }); + it('should merge the settings when the asset contains settings to override it with', () => { return chai.request(app) .get('/api/v1/stream') @@ -126,6 +136,7 @@ describe('/api/v1/stream', () => { expect(res.body.comments.length).to.equal(1); expect(res.body.users.length).to.equal(1); expect(res.body.settings).to.have.property('moderation', 'pre'); + expect(res.body.settings).to.not.have.property('wordlist'); }); }); }); From 9ea6c667443559c437dd007d5a3ad0c810ac2445 Mon Sep 17 00:00:00 2001 From: David Erwin Date: Thu, 8 Dec 2016 10:59:56 -1000 Subject: [PATCH 08/18] Revert "Status history" --- bin/cli-serve | 4 - cache.js | 15 -- models/action.js | 29 --- models/asset.js | 26 +-- models/comment.js | 296 +++++++++-------------------- models/setting.js | 117 ++++-------- models/user.js | 3 +- package.json | 1 - routes/api/comments/index.js | 87 +++------ routes/api/queue/index.js | 68 ++----- routes/api/settings/index.js | 22 +-- routes/api/stream/index.js | 20 +- services/wordlist.js | 2 +- tests/.eslintrc.json | 6 +- tests/models/action.js | 47 ++--- tests/models/asset.js | 2 +- tests/models/comment.js | 152 +++------------ tests/models/setting.js | 26 ++- tests/models/user.js | 2 +- tests/routes/api/comments/index.js | 166 +++++++--------- tests/routes/api/queue/index.js | 124 ++++++------ tests/routes/api/settings/index.js | 4 +- tests/routes/api/stream/index.js | 62 +++--- 23 files changed, 435 insertions(+), 846 deletions(-) diff --git a/bin/cli-serve b/bin/cli-serve index dd682f5ad..a6790c6a0 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -96,10 +96,6 @@ function startApp() { server.listen(port); server.on('error', onError); server.on('listening', onListening); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); }); } diff --git a/cache.js b/cache.js index 1d090e46d..9345f8cde 100644 --- a/cache.js +++ b/cache.js @@ -76,21 +76,6 @@ cache.get = (key) => new Promise((resolve, reject) => { }); }); -/** - * This invalidates a cached entry in the cache. - * @param {Mixed} key Either an array of items composing a key or a string - * @return {Promise} - */ -cache.invalidate = (key) => new Promise((resolve, reject) => { - cache.client.del(keyfunc(key), (err) => { - if (err) { - return reject(err); - } - - resolve(); - }); -}); - /** * This sets a value on the key with the expiry and then resolves once it is * done. diff --git a/models/action.js b/models/action.js index b5d2ab8b6..891dbd4f5 100644 --- a/models/action.js +++ b/models/action.js @@ -1,6 +1,5 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); -const _ = require('lodash'); const Schema = mongoose.Schema; const ActionSchema = new Schema({ @@ -67,34 +66,6 @@ ActionSchema.statics.findByItemIdArray = function(item_ids) { }); }; -/** - * Fetches the action summaries for the given asset, and comments around the - * given user id. - * @param {[type]} asset_id [description] - * @param {[type]} comments [description] - * @param {String} [current_user_id=''] [description] - * @return {[type]} [description] - */ -ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => { - - // Get the user id's from the author id's as a unique array that gets - // sorted. - let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); - - // Fetch the actions for pretty much everything at this point. - return Action.getActionSummaries(_.uniq([ - - // Actions can be on assets... - asset_id, - - // Comments... - ...comments.map((comment) => comment.id), - - // Or Authors... - ...userIDs - ].filter((e) => e)), current_user_id); -}; - /** * Returns summaries of actions for an array of ids * @param {String} ids array of user identifiers (uuid) diff --git a/models/asset.js b/models/asset.js index a0619576f..165ebbc6c 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,8 +1,6 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; -const Setting = require('./setting'); - const uuid = require('uuid'); const AssetSchema = new Schema({ @@ -60,6 +58,11 @@ AssetSchema.index({ background: true }); +/** + * Search for assets. Currently only returns all. + */ +AssetSchema.statics.search = (query) => Asset.find(query); + /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). @@ -72,25 +75,6 @@ AssetSchema.statics.findById = (id) => Asset.findOne({id}); */ AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); -/** - * Retrieves the settings given an asset query and rectifies it against the - * global settings. - * @param {Promise} assetQuery an asset query that returns a single asset. - * @return {Promise} - */ -AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ - Setting.retrieve(), - assetQuery -]).then(([settings, asset]) => { - - // If the asset exists and has settings then return the merged object. - if (asset && asset.settings) { - return Object.assign({}, settings, asset.settings); - } - - return settings; -}); - /** * Finds a asset by its url. * diff --git a/models/comment.js b/models/comment.js index af9aa4cb1..c1a32cf1b 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,37 +1,9 @@ const mongoose = require('../mongoose'); -const Schema = mongoose.Schema; const uuid = require('uuid'); const Action = require('./action'); -/** - * The Mongo schema for a Comment Status. - * @type {Schema} - */ -const StatusSchema = new Schema({ - type: { - type: String, - enum: [ - 'accepted', - 'rejected', - 'premod', - ], - }, +const Schema = mongoose.Schema; - // The User ID of the user that assigned the status. - assigned_by: { - type: String, - default: null - }, - - created_at: Date -}, { - _id: false -}); - -/** - * The Mongo schema for a Comment. - * @type {Schema} - */ const CommentSchema = new Schema({ id: { type: String, @@ -45,7 +17,11 @@ const CommentSchema = new Schema({ }, asset_id: String, author_id: String, - status: [StatusSchema], + status: { + type: String, + enum: ['accepted', 'rejected', ''], + default: '' + }, parent_id: String }, { timestamps: { @@ -54,168 +30,90 @@ const CommentSchema = new Schema({ } }); -/** - * toJSON overrides to remove fields from the json - * output. - */ -CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.hide = '_id status'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * toJSON overrides to remove fields from the json - * output. - */ -CommentSchema.options.toJSON = {}; -CommentSchema.options.toJSON.hide = '_id'; -CommentSchema.options.toJSON.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - -/** - * Sets up a virtual getter function on a comment such that when you try and - * access the `comment.last_status` it returns the last status in the array - * of status's on the comment, or `null` if there was no status. - */ -CommentSchema.virtual('last_status').get(function() { - if (this.status && this.status.length > 0) { - return this.status[this.status.length - 1].type; - } - - return null; -}); - -/** - * Creates a new Comment that came from a public source. - * @param {Mixed} comment either a single comment or an array of comments. - * @return {Promise} - */ -CommentSchema.statics.publicCreate = (comment) => { - - // Check to see if this is an array of comments, if so map it out. - if (Array.isArray(comment)) { - return Promise.all(comment.map(Comment.publicCreate)); - } - - const { - body, - asset_id, - parent_id, - status = false, - author_id - } = comment; - - comment = new Comment({ - body, - asset_id, - parent_id, - status: status ? [{ - type: status, - created_at: new Date() - }] : [], - author_id - }); - - return comment.save(); -}; +//============================================================================== +// Find Statics +//============================================================================== /** * Finds a comment by the id. * @param {String} id identifier of comment (uuid) * @return {Promise} */ -CommentSchema.statics.findById = (id) => Comment.findOne({id}); +CommentSchema.statics.findById = function(id) { + return Comment.findOne({'id': id}); +}; /** * Finds ALL the comments by the asset_id. * @param {String} asset_id identifier of the asset which owns this comment (uuid) * @return {Promise} */ -CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ - asset_id -}); +CommentSchema.statics.findByAssetId = function(asset_id) { + return Comment.find({asset_id}); +}; /** - * Finds the accepted comments by the asset_id get the comments that are - * accepted. + * Finds the accepted comments by the asset_id. + * get the comments that are accepted. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ - asset_id, - 'status.type': 'accepted' -}); +CommentSchema.statics.findAcceptedByAssetId = function(asset_id) { + return Comment.find({asset_id: asset_id, status:'accepted'}); +}; /** * Finds the new and accepted comments by the asset_id. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ - asset_id, - $or: [ - { - 'status.type': 'accepted' - }, - { - status: { - $size: 0 - } - } - ] -}); +CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) { + return Comment.find({asset_id: asset_id, status: {'$in': ['accepted', '']}}); +}; /** * Find comments by an action that was performed on them. * @param {String} action_type the type of action that was performed on the comment * @return {Promise} */ -CommentSchema.statics.findByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => Comment.find({ - id: { - $in: actions.map((a) => a.item_id) - } - })); +CommentSchema.statics.findByActionType = function(action_type) { + return Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => { + return Comment.find({'id': {'$in': actions.map(function(a){ + return a.item_id;})} + }); + }); +}; /** - * Find comment id's where the action type matches the argument. + * Find not moderated comments by an action that was performed on them. * @param {String} action_type the type of action that was performed on the comment + * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findIdsByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => actions.map(a => a.item_id)); +CommentSchema.statics.findByStatusByActionType = function(status, action_type) { + return Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => { + return Comment.find({ + status: status, + id: { + $in: actions.map(a => a.item_id) + } + }); + }); +}; /** * Find comments by their status. * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findByStatus = (status = false) => { - let q = {}; - - if (status) { - q['status.type'] = status; - } else { - q.status = {$size: 0}; - } - - return Comment.find(q); +CommentSchema.statics.findByStatus = function(status) { + return Comment.find({ + status: status === 'new' ? '' : status + }); }; /** @@ -223,59 +121,39 @@ CommentSchema.statics.findByStatus = (status = false) => { * @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings. * @return {Promise} */ -CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { +CommentSchema.statics.moderationQueue = function(moderation) { + switch(moderation){ - /** - * This adds the asset_id requirement to the query if the asset_id is defined. - */ - const assetIDWrap = (query) => { - if (asset_id) { - query = query.where('asset_id', asset_id); - } + // Pre-moderation: New comments are shown in the moderator queues immediately. + case 'pre': + return Comment.findByStatus('').then((comments) => { + return comments; + }); - return query; - }; + // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. + case 'post': + return Comment.findByStatusByActionType('', 'flag').then((comments) => { + return comments; + }); - // Decide on whether or not we need to load extended options for the - // moderation based on the moderation options. - let comments; - - if (moderation === 'pre') { - - // Pre-moderation: New comments are shown in the moderator queues immediately. - comments = assetIDWrap(CommentSchema.statics.findByStatus('premod')); - - } else { - - // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. - comments = CommentSchema.statics.findIdsByActionType('flag') - .then((ids) => assetIDWrap(Comment.find({ - id: { - $in: ids - } - }))); + default: + return Promise.reject(Error('Moderation setting not found.')); } - - return comments; }; +//============================================================================== +// Update Statics +//============================================================================== + /** - * Pushes a new status in for the user. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment - * @param {String} assigned_by the user id for the user who performed the - * moderation action + * Change the status of a comment. + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment * @return {Promise} */ -CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { - $push: { - status: { - type: status, - created_at: new Date(), - assigned_by - } - } -}); +CommentSchema.statics.changeStatus = function(id, status) { + return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}}); +}; /** * Add an action to the comment. @@ -291,13 +169,19 @@ CommentSchema.statics.addAction = (item_id, user_id, action_type) => Action.inse action_type }); +//============================================================================== +// Remove Statics +//============================================================================== + /** * Change the status of a comment. * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment * @return {Promise} */ -CommentSchema.statics.removeById = (id) => Comment.remove({id}); +CommentSchema.statics.removeById = function(id) { + return Comment.remove({'id': id}); +}; /** * Remove an action from the comment. @@ -306,18 +190,22 @@ CommentSchema.statics.removeById = (id) => Comment.remove({id}); * @param {String} user_id the id of the user performing the action * @return {Promise} */ -CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({ - action_type, - item_type: 'comment', - item_id, - user_id -}); +CommentSchema.statics.removeAction = function(item_id, user_id, action_type) { + return Action.remove({ + action_type, + item_type: 'comment', + item_id, + user_id + }); +}; /** * Returns all the comments in the collection. * @return {Promise} */ -CommentSchema.statics.all = () => Comment.find(); +CommentSchema.statics.all = () => { + return Comment.find(); +}; // Comment model. const Comment = mongoose.model('Comment', CommentSchema); diff --git a/models/setting.js b/models/setting.js index 22b2b103f..bf5ac290a 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,33 +1,18 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; -const _ = require('lodash'); -const cache = require('../cache'); /** - * SettingSchema manages application settings that get used on front and backend. + * this Schema manages application settings that get used on front and backend + * NOTE: when you set a setting here, it will not automatically be exposed to + * the front end. You must add it to the whitelist in the settings route + * in /routes/api/settings/index.js * @type {Schema} */ const SettingSchema = new Schema({ - id: { - type: String, - default: '1' - }, - moderation: { - type: String, - enum: [ - 'pre', - 'post' - ], - default: 'pre' - }, - infoBoxEnable: { - type: Boolean, - default: false - }, - infoBoxContent: { - type: String, - default: '' - }, + id: {type: String, default: '1'}, + moderation: {type: String, enum: ['pre', 'post'], default: 'pre'}, + infoBoxEnable: {type: Boolean, default: false}, + infoBoxContent: {type: String, default: ''}, wordlist: [String] }, { timestamps: { @@ -37,70 +22,48 @@ const SettingSchema = new Schema({ }); /** - * The Mongo Mongoose object. + * this is run once when the app starts to ensure settings are populated + * @return {Promise} null initialize the global settings object */ -const Setting = mongoose.model('Setting', SettingSchema); - -/** - * The Setting Service object exposing the Setting model. - * @type {Object} - */ -const SettingService = module.exports = {}; - -/** - * The selector used to uniquely identify the settings document. - */ -const selector = {id: '1'}; - -/** - * Cache expiry time in seconds for when the cached entry of the settings object - * expires. 2 minutes. - */ -const EXPIRY_TIME = 60 * 2; +SettingSchema.statics.init = function (defaults) { + return this.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); +}; /** * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector)); +SettingSchema.statics.getSettings = function () { + return this.findOne({id: '1'}); +}; + +/** + * Gets the settings visible to the public + * @return {Promise} moderation the settings for how to moderate comments + */ +SettingSchema.statics.getPublicSettings = function () { + return this.findOne({id: '1'}).select('moderation infoBoxEnable infoBoxContent'); +}; + +/** + * Gets the info box settings and sends it back + * @return {Promise} content the content of the info Box + */ +SettingSchema.statics.getInfoBoxSetting = function () { + return this.findOne({id: '1'}).select('infoBoxEnable infoBoxContent'); +}; /** * This will update the settings object with whatever you pass in * @param {object} setting a hash of whatever settings you want to update * @return {Promise} settings Promise that resolves to the entire (updated) settings object. */ -SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { - $set: settings -}, { - upsert: true, - new: true, - setDefaultsOnInsert: true -}).then((settings) => { - - // Invalidate the settings cache. - return cache - .set('settings', settings, EXPIRY_TIME) - .then(() => settings); -}); - -/** - * Filters the document to ensure that the resulting document is indeed ready - * for non authenticated users. - * @param {Object} settings the source settings object - * @return {Object} the filtered settings object - */ -SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); - -/** - * This is run once when the app starts to ensure settings are populated. - * @return {Promise} null initialize the global settings object - */ -SettingService.init = (defaults) => { - - // Inject the defaults on top of the passed in defaults to ensure that the new - // settings conform to the required selector. - defaults = Object.assign({}, defaults, selector); - - // Actually update the settings collection. - return SettingService.update(defaults); +SettingSchema.statics.updateSettings = function (setting) { + // There should only ever be one record unless something has gone wrong. + // In the future we may have multiple records for custom settings for objects/users. + return this.findOneAndUpdate({id: '1'}, {$set: setting}, {new: true}); }; + +const Setting = mongoose.model('Setting', SettingSchema); + +module.exports = Setting; diff --git a/models/user.js b/models/user.js index 336486a54..86908ab42 100644 --- a/models/user.js +++ b/models/user.js @@ -9,6 +9,7 @@ const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ + '', 'admin', 'moderator' ]; @@ -105,7 +106,7 @@ UserSchema.index({ * output. */ UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = '_id password profiles roles disabled'; +UserSchema.options.toJSON.hide = 'password profiles roles disabled'; UserSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { options.hide.split(' ').forEach((prop) => { diff --git a/package.json b/package.json index c54bd6723..be6c2b153 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "eslint-config-standard": "^6.2.1", "eslint-plugin-flowtype": "^2.25.0", "eslint-plugin-import": "^2.2.0", - "eslint-plugin-mocha": "^4.7.0", "eslint-plugin-promise": "^3.3.1", "eslint-plugin-react": "^6.6.0", "eslint-plugin-standard": "^2.0.1", diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index b82c1faf3..3bdf4afc6 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,6 +1,5 @@ const express = require('express'); const Comment = require('../../../models/comment'); -const Asset = require('../../../models/asset'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const wordlist = require('../../../services/wordlist'); @@ -10,45 +9,24 @@ const _ = require('lodash'); const router = express.Router(); router.get('/', authorization.needed('admin'), (req, res, next) => { - - const { - status = null, - action_type = null, - asset_id = null - } = req.query; - - /** - * This adds the asset_id requirement to the query if the asset_id is defined. - */ - const assetIDWrap = (query) => { - if (asset_id) { - query = query.where('asset_id', asset_id); - } - - return query; - }; - let query; - if (status) { - query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status)); - } else if (action_type) { - query = Comment - .findIdsByActionType(action_type) - .then((ids) => assetIDWrap(Comment.find({ - id: { - $in: ids - }, - }))); + if (req.query.status) { + query = Comment.findByStatus(req.query.status); + } else if (req.query.action_type) { + query = Comment.findByActionType(req.query.action_type); } else { - query = assetIDWrap(Comment.all()); + query = Comment.all(); } query.then((comments) => { return Promise.all([ comments, User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false) + Action.getActionSummaries(_.uniq([ + ...comments.map((comment) => comment.id), + ...comments.map((comment) => comment.author_id) + ])) ]); }) .then(([comments, users, actions])=> @@ -70,38 +48,21 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { parent_id } = req.body; - // Decide the status based on whether or not the current asset/settings - // has pre-mod enabled or not. If the comment was rejected based on the - // wordlist, then reject it, otherwise if the moderation setting is - // premod, set it to `premod`. - let status; + Comment + .create({ + body, + asset_id, + parent_id, + status: req.wordlist.matched ? 'rejected' : '', + author_id: req.user.id + }) + .then((comment) => { - if (req.wordlist.matched) { - status = Promise.resolve('rejected'); - } else { - status = Asset - .rectifySettings(Asset.findById(asset_id)) - - // Return `premod` if pre-moderation is enabled and an empty "new" status - // in the event that it is not in pre-moderation mode. - .then(({moderation}) => moderation === 'pre' ? 'premod' : ''); - } - - status.then((status) => Comment.publicCreate({ - body, - asset_id, - parent_id, - status, - author_id: req.user.id - })) - .then((comment) => { - - // The comment was created! Send back the created comment. - res.status(201).send(comment); - }) - .catch((err) => { - next(err); - }); + res.status(201).send(comment); + }) + .catch((err) => { + next(err); + }); }); router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { @@ -138,7 +99,7 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next } = req.body; Comment - .pushStatus(req.params.comment_id, status, req.user.id) + .changeStatus(req.params.comment_id, status) .then(() => { res.status(204).end(); }) diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index 9c85fbe94..b38d1b763 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -3,7 +3,6 @@ const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const Setting = require('../../../models/setting'); -const Asset = require('../../../models/asset'); const _ = require('lodash'); const router = express.Router(); @@ -17,52 +16,27 @@ const router = express.Router(); // Pre-moderation: New comments are shown in the moderator queues immediately. // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. router.get('/comments/pending', (req, res, next) => { - - const { - asset_id - } = req.query; - - let settings = Setting.retrieve(); - - if (asset_id) { - - // In the event that we have an asset_id, we should fetch the asset settings - // in order to actually determine if there is additional comments to parse. - settings = Promise.all([ - settings, - Asset.findById(asset_id).select('settings') - ]).then(([{moderation}, asset]) => { - if (asset.settings && asset.settings.moderation) { - return {moderation: asset.settings.moderation}; - } - - return {moderation}; - }); - } - - settings - .then(({moderation}) => { - return Comment.moderationQueue(moderation); - }).then((comments) => { - return Promise.all([ - comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) - ]); - }) - .then(([comments, users, actions]) => { - res.json({ - comments, - users, - actions - }); - }) - .catch(error => { - next(error); - }); + Setting.getPublicSettings().then(({moderation}) => + Comment.moderationQueue(moderation)) + .then((comments) => { + return Promise.all([ + comments, + User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + Action.getActionSummaries(_.uniq([ + ...comments.map((comment) => comment.id), + ...comments.map((comment) => comment.author_id) + ])) + ]); + }) + .then(([comments, users, actions])=> + res.status(200).json({ + comments, + users, + actions + })) + .catch(error => { + next(error); + }); }); module.exports = router; diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 76d16c7ec..910524fb2 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -4,21 +4,19 @@ const Setting = require('../../../models/setting'); const router = express.Router(); router.get('/', (req, res, next) => { - Setting.retrieve().then((settings) => { - res.json(settings); - }) - .catch((err) => { - next(err); - }); + Setting + .getSettings() + .then(settings => { + res.json(settings); + }) + .catch(next); }); router.put('/', (req, res, next) => { - Setting.update(req.body).then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); + Setting + .updateSettings(req.body) + .then(() => res.status(204).end()) + .catch(next); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 26947be09..4e8373999 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -26,14 +26,14 @@ router.get('/', (req, res, next) => { return asset; }), - // Get the moderation setting from the settings. - Setting.retrieve() + // Get the public settings. + Setting.getPublicSettings() ]) .then(([asset, settings]) => { // Merge the asset specific settings with the returned settings object in // the event that the asset that was returned also had settings. - if (asset && asset.settings) { + if (asset.settings) { settings = Object.assign({}, settings, asset.settings); } @@ -70,7 +70,17 @@ router.get('/', (req, res, next) => { let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; // Fetch the actions for pretty much everything at this point. - let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false); + let actions = Action.getActionSummaries(_.uniq([ + + // Actions can be on assets... + asset.id, + + // Comments... + ...comments.map((comment) => comment.id), + + // Or Authors... + ...userIDs + ]), req.user ? req.user.id : false); return Promise.all([ @@ -98,7 +108,7 @@ router.get('/', (req, res, next) => { comments, users, actions, - settings: Setting.public(settings) + settings }); }) .catch(error => { diff --git a/services/wordlist.js b/services/wordlist.js index 59a842817..e6f2ad668 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -20,7 +20,7 @@ const wordlist = { */ wordlist.init = () => { return Setting - .retrieve() + .getSettings() .then((settings) => { // Insert the settings wordlist. diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json index b188e92cf..363ef5a28 100644 --- a/tests/.eslintrc.json +++ b/tests/.eslintrc.json @@ -4,12 +4,8 @@ "node": true, "mocha": true }, - "plugins": [ - "mocha" - ], "extends": "../.eslintrc.json", "rules": { - "no-undef": [0], - "mocha/no-exclusive-tests": "warn" + "no-undef": [0] } } diff --git a/tests/models/action.js b/tests/models/action.js index 5f05dea71..c354b8f83 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -1,34 +1,35 @@ const Action = require('../../models/action'); const expect = require('chai').expect; -describe('models.Action', () => { - let mockActions = []; +describe('Action: models', () => { + let mockActions; - beforeEach(() => Action.create([{ - action_type: 'flag', - item_id: '123', - item_type: 'comment', - user_id: 'flagginguserid' - }, { - action_type: 'flag', - item_id: '456', - item_type: 'comment' - }, { - action_type: 'flag', - item_id: '123', - item_type: 'comment' - }, { - action_type: 'like', - item_id: '123', - item_type: 'comment' - }]).then((actions) => { - mockActions = actions; - })); + beforeEach(() => { + return Action.create([{ + action_type: 'flag', + item_id: '123', + item_type: 'comment', + user_id: 'flagginguserid' + }, { + action_type: 'flag', + item_id: '456', + item_type: 'comment' + }, { + action_type: 'flag', + item_id: '123', + item_type: 'comment' + }, { + action_type: 'like', + item_id: '123', + item_type: 'comment' + }]).then((actions) => { + mockActions = actions; + }); + }); describe('#findById()', () => { it('should find an action by id', () => { return Action.findById(mockActions[0].id).then((result) => { - expect(result).to.not.be.null; expect(result).to.have.property('action_type', 'flag'); }); }); diff --git a/tests/models/asset.js b/tests/models/asset.js index c9eff819e..69bf99f9d 100644 --- a/tests/models/asset.js +++ b/tests/models/asset.js @@ -6,7 +6,7 @@ const expect = chai.expect; // Use the chai should. chai.should(); -describe('models.Asset', () => { +describe('Asset: model', () => { beforeEach(() => { const defaults = {url:'http://test.com'}; diff --git a/tests/models/comment.js b/tests/models/comment.js index a9c2f4787..07834a186 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -7,57 +7,35 @@ const settings = {id: '1', moderation: 'pre'}; const expect = require('chai').expect; -describe('models.Comment', () => { +describe('Comment: models', () => { const comments = [{ body: 'comment 10', asset_id: '123', - status: [], + status: '', parent_id: '', author_id: '123', id: '1' }, { body: 'comment 20', asset_id: '123', - status: [{ - type: 'accepted' - }], + status: 'accepted', parent_id: '', author_id: '123', id: '2' }, { body: 'comment 30', asset_id: '456', - status: [], + status: '', parent_id: '', author_id: '456', id: '3' }, { body: 'comment 40', asset_id: '123', - status: [{ - type: 'rejected' - }], + status: 'rejected', parent_id: '', author_id: '456', id: '4' - }, { - body: 'comment 50', - asset_id: '1234', - status: [{ - type: 'premod' - }], - parent_id: '', - author_id: '456', - id: '5' - }, { - body: 'comment 60', - asset_id: '1234', - status: [{ - type: 'premod' - }], - parent_id: '', - author_id: '456', - id: '6' }]; const users = [{ @@ -82,69 +60,25 @@ describe('models.Comment', () => { user_id: '456' }]; - beforeEach(() => Promise.all([ - Setting.init(settings), - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ])); - - describe('#publicCreate()', () => { - - it('creates a new comment', () => { - return Comment.publicCreate({ - body: 'This is a comment!', - status: 'accepted' - }).then((c) => { - expect(c).to.not.be.null; - expect(c.id).to.not.be.null; - expect(c.id).to.be.uuid; - expect(c.status).to.have.length(1); - expect(c.status[0]).to.have.property('type', 'accepted'); - }); - }); - - it('creates many new comments', () => { - return Comment.publicCreate([{ - body: 'This is a comment!', - status: 'accepted' - }, { - body: 'This is another comment!' - }, { - body: 'This is a rejected comment!', - status: 'rejected' - }]).then(([c1, c2, c3]) => { - expect(c1).to.not.be.null; - expect(c1.id).to.be.uuid; - expect(c1.status).to.have.length(1); - expect(c1.status[0]).to.have.property('type', 'accepted'); - - expect(c2).to.not.be.null; - expect(c2.id).to.be.uuid; - expect(c2.status).to.have.length(0); - - expect(c3).to.not.be.null; - expect(c3.id).to.be.uuid; - expect(c3.status).to.have.length(1); - expect(c3.status[0]).to.have.property('type', 'rejected'); - }); - }); - + beforeEach(() => { + return Promise.all([ + Setting.create(settings), + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ]); }); describe('#findById()', () => { - it('should find a comment by id', () => { return Comment.findById('1').then((result) => { expect(result).to.not.be.null; expect(result).to.have.property('body', 'comment 10'); }); }); - }); describe('#findByAssetId()', () => { - it('should find an array of all comments by asset id', () => { return Comment.findByAssetId('123').then((result) => { expect(result).to.have.length(3); @@ -157,7 +91,6 @@ describe('models.Comment', () => { expect(result[2]).to.have.property('body', 'comment 40'); }); }); - it('should find an array of accepted comments by asset id', () => { return Comment.findAcceptedByAssetId('123').then((result) => { expect(result).to.have.length(1); @@ -168,7 +101,6 @@ describe('models.Comment', () => { expect(result[0]).to.have.property('body', 'comment 20'); }); }); - it('should find an array of new and accepted comments by asset id', () => { return Comment.findAcceptedAndNewByAssetId('123').then((result) => { expect(result).to.have.length(2); @@ -180,16 +112,13 @@ describe('models.Comment', () => { }); }); }); - describe('#moderationQueue()', () => { - it('should find an array of new comments to moderate when pre-moderation', () => { return Comment.moderationQueue('pre').then((result) => { expect(result).to.not.be.null; expect(result).to.have.lengthOf(2); }); }); - it('should find an array of new comments to moderate when post-moderation', () => { return Comment.moderationQueue('post').then((result) => { expect(result).to.not.be.null; @@ -197,56 +126,21 @@ describe('models.Comment', () => { expect(result[0]).to.have.property('body', 'comment 30'); }); }); - + // it('should fail when the moderation is not pre or post', () => { + // return Comment.moderationQueue('any').catch(function(error) { + // expect(error).to.not.be.null; + // }); + // }); }); describe('#removeAction', () => { - it('should remove an action', () => { - return Comment.removeAction('3', '123', 'flag') - .then(() => { - return Action.findByItemIdArray(['123']); - }) - .then((actions) => { - expect(actions.length).to.equal(0); - }); + return Comment.removeAction('3', '123', 'flag').then(() => { + return Action.findByItemIdArray(['123']); + }) + .then((actions) => { + expect(actions.length).to.equal(0); + }); }); }); - - describe('#changeStatus', () => { - - it('should change the status of a comment from no status', () => { - let comment_id = comments[0].id; - - return Comment.findById(comment_id) - .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(0); - - return Comment.pushStatus(comment_id, 'rejected', '123'); - }) - .then(() => Comment.findById(comment_id)) - .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(1); - expect(c.status[0]).to.have.property('type', 'rejected'); - expect(c.status[0]).to.have.property('assigned_by', '123'); - }); - }); - - it('should change the status of a comment from accepted', () => { - return Comment.pushStatus(comments[1].id, 'rejected', '123') - .then(() => Comment.findById(comments[1].id)) - .then((c) => { - expect(c).to.have.property('status'); - expect(c.status).to.have.length(2); - expect(c.status[0]).to.have.property('type', 'accepted'); - expect(c.status[0]).to.have.property('assigned_by', null); - - expect(c.status[1]).to.have.property('type', 'rejected'); - expect(c.status[1]).to.have.property('assigned_by', '123'); - }); - }); - - }); }); diff --git a/tests/models/setting.js b/tests/models/setting.js index 3e77297fc..186a65245 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -1,29 +1,34 @@ const Setting = require('../../models/setting'); const expect = require('chai').expect; -describe('models.Setting', () => { +describe('Setting: model', () => { - beforeEach(() => Setting.init({moderation: 'pre'})); + beforeEach(() => { + const defaults = { + id: 1 + }; + return Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); + }); - describe('#retrieve()', () => { + describe('#getSettings()', () => { it('should have a moderation field defined', () => { - return Setting.retrieve().then(settings => { + return Setting.getSettings().then(settings => { expect(settings).to.have.property('moderation').and.to.equal('pre'); }); }); it('should have two infoBox fields defined', () => { - return Setting.retrieve().then(settings => { + return Setting.getSettings().then(settings => { expect(settings).to.have.property('infoBoxEnable').and.to.equal(false); expect(settings).to.have.property('infoBoxContent').and.to.equal(''); }); }); }); - describe('#update()', () => { + describe('#updateSettings()', () => { it('should update the settings with a passed object', () => { const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'}; - return Setting.update(mockSettings).then(updatedSettings => { + return Setting.updateSettings(mockSettings).then(updatedSettings => { expect(updatedSettings).to.be.an('object'); expect(updatedSettings).to.have.property('moderation').and.to.equal('post'); expect(updatedSettings).to.have.property('infoBoxEnable', true); @@ -32,10 +37,13 @@ describe('models.Setting', () => { }); }); - describe('#get', () => { + describe('#getPublicSettings', () => { it('should return the moderation settings', () => { - return Setting.retrieve().then(({moderation}) => { + return Setting.getPublicSettings().then(({moderation, infoBoxEnable, infoBoxContent, wordlist}) => { expect(moderation).not.to.be.null; + expect(infoBoxEnable).not.to.be.null; + expect(infoBoxContent).not.to.be.null; + expect(wordlist).to.be.undefined; }); }); }); diff --git a/tests/models/user.js b/tests/models/user.js index 5e1677016..c8af92054 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -1,7 +1,7 @@ const User = require('../../models/user'); const expect = require('chai').expect; -describe('models.User', () => { +describe('User: models', () => { let mockUsers; beforeEach(() => { return User.createLocalUsers([{ diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bf7a06ff6..bc8b67bc1 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -10,7 +10,6 @@ chai.use(require('chai-http')); const wordlist = require('../../../../services/wordlist'); const Comment = require('../../../../models/comment'); -const Asset = require('../../../../models/asset'); const Action = require('../../../../models/action'); const User = require('../../../../models/user'); @@ -18,71 +17,63 @@ const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + }, { + id: 'def-rejected', + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: 'rejected' + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456', + author_id: '456', + status: 'accepted' + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; + + const actions = [{ + action_type: 'flag', + item_id: 'abc', + item_type: 'comment' + }, { + action_type: 'like', + item_id: 'hij', + item_type: 'comment' + }]; + + beforeEach(() => { + return Promise.all([ + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions), + wordlist.insert([ + 'bad words' + ]), + Setting.create(settings) + ]); + }); describe('#get', () => { - const comments = [{ - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: [{ - type: 'rejected' - }] - }, { - body: 'comment 30', - asset_id: '456', - status: [{ - type: 'accepted' - }] - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comment' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comment' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments).then((newComments) => { - newComments.forEach((comment, i) => { - comments[i].id = comment.id; - }); - - actions[0].item_id = comments[0].id; - actions[1].item_id = comments[1].id; - - return Action.create(actions); - }), - User.createLocalUsers(users), - wordlist.insert([ - 'bad words' - ]), - Setting.init(settings) - ]); - }); - it('should return all the comments', () => { return chai.request(app) .get('/api/v1/comments') @@ -100,9 +91,7 @@ describe('/api/v1/comments', () => { .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); - expect(res.body).to.have.property('comments'); - expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[2].id); + expect(res.body.comments[0]).to.have.property('id', 'def-rejected'); }); }); @@ -113,7 +102,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[3].id); + expect(res.body.comments[0]).to.have.property('id', 'hij'); }); }); @@ -135,7 +124,8 @@ describe('/api/v1/comments', () => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', comments[0].id); + expect(res.body.comments[0]).to.have.property('id', 'abc'); + }); }); }); @@ -161,31 +151,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'rejected'); - }); - }); - - it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => { - return Asset - .findOrCreateByUrl('https://coralproject.net/article1') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - .then((asset) => { - return chai.request(app) - .post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); - }) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('asset_id'); - expect(res.body).to.have.property('status').and.to.have.length(1); - expect(res.body.status[0]).to.have.property('type', 'premod'); + expect(res.body).to.have.property('status', 'rejected'); }); }); }); @@ -301,22 +267,18 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: [] + status: '' }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: [{ - type: 'rejected' - }] + status: 'rejected' }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: [{ - type: 'accepted' - }] + status: 'accepted' }]; const users = [{ @@ -354,8 +316,10 @@ describe('/api/v1/comments/:comment_id/actions', () => { .then((res) => { expect(res).to.have.status(201); expect(res).to.have.body; + expect(res.body).to.have.property('item_type', 'comment'); expect(res.body).to.have.property('action_type', 'flag'); expect(res.body).to.have.property('item_id', 'abc'); + expect(res.body).to.have.property('user_id', '456'); }); }); }); diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index d64423013..1e99ae61e 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -15,54 +15,56 @@ const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; -describe('/api/v1/queue', () => { - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123', - status: [{ - type: 'rejected' - }] - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: [{ - type: 'premod' - }] - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - status: [{ - type: 'accepted' - }] - }]; +beforeEach(() => { + return Setting.create(settings); +}); - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; +describe('Get moderation queues rejected, pending, flags', () => { - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comment' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comment' - }]; + describe('/api/v1/queue', () => { + let comments; - beforeEach(() => { - return User.createLocalUsers(users) + const users = [{ + id: '456', + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + id: '123', + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; + + let actions; + + beforeEach(() => { + + comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + status: 'rejected' + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset' + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456', + status: 'accepted' + }]; + + actions = [{ + action_type: 'flag', + item_type: 'comment' + }, { + action_type: 'like', + item_type: 'comment' + }]; + + return User.createLocalUsers(users) .then((u) => { comments[0].author_id = u[0].id; comments[1].author_id = u[1].id; @@ -74,24 +76,22 @@ describe('/api/v1/queue', () => { actions[0].item_id = c[0].id; actions[1].item_id = c[1].id; - return Promise.all([ - Action.create(actions), - Setting.init(settings) - ]); + return Action.create(actions); }); - }); + }); - it('should return all the pending comments, users and actions', function(done){ - chai.request(app) - .get('/api/v1/queue/comments/pending') - .set(passport.inject({roles: ['admin']})) - .end(function(err, res){ - expect(err).to.be.null; - expect(res).to.have.status(200); - expect(res.body.comments[0]).to.have.property('body'); - expect(res.body.users[0]).to.have.property('displayName'); - expect(res.body.actions[0]).to.have.property('action_type'); - done(); - }); + it('should return all the pending comments, users and actions', function(done){ + chai.request(app) + .get('/api/v1/queue/comments/pending') + .set(passport.inject({roles: ['admin']})) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + expect(res.body.comments[0]).to.have.property('body'); + expect(res.body.users[0]).to.have.property('displayName'); + expect(res.body.actions[0]).to.have.property('action_type'); + done(); + }); + }); }); }); diff --git a/tests/routes/api/settings/index.js b/tests/routes/api/settings/index.js index 318af81f4..d1a7ba81b 100644 --- a/tests/routes/api/settings/index.js +++ b/tests/routes/api/settings/index.js @@ -12,7 +12,7 @@ const defaults = {id: '1', moderation: 'pre'}; describe('/api/v1/settings', () => { - beforeEach(() => Setting.init(defaults)); + beforeEach(() => Setting.create(defaults)); describe('#get', () => { @@ -40,7 +40,7 @@ describe('/api/v1/settings', () => { .then((res) => { expect(res).to.have.status(204); - return Setting.retrieve(); + return Setting.getSettings(); }) .then((settings) => { expect(settings).to.have.property('moderation', 'post'); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index a128938ba..6484fdc12 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -20,37 +20,7 @@ describe('/api/v1/stream', () => { moderation: 'post' }; - const comments = [{ - id: 'abc', - body: 'comment 10', - author_id: '', - parent_id: '', - status: [{ - type: 'accepted' - }] - }, { - id: 'def', - body: 'comment 20', - author_id: '', - parent_id: '', - status: [] - }, { - id: 'uio', - body: 'comment 30', - asset_id: 'asset', - author_id: '456', - parent_id: '', - status: [{ - type: 'accepted' - }] - }, { - id: 'hij', - body: 'comment 40', - asset_id: '456', - status: [{ - type: 'rejected' - }] - }]; + let comments; const users = [{ displayName: 'Ana', @@ -71,6 +41,33 @@ describe('/api/v1/stream', () => { }]; beforeEach(() => { + + comments = [{ + id: 'abc', + body: 'comment 10', + author_id: '', + parent_id: '', + status: 'accepted' + }, { + id: 'def', + body: 'comment 20', + author_id: '', + parent_id: '', + status: '' + }, { + id: 'uio', + body: 'comment 30', + asset_id: 'asset', + author_id: '456', + parent_id: '', + status: 'accepted' + }, { + id: 'hij', + body: 'comment 40', + asset_id: '456', + status: 'rejected' + }]; + return Promise.all([ User.createLocalUsers(users), Asset.findOrCreateByUrl('http://test.com'), @@ -97,11 +94,10 @@ describe('/api/v1/stream', () => { return Promise.all([ Comment.create(comments), Action.create(actions), - Setting.init(settings) + Setting.init().then(() => Setting.updateSettings(settings)) ]); }); }); - it('should return a stream with comments, users and actions for an existing asset', () => { return chai.request(app) .get('/api/v1/stream') From aaebbabef9dfed3be977912a4794299d232bb695 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 8 Dec 2016 16:12:44 -0500 Subject: [PATCH 09/18] Revert "Revert "Status history"" --- bin/cli-serve | 4 + cache.js | 15 ++ models/action.js | 29 +++ models/asset.js | 26 ++- models/comment.js | 296 ++++++++++++++++++++--------- models/setting.js | 117 ++++++++---- models/user.js | 3 +- package.json | 1 + routes/api/comments/index.js | 87 ++++++--- routes/api/queue/index.js | 68 +++++-- routes/api/settings/index.js | 22 ++- routes/api/stream/index.js | 20 +- services/wordlist.js | 2 +- tests/.eslintrc.json | 6 +- tests/models/action.js | 47 +++-- tests/models/asset.js | 2 +- tests/models/comment.js | 152 ++++++++++++--- tests/models/setting.js | 26 +-- tests/models/user.js | 2 +- tests/routes/api/comments/index.js | 166 +++++++++------- tests/routes/api/queue/index.js | 124 ++++++------ tests/routes/api/settings/index.js | 4 +- tests/routes/api/stream/index.js | 62 +++--- 23 files changed, 846 insertions(+), 435 deletions(-) diff --git a/bin/cli-serve b/bin/cli-serve index a6790c6a0..dd682f5ad 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -96,6 +96,10 @@ function startApp() { server.listen(port); server.on('error', onError); server.on('listening', onListening); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); }); } diff --git a/cache.js b/cache.js index 9345f8cde..1d090e46d 100644 --- a/cache.js +++ b/cache.js @@ -76,6 +76,21 @@ cache.get = (key) => new Promise((resolve, reject) => { }); }); +/** + * This invalidates a cached entry in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.invalidate = (key) => new Promise((resolve, reject) => { + cache.client.del(keyfunc(key), (err) => { + if (err) { + return reject(err); + } + + resolve(); + }); +}); + /** * This sets a value on the key with the expiry and then resolves once it is * done. diff --git a/models/action.js b/models/action.js index 891dbd4f5..b5d2ab8b6 100644 --- a/models/action.js +++ b/models/action.js @@ -1,5 +1,6 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); +const _ = require('lodash'); const Schema = mongoose.Schema; const ActionSchema = new Schema({ @@ -66,6 +67,34 @@ ActionSchema.statics.findByItemIdArray = function(item_ids) { }); }; +/** + * Fetches the action summaries for the given asset, and comments around the + * given user id. + * @param {[type]} asset_id [description] + * @param {[type]} comments [description] + * @param {String} [current_user_id=''] [description] + * @return {[type]} [description] + */ +ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => { + + // Get the user id's from the author id's as a unique array that gets + // sorted. + let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); + + // Fetch the actions for pretty much everything at this point. + return Action.getActionSummaries(_.uniq([ + + // Actions can be on assets... + asset_id, + + // Comments... + ...comments.map((comment) => comment.id), + + // Or Authors... + ...userIDs + ].filter((e) => e)), current_user_id); +}; + /** * Returns summaries of actions for an array of ids * @param {String} ids array of user identifiers (uuid) diff --git a/models/asset.js b/models/asset.js index 165ebbc6c..a0619576f 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,6 +1,8 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const Setting = require('./setting'); + const uuid = require('uuid'); const AssetSchema = new Schema({ @@ -58,11 +60,6 @@ AssetSchema.index({ background: true }); -/** - * Search for assets. Currently only returns all. - */ -AssetSchema.statics.search = (query) => Asset.find(query); - /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). @@ -75,6 +72,25 @@ AssetSchema.statics.findById = (id) => Asset.findOne({id}); */ AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); +/** + * Retrieves the settings given an asset query and rectifies it against the + * global settings. + * @param {Promise} assetQuery an asset query that returns a single asset. + * @return {Promise} + */ +AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([ + Setting.retrieve(), + assetQuery +]).then(([settings, asset]) => { + + // If the asset exists and has settings then return the merged object. + if (asset && asset.settings) { + return Object.assign({}, settings, asset.settings); + } + + return settings; +}); + /** * Finds a asset by its url. * diff --git a/models/comment.js b/models/comment.js index c1a32cf1b..af9aa4cb1 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,9 +1,37 @@ const mongoose = require('../mongoose'); +const Schema = mongoose.Schema; const uuid = require('uuid'); const Action = require('./action'); -const Schema = mongoose.Schema; +/** + * The Mongo schema for a Comment Status. + * @type {Schema} + */ +const StatusSchema = new Schema({ + type: { + type: String, + enum: [ + 'accepted', + 'rejected', + 'premod', + ], + }, + // The User ID of the user that assigned the status. + assigned_by: { + type: String, + default: null + }, + + created_at: Date +}, { + _id: false +}); + +/** + * The Mongo schema for a Comment. + * @type {Schema} + */ const CommentSchema = new Schema({ id: { type: String, @@ -17,11 +45,7 @@ const CommentSchema = new Schema({ }, asset_id: String, author_id: String, - status: { - type: String, - enum: ['accepted', 'rejected', ''], - default: '' - }, + status: [StatusSchema], parent_id: String }, { timestamps: { @@ -30,90 +54,168 @@ const CommentSchema = new Schema({ } }); -//============================================================================== -// Find Statics -//============================================================================== +/** + * toJSON overrides to remove fields from the json + * output. + */ +CommentSchema.options.toJSON = {}; +CommentSchema.options.toJSON.hide = '_id status'; +CommentSchema.options.toJSON.transform = (doc, ret, options) => { + if (options.hide) { + options.hide.split(' ').forEach((prop) => { + delete ret[prop]; + }); + } + + return ret; +}; + +/** + * toJSON overrides to remove fields from the json + * output. + */ +CommentSchema.options.toJSON = {}; +CommentSchema.options.toJSON.hide = '_id'; +CommentSchema.options.toJSON.transform = (doc, ret, options) => { + if (options.hide) { + options.hide.split(' ').forEach((prop) => { + delete ret[prop]; + }); + } + + return ret; +}; + +/** + * Sets up a virtual getter function on a comment such that when you try and + * access the `comment.last_status` it returns the last status in the array + * of status's on the comment, or `null` if there was no status. + */ +CommentSchema.virtual('last_status').get(function() { + if (this.status && this.status.length > 0) { + return this.status[this.status.length - 1].type; + } + + return null; +}); + +/** + * Creates a new Comment that came from a public source. + * @param {Mixed} comment either a single comment or an array of comments. + * @return {Promise} + */ +CommentSchema.statics.publicCreate = (comment) => { + + // Check to see if this is an array of comments, if so map it out. + if (Array.isArray(comment)) { + return Promise.all(comment.map(Comment.publicCreate)); + } + + const { + body, + asset_id, + parent_id, + status = false, + author_id + } = comment; + + comment = new Comment({ + body, + asset_id, + parent_id, + status: status ? [{ + type: status, + created_at: new Date() + }] : [], + author_id + }); + + return comment.save(); +}; /** * Finds a comment by the id. * @param {String} id identifier of comment (uuid) * @return {Promise} */ -CommentSchema.statics.findById = function(id) { - return Comment.findOne({'id': id}); -}; +CommentSchema.statics.findById = (id) => Comment.findOne({id}); /** * Finds ALL the comments by the asset_id. * @param {String} asset_id identifier of the asset which owns this comment (uuid) * @return {Promise} */ -CommentSchema.statics.findByAssetId = function(asset_id) { - return Comment.find({asset_id}); -}; +CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ + asset_id +}); /** - * Finds the accepted comments by the asset_id. - * get the comments that are accepted. + * Finds the accepted comments by the asset_id get the comments that are + * accepted. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedByAssetId = function(asset_id) { - return Comment.find({asset_id: asset_id, status:'accepted'}); -}; +CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ + asset_id, + 'status.type': 'accepted' +}); /** * Finds the new and accepted comments by the asset_id. * @param {String} asset_id identifier of the asset which owns the comments (uuid) * @return {Promise} */ -CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) { - return Comment.find({asset_id: asset_id, status: {'$in': ['accepted', '']}}); -}; +CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ + asset_id, + $or: [ + { + 'status.type': 'accepted' + }, + { + status: { + $size: 0 + } + } + ] +}); /** * Find comments by an action that was performed on them. * @param {String} action_type the type of action that was performed on the comment * @return {Promise} */ -CommentSchema.statics.findByActionType = function(action_type) { - return Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => { - return Comment.find({'id': {'$in': actions.map(function(a){ - return a.item_id;})} - }); - }); -}; +CommentSchema.statics.findByActionType = (action_type) => Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => Comment.find({ + id: { + $in: actions.map((a) => a.item_id) + } + })); /** - * Find not moderated comments by an action that was performed on them. + * Find comment id's where the action type matches the argument. * @param {String} action_type the type of action that was performed on the comment - * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findByStatusByActionType = function(status, action_type) { - return Action - .findCommentsIdByActionType(action_type, 'comment') - .then((actions) => { - return Comment.find({ - status: status, - id: { - $in: actions.map(a => a.item_id) - } - }); - }); -}; +CommentSchema.statics.findIdsByActionType = (action_type) => Action + .findCommentsIdByActionType(action_type, 'comment') + .then((actions) => actions.map(a => a.item_id)); /** * Find comments by their status. * @param {String} status the status of the comment to search for * @return {Promise} */ -CommentSchema.statics.findByStatus = function(status) { - return Comment.find({ - status: status === 'new' ? '' : status - }); +CommentSchema.statics.findByStatus = (status = false) => { + let q = {}; + + if (status) { + q['status.type'] = status; + } else { + q.status = {$size: 0}; + } + + return Comment.find(q); }; /** @@ -121,39 +223,59 @@ CommentSchema.statics.findByStatus = function(status) { * @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings. * @return {Promise} */ -CommentSchema.statics.moderationQueue = function(moderation) { - switch(moderation){ +CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { - // Pre-moderation: New comments are shown in the moderator queues immediately. - case 'pre': - return Comment.findByStatus('').then((comments) => { - return comments; - }); + /** + * This adds the asset_id requirement to the query if the asset_id is defined. + */ + const assetIDWrap = (query) => { + if (asset_id) { + query = query.where('asset_id', asset_id); + } - // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. - case 'post': - return Comment.findByStatusByActionType('', 'flag').then((comments) => { - return comments; - }); + return query; + }; - default: - return Promise.reject(Error('Moderation setting not found.')); + // Decide on whether or not we need to load extended options for the + // moderation based on the moderation options. + let comments; + + if (moderation === 'pre') { + + // Pre-moderation: New comments are shown in the moderator queues immediately. + comments = assetIDWrap(CommentSchema.statics.findByStatus('premod')); + + } else { + + // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. + comments = CommentSchema.statics.findIdsByActionType('flag') + .then((ids) => assetIDWrap(Comment.find({ + id: { + $in: ids + } + }))); } -}; -//============================================================================== -// Update Statics -//============================================================================== + return comments; +}; /** - * Change the status of a comment. - * @param {String} id identifier of the comment (uuid) - * @param {String} status the new status of the comment + * Pushes a new status in for the user. + * @param {String} id identifier of the comment (uuid) + * @param {String} status the new status of the comment + * @param {String} assigned_by the user id for the user who performed the + * moderation action * @return {Promise} */ -CommentSchema.statics.changeStatus = function(id, status) { - return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}}); -}; +CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, { + $push: { + status: { + type: status, + created_at: new Date(), + assigned_by + } + } +}); /** * Add an action to the comment. @@ -169,19 +291,13 @@ CommentSchema.statics.addAction = (item_id, user_id, action_type) => Action.inse action_type }); -//============================================================================== -// Remove Statics -//============================================================================== - /** * Change the status of a comment. * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment * @return {Promise} */ -CommentSchema.statics.removeById = function(id) { - return Comment.remove({'id': id}); -}; +CommentSchema.statics.removeById = (id) => Comment.remove({id}); /** * Remove an action from the comment. @@ -190,22 +306,18 @@ CommentSchema.statics.removeById = function(id) { * @param {String} user_id the id of the user performing the action * @return {Promise} */ -CommentSchema.statics.removeAction = function(item_id, user_id, action_type) { - return Action.remove({ - action_type, - item_type: 'comment', - item_id, - user_id - }); -}; +CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({ + action_type, + item_type: 'comment', + item_id, + user_id +}); /** * Returns all the comments in the collection. * @return {Promise} */ -CommentSchema.statics.all = () => { - return Comment.find(); -}; +CommentSchema.statics.all = () => Comment.find(); // Comment model. const Comment = mongoose.model('Comment', CommentSchema); diff --git a/models/setting.js b/models/setting.js index bf5ac290a..22b2b103f 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,18 +1,33 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +const _ = require('lodash'); +const cache = require('../cache'); /** - * this Schema manages application settings that get used on front and backend - * NOTE: when you set a setting here, it will not automatically be exposed to - * the front end. You must add it to the whitelist in the settings route - * in /routes/api/settings/index.js + * SettingSchema manages application settings that get used on front and backend. * @type {Schema} */ const SettingSchema = new Schema({ - id: {type: String, default: '1'}, - moderation: {type: String, enum: ['pre', 'post'], default: 'pre'}, - infoBoxEnable: {type: Boolean, default: false}, - infoBoxContent: {type: String, default: ''}, + id: { + type: String, + default: '1' + }, + moderation: { + type: String, + enum: [ + 'pre', + 'post' + ], + default: 'pre' + }, + infoBoxEnable: { + type: Boolean, + default: false + }, + infoBoxContent: { + type: String, + default: '' + }, wordlist: [String] }, { timestamps: { @@ -22,48 +37,70 @@ const SettingSchema = new Schema({ }); /** - * this is run once when the app starts to ensure settings are populated - * @return {Promise} null initialize the global settings object + * The Mongo Mongoose object. */ -SettingSchema.statics.init = function (defaults) { - return this.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); -}; +const Setting = mongoose.model('Setting', SettingSchema); + +/** + * The Setting Service object exposing the Setting model. + * @type {Object} + */ +const SettingService = module.exports = {}; + +/** + * The selector used to uniquely identify the settings document. + */ +const selector = {id: '1'}; + +/** + * Cache expiry time in seconds for when the cached entry of the settings object + * expires. 2 minutes. + */ +const EXPIRY_TIME = 60 * 2; /** * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingSchema.statics.getSettings = function () { - return this.findOne({id: '1'}); -}; - -/** - * Gets the settings visible to the public - * @return {Promise} moderation the settings for how to moderate comments - */ -SettingSchema.statics.getPublicSettings = function () { - return this.findOne({id: '1'}).select('moderation infoBoxEnable infoBoxContent'); -}; - -/** - * Gets the info box settings and sends it back - * @return {Promise} content the content of the info Box - */ -SettingSchema.statics.getInfoBoxSetting = function () { - return this.findOne({id: '1'}).select('infoBoxEnable infoBoxContent'); -}; +SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector)); /** * This will update the settings object with whatever you pass in * @param {object} setting a hash of whatever settings you want to update * @return {Promise} settings Promise that resolves to the entire (updated) settings object. */ -SettingSchema.statics.updateSettings = function (setting) { - // There should only ever be one record unless something has gone wrong. - // In the future we may have multiple records for custom settings for objects/users. - return this.findOneAndUpdate({id: '1'}, {$set: setting}, {new: true}); +SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { + $set: settings +}, { + upsert: true, + new: true, + setDefaultsOnInsert: true +}).then((settings) => { + + // Invalidate the settings cache. + return cache + .set('settings', settings, EXPIRY_TIME) + .then(() => settings); +}); + +/** + * Filters the document to ensure that the resulting document is indeed ready + * for non authenticated users. + * @param {Object} settings the source settings object + * @return {Object} the filtered settings object + */ +SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent']); + +/** + * This is run once when the app starts to ensure settings are populated. + * @return {Promise} null initialize the global settings object + */ +SettingService.init = (defaults) => { + + // Inject the defaults on top of the passed in defaults to ensure that the new + // settings conform to the required selector. + defaults = Object.assign({}, defaults, selector); + + // Actually update the settings collection. + return SettingService.update(defaults); }; - -const Setting = mongoose.model('Setting', SettingSchema); - -module.exports = Setting; diff --git a/models/user.js b/models/user.js index 86908ab42..336486a54 100644 --- a/models/user.js +++ b/models/user.js @@ -9,7 +9,6 @@ const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ - '', 'admin', 'moderator' ]; @@ -106,7 +105,7 @@ UserSchema.index({ * output. */ UserSchema.options.toJSON = {}; -UserSchema.options.toJSON.hide = 'password profiles roles disabled'; +UserSchema.options.toJSON.hide = '_id password profiles roles disabled'; UserSchema.options.toJSON.transform = (doc, ret, options) => { if (options.hide) { options.hide.split(' ').forEach((prop) => { diff --git a/package.json b/package.json index be6c2b153..c54bd6723 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "eslint-config-standard": "^6.2.1", "eslint-plugin-flowtype": "^2.25.0", "eslint-plugin-import": "^2.2.0", + "eslint-plugin-mocha": "^4.7.0", "eslint-plugin-promise": "^3.3.1", "eslint-plugin-react": "^6.6.0", "eslint-plugin-standard": "^2.0.1", diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 3bdf4afc6..b82c1faf3 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,5 +1,6 @@ const express = require('express'); const Comment = require('../../../models/comment'); +const Asset = require('../../../models/asset'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const wordlist = require('../../../services/wordlist'); @@ -9,24 +10,45 @@ const _ = require('lodash'); const router = express.Router(); router.get('/', authorization.needed('admin'), (req, res, next) => { + + const { + status = null, + action_type = null, + asset_id = null + } = req.query; + + /** + * This adds the asset_id requirement to the query if the asset_id is defined. + */ + const assetIDWrap = (query) => { + if (asset_id) { + query = query.where('asset_id', asset_id); + } + + return query; + }; + let query; - if (req.query.status) { - query = Comment.findByStatus(req.query.status); - } else if (req.query.action_type) { - query = Comment.findByActionType(req.query.action_type); + if (status) { + query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status)); + } else if (action_type) { + query = Comment + .findIdsByActionType(action_type) + .then((ids) => assetIDWrap(Comment.find({ + id: { + $in: ids + }, + }))); } else { - query = Comment.all(); + query = assetIDWrap(Comment.all()); } query.then((comments) => { return Promise.all([ comments, User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) + Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false) ]); }) .then(([comments, users, actions])=> @@ -48,21 +70,38 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { parent_id } = req.body; - Comment - .create({ - body, - asset_id, - parent_id, - status: req.wordlist.matched ? 'rejected' : '', - author_id: req.user.id - }) - .then((comment) => { + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + let status; - res.status(201).send(comment); - }) - .catch((err) => { - next(err); - }); + if (req.wordlist.matched) { + status = Promise.resolve('rejected'); + } else { + status = Asset + .rectifySettings(Asset.findById(asset_id)) + + // Return `premod` if pre-moderation is enabled and an empty "new" status + // in the event that it is not in pre-moderation mode. + .then(({moderation}) => moderation === 'pre' ? 'premod' : ''); + } + + status.then((status) => Comment.publicCreate({ + body, + asset_id, + parent_id, + status, + author_id: req.user.id + })) + .then((comment) => { + + // The comment was created! Send back the created comment. + res.status(201).send(comment); + }) + .catch((err) => { + next(err); + }); }); router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { @@ -99,7 +138,7 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next } = req.body; Comment - .changeStatus(req.params.comment_id, status) + .pushStatus(req.params.comment_id, status, req.user.id) .then(() => { res.status(204).end(); }) diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index b38d1b763..9c85fbe94 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -3,6 +3,7 @@ const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const Setting = require('../../../models/setting'); +const Asset = require('../../../models/asset'); const _ = require('lodash'); const router = express.Router(); @@ -16,27 +17,52 @@ const router = express.Router(); // Pre-moderation: New comments are shown in the moderator queues immediately. // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. router.get('/comments/pending', (req, res, next) => { - Setting.getPublicSettings().then(({moderation}) => - Comment.moderationQueue(moderation)) - .then((comments) => { - return Promise.all([ - comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) - ]); - }) - .then(([comments, users, actions])=> - res.status(200).json({ - comments, - users, - actions - })) - .catch(error => { - next(error); - }); + + const { + asset_id + } = req.query; + + let settings = Setting.retrieve(); + + if (asset_id) { + + // In the event that we have an asset_id, we should fetch the asset settings + // in order to actually determine if there is additional comments to parse. + settings = Promise.all([ + settings, + Asset.findById(asset_id).select('settings') + ]).then(([{moderation}, asset]) => { + if (asset.settings && asset.settings.moderation) { + return {moderation: asset.settings.moderation}; + } + + return {moderation}; + }); + } + + settings + .then(({moderation}) => { + return Comment.moderationQueue(moderation); + }).then((comments) => { + return Promise.all([ + comments, + User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + Action.getActionSummaries(_.uniq([ + ...comments.map((comment) => comment.id), + ...comments.map((comment) => comment.author_id) + ])) + ]); + }) + .then(([comments, users, actions]) => { + res.json({ + comments, + users, + actions + }); + }) + .catch(error => { + next(error); + }); }); module.exports = router; diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 910524fb2..76d16c7ec 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -4,19 +4,21 @@ const Setting = require('../../../models/setting'); const router = express.Router(); router.get('/', (req, res, next) => { - Setting - .getSettings() - .then(settings => { - res.json(settings); - }) - .catch(next); + Setting.retrieve().then((settings) => { + res.json(settings); + }) + .catch((err) => { + next(err); + }); }); router.put('/', (req, res, next) => { - Setting - .updateSettings(req.body) - .then(() => res.status(204).end()) - .catch(next); + Setting.update(req.body).then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 4e8373999..26947be09 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -26,14 +26,14 @@ router.get('/', (req, res, next) => { return asset; }), - // Get the public settings. - Setting.getPublicSettings() + // Get the moderation setting from the settings. + Setting.retrieve() ]) .then(([asset, settings]) => { // Merge the asset specific settings with the returned settings object in // the event that the asset that was returned also had settings. - if (asset.settings) { + if (asset && asset.settings) { settings = Object.assign({}, settings, asset.settings); } @@ -70,17 +70,7 @@ router.get('/', (req, res, next) => { let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; // Fetch the actions for pretty much everything at this point. - let actions = Action.getActionSummaries(_.uniq([ - - // Actions can be on assets... - asset.id, - - // Comments... - ...comments.map((comment) => comment.id), - - // Or Authors... - ...userIDs - ]), req.user ? req.user.id : false); + let actions = Action.getActionSummariesFromComments(asset.id, comments, req.user ? req.user.id : false); return Promise.all([ @@ -108,7 +98,7 @@ router.get('/', (req, res, next) => { comments, users, actions, - settings + settings: Setting.public(settings) }); }) .catch(error => { diff --git a/services/wordlist.js b/services/wordlist.js index e6f2ad668..59a842817 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -20,7 +20,7 @@ const wordlist = { */ wordlist.init = () => { return Setting - .getSettings() + .retrieve() .then((settings) => { // Insert the settings wordlist. diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json index 363ef5a28..b188e92cf 100644 --- a/tests/.eslintrc.json +++ b/tests/.eslintrc.json @@ -4,8 +4,12 @@ "node": true, "mocha": true }, + "plugins": [ + "mocha" + ], "extends": "../.eslintrc.json", "rules": { - "no-undef": [0] + "no-undef": [0], + "mocha/no-exclusive-tests": "warn" } } diff --git a/tests/models/action.js b/tests/models/action.js index c354b8f83..5f05dea71 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -1,35 +1,34 @@ const Action = require('../../models/action'); const expect = require('chai').expect; -describe('Action: models', () => { - let mockActions; +describe('models.Action', () => { + let mockActions = []; - beforeEach(() => { - return Action.create([{ - action_type: 'flag', - item_id: '123', - item_type: 'comment', - user_id: 'flagginguserid' - }, { - action_type: 'flag', - item_id: '456', - item_type: 'comment' - }, { - action_type: 'flag', - item_id: '123', - item_type: 'comment' - }, { - action_type: 'like', - item_id: '123', - item_type: 'comment' - }]).then((actions) => { - mockActions = actions; - }); - }); + beforeEach(() => Action.create([{ + action_type: 'flag', + item_id: '123', + item_type: 'comment', + user_id: 'flagginguserid' + }, { + action_type: 'flag', + item_id: '456', + item_type: 'comment' + }, { + action_type: 'flag', + item_id: '123', + item_type: 'comment' + }, { + action_type: 'like', + item_id: '123', + item_type: 'comment' + }]).then((actions) => { + mockActions = actions; + })); describe('#findById()', () => { it('should find an action by id', () => { return Action.findById(mockActions[0].id).then((result) => { + expect(result).to.not.be.null; expect(result).to.have.property('action_type', 'flag'); }); }); diff --git a/tests/models/asset.js b/tests/models/asset.js index 69bf99f9d..c9eff819e 100644 --- a/tests/models/asset.js +++ b/tests/models/asset.js @@ -6,7 +6,7 @@ const expect = chai.expect; // Use the chai should. chai.should(); -describe('Asset: model', () => { +describe('models.Asset', () => { beforeEach(() => { const defaults = {url:'http://test.com'}; diff --git a/tests/models/comment.js b/tests/models/comment.js index 07834a186..a9c2f4787 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -7,35 +7,57 @@ const settings = {id: '1', moderation: 'pre'}; const expect = require('chai').expect; -describe('Comment: models', () => { +describe('models.Comment', () => { const comments = [{ body: 'comment 10', asset_id: '123', - status: '', + status: [], parent_id: '', author_id: '123', id: '1' }, { body: 'comment 20', asset_id: '123', - status: 'accepted', + status: [{ + type: 'accepted' + }], parent_id: '', author_id: '123', id: '2' }, { body: 'comment 30', asset_id: '456', - status: '', + status: [], parent_id: '', author_id: '456', id: '3' }, { body: 'comment 40', asset_id: '123', - status: 'rejected', + status: [{ + type: 'rejected' + }], parent_id: '', author_id: '456', id: '4' + }, { + body: 'comment 50', + asset_id: '1234', + status: [{ + type: 'premod' + }], + parent_id: '', + author_id: '456', + id: '5' + }, { + body: 'comment 60', + asset_id: '1234', + status: [{ + type: 'premod' + }], + parent_id: '', + author_id: '456', + id: '6' }]; const users = [{ @@ -60,25 +82,69 @@ describe('Comment: models', () => { user_id: '456' }]; - beforeEach(() => { - return Promise.all([ - Setting.create(settings), - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); + beforeEach(() => Promise.all([ + Setting.init(settings), + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ])); + + describe('#publicCreate()', () => { + + it('creates a new comment', () => { + return Comment.publicCreate({ + body: 'This is a comment!', + status: 'accepted' + }).then((c) => { + expect(c).to.not.be.null; + expect(c.id).to.not.be.null; + expect(c.id).to.be.uuid; + expect(c.status).to.have.length(1); + expect(c.status[0]).to.have.property('type', 'accepted'); + }); + }); + + it('creates many new comments', () => { + return Comment.publicCreate([{ + body: 'This is a comment!', + status: 'accepted' + }, { + body: 'This is another comment!' + }, { + body: 'This is a rejected comment!', + status: 'rejected' + }]).then(([c1, c2, c3]) => { + expect(c1).to.not.be.null; + expect(c1.id).to.be.uuid; + expect(c1.status).to.have.length(1); + expect(c1.status[0]).to.have.property('type', 'accepted'); + + expect(c2).to.not.be.null; + expect(c2.id).to.be.uuid; + expect(c2.status).to.have.length(0); + + expect(c3).to.not.be.null; + expect(c3.id).to.be.uuid; + expect(c3.status).to.have.length(1); + expect(c3.status[0]).to.have.property('type', 'rejected'); + }); + }); + }); describe('#findById()', () => { + it('should find a comment by id', () => { return Comment.findById('1').then((result) => { expect(result).to.not.be.null; expect(result).to.have.property('body', 'comment 10'); }); }); + }); describe('#findByAssetId()', () => { + it('should find an array of all comments by asset id', () => { return Comment.findByAssetId('123').then((result) => { expect(result).to.have.length(3); @@ -91,6 +157,7 @@ describe('Comment: models', () => { expect(result[2]).to.have.property('body', 'comment 40'); }); }); + it('should find an array of accepted comments by asset id', () => { return Comment.findAcceptedByAssetId('123').then((result) => { expect(result).to.have.length(1); @@ -101,6 +168,7 @@ describe('Comment: models', () => { expect(result[0]).to.have.property('body', 'comment 20'); }); }); + it('should find an array of new and accepted comments by asset id', () => { return Comment.findAcceptedAndNewByAssetId('123').then((result) => { expect(result).to.have.length(2); @@ -112,13 +180,16 @@ describe('Comment: models', () => { }); }); }); + describe('#moderationQueue()', () => { + it('should find an array of new comments to moderate when pre-moderation', () => { return Comment.moderationQueue('pre').then((result) => { expect(result).to.not.be.null; expect(result).to.have.lengthOf(2); }); }); + it('should find an array of new comments to moderate when post-moderation', () => { return Comment.moderationQueue('post').then((result) => { expect(result).to.not.be.null; @@ -126,21 +197,56 @@ describe('Comment: models', () => { expect(result[0]).to.have.property('body', 'comment 30'); }); }); - // it('should fail when the moderation is not pre or post', () => { - // return Comment.moderationQueue('any').catch(function(error) { - // expect(error).to.not.be.null; - // }); - // }); + }); describe('#removeAction', () => { + it('should remove an action', () => { - return Comment.removeAction('3', '123', 'flag').then(() => { - return Action.findByItemIdArray(['123']); - }) - .then((actions) => { - expect(actions.length).to.equal(0); - }); + return Comment.removeAction('3', '123', 'flag') + .then(() => { + return Action.findByItemIdArray(['123']); + }) + .then((actions) => { + expect(actions.length).to.equal(0); + }); }); }); + + describe('#changeStatus', () => { + + it('should change the status of a comment from no status', () => { + let comment_id = comments[0].id; + + return Comment.findById(comment_id) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(0); + + return Comment.pushStatus(comment_id, 'rejected', '123'); + }) + .then(() => Comment.findById(comment_id)) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(1); + expect(c.status[0]).to.have.property('type', 'rejected'); + expect(c.status[0]).to.have.property('assigned_by', '123'); + }); + }); + + it('should change the status of a comment from accepted', () => { + return Comment.pushStatus(comments[1].id, 'rejected', '123') + .then(() => Comment.findById(comments[1].id)) + .then((c) => { + expect(c).to.have.property('status'); + expect(c.status).to.have.length(2); + expect(c.status[0]).to.have.property('type', 'accepted'); + expect(c.status[0]).to.have.property('assigned_by', null); + + expect(c.status[1]).to.have.property('type', 'rejected'); + expect(c.status[1]).to.have.property('assigned_by', '123'); + }); + }); + + }); }); diff --git a/tests/models/setting.js b/tests/models/setting.js index 186a65245..3e77297fc 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -1,34 +1,29 @@ const Setting = require('../../models/setting'); const expect = require('chai').expect; -describe('Setting: model', () => { +describe('models.Setting', () => { - beforeEach(() => { - const defaults = { - id: 1 - }; - return Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); - }); + beforeEach(() => Setting.init({moderation: 'pre'})); - describe('#getSettings()', () => { + describe('#retrieve()', () => { it('should have a moderation field defined', () => { - return Setting.getSettings().then(settings => { + return Setting.retrieve().then(settings => { expect(settings).to.have.property('moderation').and.to.equal('pre'); }); }); it('should have two infoBox fields defined', () => { - return Setting.getSettings().then(settings => { + return Setting.retrieve().then(settings => { expect(settings).to.have.property('infoBoxEnable').and.to.equal(false); expect(settings).to.have.property('infoBoxContent').and.to.equal(''); }); }); }); - describe('#updateSettings()', () => { + describe('#update()', () => { it('should update the settings with a passed object', () => { const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'}; - return Setting.updateSettings(mockSettings).then(updatedSettings => { + return Setting.update(mockSettings).then(updatedSettings => { expect(updatedSettings).to.be.an('object'); expect(updatedSettings).to.have.property('moderation').and.to.equal('post'); expect(updatedSettings).to.have.property('infoBoxEnable', true); @@ -37,13 +32,10 @@ describe('Setting: model', () => { }); }); - describe('#getPublicSettings', () => { + describe('#get', () => { it('should return the moderation settings', () => { - return Setting.getPublicSettings().then(({moderation, infoBoxEnable, infoBoxContent, wordlist}) => { + return Setting.retrieve().then(({moderation}) => { expect(moderation).not.to.be.null; - expect(infoBoxEnable).not.to.be.null; - expect(infoBoxContent).not.to.be.null; - expect(wordlist).to.be.undefined; }); }); }); diff --git a/tests/models/user.js b/tests/models/user.js index c8af92054..5e1677016 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -1,7 +1,7 @@ const User = require('../../models/user'); const expect = require('chai').expect; -describe('User: models', () => { +describe('models.User', () => { let mockUsers; beforeEach(() => { return User.createLocalUsers([{ diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index bc8b67bc1..bf7a06ff6 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -10,6 +10,7 @@ chai.use(require('chai-http')); const wordlist = require('../../../../services/wordlist'); const Comment = require('../../../../models/comment'); +const Asset = require('../../../../models/asset'); const Action = require('../../../../models/action'); const User = require('../../../../models/user'); @@ -17,63 +18,71 @@ const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; describe('/api/v1/comments', () => { - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - id: 'def-rejected', - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: 'rejected' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - author_id: '456', - status: 'accepted' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc', - item_type: 'comment' - }, { - action_type: 'like', - item_id: 'hij', - item_type: 'comment' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions), - wordlist.insert([ - 'bad words' - ]), - Setting.create(settings) - ]); - }); describe('#get', () => { + const comments = [{ + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + }, { + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: [{ + type: 'rejected' + }] + }, { + body: 'comment 30', + asset_id: '456', + status: [{ + type: 'accepted' + }] + }]; + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; + + const actions = [{ + action_type: 'flag', + item_id: 'abc', + item_type: 'comment' + }, { + action_type: 'like', + item_id: 'hij', + item_type: 'comment' + }]; + + beforeEach(() => { + return Promise.all([ + Comment.create(comments).then((newComments) => { + newComments.forEach((comment, i) => { + comments[i].id = comment.id; + }); + + actions[0].item_id = comments[0].id; + actions[1].item_id = comments[1].id; + + return Action.create(actions); + }), + User.createLocalUsers(users), + wordlist.insert([ + 'bad words' + ]), + Setting.init(settings) + ]); + }); + it('should return all the comments', () => { return chai.request(app) .get('/api/v1/comments') @@ -91,7 +100,9 @@ describe('/api/v1/comments', () => { .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); - expect(res.body.comments[0]).to.have.property('id', 'def-rejected'); + expect(res.body).to.have.property('comments'); + expect(res.body.comments).to.have.length(1); + expect(res.body.comments[0]).to.have.property('id', comments[2].id); }); }); @@ -102,7 +113,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', 'hij'); + expect(res.body.comments[0]).to.have.property('id', comments[3].id); }); }); @@ -124,8 +135,7 @@ describe('/api/v1/comments', () => { expect(res).to.have.status(200); expect(res.body.comments).to.have.length(1); - expect(res.body.comments[0]).to.have.property('id', 'abc'); - + expect(res.body.comments[0]).to.have.property('id', comments[0].id); }); }); }); @@ -151,7 +161,31 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); + expect(res.body).to.have.property('status').and.to.have.length(1); + expect(res.body.status[0]).to.have.property('type', 'rejected'); + }); + }); + + it('should create a comment with a premod status if it\'s asset is has pre-moderation enabled', () => { + return Asset + .findOrCreateByUrl('https://coralproject.net/article1') + .then((asset) => { + return Asset + .overrideSettings(asset.id, {moderation: 'pre'}) + .then(() => asset); + }) + .then((asset) => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); + }) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('asset_id'); + expect(res.body).to.have.property('status').and.to.have.length(1); + expect(res.body.status[0]).to.have.property('type', 'premod'); }); }); }); @@ -267,18 +301,22 @@ describe('/api/v1/comments/:comment_id/actions', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', - status: '' + status: [] }, { id: 'def', body: 'comment 20', asset_id: 'asset', author_id: '456', - status: 'rejected' + status: [{ + type: 'rejected' + }] }, { id: 'hij', body: 'comment 30', asset_id: '456', - status: 'accepted' + status: [{ + type: 'accepted' + }] }]; const users = [{ @@ -316,10 +354,8 @@ describe('/api/v1/comments/:comment_id/actions', () => { .then((res) => { expect(res).to.have.status(201); expect(res).to.have.body; - expect(res.body).to.have.property('item_type', 'comment'); expect(res.body).to.have.property('action_type', 'flag'); expect(res.body).to.have.property('item_id', 'abc'); - expect(res.body).to.have.property('user_id', '456'); }); }); }); diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index 1e99ae61e..d64423013 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -15,56 +15,54 @@ const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; -beforeEach(() => { - return Setting.create(settings); -}); +describe('/api/v1/queue', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123', + status: [{ + type: 'rejected' + }] + }, { + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456', + status: [{ + type: 'premod' + }] + }, { + id: 'hij', + body: 'comment 30', + asset_id: '456', + status: [{ + type: 'accepted' + }] + }]; -describe('Get moderation queues rejected, pending, flags', () => { + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; - describe('/api/v1/queue', () => { - let comments; + const actions = [{ + action_type: 'flag', + item_id: 'abc', + item_type: 'comment' + }, { + action_type: 'like', + item_id: 'hij', + item_type: 'comment' + }]; - const users = [{ - id: '456', - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - id: '123', - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - let actions; - - beforeEach(() => { - - comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - status: 'rejected' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - status: 'accepted' - }]; - - actions = [{ - action_type: 'flag', - item_type: 'comment' - }, { - action_type: 'like', - item_type: 'comment' - }]; - - return User.createLocalUsers(users) + beforeEach(() => { + return User.createLocalUsers(users) .then((u) => { comments[0].author_id = u[0].id; comments[1].author_id = u[1].id; @@ -76,22 +74,24 @@ describe('Get moderation queues rejected, pending, flags', () => { actions[0].item_id = c[0].id; actions[1].item_id = c[1].id; - return Action.create(actions); + return Promise.all([ + Action.create(actions), + Setting.init(settings) + ]); }); - }); + }); - it('should return all the pending comments, users and actions', function(done){ - chai.request(app) - .get('/api/v1/queue/comments/pending') - .set(passport.inject({roles: ['admin']})) - .end(function(err, res){ - expect(err).to.be.null; - expect(res).to.have.status(200); - expect(res.body.comments[0]).to.have.property('body'); - expect(res.body.users[0]).to.have.property('displayName'); - expect(res.body.actions[0]).to.have.property('action_type'); - done(); - }); - }); + it('should return all the pending comments, users and actions', function(done){ + chai.request(app) + .get('/api/v1/queue/comments/pending') + .set(passport.inject({roles: ['admin']})) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + expect(res.body.comments[0]).to.have.property('body'); + expect(res.body.users[0]).to.have.property('displayName'); + expect(res.body.actions[0]).to.have.property('action_type'); + done(); + }); }); }); diff --git a/tests/routes/api/settings/index.js b/tests/routes/api/settings/index.js index d1a7ba81b..318af81f4 100644 --- a/tests/routes/api/settings/index.js +++ b/tests/routes/api/settings/index.js @@ -12,7 +12,7 @@ const defaults = {id: '1', moderation: 'pre'}; describe('/api/v1/settings', () => { - beforeEach(() => Setting.create(defaults)); + beforeEach(() => Setting.init(defaults)); describe('#get', () => { @@ -40,7 +40,7 @@ describe('/api/v1/settings', () => { .then((res) => { expect(res).to.have.status(204); - return Setting.getSettings(); + return Setting.retrieve(); }) .then((settings) => { expect(settings).to.have.property('moderation', 'post'); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index 6484fdc12..a128938ba 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -20,7 +20,37 @@ describe('/api/v1/stream', () => { moderation: 'post' }; - let comments; + const comments = [{ + id: 'abc', + body: 'comment 10', + author_id: '', + parent_id: '', + status: [{ + type: 'accepted' + }] + }, { + id: 'def', + body: 'comment 20', + author_id: '', + parent_id: '', + status: [] + }, { + id: 'uio', + body: 'comment 30', + asset_id: 'asset', + author_id: '456', + parent_id: '', + status: [{ + type: 'accepted' + }] + }, { + id: 'hij', + body: 'comment 40', + asset_id: '456', + status: [{ + type: 'rejected' + }] + }]; const users = [{ displayName: 'Ana', @@ -41,33 +71,6 @@ describe('/api/v1/stream', () => { }]; beforeEach(() => { - - comments = [{ - id: 'abc', - body: 'comment 10', - author_id: '', - parent_id: '', - status: 'accepted' - }, { - id: 'def', - body: 'comment 20', - author_id: '', - parent_id: '', - status: '' - }, { - id: 'uio', - body: 'comment 30', - asset_id: 'asset', - author_id: '456', - parent_id: '', - status: 'accepted' - }, { - id: 'hij', - body: 'comment 40', - asset_id: '456', - status: 'rejected' - }]; - return Promise.all([ User.createLocalUsers(users), Asset.findOrCreateByUrl('http://test.com'), @@ -94,10 +97,11 @@ describe('/api/v1/stream', () => { return Promise.all([ Comment.create(comments), Action.create(actions), - Setting.init().then(() => Setting.updateSettings(settings)) + Setting.init(settings) ]); }); }); + it('should return a stream with comments, users and actions for an existing asset', () => { return chai.request(app) .get('/api/v1/stream') From fed41e1fba80250545f8192ca1c7e7fb7ec7eb5d Mon Sep 17 00:00:00 2001 From: Dan Zajdband Date: Thu, 8 Dec 2016 16:59:29 -0500 Subject: [PATCH 10/18] Linted code --- client/coral-embed-stream/src/CommentStream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index 69fc5f73f..7f3b1b30f 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -114,7 +114,7 @@ class CommentStream extends Component { author={user} /> - :

{this.props.config.closedMessage}

+ :

{closedMessage}

} {!loggedIn && } { From 413db123c4e99606336fdb80593b02948a06a2c1 Mon Sep 17 00:00:00 2001 From: gaba Date: Thu, 8 Dec 2016 12:18:19 -1000 Subject: [PATCH 11/18] Type on comment. --- bin/cli-settings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/cli-settings b/bin/cli-settings index c639a5ca5..e7ba30151 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -15,7 +15,7 @@ const mongoose = require('../mongoose'); const Setting = require('../models/setting'); const util = require('../util'); -// Regeister the shutdown criteria. +// Register the shutdown criteria. util.onshutdown([ () => mongoose.disconnect() ]); From a86b5f84ac2fb6196ec111cbda1c997a19ca99d9 Mon Sep 17 00:00:00 2001 From: gaba Date: Thu, 8 Dec 2016 12:18:53 -1000 Subject: [PATCH 12/18] Adds a role option when creating the user. --- bin/cli-users | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/bin/cli-users b/bin/cli-users index 2e0dc84e6..e77259301 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -34,6 +34,7 @@ function createUser(options) { email: options.email, password: options.password, displayName: options.name, + role: options.role }); } @@ -62,6 +63,11 @@ function createUser(options) { name: 'displayName', description: 'Display Name', required: true + }, + { + name: 'role', + description: 'User Role', + required: false } ], (err, result) => { if (err) { @@ -76,15 +82,24 @@ function createUser(options) { }); }) .then((result) => { - return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()); - }) - .then((user) => { - console.log(`Created user ${user.id}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(); + return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) + .then((user) => { + console.log(`Created user ${user.id}.`); + User + .addRoleToUser(user.id, result.role.trim()) + .then(() => { + console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); + }) + .catch((err) => { + console.error(err); + util.shutdown(); + }); }); } @@ -330,6 +345,7 @@ program .option('--email [email]', 'Email to use') .option('--password [password]', 'Password to use') .option('--name [name]', 'Name to use') + .option('--role [role]', 'Role to add') .option('-f, --flag_mode', 'Source from flags instead of prompting') .description('create a new user') .action(createUser); From 4572d8e68ef9570e6f1ba324ec655ec889c1e6f7 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Thu, 8 Dec 2016 12:20:54 -1000 Subject: [PATCH 13/18] filter by premod status --- .../src/containers/ModerationQueue/ModerationQueue.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index e5670d169..3774ed5f9 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -81,9 +81,11 @@ class ModerationQueue extends React.Component { singleView={singleView} commentIds={ comments.get('ids') - .filter(id => !comments.get('byId') + .filter(id => + comments + .get('byId') .get(id) - .get('status')) + .get('status') === 'premod') } comments={comments.get('byId')} users={users.get('byId')} From bc88cd1e9d231b85a871140626e91f29fe298fd8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 8 Dec 2016 17:23:31 -0500 Subject: [PATCH 14/18] Adjusted promise --- bin/cli-users | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bin/cli-users b/bin/cli-users index e77259301..012eba9d4 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -85,15 +85,12 @@ function createUser(options) { return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim()) .then((user) => { console.log(`Created user ${user.id}.`); - User + + return User .addRoleToUser(user.id, result.role.trim()) .then(() => { console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); }); }) .catch((err) => { From c8d0fae0970fa0bb9fe0639c9077ce125b272856 Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Thu, 8 Dec 2016 12:50:12 -1000 Subject: [PATCH 15/18] Fix formatting --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6589cddb9..51c0d1ab9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ You can view product ideas and our longer term roadmap here: https://trello.com/ ### Local Dependencies Node + Mongo ### Getting Started @@ -26,17 +27,17 @@ inside the environment in order to run, those variables are listed here: - `TALK_SESSION_SECRET` (*required*) - a random string which will be used to secure cookies. -`TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook +- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook Login enabled app. -`TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your +- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your Facebook Login enabled app. -`TALK_ROOT_URL` (*required*) - root url of the installed application externally +- `TALK_ROOT_URL` (*required*) - root url of the installed application externally available in the format: `://` without the path. -`TALK_SMTP_PROVIDER` (*required*) - SMTP provider name. -`TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. -`TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. -`TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. -`TALK_SMTP_PORT` (*required*) - SMTP port. +- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name. +- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. +- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. +- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. +- `TALK_SMTP_PORT` (*required*) - SMTP port. ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` From 500dafbafa5d3e513641b3092274ed58ef2760cb Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Thu, 8 Dec 2016 12:51:45 -1000 Subject: [PATCH 16/18] riley being anal --- middleware/payload-filter.js | 2 +- tests/models/setting.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware/payload-filter.js b/middleware/payload-filter.js index 8ce3d39d5..0cafd91fe 100644 --- a/middleware/payload-filter.js +++ b/middleware/payload-filter.js @@ -38,7 +38,7 @@ module.exports = (req, res, next) => { } else if (typeof o === 'object') { - // Itterate over the props, find only properties owned by the object. + // Iterate over the props, find only properties owned by the object. for (let prop in o) { // If and only if the object owns the property do we actually pull the diff --git a/tests/models/setting.js b/tests/models/setting.js index fbd5bf77f..8fd23d6a0 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -41,7 +41,7 @@ describe('models.Setting', () => { }); describe('#merge', () => { - it('should merge a settings object and it\'s overrides', () => { + it('should merge a settings object and its overrides', () => { return Setting .retrieve() .then((settings) => { From 7ce44ccd8c40d7098d2283a2d8bed836a232239b Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Thu, 8 Dec 2016 12:53:39 -1000 Subject: [PATCH 17/18] More formatting --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 51c0d1ab9..0b77e95b7 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ Make sure you have Docker running first and then run `docker-compose up -d` `npm run lint` ### Helpful URLs -Bare comment stream: http://localhost:5000/client/coral-embed-stream/ -Comment stream embedded on sample article: http://localhost:5000/client/coral-embed-stream/samplearticle.html -Moderator view: http://localhost:5000/admin/ +Comment stream: http://localhost:3000/ + +Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html + +Moderator view: http://localhost:3000/admin ### Docs `swagger.yaml` From dd5c114dc2ea82bd521ff6327db1ee129328ca32 Mon Sep 17 00:00:00 2001 From: Riley Davis Date: Thu, 8 Dec 2016 18:36:20 -1000 Subject: [PATCH 18/18] don't use well-known email settings --- docker-compose.yml | 1 + services/mailer.js | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d7a180277..16df60095 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: always ports: - "5000:5000" + - "2525:2525" environment: - "TALK_PORT=5000" - "TALK_MONGO_URL=mongodb://mongo" diff --git a/services/mailer.js b/services/mailer.js index b7d621954..fe07f063b 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -3,7 +3,7 @@ const nodemailer = require('nodemailer'); const smtpRequiredProps = [ 'TALK_SMTP_USERNAME', 'TALK_SMTP_PASSWORD', - 'TALK_SMTP_PROVIDER' + 'TALK_SMTP_HOST' ]; smtpRequiredProps.forEach(prop => { @@ -13,9 +13,7 @@ smtpRequiredProps.forEach(prop => { }); const options = { - // list of providers here: - // https://github.com/nodemailer/nodemailer-wellknown#supported-services - service: process.env.TALK_SMTP_PROVIDER, + host: process.env.TALK_SMTP_HOST, auth: { user: process.env.TALK_SMTP_USERNAME, pass: process.env.TALK_SMTP_PASSWORD @@ -24,10 +22,8 @@ const options = { if (process.env.TALK_SMTP_PORT) { options.port = process.env.TALK_SMTP_PORT; -} - -if (process.env.TALK_SMTP_HOST) { - options.host = process.env.TALK_SMTP_HOST; +} else { + options.port = 25; } const defaultTransporter = nodemailer.createTransport(options);