diff --git a/.gitignore b/.gitignore index 0f4ef0a..603fb12 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,11 @@ npm-debug.log .deploy/ public/locales/undefined/ .deploy-staging +.screenshots +.vscode +output.json + +# fase-0 higiene: variantes de settings con secretos (no versionar) +settings-production-*.json +settings-tests.json +*.json~ diff --git a/.meteor/packages b/.meteor/packages index de1e606..5cca156 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -4,33 +4,33 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -meteor-base@1.2.0 # Packages every Meteor app needs to have +meteor-base@1.3.0 # Packages every Meteor app needs to have mobile-experience@1.0.5 # Packages for a great mobile UX -mongo@1.3.0 # The database Meteor supports right now +mongo@1.4.7 # The database Meteor supports right now reactive-var@1.0.11 # Reactive variable for tracker tracker@1.1.3 # Meteor's client-side reactive programming library -standard-minifier-css@1.3.5 # CSS minifier run for production mode -standard-minifier-js@2.2.0 # JS minifier run for production mode -es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. -ecmascript@0.9.0 # Enable ECMAScript2015+ syntax in app code -shell-server@0.3.0 # Server-side component of the `meteor shell` command +standard-minifier-css@1.4.0 # CSS minifier run for production mode +standard-minifier-js@2.3.1 # JS minifier run for production mode +es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers. +ecmascript@0.10.6 # Enable ECMAScript2015+ syntax in app code +shell-server@0.3.1 # Server-side component of the `meteor shell` command react-meteor-data alanning:roles fourseven:scss -accounts-base@1.4.0 +accounts-base@1.4.2 accounts-password@1.5.0 service-configuration@1.0.11 -accounts-facebook@1.3.0 -accounts-github@1.4.0 -accounts-google@1.3.0 +accounts-facebook@1.3.1 +accounts-github@1.4.1 +accounts-google@1.3.1 themeteorchef:bert fortawesome:fontawesome aldeed:collection2-core@2.0.1 audit-argument-checks@1.0.7 ddp-rate-limiter@1.0.7 -dynamic-import@0.2.0 +dynamic-import@0.3.0 static-html alexwine:bootstrap-4 @@ -45,7 +45,6 @@ natestrauser:publish-performant-counts maximum:server-transform mys:fonts # fonst in npm ostrio:mailer # mailer -mizzao:user-status # user status (online, etc) # babrahams:constellation # dev utility percolate:migrations # db migrations ostrio:meteor-root @@ -54,11 +53,17 @@ practicalmeteor:chai aldeed:schema-deny aldeed:template-extension dburles:collection-helpers -less -markdown -mongo-livedata +less@2.7.11 +markdown@1.0.12 +mongo-livedata@1.0.12 reywood:publish-composite barbatus:stars-rating arkham:comments-ui -facts +facts@1.0.9 gadicohen:sitemaps +nspangler:autoreconnect +saucecode:timezoned-synced-cron +# lmachens:kadira +meteorhacks:zones +nimble:restivus +xolvio:cleaner diff --git a/.meteor/release b/.meteor/release index 0fa8d22..011385b 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.6 +METEOR@1.6.1.1 diff --git a/.meteor/versions b/.meteor/versions index e9ae166..9cbf1c3 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,52 +1,56 @@ 255kb:meteor-status@1.5.0 -accounts-base@1.4.0 -accounts-facebook@1.3.0 -accounts-github@1.4.0 -accounts-google@1.3.0 +accounts-base@1.4.2 +accounts-facebook@1.3.1 +accounts-github@1.4.1 +accounts-google@1.3.1 accounts-oauth@1.1.15 -accounts-password@1.5.0 +accounts-password@1.5.1 alanning:roles@1.2.16 -aldeed:collection2-core@2.0.1 +aldeed:collection2-core@2.1.2 aldeed:schema-deny@2.0.1 aldeed:template-extension@4.1.0 -alexwine:bootstrap-4@4.0.0-beta.2 +alexwine:bootstrap-4@4.1.0 allow-deny@1.1.0 arkham:comments-ui@1.4.4 audit-argument-checks@1.0.7 -autoupdate@1.3.12 -babel-compiler@6.24.7 -babel-runtime@1.1.1 +autoupdate@1.4.0 +babel-compiler@7.0.7 +babel-runtime@1.2.2 barbatus:stars-rating@1.1.1 -base64@1.0.10 +base64@1.0.11 binary-heap@1.0.10 blaze@2.3.2 blaze-tools@1.0.10 -boilerplate-generator@1.3.0 -caching-compiler@1.1.9 +boilerplate-generator@1.4.0 +browser-policy@1.1.0 +browser-policy-common@1.0.11 +browser-policy-content@1.1.0 +browser-policy-framing@1.1.0 +caching-compiler@1.1.11 caching-html-compiler@1.1.2 -callback-hook@1.0.10 -check@1.2.5 +callback-hook@1.1.0 +check@1.3.1 coffeescript@1.0.17 dburles:collection-helpers@1.1.0 dburles:mongo-collection-instances@0.3.5 ddp@1.4.0 -ddp-client@2.2.0 -ddp-common@1.3.0 +ddp-client@2.3.2 +ddp-common@1.4.0 ddp-rate-limiter@1.0.7 -ddp-server@2.1.0 +ddp-server@2.1.2 deps@1.0.12 -diff-sequence@1.0.7 -dynamic-import@0.2.0 -ecmascript@0.9.0 +diff-sequence@1.1.0 +dynamic-import@0.3.0 +ecmascript@0.10.7 ecmascript-runtime@0.5.0 -ecmascript-runtime-client@0.5.0 +ecmascript-runtime-client@0.6.2 ecmascript-runtime-server@0.5.0 ejson@1.1.0 email@1.2.3 -es5-shim@4.6.15 +es5-shim@4.7.3 facebook-oauth@1.4.0 facts@1.0.9 -flowkey:raven@1.0.0 +flowkey:raven@1.1.0 fortawesome:fontawesome@4.7.0 fourseven:scss@4.5.4 gadicc:blaze-react-component@1.4.0 @@ -54,51 +58,53 @@ gadicohen:robots-txt@0.0.10 gadicohen:sitemaps@0.0.26 geojson-utils@1.0.10 github-oauth@1.2.0 -google-oauth@1.2.4 +google-oauth@1.2.5 hot-code-push@1.0.4 html-tools@1.0.11 htmljs@1.0.11 -http@1.3.0 -id-map@1.0.9 -jquery@1.11.10 +http@1.4.0 +id-map@1.1.0 +jquery@1.11.11 lai:collection-extensions@0.2.1_1 launch-screen@1.1.1 -less@2.7.11 +less@2.7.12 livedata@1.0.18 localstorage@1.2.0 -logging@1.1.19 +logging@1.1.20 markdown@1.0.12 maximum:package-base@1.1.2 maximum:server-transform@0.5.0 mdg:geolocation@1.3.0 -meteor@1.8.0 -meteor-base@1.2.0 -meteortesting:browser-tests@0.1.2 -meteortesting:mocha@0.5.0 -minifier-css@1.2.16 -minifier-js@2.2.1 -minimongo@1.4.0 -mizzao:timesync@0.3.4 -mizzao:user-status@0.6.7 +meteor@1.8.6 +meteor-base@1.3.0 +meteorhacks:inject-initial@1.0.4 +meteorhacks:zones@1.6.0 +meteortesting:browser-tests@0.2.0 +meteortesting:mocha@0.5.1 +minifier-css@1.3.1 +minifier-js@2.3.4 +minimongo@1.4.4 mobile-experience@1.0.5 mobile-status-bar@1.0.14 -modules@0.11.0 -modules-runtime@0.9.0 -mongo@1.3.0 +modules@0.11.6 +modules-runtime@0.9.2 +mongo@1.4.7 mongo-dev-server@1.1.0 -mongo-id@1.0.6 +mongo-id@1.0.7 mongo-livedata@1.0.12 mys:fonts@0.0.2 natestrauser:publish-performant-counts@0.1.2 +nimble:restivus@0.8.12 npm-bcrypt@0.9.3 npm-mongo@2.2.34 -oauth@1.2.0 +nspangler:autoreconnect@0.0.1 +oauth@1.2.3 oauth2@1.2.0 observe-sequence@1.0.16 -ordered-dict@1.0.9 +ordered-dict@1.1.0 ostrio:cookies@2.1.3 ostrio:mailer@2.1.0 -ostrio:meteor-root@1.0.6 +ostrio:meteor-root@1.0.7 peerlibrary:assert@0.2.5 peerlibrary:fiber-utils@0.6.0 peerlibrary:reactive-mongo@0.1.1 @@ -106,37 +112,44 @@ peerlibrary:server-autorun@0.5.2 percolate:migrations@1.0.2 practicalmeteor:chai@2.1.0_1 practicalmeteor:mocha-core@1.0.1 -promise@0.10.0 +promise@0.10.2 raix:eventemitter@0.1.3 -random@1.0.10 -rate-limit@1.0.8 -react-meteor-data@0.2.15 +random@1.1.0 +rate-limit@1.0.9 +react-meteor-data@0.2.16 reactive-dict@1.2.0 reactive-var@1.0.11 -reload@1.1.11 -retry@1.0.9 -reywood:publish-composite@1.5.2 -routepolicy@1.0.12 +reload@1.2.0 +retry@1.1.0 +reywood:publish-composite@1.6.0 +routepolicy@1.0.13 +saucecode:timezoned-synced-cron@1.2.11 selaias:cookie-consent@0.4.0 +server-render@0.3.0 service-configuration@1.0.11 session@1.1.7 sha@1.0.9 -shell-server@0.3.0 +shell-server@0.3.1 +shim-common@0.1.0 +simple:json-routes@2.1.0 +socket-stream-client@0.1.0 spacebars@1.0.15 spacebars-compiler@1.1.3 srp@1.0.10 -standard-minifier-css@1.3.5 -standard-minifier-js@2.2.1 +standard-minifier-css@1.4.1 +standard-minifier-js@2.3.3 static-html@1.2.2 templating@1.3.2 templating-compiler@1.3.3 templating-runtime@1.3.2 templating-tools@1.1.2 themeteorchef:bert@2.1.3 -tmeasday:check-npm-versions@0.3.1 +tmeasday:check-npm-versions@0.3.2 tracker@1.1.3 +ui@1.0.13 underscore@1.0.10 -url@1.1.0 +url@1.2.0 vjrj:piwik@0.3.1 -webapp@1.4.0 +webapp@1.5.0 webapp-hashing@1.0.9 +xolvio:cleaner@0.3.3 diff --git a/.meteorignore b/.meteorignore new file mode 100644 index 0000000..b25101c --- /dev/null +++ b/.meteorignore @@ -0,0 +1,3 @@ +# Don't reload meteor server when we modify or do test +cucumber +output.json diff --git a/API.md b/API.md new file mode 100644 index 0000000..ed2561b --- /dev/null +++ b/API.md @@ -0,0 +1,7 @@ +Basic API: + +```bash +curl -X GET http://localhost:3000/api/v1/status/last-fire-detected +curl -X GET http://localhost:3000/api/v1/status/last-fire-check +curl -X GET http://localhost:3000/api/v1/status/active-fires-count +``` diff --git a/README.md b/README.md index 29a6a62..4417536 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # Tod@s contra el Fuego (All Against Fire) +[![Build Status](http://ci.comunes.org/buildStatus/icon?job=todos-contra-el-fuego-web)](http://ci.comunes.org/job/todos-contra-el-fuego-web/) + This is web service that notifies about fires detected in an area of your interest. It helps the early detection of fires and facilitates local mobilization, while the professional extinction services arrive. ## Install -Install `meteor` and run `npm start` +Prerrequisites: a running meteor (we share the `fuegos` database with the telegram bot). + +Install `meteor` and run `meteor npm install` and `npm start` + +Some other development deps (in debian & ubuntu): +``` +apt-get install libcairo2-dev libjpeg-dev libgif-dev pkg-config +``` ### GeoIP @@ -20,9 +29,23 @@ More platforms and services in the future... We do tests via: ``` -TEST_WATCH=1 MONGO_URL=mongodb://localhost:27017/fuegos meteor --settings settings-development.json test --driver-package meteortesting:mocha --port 3010 +TEST_PORT=3000 TEST_WATCH=1 TEST_CLIENT=0 MONGO_URL=mongodb://localhost:27017/fuegostest meteor --settings settings-development.json test --driver-package meteortesting:mocha --port 3010 + +# and + +node_modules/.bin/chimp --watch --ddp=http://localhost:3000 --path=cucumber + +# and + +node_modules/.bin/chimp --ddp=http://localhost:3000 --path=cucumber + ``` +### FAQ & Troubleshooting + +**Q** - I get something like `(...) /node_modules/fibers/future.js:280 (...) Error: Could not locate the bindings file.` What can I do? +**A** - Try `meteor npm rebuild` + ## Data source acknowledgements *We acknowledge the use of data and imagery from LANCE FIRMS operated by the NASA/GSFC/Earth Science Data and Information System (ESDIS) with funding provided by NASA/HQ*. @@ -33,4 +56,5 @@ GNU APLv3. See our [LICENSE](https://github.com/comunes/todos-contra-el-fuego-we ## Thanks & other acknowlegments -(...) +Thanks indeed to: +- Lupe Bao Reixa for Galician translation diff --git a/bin/au-dee-npi.awk b/bin/au-dee-npi.awk new file mode 100755 index 0000000..dec11cd --- /dev/null +++ b/bin/au-dee-npi.awk @@ -0,0 +1,16 @@ +# cat file.csv| ./au-dee-npi.awk | mongoimport -d "fuegos" -c "industries" + +gawk -F',' 'NR>1 { \ + gsub(/"/,"",$5); + gsub(/"/,"",$6); + gsub(/\t/,"",$13); + gsub(/\t/,"",$14); + gsub(/\x1a/,"",$13); + gsub(/\x1a/,"",$14); + lon = $14+0; + lat = $13+0; + name= $5", "$6 + gsub(/^, /,"",name); + if (lon != 0 && lat != 0 && lon > -180 && lon < 180 && lat >= -90 && lat <= 90) + printf("{name:\"%s\",geo:{type:\"Point\",coordinates:[%s,%s]},registry:\"4\"}\n" \ + ,name,lon,lat)}' diff --git a/bin/ca-godp-npri.awk b/bin/ca-godp-npri.awk new file mode 100755 index 0000000..e618ec6 --- /dev/null +++ b/bin/ca-godp-npri.awk @@ -0,0 +1,19 @@ +# cat file.csv| ./ca-godp-npri.awk | uniq | mongoimport -d "fuegos" -c "industries" + +gawk -F',' 'NR>1 { \ + gsub(/"/,"",$3); + gsub(/"/,"",$4); + gsub(/\t/,"",$3); + gsub(/\t/,"",$4); + gsub(/\x1a/,"",$3); + gsub(/\x1a/,"",$4); + lon = $16+0; + lat = $15+0; + if ($3 != $4) + name= $3", "$4 + else + name=$3 + gsub(/^, /,"",name); + # if (lon != 0 && lat != 0 && lon > -180 && lon < 180 && lat >= -90 && lat <= 90) + printf("{name:\"%s\",geo:{type:\"Point\",coordinates:[%s,%s]},registry:\"3\"}\n" \ + ,name,lon,lat)}' diff --git a/bin/eu-eea-e-prtr.awk b/bin/eu-eea-e-prtr.awk new file mode 100755 index 0000000..afaa839 --- /dev/null +++ b/bin/eu-eea-e-prtr.awk @@ -0,0 +1,14 @@ +# cat dbo.PUBLISH_FACILITYREPORT.csv| ./eu-eea-e-prtr.awk | mongoimport -d "fuegos" -c "industries" + +gawk -F',' 'NR>1 { \ + gsub(/"/,"",$6); + gsub(/"/,"",$5); + gsub(/\t/,"",$6); + gsub(/\t/,"",$5); + gsub(/\x1a/,"",$5); + gsub(/\x1a/,"",$6); + lon = $14+0; + lat = $13+0; + if (lon != 0 && lat != 0 && lon > -180 && lon < 180 && lat >= -90 && lat <= 90) + printf("{name:\"%s, %s\",geo:{type:\"Point\",coordinates:[%s,%s]},registry:\"1\"}\n" \ + ,$6,$5,lon,lat)}' diff --git a/bin/us-epa-frs.awk b/bin/us-epa-frs.awk new file mode 100755 index 0000000..56aec61 --- /dev/null +++ b/bin/us-epa-frs.awk @@ -0,0 +1,10 @@ +# cat NATIONAL_SINGLE.CSV| ./us-epa-frs.awk | mongoimport -d "fuegos" -c "industries" + +gawk -F',' 'NR>1{ \ + gsub(/"/,"",$33); + gsub(/"/,"",$32); + gsub(/"/,"",$3); + gsub(/\t/,"",$3); + lon = $33+0; + lat = $32+0; + if (lon != 0 && lat != 0 && lon > -180 && lon < 180 && lat >= -90 && lat <= 90) { printf("{name:\"%s\",geo:{type:\"Point\",coordinates:[%s,%s]},registry:\"2\"}\n",name,lon,lat)}}' diff --git a/client/main.html b/client/main.html index bf46507..a2728e0 100644 --- a/client/main.html +++ b/client/main.html @@ -1,7 +1,8 @@ - All agaist Fire | Tod@s contra el Fuego - + + All Against the Fire | Tod@s contra el Fuego + diff --git a/client/main.js b/client/main.js index 1412f30..f139266 100644 --- a/client/main.js +++ b/client/main.js @@ -1 +1,4 @@ import '../imports/startup/client'; + +// https://github.com/GoogleChrome/rendertron#rendering-budget-timeout +window.renderComplete = false; diff --git a/cucumber/chimp-config.js b/cucumber/chimp-config.js new file mode 100644 index 0000000..7ec19dc --- /dev/null +++ b/cucumber/chimp-config.js @@ -0,0 +1,29 @@ +module.exports = { + // - - - - CHIMP - - - - + /* watch: false, + * watchTags: '@watch', + * offline: false, + */ + // - - - - CUCUMBER - - - - + /* path: './features', + * jsonOutput: 'output.json', */ + + // '- - - - DEBUGGING - - - - + log: 'info', + debug: false, + seleniumDebug: false, + webdriverLogLevel: false, + // debugBrkCucumber: 5858, + // - - - - WEBDRIVER-IO - - - - + webdriverio: { + waitforTimeout: 10000, + waitforInterval: 250, // KEEP SMALL (!!!) this is the INTERVAL in which waitFor* is looped fo + desiredCapabilities: { + chromeOptions: { + // args: ["headless", "disable-gpu"] + args: ['--disable-gpu', '--no-sandbox', '--headless'] + }, + isHeadless: true + } + } +}; diff --git a/cucumber/features/feedback.feature b/cucumber/features/feedback.feature new file mode 100644 index 0000000..3bbb113 --- /dev/null +++ b/cucumber/features/feedback.feature @@ -0,0 +1,22 @@ +Feature: Allow users to give feedback + + As a user (authenticated or not) + I want to give feedback about this site + + Scenario: A user can login and give feedback + Given I am on the zones page + And I have an account and I logged in + # Because is not verified + Then I can send feedback with email + When I sign out + Given I'm not logged + Then I can send feedback with email + + Scenario: A anon user can give feedback + Given I am on the fires page + Given I'm not logged + Then I can send feedback with email + + Scenario: A user cannot give feedback in home + Given I am on the home page + Then I cannot send feedback diff --git a/cucumber/features/general.feature b/cucumber/features/general.feature index c3ea6a8..b1fd8f9 100644 --- a/cucumber/features/general.feature +++ b/cucumber/features/general.feature @@ -1,6 +1,5 @@ Feature: This app should generate a correct sitemap, etc - @watch Scenario: I verify that a sitemap is generated correctly Given the page sitemap.xml Then I check that exist this list of pages in the sitemap @@ -11,4 +10,3 @@ Feature: This app should generate a correct sitemap, etc | fires | Active Fires | | login | Login | | signup | Signup | - # And should be spiderables diff --git a/cucumber/features/pages.feature b/cucumber/features/pages.feature index 1d6ff70..0807c2c 100644 --- a/cucumber/features/pages.feature +++ b/cucumber/features/pages.feature @@ -1,5 +1,8 @@ Feature: Test all secundary pages + Background: + Given I am on the home page + Scenario: Check that all secondary pages work well Given a list of page ids and contents | license | License | @@ -7,20 +10,42 @@ Feature: Test all secundary pages | credits | Credits | | privacy | Privacy Policy | | activeFires | Active Fires | + | about | About | Then I check that all pages works properly - Scenario: Check that all secondary pages work well - Given a list of page urls and contents + Scenario: Check that all secondary pages work well when not logged + Given I logout if logged + And a list of page urls and contents | login | Login | true | | signup | Sign Up | true | + | fires | Fires | true | + | zones | Monitored | true | | recover-password | Recover Password | true | | verify-email/something | Verify | true | | fire/inexistent | This page doesn't exist | false | | fire/Fe26.2**1a0361ed0384f741403682e26b4bbc3850bee24e775da13c9782b22365ea895f*r_3gmVad5vzkeyqPpo6UcA*1s5fFz3iDGKTYP2RCvXMshof00QCHf4ErDl9K2dxoX0u-J-t6scyOWG8pGp3ehg_FfEtyR_kYcEKU3rE0jaSlZbD09TIvhiIJeS3C6Uc8YD-rit0XBrgsVKfYSxKzTRoOYiYFJ8JYd298hMtfiASePjS05Z58hhicyCcJYYRlarqDScG3LiVY3lL5y2nfcdIMNuSjCiKOJWuMkxwd9nR1UHMudLl0hEoy56mPdnHpDYtP9IYUlIOk1LlWBxcmHKifbXeqHu94p8j13Kk20dh2R49Hw3KsSoE9UbWmGQA9wAZXT82301i3rGF5GPAKjlTlRYcWisQurnPwHSVmx3DhUdiYwKGxt4KeaM5QVI4BE9octvE41OOprB_-Il105diQEh2Y9vdvX51ZVWIRfCboICPM6rJb0Oin7U7F1iM-oD_5s3DGnelfM5LGBcKwiB5paMo5M5vdBMaO-zR216cW9yGVXw9IZqHx8xDQWnoHAZjt8NLHeiGF2QOmIGtEUH7qnwhGpkcvszajmAZzR8saZgoH1qfBfvpVA41YfV14gU**4d030f05e23ad75409cebc609107467fc60be5077d52cf041087cd024fc4dc45*GY97aGFc1MyAsoO3Qxqtgwk9j-MbAPdEGBmEHq6r8VU | Additional information | false | Then I check that all page urls works properly + # And they are spiderable + + @watch + Scenario: Check that all secondary pages work well when logged + Given I have an account and I logged in + And a list of page urls and contents + | status | Status | false | + | fires | Fires | true | + | zones | Monitored | true | + | verify-email/something | Verify | true | + | fire/inexistent | This page doesn't exist | false | + | fire/Fe26.2**1a0361ed0384f741403682e26b4bbc3850bee24e775da13c9782b22365ea895f*r_3gmVad5vzkeyqPpo6UcA*1s5fFz3iDGKTYP2RCvXMshof00QCHf4ErDl9K2dxoX0u-J-t6scyOWG8pGp3ehg_FfEtyR_kYcEKU3rE0jaSlZbD09TIvhiIJeS3C6Uc8YD-rit0XBrgsVKfYSxKzTRoOYiYFJ8JYd298hMtfiASePjS05Z58hhicyCcJYYRlarqDScG3LiVY3lL5y2nfcdIMNuSjCiKOJWuMkxwd9nR1UHMudLl0hEoy56mPdnHpDYtP9IYUlIOk1LlWBxcmHKifbXeqHu94p8j13Kk20dh2R49Hw3KsSoE9UbWmGQA9wAZXT82301i3rGF5GPAKjlTlRYcWisQurnPwHSVmx3DhUdiYwKGxt4KeaM5QVI4BE9octvE41OOprB_-Il105diQEh2Y9vdvX51ZVWIRfCboICPM6rJb0Oin7U7F1iM-oD_5s3DGnelfM5LGBcKwiB5paMo5M5vdBMaO-zR216cW9yGVXw9IZqHx8xDQWnoHAZjt8NLHeiGF2QOmIGtEUH7qnwhGpkcvszajmAZzR8saZgoH1qfBfvpVA41YfV14gU**4d030f05e23ad75409cebc609107467fc60be5077d52cf041087cd024fc4dc45*GY97aGFc1MyAsoO3Qxqtgwk9j-MbAPdEGBmEHq6r8VU | Additional information | false | + Then I check that all page urls works properly + Scenario: Check that other non visible pages work well - Given a list of non visible pages ids and contents - | status | Status | - | error | Upps | + Given I logout if logged + And a list of non visible pages ids and contents + | error | Upppps | false | + # FIXME: find a way to monitor json pages + # | api/v1/status/last-fire-detected | updateAt | + # | api/v1/status/last-fire-check | description | + # | api/v1/status/active-fires-count | total | Then I check that all non visible pages works properly diff --git a/cucumber/features/steps_definitions/feedback.js b/cucumber/features/steps_definitions/feedback.js new file mode 100644 index 0000000..ffd78be --- /dev/null +++ b/cucumber/features/steps_definitions/feedback.js @@ -0,0 +1,50 @@ +/* global module expect require process client */ + +const { randomText, randomEmail } = require('./helper.js'); + +module.exports = function doSteps() { + this.Given(/^I am on the zones page$/, () => { + client.url(`${process.env.ROOT_URL}/zones`); + if (client.isVisible('#logout')) { + client.click('#logout'); + } + }); + + this.Then(/^I can send feedback without email$/, () => { + client.waitForVisible('#feedback-tab', 10000); + client.click('#feedback-tab'); + client.waitForVisible('#feedback-form', 10000); + client.setValue('#feedbackTextarea', randomText(500)); + client.click('#sendFeedbackBtn'); + client.waitForVisible('.bert-alert', 10000, true); + client.waitUntil(() => client.isVisible('.bert-alert') === false, 10000); + }); + + this.Then(/^I can send feedback with email$/, () => { + client.waitForVisible('#feedback-tab', 10000); + client.click('#feedback-tab'); + client.waitForVisible('#feedback-form', 10000); + client.waitForEnabled('input[name="email"]'); + client.setValue('input[name="email"]', randomEmail()); + client.setValue('#feedbackTextarea', randomText(500)); + client.click('#sendFeedbackBtn'); + client.waitForVisible('.bert-alert', 10000, true); + client.waitUntil(() => client.isVisible('.bert-alert') === false, 10000); + }); + + this.Given(/^I'm not logged$/, () => { + client.waitForVisible('#react-root', 5000); + if (client.isVisible('#logout')) { + client.click('#logout'); + } + }); + + this.Given(/^I am on the fires page$/, () => { + client.url(`${process.env.ROOT_URL}/fires`); + }); + + this.Then(/^I cannot send feedback$/, () => { + client.waitForVisible('#react-root', 5000); + client.waitUntil(() => client.isVisible('#feedback-tab') === false, 10000); + }); +}; diff --git a/cucumber/features/steps_definitions/helper.js b/cucumber/features/steps_definitions/helper.js index 9961796..7274a40 100644 --- a/cucumber/features/steps_definitions/helper.js +++ b/cucumber/features/steps_definitions/helper.js @@ -8,6 +8,8 @@ function makeid(size) { return text; } +export const randomText = n => makeid(n); + export const randomUsername = () => makeid(7); export const randomPassword = () => randomUsername(); diff --git a/cucumber/features/steps_definitions/hooks.js b/cucumber/features/steps_definitions/hooks.js index ba4c0c5..64b405a 100644 --- a/cucumber/features/steps_definitions/hooks.js +++ b/cucumber/features/steps_definitions/hooks.js @@ -2,17 +2,52 @@ (function () { module.exports = function () { + const printLog = (type) => { + client.log(type).value.forEach((logItem) => { + const { source, message } = logItem; + if (type === 'browser' && source === 'console-api') { + // Clean messages of type: + // http://localhost:3000/app/app.js?hash=34da4258bd2caa4e14c7c0c20ba6396f309e7236 13325:18 "GMaps script just loaded" + // to get the last part + const cleanMessage = message.replace(/[^ ]{1,} [^ ]{1,} "(.*)"$/g, '$1'); + console.log(`${logItem.level}: ${cleanMessage}`); + } else { + // Right now we don't need logs like: + // %cDownload the React DevTools for a better development experience: https://fb.me/react-devtools + // console.log(`${logItem.level}: ${message}`); + } + }); + }; + + this.StepResult((stepResult) => { + if (stepResult.getStatus() === 'failed') { + // http://webdriver.io/api/protocol/log.html + console.log(`Step result: ${stepResult.getStatus()}`); + printLog('browser'); + } + }); + this.Before(() => { // global.expect = require('@xolvio/jasmine-expect').expect; - /* Not used + // https://github.com/webdriverio/webdriverio/issues/1145 if (!this.initMyCmds) { client.addCommand('waitForClickable', function elementClickable(selector, timeout) { this.waitForVisible(selector, timeout); this.waitForEnabled(selector, timeout); }); + + client.addCommand('waitUntilText', function waitUntilText(selector, text, ms) { + ms = ms || 5000; + this.waitForVisible(selector); + const self = this; + this.waitUntil(() => self.getText(selector).includes(text), ms, `expected text in ${selector} be different after ${ms / 1000}s. Obtained: '${self.getText(selector)}' expected: '${text}'`); + }); + this.initMyCmds = true; - } */ + } + + client.windowHandleSize({ width: 1500, height: 1000 }); // server.call('fixtures/reset'); // server.call('emailStub/reset'); diff --git a/cucumber/features/steps_definitions/login.js b/cucumber/features/steps_definitions/login.js index 03f11f8..b255b96 100644 --- a/cucumber/features/steps_definitions/login.js +++ b/cucumber/features/steps_definitions/login.js @@ -10,7 +10,7 @@ let pass; let email; const setUserValues = (isEdit) => { - client.waitForVisible('input[name="firstName"]', 5000); + client.waitForVisible('input[name="firstName"]', 10000); firstName = randomUsername(); lastName = randomUsername(); email = randomEmail(); @@ -32,31 +32,54 @@ module.exports = function doSteps(notos) { goHome(client); }); - function register() { + function waitNoBert() { + client.pause(2000); + if (client.isVisible('.bert-container')) { + // console.log('Removing bert alert'); + client.click('.bert-container'); + client.waitForVisible('.bert-container', 10000, true); + } else { + // console.log('No bert alert'); + } + } + + function logoutIfLogged() { if (client.isVisible('#logout')) { client.click('#logout'); } - client.waitForVisible('#signup', 5000); + client.waitForVisible('#signup', 10000); + } + + function register() { + logoutIfLogged(); + client.waitForVisible('#signup', 10000); client.click('#signup'); setUserValues(); if (!notos) { client.click('input[name="tos"]'); } client.click('#signUpSubmit'); + client.waitForVisible('#logout', 10000); } + this.Given(/^I logout if logged$/, () => { + waitNoBert(); + logoutIfLogged(); + }); + this.Given(/^I have an account and I logged in$/, () => { register(); }); function checkName() { client.waitForVisible('#profile', 5000); - client.waitForText('#profile', firstName); - client.waitForText('#profile', lastName); + client.waitUntilText('#profile', firstName); + client.waitUntilText('#profile', lastName); } this.Then(/^I should be logged in$/, () => { client.waitUntil(() => client.isVisible('#logout') === true, 10000); + client.waitUntil(() => client.isVisible('#profile') === true, 10000); }); /* this.Given(/^I am signed out$/, () => { @@ -64,29 +87,24 @@ module.exports = function doSteps(notos) { * }); */ - function closeAlert() { - // client.waitForVisible('.bert-alert', 5000); - // client.click('.bert-content'); - client.waitUntil(() => client.isVisible('.bert-alert') === false, 10000); - } this.When(/^I sign out$/, () => { - if (client.isVisible('.bert-alert')) { - client.waitUntil(() => client.isVisible('.bert-alert') === false, 10000); - } - client.waitForVisible('#logout', 5000); + waitNoBert(); + client.waitForClickable('#logout', 10000); + // client.waitForVisible('#logout', 10000); client.click('#logout'); + client.waitForVisible('#login', 10000); }); function login(badpass) { + waitNoBert(); client.waitForVisible('#login', 10000); - client.waitUntil(() => client.isVisible('.bert-alert') === false, 10000); client.click('#login'); client.waitForVisible('#loginSubmit', 5000); client.setValue('input[name="emailAddress"]', email); client.setValue('input[name="password"]', badpass ? randomPassword() : pass); client.click('#loginSubmit'); - closeAlert(); + waitNoBert(); } this.When(/^I enter my email and password$/, () => { @@ -94,11 +112,13 @@ module.exports = function doSteps(notos) { }); this.Then(/^I can edit my profile$/, () => { - client.waitForVisible('#profile', 5000); + waitNoBert(); + client.waitForClickable('#profile', 10000); client.click('#profile'); + client.pause(2000); setUserValues(true); client.click('#profileSubmit'); - closeAlert(); + waitNoBert(); checkName(); }); @@ -116,7 +136,7 @@ module.exports = function doSteps(notos) { this.Then(/^I should be registered$/, () => { client.waitForVisible('.bert-alert', 10000, true); - closeAlert(); + waitNoBert(); }); this.Given(/^I register with some name, password and email but without accept conditions$/, () => { diff --git a/cucumber/features/steps_definitions/pages.js b/cucumber/features/steps_definitions/pages.js index 1ce3c8c..29f6aab 100644 --- a/cucumber/features/steps_definitions/pages.js +++ b/cucumber/features/steps_definitions/pages.js @@ -16,7 +16,7 @@ module.exports = function () { const content = pages[i][1]; client.waitForVisible(id, 10000); client.click(id); - client.waitForText('#react-root', content); + client.waitUntilText('#react-root', content); // https://jasmine.github.io/2.3/introduction.html#section-Expectations expect(client.getTitle()).toContain(pages[i][1]); } @@ -26,7 +26,7 @@ module.exports = function () { const content = pages[i][1]; client.url(`${process.env.ROOT_URL}/${url}`); client.waitForVisible('#react-root', 10000); - client.waitForText('#react-root', content); + client.waitUntilText('#react-root', content); const checkTitle = pages[i][2] === 'true'; // https://jasmine.github.io/2.3/introduction.html#section-Expectations if (checkTitle) { @@ -41,9 +41,7 @@ module.exports = function () { this.Then(/^I check that all non visible pages works properly$/, (callback) => { for (let i = 0; i < pages.length; i += 1) { - client.url(`${process.env.ROOT_URL}/${pages[i][0]}`); - const content = pages[i][1]; - client.waitForText('#react-root', content); + processPageUrl(i); } callback(); }); @@ -71,4 +69,13 @@ module.exports = function () { } callback(); }); + + this.Then(/^they are spiderable$/, (callback) => { + // Write code here that turns the phrase above into concrete actions + for (let i = 0; i < pages.length; i += 1) { + client.url(`${process.env.ROOT_URL}/${pages[i][0]}?_escaped_fragment_=`); + client.waitUntilText('#react-root', pages[i][1]); + } + callback(); + }); }; diff --git a/designs/icons-tcef.svg b/designs/icons-tcef.svg index fd3a447..0835ab8 100644 --- a/designs/icons-tcef.svg +++ b/designs/icons-tcef.svg @@ -7,6 +7,7 @@ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="210mm" @@ -245,9 +246,9 @@ borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="0.25" - inkscape:cx="-324.9908" - inkscape:cy="997.3585" + inkscape:zoom="0.23559314" + inkscape:cx="-2890.4884" + inkscape:cy="-486.23766" inkscape:document-units="mm" inkscape:current-layer="layer1" showgrid="false" @@ -338,9 +339,9 @@ + inkscape:export-filename="/mnt/md1/proyectos/dev/todos-contra-el-fuego-web/public/fire-marker-s.png" + inkscape:export-xdpi="2" + inkscape:export-ydpi="2"> + + id="g8543" + inkscape:export-filename="/mnt/md1/proyectos/dev/todos-contra-el-fuego-web/public/fire-marker-l.png" + inkscape:export-xdpi="4" + inkscape:export-ydpi="4"> + inkscape:export-ydpi="4.0856881" + style="stroke-width:5.62737894"> + id="g8559" + style="stroke-width:5.62737894"> + id="g22554-2" + style="stroke-width:5.62737894"> + style="fill:#d41300;fill-opacity:1;stroke:none;stroke-width:59.556427;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:59.556427;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + id="g22554-5" + style="stroke-width:5.62737894"> + style="fill:#d41300;fill-opacity:1;stroke:none;stroke-width:59.556427;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:59.556427;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + id="g8542" + style="stroke-width:5.62737894"> + + + + + + + + + + + + + + + diff --git a/fonts.json b/fonts.json index ba2e995..81a6ae1 100644 --- a/fonts.json +++ b/fonts.json @@ -1,10 +1,9 @@ { "extensions": [ "ttf", "woff", "woff2" ], "map": { - "node_modules/simple-line-icons/fonts/Simple-Line-Icons.woff": "fonts/Simple-Line-Icons.woff", - "node_modules/simple-line-icons/fonts/Simple-Line-Icons.woff": "fonts/Simple-Line-Icons.woff?v=2.4.0", - "node_modules/simple-line-icons/fonts/Simple-Line-Icons.woff2": "fonts/Simple-Line-Icons.woff2?v=2.4.0", - "node_modules/simple-line-icons/fonts/Simple-Line-Icons.ttf": "fonts/Simple-Line-Icons.ttf?v=2.4.0" + "node_modules/simple-line-icons/fonts/Simple-Line-Icons.woff": "/fonts/Simple-Line-Icons.woff?v=2.4.1", + "node_modules/simple-line-icons/fonts/Simple-Line-Icons.woff2": "/fonts/Simple-Line-Icons.woff2?v=2.4.1", + "node_modules/simple-line-icons/fonts/Simple-Line-Icons.ttf": "/fonts/Simple-Line-Icons.ttf?v=2.4.1" }, "verbose": false } diff --git a/imports/api/ActiveFires/ActiveFires.js b/imports/api/ActiveFires/ActiveFires.js index 79437af..16e2760 100644 --- a/imports/api/ActiveFires/ActiveFires.js +++ b/imports/api/ActiveFires/ActiveFires.js @@ -1,5 +1,6 @@ /* eslint-disable consistent-return */ +import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from 'simpl-schema'; import firesCommonSchema from '../Common/FiresSchema'; @@ -18,8 +19,11 @@ ActiveFires.deny({ remove: () => true }); +const activeFiresSchema = firesCommonSchema; -ActiveFires.schema = new SimpleSchema(firesCommonSchema); +activeFiresSchema.fireUnion = { type: Meteor.Collection.ObjectID, optional: true, blackbox: true }; + +ActiveFires.schema = new SimpleSchema(activeFiresSchema); ActiveFires.attachSchema(ActiveFires.schema); diff --git a/imports/api/ActiveFires/server/countFires.js b/imports/api/ActiveFires/server/countFires.js new file mode 100644 index 0000000..b3b8511 --- /dev/null +++ b/imports/api/ActiveFires/server/countFires.js @@ -0,0 +1,79 @@ +/* eslint-disable import/no-absolute-path */ +import { whichAreFalsePositives, firesUnion } from '/imports/api/FalsePositives/server/publications'; +import FalsePositives from '/imports/api/FalsePositives/FalsePositives'; +import Industries from '/imports/api/Industries/Industries'; +import ravenLogger from '/imports/startup/server/ravenLogger'; +import ActiveFires from '../ActiveFires'; + +const debug = 0; + +const cleanProv = (prov, stringsToRemove) => { + let lprov = prov; + stringsToRemove.forEach((st) => { + lprov = lprov.replace(st[0], st[1]); + }); + return lprov; +}; + +const findFiresInRegion = (zone) => { + const result = ActiveFires.find({ + ourid: { + $geoWithin: { + $geometry: zone.geometry + } + } + }); + + /* console.log(`False positive total: ${falsePos.count()}`); + console.log(`False positives: ${JSON.stringify(falsePos.fetch())}`); */ + return result; +}; + +export const countRealFires = (firesCursor) => { + const realFires = []; + firesCursor.forEach((fire) => { + if (debug) console.log(`${JSON.stringify(fire)} -----`); + const union = firesUnion([fire]); + if (debug) console.log(`${JSON.stringify(union)} -----`); + const falsePos = whichAreFalsePositives(FalsePositives, union); + const industries = whichAreFalsePositives(Industries, union); + if (falsePos.count() === 0 && industries.count() === 0) { + realFires.push(fire); + } + }); + // group fires + const realUnion = firesUnion(realFires); + const unionCount = realUnion[0] && + realUnion[0].geometry && + realUnion[0].geometry.coordinates ? realUnion[0].geometry.coordinates.length : 0; + return unionCount; +}; + +const countFiresInRegions = (regions, stringsToRemove) => { + let total = 0; + const fireStats = {}; + + regions.features.forEach((region) => { + const regionName = cleanProv(region.properties.name, stringsToRemove); + const regionCode = region.properties.iso_a2; + if (debug) console.log(`${regionName} -----`); + try { + const fires = findFiresInRegion(region); + // TODO Also check neighbour fires (when better implementation of that part) + const initialCount = fires.count(); + if (initialCount > 0) { + const unionCount = countRealFires(fires); + if (debug) console.log(`${regionName} initial: ${initialCount}, union calc: ${unionCount}`); + if (unionCount > 0) { + total += unionCount; + fireStats[regionName] = { count: unionCount, code: regionCode }; + } + } + } catch (e) { + ravenLogger.log(e); + } + }); + return { total, fires: fireStats }; +}; + +export default countFiresInRegions; diff --git a/imports/api/ActiveFires/server/publications.js b/imports/api/ActiveFires/server/publications.js index 278d937..03878b3 100644 --- a/imports/api/ActiveFires/server/publications.js +++ b/imports/api/ActiveFires/server/publications.js @@ -5,6 +5,9 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { NumberBetween } from '/imports/modules/server/other-checks'; +import { whichAreFalsePositives, firesUnion } from '/imports/api/FalsePositives/server/publications'; +import FalsePositives from '/imports/api/FalsePositives/FalsePositives'; +import Industries from '/imports/api/Industries/Industries'; import ActiveFires from '../ActiveFires'; const counter = new Counter('countActiveFires', ActiveFires.find({})); @@ -13,7 +16,7 @@ Meteor.publish('activefirestotal', function total() { return counter; }); -const activefires = (northEastLng, northEastLat, southWestLng, southWestLat) => { +const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, withMarks) => { const fires = ActiveFires.find({ ourid: { $geoWithin: { @@ -28,19 +31,35 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat) => lat: 1, lon: 1, when: 1, - scan: 1 + scan: 1, + track: 1 } }); - // console.log(`Fires total: ${fires.count()}`); + + if (Meteor.isDevelopment) console.log(`Active fires total: ${fires.count()}`); + + if (withMarks && fires.fetch().length > 0) { + const union = firesUnion(fires); + const falsePos = whichAreFalsePositives(FalsePositives, union); + const industries = whichAreFalsePositives(Industries, union); + return [fires, falsePos, industries]; + } + return fires; }; -Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { +Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { // latitude -90 and 90 and the longitude between -180 and 180 check(northEastLng, NumberBetween(-180, 180)); check(southWestLat, NumberBetween(-90, 90)); check(southWestLng, NumberBetween(-180, 180)); check(northEastLat, NumberBetween(-90, 90)); + check(withMarks, Boolean); - return activefires(northEastLng, northEastLat, southWestLng, southWestLat); + return activefires(northEastLng, northEastLat, southWestLng, southWestLat, withMarks); +}); + +// Warning: this increase always by one the fire stats +Meteor.publish('lastFireDetected', function lastFireDetected() { + return ActiveFires.find({}, { limit: 1, sort: { when: -1 } }); }); diff --git a/imports/api/ActiveFires/server/tweetFiresInZone.js b/imports/api/ActiveFires/server/tweetFiresInZone.js new file mode 100644 index 0000000..74d40fb --- /dev/null +++ b/imports/api/ActiveFires/server/tweetFiresInZone.js @@ -0,0 +1,155 @@ +/* eslint-disable import/no-absolute-path */ +import getPrivateFile from '/imports/modules/server/get-private-file'; +import { Meteor } from 'meteor/meteor'; +import tbuffer from '@turf/buffer'; +import emojiFlags from 'emoji-flags'; +import tweet from '/imports/modules/server/twitter'; +import countFiresInRegions from './countFires'; + +const debug = false; + +const autonomies = tbuffer(JSON.parse(getPrivateFile('data/es-atlas-autonomies.json')), 0); +const portugal = tbuffer(JSON.parse(getPrivateFile('data/pt-atlas.json')), 0); + +const europe = tbuffer(JSON.parse(getPrivateFile('data/europe-atlas.json')), 0); + +const stringsToRemoveIberia = [ + ['Ciudad Autónoma de', ''], + ['País Vasco/', ''], + ['Pais Vasco', 'Euskadi'], + ['Comunidad Foral de', ''], + ['Región de', ''], + ['Valencia', 'CValenciana'], + ['Comunidad de Madrid', 'CMadrid'], + ['León', 'CyLeón'], + ['Leon', 'CyLeón'], + ['Castilla-', ''], + ['Cataluña/', ''], + ['Cataluña', 'Catalunya'], + ['La Mancha', 'CLMancha'], + ['Baleares', 'IllesBalears'], + ['Galicia', 'Galiza'], + ['Principado de', ''], + [' ', ''] +]; + +const emoji = (key, name, fires) => { + const emo = emojiFlags.countryCode(key.code); + if (!emo) { + if (Meteor.isDevelopment) console.log(`Cannot find flag for country ${name}: ${JSON.stringify(key)}`); + return ''; + } + return `${emo.emoji} ${fires}`; +}; + +const composeEuropeTweet = (num, stats, abrev) => { + if (num <= 0) { + return ''; + } + const keys = Object.keys(stats); + const numZones = keys.length; + let text = ''; + keys.map((key, index) => { // , index + const zoneFires = stats[key].count; + const zoneFiresText = zoneFires > 1 ? index === 0 ? zoneFires : `${zoneFires}` : index === 0 ? 'One' : '1'; + const what = index > 0 ? ' ' : zoneFires > 1 ? ' active #wildfires ' : ' active #wildfire '; + const subText = abrev ? emoji(stats[key], key, zoneFires) : + `${zoneFiresText}${what}in #{key}`; + let delimiter; + if (!abrev) { + delimiter = index === numZones - 1 ? ' and' : ','; + } else { + delimiter = ','; + } + + const subTexts = index === 0 ? subText : `${delimiter} ${subText}`; + text += subTexts; + return ''; + }); + return `${text}`; +}; + +const composeIberiaTweet = (num, stats) => { + if (num <= 0) { + return ''; + } + const keys = Object.keys(stats); + const numZones = keys.length; + let text = ''; + keys.map((key, index) => { // , index + const zoneFires = stats[key].count; + const zoneFiresText = zoneFires > 1 ? index === 0 ? zoneFires : `otros ${zoneFires}` : index === 0 ? 'Un' : 'otro'; + const what = index > 0 ? ' ' : zoneFires > 1 ? ' #fuegos activos ' : ' #fuego activo '; + const subText = `${zoneFiresText}${what}en #${key}`; + const delimiter = index === numZones - 1 ? ' y' : ','; + const subTexts = index === 0 ? subText : `${delimiter} ${subText}`; + text += subTexts; + return ''; + }); + return `${text}`; +}; + +const tweetHeaders = ['🔥']; +const tweetFooters = ['Más info en: https://fuegos.comunes.org/fires']; + + +const correctTweetSize = (tweetText) => { + const length = tweetText.length; + if (length > 280) { + if (Meteor.isDevelopment) console.log(`Tweet is too big (${length}): ${tweetText}`); + return false; + } + return true; +}; + +const tweetEuropeFires = () => { + const resultEu = countFiresInRegions(europe, [[' ', '']]); + + if (resultEu.total > 0) { + let tweetText = `${tweetHeaders[0].trim()} ${composeEuropeTweet(resultEu.total, resultEu.fires, false)}. More info: https://fires.comunes.org/fires`; + if (!correctTweetSize(tweetText)) { + tweetText = `${tweetHeaders[0].trim()}Active #wildfires in #Europe:\n${composeEuropeTweet(resultEu.total, resultEu.fires, true)}. More info: https://fires.comunes.org/fires`; + } + if (!correctTweetSize(tweetText)) { + tweetText = `${tweetHeaders[0].trim()}Active #wildfires in #Europe:\n${composeEuropeTweet(resultEu.total, resultEu.fires, true)}`; + } + if (!correctTweetSize(tweetText)) { + tweetText = `${tweetHeaders[0].trim()} There are ${resultEu.total} active #wildfires in #Europe. More info: https://fires.comunes.org/fires`; + } + if (Meteor.isDevelopment) { + console.log(tweetText); + } else { + tweet(tweetText, 'en'); + } + return tweetText; + } +}; + +const tweetIberiaFires = () => { + const resultEs = countFiresInRegions(autonomies, stringsToRemoveIberia); + const resultPt = countFiresInRegions(portugal, stringsToRemoveIberia); + + const fires = Object.assign(resultEs.fires, resultPt.fires); + const total = resultEs.total + resultPt.total; + + // console.log(`Total fires ${total}, ${JSON.stringify(fires)}`); + if (total > 0) { + const tweetText = `${tweetHeaders[0].trim()} ${composeIberiaTweet(total, fires)}. ${tweetFooters[0]}`; + if (correctTweetSize(tweetText)) { + if (Meteor.isDevelopment) { + console.log(tweetText); + } else { + tweet(tweetText, 'es'); + } + } + return tweetText; + } + return ''; +}; + +if (Meteor.isDevelopment && debug) { + tweetIberiaFires(); + tweetEuropeFires(); +} + +export { composeIberiaTweet, tweetHeaders, tweetFooters, tweetIberiaFires, tweetEuropeFires }; diff --git a/imports/api/ActiveFiresUnion/ActiveFiresUnion.js b/imports/api/ActiveFiresUnion/ActiveFiresUnion.js new file mode 100644 index 0000000..2437e54 --- /dev/null +++ b/imports/api/ActiveFiresUnion/ActiveFiresUnion.js @@ -0,0 +1,71 @@ +/* eslint-disable consistent-return */ +/* eslint-disable import/no-absolute-path */ + +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; +import LocationSchema from '/imports/api/Utility/LocationSchema.js'; +import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js'; + +const ActiveFiresUnion = new Mongo.Collection('activefiresunion', { idGeneration: 'MONGO' }); + +/* Sample: + * + * TODO + * + * */ + +// We have to store polygons and their centroids +// {"type":"Polygon","coordinates":[[[-5.8645,43.5524],[-5.8621,43.5524],[-5.8621,43.5546],[-5.8645,43.5546],[-5.8645,43.5524]]]} + +ActiveFiresUnion.allow({ + insert: () => false, + update: () => false, + remove: () => false +}); + +ActiveFiresUnion.deny({ + insert: () => true, + update: () => true, + remove: () => true +}); + +// https://www.npmjs.com/package/simpl-schema +const GeoJsonSchema = new SimpleSchema({ + type: { + type: String, + allowedValues: ['Polygon'] + }, + coordinates: { + type: Array + }, + 'coordinates.$': { + type: Array + }, + 'coordinates.$.$': { + type: Array + }, + 'coordinates.$.$.$': { + type: Number + } +}); + +const firesUnionSchema = { + centerid: LocationSchema, + + // https://docs.mongodb.com/manual/reference/geojson/ + // : { type: , coordinates: } + shape: GeoJsonSchema, + history: { type: Array, optional: true }, + 'history.$': GeoJsonSchema, + + when: Date, // First date + + createdAt: defaultCreatedAt, + updatedAt: defaultUpdateAt +}; + +ActiveFiresUnion.schema = new SimpleSchema(firesUnionSchema); + +ActiveFiresUnion.attachSchema(ActiveFiresUnion.schema); + +export default ActiveFiresUnion; diff --git a/imports/api/ActiveFiresUnion/server/publications.js b/imports/api/ActiveFiresUnion/server/publications.js new file mode 100644 index 0000000..6a126d8 --- /dev/null +++ b/imports/api/ActiveFiresUnion/server/publications.js @@ -0,0 +1,80 @@ +/* global Counter */ +/* eslint-disable import/no-absolute-path */ +/* eslint-disable prefer-arrow-callback */ + +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { NumberBetween } from '/imports/modules/server/other-checks'; +// import { whichAreFalsePositives, firesUnion } from '/imports/api/FalsePositives/server/publications'; +// import FalsePositives from '/imports/api/FalsePositives/FalsePositives'; +// import Industries from '/imports/api/Industries/Industries'; +import ActiveFiresUnion from '../ActiveFiresUnion'; + +const counter = new Counter('countActiveFiresUnion', ActiveFiresUnion.find({})); + +Meteor.publish('activefiresuniontotal', function total() { + return counter; +}); + +const activeFiresUnion = (b1, b2, c1, c2, withMarks) => { + // a --- b + // c --- d + const geometry = { + $geometry: { + type: 'Polygon', + coordinates: [[ + [c1, c2], + [c1, b2], + [b1, b2], + [b1, c2], + [c1, c2] + ]] + } + }; + // console.log(JSON.stringify(geometry)); + const fires = ActiveFiresUnion.find({ + shape: { + $geoIntersects: geometry + } + }, { + fields: { + shape: 1, + centerid: 1, + when: 1, + createdAt: 1, + updatedAt: 1 + } + }); + + if (Meteor.isDevelopment) console.log(`Active fires union total: ${fires.count()}`); + + /* + if (withMarks && fires.fetch().length > 0) { + const union = firesUnion(fires); + const falsePos = whichAreFalsePositives(FalsePositives, union); + const industries = whichAreFalsePositives(Industries, union); + return [fires, falsePos, industries]; + } */ + + return fires; +}; + +Meteor.publish('activefiresunionmyloc', function activeInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat, withMarks) { + // latitude -90 and 90 and the longitude between -180 and 180 + check(northEastLng, NumberBetween(-180, 180)); + check(southWestLat, NumberBetween(-90, 90)); + check(southWestLng, NumberBetween(-180, 180)); + check(northEastLat, NumberBetween(-90, 90)); + check(withMarks, Boolean); + + // I use this for fire stats + // if (!Meteor.isDevelopment) return this.ready(); // empty + return activeFiresUnion(northEastLng, northEastLat, southWestLng, southWestLat, withMarks); +}); + +// Warning: this increase always by one the fire stats +Meteor.publish('lastFireUnionDetected', function lastFireDetected() { + // I use this for fire stats + // if (!Meteor.isDevelopment) return this.ready(); // empty + return ActiveFiresUnion.find({}, { limit: 1, sort: { when: -1 } }); +}); diff --git a/imports/api/Common/dates.js b/imports/api/Common/dates.js index 153bce3..7fb4bf3 100644 --- a/imports/api/Common/dates.js +++ b/imports/api/Common/dates.js @@ -3,4 +3,5 @@ import moment from 'moment-timezone'; export const momentTz = date => moment.tz(date, moment.tz.guess()); export const dateLongFormat = date => momentTz(date).format('LLLL (z)'); export const dateParseShortFormat = date => moment(date, 'YYYY-MM-DD').format('LL'); +export const dateYYYYMMDD = date => moment(date).format('YYYY-MM-DD'); export const dateFromNow = date => momentTz(date).fromNow(); diff --git a/imports/api/FalsePositives/FalsePositiveTypes.js b/imports/api/FalsePositives/FalsePositiveTypes.js index 402d8b3..274fd5d 100644 --- a/imports/api/FalsePositives/FalsePositiveTypes.js +++ b/imports/api/FalsePositives/FalsePositiveTypes.js @@ -2,6 +2,7 @@ const types = { industry: 'Es una industria', + // TODO volcano: 'Es un volcán', controled: 'Es una quema controlada', falsealarm: 'Parece una falsa alarma' }; diff --git a/imports/api/FalsePositives/FalsePositives.js b/imports/api/FalsePositives/FalsePositives.js index 592fe12..eead435 100644 --- a/imports/api/FalsePositives/FalsePositives.js +++ b/imports/api/FalsePositives/FalsePositives.js @@ -34,4 +34,15 @@ FalsePositives.schema = new SimpleSchema({ FalsePositives.attachSchema(FalsePositives.schema); +export const falsePositivesRemap = (odoc) => { + const doc = odoc; + const geo = doc.geo; + doc.lat = geo.coordinates[1]; + doc.lon = geo.coordinates[0]; + doc._id = doc.fireId; + doc.id = doc.fireId; + delete doc.geo; + return doc; +}; + export default FalsePositives; diff --git a/imports/api/FalsePositives/methods.js b/imports/api/FalsePositives/methods.js index 7aa4ef1..85005ba 100644 --- a/imports/api/FalsePositives/methods.js +++ b/imports/api/FalsePositives/methods.js @@ -6,6 +6,29 @@ import FalsePositiveTypes from './FalsePositiveTypes'; import Fires from '../Fires/Fires'; import rateLimit from '../../modules/rate-limit'; +export function upsertFalsePositive(keyType, owner, fire) { + const fireType = fire.type; + if (fireType === 'vecinal') { + throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego'); + } + const date = new Date(); + const formated = moment(date).format('YYYYMMDD'); + try { + const set = { + owner, + type: keyType, + when: date, + whendateformat: formated, + fireId: fire._id, + geo: fire.ourid + }; + return FalsePositives.upsert({ geo: fire.ourid, owner }, { $set: set }, { multi: false, upsert: true }); + } catch (exception) { + console.log(exception); + throw new Meteor.Error('500', exception); + } +} + Meteor.methods({ 'falsePositives.insert': function falsePositivesInsert(fireId, keyType) { check(fireId, Meteor.Collection.ObjectID); @@ -17,27 +40,9 @@ Meteor.methods({ } check(this.userId, String); const fire = Fires.findOne(fireId); + const owner = this.userId; if (fire) { - const fireType = fire.type; - if (fireType === 'vecinal') { - throw new Meteor.Error('500', 'No se puede marcar este tipo de fuego'); - } - const date = new Date(); - const formated = moment(date).format('YYYYMMDD'); - try { - const set = { - owner: this.userId, - type: keyType, - when: date, - whendateformat: formated, - fireId, - geo: fire.ourid - }; - return FalsePositives.upsert({ geo: fire.ourid, owner: this.userId }, { $set: set }, { multi: false, upsert: true }); - } catch (exception) { - console.log(exception); - throw new Meteor.Error('500', exception); - } + upsertFalsePositive(keyType, owner, fire); } else { throw new Meteor.Error('500', 'Fuego no encontrado'); } diff --git a/imports/api/FalsePositives/server/publications.js b/imports/api/FalsePositives/server/publications.js index 9585907..a79ae1a 100644 --- a/imports/api/FalsePositives/server/publications.js +++ b/imports/api/FalsePositives/server/publications.js @@ -5,6 +5,10 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { NumberBetween } from '/imports/modules/server/other-checks'; +import '/imports/startup/server/leaflet-workaround.js'; +import L from 'leaflet'; +import calcUnion from '/imports/ui/components/Maps/SubsUnion/Unify'; +import Industries from '/imports/api/Industries/Industries'; import FalsePositives from '../FalsePositives'; const counter = new Counter('countFalsePositives', FalsePositives.find({})); @@ -13,8 +17,53 @@ Meteor.publish('falsePositivesTotal', function total() { return counter; }); -const falsePositives = (northEastLng, northEastLat, southWestLng, southWestLat) => { - const fires = FalsePositives.find({ +export const firesUnion = (fires) => { + const firesArray = Array.isArray(fires) ? fires : fires.fetch(); // if not is a cursor + const remap = firesArray.map(function remap(doc) { + const isNASA = doc.type === 'modis' || doc.type === 'viirs'; + const pixelSize = doc.type === 'viirs' ? 0.375 : 1; // viirs has 375m pixel size, modis 1000m + // default 1 km for neightbor alerts + const map = { + location: { lat: doc.lat, lon: doc.lon }, + distance: isNASA ? doc.scan * pixelSize : 1, + distanceY: isNASA ? doc.track * pixelSize : 1 + }; + // console.log(map); + return map; + }); + const union = calcUnion(L, remap, sub => sub, false); + return union; +}; + +export const zoneToUnion = (lat, lon, distance) => { + const remap = [{ location: { lat, lon }, distance }]; + const union = calcUnion(L, remap, sub => sub, true); + return union; +}; + +export const whichAreFalsePositives = (collection, union) => { + const result = collection.find({ + geo: { + $geoWithin: { + $geometry: union[0].geometry + } + } + }, { + fields: { + geo: 1, + // type: 1, + // when: 1, + fireId: 1 + } + }); + + /* console.log(`False positive total: ${falsePos.count()}`); + console.log(`False positives: ${JSON.stringify(falsePos.fetch())}`); */ + return result; +}; + +const find = (collection, northEastLng, northEastLat, southWestLng, southWestLat) => { + const fires = collection.find({ geo: { $geoWithin: { $box: [ @@ -30,19 +79,6 @@ const falsePositives = (northEastLng, northEastLat, southWestLng, southWestLat) // when: 1, fireId: 1 } - }).serverTransform(function transformDoc(odoc) { - const doc = odoc; - // Destructuring gives me an error: "Cannot destructure property `geo` of 'undefined'" - const geo = doc.geo; - if (geo) { - doc.lat = geo.coordinates[1]; - doc.lon = geo.coordinates[0]; - } - doc._id = doc.fireId; - doc.id = doc.fireId; - delete doc.geo; - // console.log(doc); - return doc; }); // console.log(`Fires total: ${fires.count()}`); return fires; @@ -55,5 +91,15 @@ Meteor.publishTransformed('falsePositivesMyloc', function falsePositivesInMyLoc( check(southWestLng, NumberBetween(-180, 180)); check(northEastLat, NumberBetween(-90, 90)); - return falsePositives(northEastLng, northEastLat, southWestLng, southWestLat); + return find(FalsePositives, northEastLng, northEastLat, southWestLng, southWestLat); +}); + +Meteor.publishTransformed('industriesMyloc', function industriesInMyLoc(northEastLng, northEastLat, southWestLng, southWestLat) { + // latitude -90 and 90 and the longitude between -180 and 180 + check(northEastLng, NumberBetween(-180, 180)); + check(southWestLat, NumberBetween(-90, 90)); + check(southWestLng, NumberBetween(-180, 180)); + check(northEastLat, NumberBetween(-90, 90)); + + return find(Industries, northEastLng, northEastLat, southWestLng, southWestLat); }); diff --git a/imports/api/FireAlerts/server/publications.js b/imports/api/FireAlerts/server/publications.js index dca2c5d..6642f35 100644 --- a/imports/api/FireAlerts/server/publications.js +++ b/imports/api/FireAlerts/server/publications.js @@ -33,9 +33,10 @@ Meteor.publish('fireAlerts', function fireAlerts(northEastLng, northEastLat, sou fields: { lat: 1, lon: 1, - scan: 1 + scan: 1, + track: 1 } }); - // console.log(`Fires total: ${fires.count()}`); + if (Meteor.isDevelopment) console.log(`Neighbour alerts total: ${fires.count()}`); return fires; }); diff --git a/imports/api/Fires/server/publications.js b/imports/api/Fires/server/publications.js index 575c7af..3862beb 100644 --- a/imports/api/Fires/server/publications.js +++ b/imports/api/Fires/server/publications.js @@ -8,6 +8,12 @@ import { Promise } from 'meteor/promise'; import NodeGeocoder from 'node-geocoder'; import { gmapServerKey } from '/imports/startup/server/IPGeocoder'; import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires'; +import FireAlertsCollection from '/imports/api/FireAlerts/FireAlerts'; +import { whichAreFalsePositives, firesUnion } from '/imports/api/FalsePositives/server/publications'; +import FalsePositives from '/imports/api/FalsePositives/FalsePositives'; +import Industries from '/imports/api/Industries/Industries'; +import ravenLogger from '/imports/startup/server/ravenLogger'; + import FiresCollection from '../Fires'; function findFire(unsealed) { @@ -70,6 +76,24 @@ const findOrCreateFire = (obj) => { return findFire(obj); }; +Meteor.publish('fireFromAlertId', function fireFromAlertId(_id) { + try { + check(_id, String); + // console.log(`Looking for alert fire ${_id}`); + const fire = FireAlertsCollection.findOne(new Meteor.Collection.ObjectID(_id)); + if (fire) { + // console.info(`Active fire found: ${_id}`); + return findOrCreateFire(fixConfidence(fire)); + } + console.info(`Alert fire not found: ${_id}`); + // Not found in active fires! + return this.ready(); + } catch (e) { + console.info(`Alert fire not found (with error): ${_id}`); + return this.ready(); + } +}); + Meteor.publish('fireFromActiveId', function fireFromActiveId(_id) { try { check(_id, String); @@ -95,43 +119,60 @@ Meteor.publish('fireFromId', function fireFromId(_id) { const fire = FiresCollection.find(new Meteor.Collection.ObjectID(_id)); if (fire.count() !== 0) { // console.info(`Archive fire found: ${_id}`); - return fire; + const union = firesUnion(fire); + const falsePos = whichAreFalsePositives(FalsePositives, union); + const industries = whichAreFalsePositives(Industries, union); + return [fire, falsePos, industries]; } console.info(`Fire not found: ${_id}`); // Not found in active fires! return this.ready(); } catch (e) { - console.info(`Active fire not found (with error): ${_id}`); + console.info(`Archive fire not found (with error): ${_id}`); return this.ready(); } }); -Meteor.publish('fireFromHash', function fireFromHash(fireEnc) { +function logUrl(fireEnc, params) { + const message = `Wrong fire: ${fireEnc}. Params received in url: ${JSON.stringify(params)}`; + console.log(message); + ravenLogger.log(message); +} + +export function fireFromHash(fireEnc, params) { check(fireEnc, String); + check(params, Object); + + // console.log(fireEnc); + // const unsealed = Promise.await(urlEnc.decrypt(fireEnc)); + const unsealed = Promise.await(unsealW(fireEnc)); + if (unsealed === undefined) { + throw Error(`Fail to unseal '${fireEnc}'`); + } + const w = unsealed.when; + unsealed.when = new Date(w); + const c = unsealed.createdAt; + unsealed.createdAt = !c ? new Date() : new Date(c); + const u = unsealed.updatedAt; + unsealed.updatedAt = !u ? new Date() : new Date(u); + // console.log(unsealed); + // FIXME: + const unsealedFix = fixConfidence(unsealed); + FiresCollection.schema.validate(unsealedFix); + return findOrCreateFire(unsealedFix); + /* console.log(`fires: ${fire.count()}`); + * return fire; */ +} + +Meteor.publish('fireFromHash', function fireFromHashFunc(fireEnc, params) { + check(fireEnc, String); + check(params, Object); try { - // console.log(fireEnc); - // const unsealed = Promise.await(urlEnc.decrypt(fireEnc)); - const unsealed = Promise.await(unsealW(fireEnc)); - if (unsealed === undefined) { - console.info(`Wrong fire: ${fireEnc}`); - // https://guide.meteor.com/data-loading.html - return this.ready(); - } - const w = unsealed.when; - unsealed.when = new Date(w); - const c = unsealed.createdAt; - unsealed.createdAt = !c ? new Date() : new Date(c); - const u = unsealed.updatedAt; - unsealed.updatedAt = !u ? new Date() : new Date(u); - // console.log(unsealed); - // FIXME: - const unsealedFix = fixConfidence(unsealed); - FiresCollection.schema.validate(unsealedFix); - return findOrCreateFire(unsealedFix); - /* console.log(`fires: ${fire.count()}`); - * return fire; */ + return fireFromHash(fireEnc, params); } catch (e) { console.warn(e); + logUrl(fireEnc, params); + // https://guide.meteor.com/data-loading.html return this.ready(); } }); diff --git a/imports/api/Industries/Industries.js b/imports/api/Industries/Industries.js new file mode 100644 index 0000000..dd1fde0 --- /dev/null +++ b/imports/api/Industries/Industries.js @@ -0,0 +1,58 @@ +/* eslint-disable consistent-return */ + +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; +import LocationSchema from '/imports/api/Utility/LocationSchema.js'; + +const Industries = new Mongo.Collection('industries'); + +// https://prtr.unece.org/prtr-global-map +// For now using: + +// AUSTRALIA +// https://data.gov.au/dataset/npi# +// +// EUROPE +// https://www.eea.europa.eu/data-and-maps/data/member-states-reporting-art-7-under-the-european-pollutant-release-and-transfer-register-e-prtr-regulation-16 +// +// CANADA +// https://www.canada.ca/en/environment-climate-change/services/national-pollutant-release-inventory/tools-resources-data/access.html +// +// USA +// https://www.epa.gov/enviro/epa-frs-facilities-state-single-file-csv-download + +// TODO: +// http://www.nite.go.jp/ +// http://www.retc.cl/datos-retc/ + +Industries.allow({ + insert: () => false, + update: () => false, + remove: () => false +}); + +Industries.deny({ + insert: () => true, + update: () => true, + remove: () => true +}); + +Industries.schema = new SimpleSchema({ + geo: LocationSchema, + name: { type: String, optional: true }, + registry: String +}); + +Industries.attachSchema(Industries.schema); + +export const industriesRemap = (odoc) => { + const doc = odoc; + const geo = doc.geo; + doc.lat = geo.coordinates[1]; + doc.lon = geo.coordinates[0]; + doc.id = doc._id; + delete doc.geo; + return doc; +}; + +export default Industries; diff --git a/imports/api/Industries/IndustryRegistries.js b/imports/api/Industries/IndustryRegistries.js new file mode 100644 index 0000000..5eec43b --- /dev/null +++ b/imports/api/Industries/IndustryRegistries.js @@ -0,0 +1,29 @@ +/* eslint-disable consistent-return */ + +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; + +const IndustryRegistries = new Mongo.Collection('industryregistries', { idGeneration: 'MONGO' }); + +IndustryRegistries.allow({ + insert: () => false, + update: () => false, + remove: () => false +}); + +IndustryRegistries.deny({ + insert: () => true, + update: () => true, + remove: () => true +}); + +IndustryRegistries.schema = new SimpleSchema({ + name: String, + agency: String, + region: String + +}); + +IndustryRegistries.attachSchema(IndustryRegistries.schema); + +export default IndustryRegistries; diff --git a/imports/api/Notifications/Notifications.js b/imports/api/Notifications/Notifications.js index 0c89c55..0355540 100644 --- a/imports/api/Notifications/Notifications.js +++ b/imports/api/Notifications/Notifications.js @@ -2,6 +2,7 @@ /* eslint-disable import/no-absolute-path */ import { Mongo } from 'meteor/mongo'; +import { Meteor } from 'meteor/meteor'; import SimpleSchema from 'simpl-schema'; import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js'; import LocationSchema from '/imports/api/Utility/LocationSchema.js'; @@ -22,11 +23,12 @@ Notifications.deny({ Notifications.schema = new SimpleSchema({ userId: String, + subsId: { type: Meteor.Collection.ObjectID, optional: true, blackbox: true }, content: String, geo: LocationSchema, type: String, - webNotified: { type: Boolean, optional: true }, - webNotifiedAt: { type: Date, optional: true }, + notified: { type: Boolean, optional: true }, + notifiedAt: { type: Date, optional: true }, emailNotified: { type: Boolean, optional: true }, emailNotifiedAt: { type: Date, optional: true }, when: Date, diff --git a/imports/api/Notifications/methods.js b/imports/api/Notifications/methods.js index 604c6e0..05d7d08 100644 --- a/imports/api/Notifications/methods.js +++ b/imports/api/Notifications/methods.js @@ -8,7 +8,7 @@ Meteor.methods({ check(notifId, Meteor.Collection.ObjectID); try { - Notifications.update(notifId, { $set: { webNotified: true, webNotifiedAt: new Date() } }); + Notifications.update(notifId, { $set: { notified: true, notifiedAt: new Date() } }); return notifId; } catch (exception) { throw new Meteor.Error('500', exception); diff --git a/imports/api/Notifications/server/publications.js b/imports/api/Notifications/server/publications.js index 83ceaaf..d868692 100644 --- a/imports/api/Notifications/server/publications.js +++ b/imports/api/Notifications/server/publications.js @@ -4,7 +4,7 @@ import { Meteor } from 'meteor/meteor'; import Notifications from '../Notifications'; Meteor.publish('mynotifications', function notifications() { - const notif = Notifications.find({ userId: this.userId, type: 'web', webNotified: null }); + const notif = Notifications.find({ userId: this.userId, type: 'web', notified: null }); // console.log(`Notifications for user ${this.userId}: ${notif.count()}`); return notif; }); diff --git a/imports/api/Rest/Rest.js b/imports/api/Rest/Rest.js new file mode 100644 index 0000000..ae5e383 --- /dev/null +++ b/imports/api/Rest/Rest.js @@ -0,0 +1,457 @@ +/* global Restivus */ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import { NumberBetween } from '/imports/modules/server/other-checks'; +import Fires from '/imports/api/Fires/Fires'; +import { check } from 'meteor/check'; +import SiteSettings from '/imports/api/SiteSettings/SiteSettings'; +import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires'; +import { countRealFires } from '/imports/api/ActiveFires/server/countFires'; +import { whichAreFalsePositives, firesUnion, zoneToUnion } from '/imports/api/FalsePositives/server/publications'; +import { fireFromHash } from '/imports/api/Fires/server/publications.js'; +import FalsePositives from '/imports/api/FalsePositives/FalsePositives'; +import Industries from '/imports/api/Industries/Industries'; +import Subscriptions from '/imports/api/Subscriptions/Subscriptions'; +import jsend from 'jsend'; +import { Random } from 'meteor/random'; +import { subscriptionsInsert } from '/imports/api/Subscriptions/methods.js'; +import { Mongo } from 'meteor/mongo'; +import { upsertFalsePositive } from '/imports/api/FalsePositives/methods.js'; + +const debug = false; + +const uptime = new Date(); + +const restivusError = (code, message) => ({ status: 'error', statusCode: code, body: message }); + + +function failMsg(msg) { + return restivusError(500, `Unexpected error in REST call: ${msg}`); +} + +function fail(e) { + return restivusError(500, `Unexpected error in REST call: ${e}`); +} + +function defaultFailParams(e) { + return restivusError(400, `Wrong REST params: ${e}`); +} + +function checkAuthToken(token) { + if (!Meteor.settings.private.internalApiToken || token !== Meteor.settings.private.internalApiToken) { + const message = 'Unauthorized auth token in REST API'; + console.warn(message); + return restivusError(401, message); + } + return undefined; +} + +function checkLatLonDist(km, lat, lng) { + check(lng, NumberBetween(-180, 180)); + check(lat, NumberBetween(-90, 90)); + check(km, NumberBetween(0, Meteor.isDevelopment ? 1000 : 100)); +} + +if (!Meteor.settings.private.internalApiToken) { + console.warn('Meteor.settings.private.internalApiToken is not configured so we don\'t enable our REST API'); +} else { +// export + const apiV1 = new Restivus({ + useDefaultAuth: true, + apiPath: 'api', + version: 'v1', + prettyJson: true + }); + + // Generates: POST on /api/users and GET, DELETE /api/users/:id for + // Meteor.users collection + /* apiV1.addCollection(Meteor.users, { + * excludedEndpoints: ['getAll', 'put', 'delete', 'patch', 'get'], + * routeOptions: { + * authRequired: true + * }, + * endpoints: { + * post: { + * authRequired: false + * }, + * delete: { + * roleRequired: 'admin' + * } + * } + * }); */ + + apiV1.addCollection(Fires, { + excludedEndpoints: ['put', 'post', 'patch', 'delete'], + // excludedEndpoints: ['getAll', 'put', 'post', 'patch', 'delete'], + routeOptions: { + authRequired: false + }, + endpoints: { + get: { + action: function getFire() { + return Fires.findOne(new Meteor.Collection.ObjectID(this.urlParams.id)); + } + } + } + }); + + // Maps to: /api/v1/status/last-fire-check + apiV1.addRoute('status/last-fire-check', { authRequired: false }, { + get: function get() { + return SiteSettings.findOne({ name: 'last-fire-check' }); + } + }); + + // Maps to: /api/v1/status/last-fire-detected + apiV1.addRoute('status/last-fire-detected', { authRequired: false }, { + get: function get() { + return ActiveFiresCollection.findOne({}, { sort: { when: -1 } }); + } + }); + + // Maps to: /api/v1/status/active-fires-count + apiV1.addRoute('status/active-fires-count', { authRequired: false }, { + get: function get() { + return { total: ActiveFiresCollection.find({}).count() }; + } + }); + + // Maps to: /api/v1/status/uptime + apiV1.addRoute('status/uptime', { authRequired: false }, { + get: function get() { + return { ms: new Date() - uptime }; + } + }); + + function getFires(route, full) { + const lat = Number(route.urlParams.lat); + const lng = Number(route.urlParams.lng); + const km = Number(route.urlParams.km); + const { token } = route.urlParams; + try { + checkLatLonDist(km, lat, lng); + check(token, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius`); + + const fires = ActiveFiresCollection.find({ + ourid: { + $near: { + $geometry: { + type: 'Point', + coordinates: [lng, lat] + }, + $maxDistance: km * 1000, + $minDistance: 0 + } + } + }, { + fields: { + lat: 1, + lon: 1, + when: 1, + type: 1, // modis, viirs, vecinal + scan: 1, + track: 1 + } + }); + + const result = { total: fires.count(), real: countRealFires(fires) }; + if (debug) console.log(`Query for fires in ${lat}, ${lng} in ${km} km radius ${result}`); + + if (!full) { + return result; + } + + let union; + + // TODO only get real + const firesA = fires.fetch(); + + if (firesA.length > 0) { + union = firesUnion(fires); + } else { + union = zoneToUnion(lat, lng, km); + } + const falsePos = whichAreFalsePositives(FalsePositives, union); + const industries = whichAreFalsePositives(Industries, union); + result.fires = firesA; + result.industries = industries.fetch(); + result.falsePos = falsePos.fetch(); + return result; + } + + // Maps to: /api/v1/fires-in/:lat/:lng/:km + // 100 km max + // Ex: http://127.0.0.1:3000/api/v1/fires-in/token/38.736946/-9.142685/100 + apiV1.addRoute('fires-in/:token/:lat/:lng/:km', { authRequired: false }, { + get: function get() { + return getFires(this, false); + } + }); + + apiV1.addRoute('fires-in-full/:token/:lat/:lng/:km', { authRequired: false }, { + get: function get() { + return getFires(this, true); + } + }); + + // Add mobile user: + // curl -X POST http://localhost:3000/api/v1/users/mobile -d "token: thisAppAutToken" -d "mobileToken: user-mobile-firebase-token" + // Response: + // + // https://docs.meteor.com/api/passwords.html#Accounts-createUser + apiV1.addRoute('mobile/users', { authRequired: false }, { + post: function post() { + const { token, mobileToken, lang } = this.bodyParams; + try { + check(token, String); + check(lang, String); + check(mobileToken, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + let username; + + const already = Meteor.users.find({ fireBaseToken: mobileToken }); + + if (already.count() > 1) { + return restivusError(500, 'Unexpected error in REST call: several users with that mobile token?'); + } else if (already.count() === 1) { + username = already.fetch()[0].username; + } else { + do { + username = Random.id(15); + } while (Meteor.users.find({ username }).count() !== 0); + } + + // FIXME check valid lang + + const now = new Date(); + + const result = Meteor.users.upsert({ + fireBaseToken: mobileToken + }, { + $set: { + username, + fireBaseToken: mobileToken, + lang, + profile: { }, + createdAt: now, + updatedAt: now + } + }); + + + if (debug) { + console.log(this.bodyParams); + console.log(this.urlParams); + console.log(this.queryParams); + } + + const upsertUser = Meteor.users.findOne({ username }); + + if (debug) console.log(upsertUser); + + return jsend.success({ + upsertResult: result, + username, + userId: upsertUser._id, + lang: upsertUser.lang, + mobileToken: upsertUser.fireBaseToken + }); + } + + }); + + // max sitance: 100km + apiV1.addRoute('mobile/subscriptions', { authRequired: false }, { + post: function post() { + const { + token, + mobileToken, + id, + lat, + lon, + distance + } = this.bodyParams; + let oid; + try { + check(token, String); + check(mobileToken, String); + check(id, String); + oid = new Mongo.ObjectID(id); + check(oid, Meteor.Collection.ObjectID); + check(lat, Number); + check(lon, Number); + check(distance, Number); + checkLatLonDist(distance, lat, lon); + } catch (e) { + if (debug) console.log(e); + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + const newSubs = {}; + newSubs._id = new Meteor.Collection.ObjectID(id); + newSubs.location = {}; + newSubs.location.lat = lat; + newSubs.location.lon = lon; + newSubs.distance = distance; + + let result; + try { + result = subscriptionsInsert(newSubs, user._id, 'mobile'); + } catch (e) { + return fail(e); + } + return jsend.success({ subsId: result._str }); + } + }); + + apiV1.addRoute('mobile/subscriptions/:token/:mobileToken/:subsId', { authRequired: false }, { + delete: function del() { + const { + token, + mobileToken, + subsId + } = this.urlParams; + try { + check(token, String); + check(mobileToken, String); + check(subsId, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + try { + Subscriptions.remove({ owner: user._id, _id: new Meteor.Collection.ObjectID(subsId) }); + } catch (e) { + return fail(e); + } + + return jsend.success({}); + } + }); + + apiV1.addRoute('mobile/subscriptions/all/:token/:mobileToken', { authRequired: false }, { + get: function get() { + const { token, mobileToken } = this.urlParams; + try { + check(token, String); + check(mobileToken, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + const result = Subscriptions.find({ owner: user._id }); + + return jsend.success({ subscriptions: result.fetch(), count: result.count() }); + }, + delete: function delAll() { + const { token, mobileToken } = this.urlParams; + try { + check(token, String); + check(mobileToken, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed('Auth api check failed'); + + if (Meteor.users.find({ fireBaseToken: mobileToken }).count() !== 1) return fail; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + const toRemove = Subscriptions.find({ owner: user._id }).count(); + Subscriptions.remove({ owner: user._id }); + + return jsend.success({ count: toRemove }); + } + }); + + apiV1.addRoute('status/subs-public-union/:token', { authRequired: false }, { + get: function get() { + const { token } = this.urlParams; + try { + check(token, String); + } catch (e) { + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' }); + const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' }); + + return jsend.success({ union: currentUnion, bounds: userSubsBounds }); + } + }); + + apiV1.addRoute('mobile/falsepositive', { authRequired: false }, { + post: function post() { + const { + token, + mobileToken, + sealed, + type + } = this.bodyParams; + try { + check(token, String); + check(mobileToken, String); + check(sealed, String); + check(type, String); + } catch (e) { + if (debug) console.log(e); + return defaultFailParams(e); + } + + const failed = checkAuthToken(token); + if (failed) return failed; + + const user = Meteor.users.findOne({ fireBaseToken: mobileToken }); + if (!user) return failMsg('User not found'); + + console.log(`Marking hash fire as '${type}' false positive: ${sealed}`); + const fireC = fireFromHash(sealed, { id: sealed }); + if (fireC && typeof fireC === 'object') { + const fire = fireC.fetch()[0]; + console.log(`Marking fire as false positive: ${fire._id}`); + const result = upsertFalsePositive(type, user._id, fire); + return jsend.success({ upsert: result }); + } + return failMsg('Cannot mark fire as false positive'); + } + }); +} diff --git a/imports/api/SiteSettings/SiteSettings.js b/imports/api/SiteSettings/SiteSettings.js index e690671..2bbd95f 100644 --- a/imports/api/SiteSettings/SiteSettings.js +++ b/imports/api/SiteSettings/SiteSettings.js @@ -40,6 +40,7 @@ SiteSettings.getSchema = type => new SimpleSchema({ type: String, description: String, value: SiteSettingsTypes[type].value, + isPublic: Boolean, createdAt: defaultCreatedAt, updatedAt: defaultUpdateAt }); diff --git a/imports/api/SiteSettings/methods.js b/imports/api/SiteSettings/methods.js index 737bc3a..5f9d73d 100644 --- a/imports/api/SiteSettings/methods.js +++ b/imports/api/SiteSettings/methods.js @@ -1,11 +1,11 @@ import { Meteor } from 'meteor/meteor'; -import { check } from 'meteor/check'; +/* import { check } from 'meteor/check'; import SiteSettings from './SiteSettings'; -import SiteSettingsTypes from './SiteSettingsTypes'; +import SiteSettingsTypes from './SiteSettingsTypes'; */ import rateLimit from '../../modules/rate-limit'; Meteor.methods({ - 'siteSettings.insert': function siteSettingsInsert(setting) { +/* 'siteSettings.insert': function siteSettingsInsert(setting) { check(setting, { name: String, type: String, @@ -46,14 +46,14 @@ Meteor.methods({ } catch (exception) { throw new Meteor.Error('500', exception); } - } + } */ }); rateLimit({ methods: [ - 'siteSettings.insert', + /* 'siteSettings.insert', 'siteSettings.update', - 'siteSettings.remove' + 'siteSettings.remove' */ ], limit: 5, timeRange: 1000 diff --git a/imports/api/SiteSettings/server/publications.js b/imports/api/SiteSettings/server/publications.js index cd7f33d..ab9f1b2 100644 --- a/imports/api/SiteSettings/server/publications.js +++ b/imports/api/SiteSettings/server/publications.js @@ -3,4 +3,4 @@ import { Meteor } from 'meteor/meteor'; import SiteSettings from '../SiteSettings'; -Meteor.publish('settings', () => SiteSettings.find()); +Meteor.publish('settings', () => SiteSettings.find({ isPublic: true })); diff --git a/imports/api/Subscriptions/Subscriptions.js b/imports/api/Subscriptions/Subscriptions.js index 69b7ff5..c69e20b 100644 --- a/imports/api/Subscriptions/Subscriptions.js +++ b/imports/api/Subscriptions/Subscriptions.js @@ -1,6 +1,7 @@ /* eslint-disable consistent-return */ /* eslint-disable import/no-absolute-path */ +import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from 'simpl-schema'; import { defaultCreatedAt, defaultUpdateAt } from '/imports/api/Utility/Utils.js'; @@ -42,12 +43,14 @@ Subscriptions.deny({ Subscriptions.schema = new SimpleSchema({ + _id: { type: Meteor.Collection.ObjectID, optional: true, blackbox: true }, location: Object, 'location.lat': Number, 'location.lon': Number, geo: LocationSchema, distance: Number, chatId: { type: Number, optional: true }, // only in 'telegram' type + telegramBot: { type: String, optional: true }, // only in 'telegram' type owner: String, type: String, createdAt: defaultCreatedAt, diff --git a/imports/api/Subscriptions/methods.js b/imports/api/Subscriptions/methods.js index e7e3bef..cf927e0 100644 --- a/imports/api/Subscriptions/methods.js +++ b/imports/api/Subscriptions/methods.js @@ -10,26 +10,49 @@ function geo(doc) { }; } +export function subscriptionsInsert(doc, userId, type) { + check(doc, { + _id: Match.Maybe(Meteor.Collection.ObjectID), + location: Match.ObjectIncluding({ lat: Number, lon: Number }), + distance: Number + }); + const newDoc = { + owner: userId, + type, + geo: geo(doc), + ...doc + }; + // console.log(newDoc); + const already = Subscriptions.findOne(newDoc); + if (already) { + throw new Meteor.Error('on-already-subscribed', 'The user is already subscribed to this area'); + } + try { + return Subscriptions.insert(newDoc); + } catch (exception) { + // console.error(exception); + throw new Meteor.Error('500', exception); + } +} + +export function subscriptionsRemove(subscriptionId) { + check(subscriptionId, Meteor.Collection.ObjectID); + + try { + return Subscriptions.remove(subscriptionId); + } catch (exception) { + console.error(exception); + throw new Meteor.Error('500', exception); + } +} + Meteor.methods({ - 'subscriptions.insert': function subscriptionsInsert(doc) { + 'subscriptions.insert': function subscriptionInsertMethod(doc) { check(doc, { location: Match.ObjectIncluding({ lat: Number, lon: Number }), distance: Number }); - const type = 'web'; - try { - const newDoc = { - owner: this.userId, - type, - geo: geo(doc), - ...doc - }; - // console.log(newDoc); - return Subscriptions.insert(newDoc); - } catch (exception) { - console.error(exception); - throw new Meteor.Error('500', exception); - } + return subscriptionsInsert(doc, this.userId, 'web'); }, 'subscriptions.update': function subscriptionsUpdate(doc) { check(doc, { @@ -48,15 +71,9 @@ Meteor.methods({ throw new Meteor.Error('500', exception); } }, - 'subscriptions.remove': function subscriptionsRemove(subscriptionId) { + 'subscriptions.remove': function subscriptionsRemoveMethod(subscriptionId) { check(subscriptionId, Meteor.Collection.ObjectID); - - try { - return Subscriptions.remove(subscriptionId); - } catch (exception) { - console.error(exception); - throw new Meteor.Error('500', exception); - } + return subscriptionsRemove(subscriptionId); } }); diff --git a/imports/api/Subscriptions/server/publications.js b/imports/api/Subscriptions/server/publications.js index f67fc3a..adf6e13 100644 --- a/imports/api/Subscriptions/server/publications.js +++ b/imports/api/Subscriptions/server/publications.js @@ -3,43 +3,8 @@ import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { check } from 'meteor/check'; -import Perlin from 'loms.perlin'; import Subscriptions from '../Subscriptions'; -Perlin.seed(Math.random()); - -Meteor.publishTransformed('userSubsToFires', function transform() { - // https://en.wikipedia.org/wiki/Location_obfuscation - // https://en.wikipedia.org/wiki/Decimal_degrees#Precision - // https://gis.stackexchange.com/questions/27792/what-simple-effective-techniques-for-obfuscating-points-are-available - return Subscriptions.find().serverTransform(function transformDoc(odoc) { - const doc = odoc; - // Destructuring gives me an error: "Cannot destructure property `location` of 'undefined'" - const location = doc.location; - /* doc.lat = location.lat; - * doc.lon = location.lon; */ - let lat; - let lon; - if (location) { - lat = Math.round(location.lat * 10) / 10; - lon = Math.round(location.lon * 10) / 10; - // console.log(`[${lat}, ${lon}]`); - const noiseBase = Perlin.perlin2(lat, lon); - const noise = Math.abs(noiseBase / 3); - // console.log(`Noise ${noise}, abs: ${Math.abs(noise)}`); - lat += noise; - lon += noise; - doc.location.lat = lat; - doc.location.lon = lon; - doc.distance += noiseBase; - } - // console.log(`with noise: [${doc.lat}, ${doc.lon}]`); - delete doc.chatId; - delete doc.geo; - return doc; - }); -}); - Meteor.publish('mysubscriptions', function subscriptions() { return Subscriptions.find({ owner: this.userId }); // type: 'web' }); diff --git a/imports/api/Users/Users.js b/imports/api/Users/Users.js index 34ade38..8911a47 100644 --- a/imports/api/Users/Users.js +++ b/imports/api/Users/Users.js @@ -78,11 +78,8 @@ const schemaUser = new SimpleSchema({ // Option 2: [String] type // If you are sure you will never need to use role groups, then // you can specify [String] as the type - roles: { - type: Array, - // autoform: { type: 'hidden' }, - optional: true - }, + roles: { type: Array, optional: true }, + 'roles.$': { type: String, optional: true }, // https://github.com/todda00/meteor-friendly-slugs/issues/1 friendlySlugs: { type: Object, @@ -110,6 +107,7 @@ const schemaUser = new SimpleSchema({ telegramUsername: { type: String, optional: true }, telegramFirstName: { type: String, optional: true }, telegramLanguageCode: { type: String, optional: true }, + fireBaseToken: { type: String, optional: true }, createdAt: defaultCreatedAt, updatedAt: defaultUpdateAt }); diff --git a/imports/api/Users/server/send-welcome-email.js b/imports/api/Users/server/send-welcome-email.js index 10748dd..f93c7d2 100644 --- a/imports/api/Users/server/send-welcome-email.js +++ b/imports/api/Users/server/send-welcome-email.js @@ -10,11 +10,12 @@ export default (options, user, lang) => { const applicationName = i18n.t('AppName'); const firstName = OAuthProfile ? OAuthProfile.name.first : options.profile.name.first; const emailAddress = OAuthProfile ? OAuthProfile.email : options.email; + const welcome = i18n.t('welcome'); if (emailAddress) { sendEmail({ to: emailAddress, from: `${applicationName} `, - subject: `[${applicationName}] Welcome, ${firstName}!`, + subject: `[${applicationName}] ${welcome} ${firstName}!`, lang, template: 'welcome', templateVars: { diff --git a/imports/api/Utility/LocationSchema.js b/imports/api/Utility/LocationSchema.js index 9cb603b..6aaf812 100644 --- a/imports/api/Utility/LocationSchema.js +++ b/imports/api/Utility/LocationSchema.js @@ -12,10 +12,10 @@ const LocationSchema = new SimpleSchema({ minCount: 2, maxCount: 2, custom: function custom() { - if (!(this.value[0] >= -90 && this.value[0] <= 90)) { + if (!(this.value[0] >= -180 && this.value[0] <= 180)) { return 'lngOutOfRange'; } - if (!(this.value[1] >= -180 && this.value[1] <= 180)) { + if (!(this.value[1] >= -90 && this.value[1] <= 90)) { return 'latOutOfRange'; } return true; @@ -27,8 +27,8 @@ const LocationSchema = new SimpleSchema({ }); LocationSchema.messageBox.messages({ - lonOutOfRange: '[label] longitude should be between -90 and 90', - latOutOfRange: '[label] latitude should be between -180 and 180' + latOutOfRange: '[label] latitude should be between -90 and 90', + lngOutOfRange: '[label] longitude should be between -180 and 180' }); export default LocationSchema; diff --git a/imports/api/Utility/server/files.js b/imports/api/Utility/server/files.js index fe800f8..6f02058 100644 --- a/imports/api/Utility/server/files.js +++ b/imports/api/Utility/server/files.js @@ -1,15 +1,10 @@ +/* eslint-disable import/no-absolute-path */ + import fs from 'fs'; +import getFallbackLang from '/imports/modules/lang-fallback'; const as = f => `assets/app/${f}`; -export const getFallbackLang = (lang) => { - if (lang === 'ast' || lang === 'gl' || lang === 'eu' || lang === 'ca') { - return 'es'; - } - return 'en'; -}; - - export const getFileNameOfLang = (dir, fileName, ext, lang) => { const base = `${dir}/${fileName}`; const fallback = getFallbackLang(lang); diff --git a/imports/modules/get-email-of-user.js b/imports/modules/get-email-of-user.js new file mode 100644 index 0000000..c69262d --- /dev/null +++ b/imports/modules/get-email-of-user.js @@ -0,0 +1,19 @@ +/* eslint-disable import/no-absolute-path */ + +import getOAuthProfile from '/imports/modules/get-oauth-profile'; + +const getEmailOf = (user) => { + const hasPassword = user.services && user.services.password && user.services.password.bcrypt; + + // console.log(`Has password: ${hasPassword}`); + const OAuthProfile = getOAuthProfile({ + password: hasPassword, + profile: user.profile + }, user); + const firstName = OAuthProfile ? OAuthProfile.name.first : user.profile.name.first; + const emailAddress = OAuthProfile ? OAuthProfile.email : + user && user.emails[0] && user.emails[0].verified ? user.emails[0].address : null; + return { emailAddress, firstName }; +}; + +export default getEmailOf; diff --git a/imports/modules/get-oauth-profile.js b/imports/modules/get-oauth-profile.js index 91866dc..8e427a1 100644 --- a/imports/modules/get-oauth-profile.js +++ b/imports/modules/get-oauth-profile.js @@ -1,12 +1,11 @@ -const parseGoogleData = service => { - return { - email: service.email, - name: { - first: service.given_name, - last: service.family_name, - }, - }; -}; +const parseGoogleData = service => ({ + email: service.email, + name: { + first: service.given_name, + last: service.family_name + }, + lang: service.locale +}); const parseGithubData = (profile, service) => { const name = profile.name.split(' '); @@ -14,20 +13,18 @@ const parseGithubData = (profile, service) => { email: service.email, name: { first: name[0], - last: name[1], - }, + last: name[1] + } }; }; -const parseFacebookData = service => { - return { - email: service.email, - name: { - first: service.first_name, - last: service.last_name, - }, - }; -}; +const parseFacebookData = service => ({ + email: service.email, + name: { + first: service.first_name, + last: service.last_name + } +}); const getDataForService = (profile, services) => { if (services.facebook) return parseFacebookData(services.facebook); diff --git a/imports/modules/lang-fallback.js b/imports/modules/lang-fallback.js new file mode 100644 index 0000000..d912347 --- /dev/null +++ b/imports/modules/lang-fallback.js @@ -0,0 +1,9 @@ +const getFallbackLang = (lang) => { + if (lang && (lang === 'ast' || lang === 'gl' || lang === 'eu' || + lang === 'es' || lang === 'ca' || lang.match(/^es-/))) { + return 'es'; + } + return 'en'; +}; + +export default getFallbackLang; diff --git a/imports/modules/server/notificationsProcess.js b/imports/modules/server/notificationsProcess.js new file mode 100644 index 0000000..657aa2a --- /dev/null +++ b/imports/modules/server/notificationsProcess.js @@ -0,0 +1,153 @@ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import Notifications from '/imports/api/Notifications/Notifications'; +import i18n from 'i18next'; +import moment from 'moment'; +import { dateLongFormat } from '/imports/api/Common/dates'; +// import sendMail from '/imports/startup/server/email'; +import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email'; +import { isMailServerMaster } from '/imports/startup/server/email'; +// import { hr } from '/imports/startup/server/email'; +import getEmailOf from '/imports/modules/get-email-of-user'; +import image from 'google-maps-image-api-url'; +import gcm from 'node-gcm'; +import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; +import ravenLogger from '/imports/startup/server/ravenLogger'; + +let validFcmSender = true; + +if (!(Meteor.settings.private.fcmApiToken && Meteor.settings.private.fcmApiToken.length > 0)) { + console.warn('Missing settings.private.fcmApiToken key, mobile notifications will not work'); + validFcmSender = false; +} + +// https://www.npmjs.com/package/google-maps-image-api-url +// https://stackoverflow.com/questions/24355007/is-there-no-way-to-embed-a-google-map-into-an-html-email +// https://developers.google.com/maps/documentation/static-maps/intro +function imgUrl(lat, lng) { + return image({ + key: Meteor.settings.gmaps.key, + type: 'staticmap', + center: `${lat},${lng}`, + size: '640x480', + zoom: 16, + maptype: 'hybrid', + language: 'es', + markers: `icon: ${Meteor.settings.private.fireIconUrl}|${lat},${lng}` + }); +} + +/* + function imgEl(lat, lng) { + return ``; + } */ + +// Cutover flag (fase 1a): channels listed in +// Meteor.settings.private.notifDisabledChannels (e.g. ['mobile','web']) are +// handled by the tcef-notifications microservice, so the old observer/cron must +// NOT send them. Mutual exclusion per channel — reversible by editing settings, +// no code deploy needed. ES5-compatible on purpose (Meteor 1.6 / Node 8). +const notifDisabledChannels = (Meteor.settings.private && Meteor.settings.private.notifDisabledChannels) || []; + +const processNotif = (notif) => { + if (notifDisabledChannels.indexOf(notif.type) !== -1) { + // This channel was migrated to tcef-notifications; do nothing here. + return; + } + if (isMailServerMaster && validFcmSender && notif.type === 'mobile' && notif.notified !== true) { + const fcmSender = new gcm.Sender(Meteor.settings.private.fcmApiToken); + const user = Meteor.users.findOne({ _id: notif.userId }); + moment.locale(user.lang); + // duplicate code below + const body = `${trim(notif.content)}`; + + // https://firebase.google.com/docs/cloud-messaging/concept-options + const msg = new gcm.Message(); + + msg.addNotification({ + title: i18n.t('Alerta de fuego'), + body, + click_action: 'FLUTTER_NOTIFICATION_CLICK', + tag: notif._id, // prevent duplication of fire notifications + sound: 'default', // Indicates sound to be played. Supports only default currently. + icon: 'launch_image' // 'ic_launcher' + }); + + msg.addData('id', notif._id._str); + msg.addData('description', body); + msg.addData('lat', notif.geo.coordinates[1]); + msg.addData('lon', notif.geo.coordinates[0]); + msg.addData('when', notif.when); + msg.addData('subsId', notif.subsId._str); + msg.addData('sealed', notif.sealed); + + const registrationTokens = []; + if (!user.fireBaseToken) { + console.warn('This mobile user doesn\'t have a firebase registration token'); + } else { + registrationTokens.push(user.fireBaseToken); + // FIXME: better join users + if (validFcmSender) { + fcmSender.send(msg, { registrationTokens }, Meteor.bindEnvironment(function processResult(err, response) { + if (err) { + console.error(`FCM error: ${err}`); + // FIXME send to sentry + ravenLogger.log(err); + } else { + // console.log(`FCM response: ${response}`); + Notifications.update(notif._id, { $set: { notified: true, notifiedAt: new Date() } }); + } + })); + } + } + } + if (isMailServerMaster && notif.type === 'web' && notif.emailNotified !== true) { + const user = Meteor.users.findOne({ _id: notif.userId }); + const { firstName, emailAddress } = getEmailOf(user); + + if (emailAddress) { + const img = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); + // const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); + const fireUrl = `${Meteor.absoluteUrl('fire/')}${notif.sealed}`; + // const fireHtmlUrl = `${i18n.t('Más información sobre este fuego')}`; + // TODO get _id of fire + // const fireTextUrl = `${i18n.t('Más información sobre este fuego')}:\n${fireUrl}`; + // FIXME use our map as url and static map as img + moment.locale(user.lang); + // moment user tz ? + const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).`; + + // TODO Comunes Address + + const emailOpts = { + to: emailAddress, + // userName: firstName, + // sendAt: new Date(), + subject: subjectTruncate.apply(message), + // text: `${message}\n\n${fireTextUrl}\n\n`, + // template: '

{{appName}}

{{{html}}}', + lang: user.lang, + template: 'new-fire', + templateVars: { + applicationName: i18n.t('AppName'), + firstName, + message, + fireUrl, + img, + subsUrl: Meteor.absoluteUrl('subscriptions') + } + }; + sendEmail(emailOpts).catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); + // sendMail(emailOpts, true); + Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } }); + } else { + // Not email or not verified -> remove notif so we don't retry to send it + Notifications.remove({ _id: notif._id }); + } + } +}; + +export default processNotif; diff --git a/imports/modules/server/send-email.js b/imports/modules/server/send-email.js index 8d6fc32..8fc4629 100644 --- a/imports/modules/server/send-email.js +++ b/imports/modules/server/send-email.js @@ -1,11 +1,21 @@ +/* eslint-disable import/no-absolute-path */ + import { Meteor } from 'meteor/meteor'; -// import { Email } from 'meteor/email'; +import i18n from 'i18next'; +import { getFileNameOfLang } from '/imports/api/Utility/server/files.js'; +import sendMail from '/imports/startup/server/email'; import getPrivateFile from './get-private-file'; import templateToText from './handlebars-email-to-text'; import templateToHTML from './handlebars-email-to-html'; -import { getFileNameOfLang } from '/imports/api/Utility/server/files.js'; -import sendMail from '/imports/startup/server/email'; -import i18n from 'i18next'; + +export function subjectTruncate(n = 70, useWordBoundary = true) { + // https://stackoverflow.com/questions/1199352/smart-way-to-shorten-long-strings-with-javascript + if (this.length <= n) { + return `${this}`; + } + const subString = this.substr(0, n - 1); + return `${useWordBoundary ? subString.substr(0, subString.lastIndexOf(' ')) : subString}...`; +} const sendEmail = (options, { resolve, reject }) => { try { diff --git a/imports/modules/server/twitter.js b/imports/modules/server/twitter.js new file mode 100644 index 0000000..bff6c15 --- /dev/null +++ b/imports/modules/server/twitter.js @@ -0,0 +1,27 @@ +import { Meteor } from 'meteor/meteor'; +import Twit from 'twit'; + +// https://www.npmjs.com/package/twit +const confTwitter = (lang) => { + const tConf = Meteor.settings.private.twitter[lang]; + + const T = new Twit({ + consumer_key: tConf.consumer_key, + consumer_secret: tConf.consumer_secret, + access_token: tConf.access_token, + access_token_secret: tConf.access_token_secret + // timeout_ms: 60*1000, // optional HTTP request timeout to apply to all requests. + }); + + return T; +}; + +const tweet = (message, lang) => { + const T = confTwitter(lang); + T.post('statuses/update', { status: message }, (err, data, response) => { + // console.log(data); + // console.log(response); + }); +}; + +export default tweet; diff --git a/imports/startup/client/comments.js b/imports/startup/client/comments.js index 62fc868..94cbe90 100644 --- a/imports/startup/client/comments.js +++ b/imports/startup/client/comments.js @@ -37,6 +37,6 @@ i18n.init((err, t) => { template: 'bootstrap', // default 'semantic-ui' // default 'http://s3.amazonaws.com/37assets/svn/765-default-avatar.png' defaultAvatar: '/default-avatar.png', - markdown: false + markdown: true }); }); diff --git a/imports/startup/client/i18n.js b/imports/startup/client/i18n.js index d4e3fcc..384053f 100644 --- a/imports/startup/client/i18n.js +++ b/imports/startup/client/i18n.js @@ -17,7 +17,7 @@ import i18nOpts from '../common/i18n'; const detectorOptions = { // order and from where user language should be detected - order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'], + order: ['querystring', 'navigator', 'cookie', 'localStorage', 'htmlTag'], // keys or params to lookup language from lookupQuerystring: 'lng', @@ -53,15 +53,22 @@ i18nOpts.react = { nsMode: 'default' */ }; -const sendMissing = Meteor.isDevelopment; -if (sendMissing && !Meteor.isProduction) { +const sendMissing = true; // Meteor.isDevelopment; +if (sendMissing && Meteor.isDevelopment) { i18nOpts.sendMissing = true; - i18nOpts.sendMissingTo = 'fallback'; - i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue, lngs) { + i18nOpts.missingKeyHandler = function miss(lng, ns, key, defaultValue) { Meteor.call('utility.saveMissingI18n', key, defaultValue); }; } +function setT9(lang) { + if (lang === 'gl') { + T9n.setLanguage('es'); + } else { + T9n.setLanguage(lang); + } +} + i18n.use(backend) .use(LngDetector) .use(Cache) @@ -71,16 +78,17 @@ i18n.use(backend) console.error(err); return; } - i18nReady.set(true); + console.log(`Language initialized: ${i18n.language}`); + // document.title = t('AppName'); // Accounts translation // https://github.com/softwarerero/meteor-accounts-t9n - // console.log("Language: " + i18n.language); - T9n.setLanguage(i18n.language); + setT9(i18n.language); // console.log(T9n.get('error.accounts.User not found')); - moment.locale(i18n.language); + i18nReady.set(true); + // cookies eu consent const cookiesOpt = { cookieTitle: t('Uso de Cookies'), @@ -91,7 +99,7 @@ i18n.use(backend) position: 'bottom', linkText: 'Lee más', linkRouteName: '/privacy', - acceptButtonText: 'Aceptar', + acceptButtonText: t('Aceptar'), html: false, expirationInDays: 70, forceShow: false @@ -104,7 +112,8 @@ Meteor.subscribe('userData'); // lang is there i18n.on('languageChanged', (lng) => { moment.locale(lng); - T9n.setLanguage(lng); + // TODO fix this when gl is translated + setT9(lng); Tracker.autorun((computation) => { if (Meteor.userId()) { // logged diff --git a/imports/startup/client/index.js b/imports/startup/client/index.js index ccebfda..147cf4f 100644 --- a/imports/startup/client/index.js +++ b/imports/startup/client/index.js @@ -1,8 +1,19 @@ +/* global */ import React from 'react'; import { render } from 'react-dom'; import { Meteor } from 'meteor/meteor'; +import '/imports/startup/client/modernizr'; import App from '../../ui/layouts/App/App'; import '../../ui/stylesheets/app.scss'; -Meteor.startup(() => render(, document.getElementById('react-root'))); +// https://stackoverflow.com/questions/19562207/jquery-detect-browser-ie9-and-below-and-throw-up-a-modal-to-upgrade +const isIE9OrBelow = () => /MSIE\s/.test(navigator.userAgent) && parseFloat(navigator.appVersion.split('MSIE')[1]) < 10; + +if (isIE9OrBelow()) window.alert('You are using an outdated browser. Please use Chrome or Firefox to display this site.'); +else { + Modernizr.on('webp', (result) => { + if (Meteor.isDevelopment) console.log(`webp ${result ? '' : 'not '}supported`); + }); + Meteor.startup(() => render(, document.getElementById('react-root'))); +} diff --git a/imports/startup/client/modernizr.js b/imports/startup/client/modernizr.js new file mode 100644 index 0000000..f6ac835 --- /dev/null +++ b/imports/startup/client/modernizr.js @@ -0,0 +1,3 @@ +/*! modernizr 3.6.0 (Custom Build) | MIT * + * https://modernizr.com/download/?-webp-prefixedcss-setclasses !*/ +!function(e,n,t){function r(e,n){return typeof e===n}function o(){var e,n,t,o,i,a,s;for(var l in w)if(w.hasOwnProperty(l)){if(e=[],n=w[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;tp;p++)if(d=e[p],m=E.style[d],f(d,"-")&&(d=s(d)),E.style[d]!==t){if(i||r(o,"undefined"))return a(),"pfx"==n?d:!0;try{E.style[d]=o}catch(v){}if(E.style[d]!=m)return a(),"pfx"==n?d:!0}return a(),!1}function v(e,n,t,o,i){var a=e.charAt(0).toUpperCase()+e.slice(1),s=(e+" "+B.join(a+" ")+a).split(" ");return r(n,"string")||r(n,"undefined")?h(s,n,o,i):(s=(e+" "+U.join(a+" ")+a).split(" "),p(s,n,t))}var y=[],w=[],C={_version:"3.6.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var t=this;setTimeout(function(){n(t[e])},0)},addTest:function(e,n,t){w.push({name:e,fn:n,options:t})},addAsyncTest:function(e){w.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=C,Modernizr=new Modernizr;var _,b=n.documentElement,S="svg"===b.nodeName.toLowerCase();!function(){var e={}.hasOwnProperty;_=r(e,"undefined")||r(e.call,"undefined")?function(e,n){return n in e&&r(e.constructor.prototype[n],"undefined")}:function(n,t){return e.call(n,t)}}(),C._l={},C.on=function(e,n){this._l[e]||(this._l[e]=[]),this._l[e].push(n),Modernizr.hasOwnProperty(e)&&setTimeout(function(){Modernizr._trigger(e,Modernizr[e])},0)},C._trigger=function(e,n){if(this._l[e]){var t=this._l[e];setTimeout(function(){var e,r;for(e=0;ea;a++){var s=prefixes[a],l=s.toUpperCase()+"_"+r;if(l in i)return"@-"+s.toLowerCase()+"-"+n}return!1};C.atRule=Q;var U=C._config.usePrefixes?x.toLowerCase().split(" "):[];C._domPrefixes=U;var R={elem:u("modernizr")};Modernizr._q.push(function(){delete R.elem});var E={style:R.elem.style};Modernizr._q.unshift(function(){delete E.style}),C.testAllProps=v;var T=C.prefixed=function(e,n,t){return 0===e.indexOf("@")?Q(e):(-1!=e.indexOf("-")&&(e=s(e)),n?v(e,n,t):v(e,"pfx"))};C.prefixedCSS=function(e){var n=T(e);return n&&a(n)};o(),i(y),delete C.addTest,delete C.addAsyncTest;for(var P=0;P Tracker.autorun(() => { + const userId = Meteor.userId(); + Meteor.Piwik.setUserInfo(userId); +})); diff --git a/imports/startup/client/ravenLogger.js b/imports/startup/client/ravenLogger.js index 509121a..d678591 100644 --- a/imports/startup/client/ravenLogger.js +++ b/imports/startup/client/ravenLogger.js @@ -2,12 +2,15 @@ import RavenLogger from 'meteor/flowkey:raven'; import { Meteor } from 'meteor/meteor'; const ravenOptions = {}; +const publicDSN = Meteor.settings.public.sentryPublicDSN; +const enabled = !!publicDSN; -const ravenLogger = new RavenLogger({ - publicDSN: Meteor.settings.public.sentryPublicDSN, // will be used on the client - privateDSN: Meteor.settings.sentryPrivateDSN, // will be used on the server +const ravenLogger = enabled ? new RavenLogger({ + publicDSN, // will be used on the client shouldCatchConsoleError: true, // default - trackUser: false // default -}, ravenOptions); + trackUser: true // default +}, ravenOptions) : { log: (error) => { console.log(error); } }; + +console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`); export default ravenLogger; diff --git a/imports/startup/client/reconnect.js b/imports/startup/client/reconnect.js new file mode 100644 index 0000000..4659b20 --- /dev/null +++ b/imports/startup/client/reconnect.js @@ -0,0 +1,8 @@ +/* global reconnecToServer */ + +import { Meteor } from 'meteor/meteor'; + +Meteor.startup(() => { + // Reconnect Function + reconnectToServer(5000, true); +}); diff --git a/imports/startup/common/comments.js b/imports/startup/common/comments.js index 09fa297..7b9ab18 100644 --- a/imports/startup/common/comments.js +++ b/imports/startup/common/comments.js @@ -3,11 +3,16 @@ // Client and Server Comments.config({ rating: 'likes-and-dislikes', - allowAnonymous: () => true, + allowAnonymous: () => false, + allowReplies: () => false, // disabled right now: I don't know hot to get referenceId of replies anonymousSalt: 'klasddl3lala0l3lasdlas0ol3lasdlao3lasdoaslaldal3lasdclasdlal3lasdladlaq', publishUserFields: { profile: 1 }, + mediaAnalyzers: [ + Comments.analyzers.image, + Comments.analyzers.youtube + ], generateUsername: function genUser(user) { // console.log(JSON.stringify(user)); // FIXME diff --git a/imports/startup/common/i18n.js b/imports/startup/common/i18n.js index 2db4371..218e46d 100644 --- a/imports/startup/common/i18n.js +++ b/imports/startup/common/i18n.js @@ -3,6 +3,8 @@ import moment from 'moment'; // Load the js langs import es from 'meteor-accounts-t9n/build/es'; import en from 'meteor-accounts-t9n/build/en'; +// TODO ask for translation of this +// import gl from 'meteor-accounts-t9n/build/gl'; const backOpts = { // path where resources get loaded from @@ -21,7 +23,11 @@ const shouldDebug = (forceDebug && !Meteor.isProduction); const i18nOpts = { backend: backOpts, // lng: 'es', - fallbackLng: ['es', 'en'], + fallbackLng: { + gl: ['es'], + default: ['en'] + }, + sendMissingTo: 'fallback', interpolation: { escapeValue: false, // not needed for react!! formatSeparator: ',', @@ -34,8 +40,7 @@ const i18nOpts = { return value; } }, - whitelist: false, - // whitelist: ['es', 'en'], // allowed languages + whitelist: ['es', 'en', 'gl'], // allowed languages load: 'languageOnly', // 'es' o 'en', previously: 'all', // es-ES -> es, en-US -> en debug: shouldDebug, ns: 'common', diff --git a/imports/startup/server/accounts/on-create-user.js b/imports/startup/server/accounts/on-create-user.js index fa2a99e..566acc6 100644 --- a/imports/startup/server/accounts/on-create-user.js +++ b/imports/startup/server/accounts/on-create-user.js @@ -1,18 +1,27 @@ +/* eslint-disable import/no-absolute-path */ + import { Accounts } from 'meteor/accounts-base'; -import sendWelcomeEmail from '../../../api/Users/server/send-welcome-email'; +import getFallbackLang from '/imports/modules/lang-fallback'; +import sendWelcomeEmail from '/imports/api/Users/server/send-welcome-email'; +import getOAuthProfile from '/imports/modules/get-oauth-profile'; Accounts.onCreateUser((options, user) => { const userToCreate = user; // console.log(JSON.stringify(user)); // console.log(JSON.stringify(options)); + let lang = 'en'; // fallback if (options.profile) { userToCreate.profile = options.profile; userToCreate.lang = options.profile.lang; - lang = options.profile.lang; + // console.log(JSON.stringify(user)); + lang = userToCreate.lang; delete options.profile.lang; - } else { - // TODO others (google, etc) ? + } + const OAuthProfile = getOAuthProfile(options, user); + if (OAuthProfile) { + userToCreate.lang = getFallbackLang(OAuthProfile && OAuthProfile.lang ? OAuthProfile.lang : lang); + lang = userToCreate.lang; } sendWelcomeEmail(options, userToCreate, lang); return userToCreate; diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js index 73f1fa2..a6044b4 100644 --- a/imports/startup/server/api.js +++ b/imports/startup/server/api.js @@ -9,6 +9,7 @@ import '../../api/Users/server/publications'; import '../../api/Utility/server/methods'; import '../../api/ActiveFires/server/publications'; +import '../../api/ActiveFiresUnion/server/publications'; import '../../api/FireAlerts/server/publications'; import '../../api/Subscriptions/methods'; diff --git a/imports/startup/server/catchExceptions.js b/imports/startup/server/catchExceptions.js new file mode 100644 index 0000000..179ab8b --- /dev/null +++ b/imports/startup/server/catchExceptions.js @@ -0,0 +1,19 @@ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import ravenLogger from '/imports/startup/server/ravenLogger'; + +Meteor.startup(() => { + const bound = Meteor.bindEnvironment((callback) => { callback(); }); + process.on('uncaughtException', (err) => { + bound(() => { + console.error(err); + + // https://github.com/flowkey/meteor-raven + ravenLogger.log(err); + + // https://github.com/nodejs/node-v0.x-archive/blob/master/doc/api/process.markdown#exit-codes + process.exit(7); + }); + }); +}); diff --git a/imports/startup/server/comments.js b/imports/startup/server/comments.js new file mode 100644 index 0000000..b010405 --- /dev/null +++ b/imports/startup/server/comments.js @@ -0,0 +1,80 @@ +/* global Comments */ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import i18n from 'i18next'; +import sendEmail, { subjectTruncate } from '/imports/modules/server/send-email'; +import getEmailOf from '/imports/modules/get-email-of-user'; +import '../common/comments'; + +/* import i18n from 'i18next'; +import moment from 'moment'; +import { dateLongFormat } from '/imports/api/Common/dates'; +import Notifications from '/imports/api/Notifications/Notifications'; +// import sendMail from '/imports/startup/server/email'; +// import { hr } from '/imports/startup/server/email'; +import getOAuthProfile from '/imports/modules/get-oauth-profile'; +import image from 'google-maps-image-api-url'; +import { trim } from '/imports/ui/components/NotificationsObserver/util.js'; */ + +Meteor.startup(() => { + // On Server or Client (preferably on Server) + Comments.config({ + onEvent: (name, action, payload) => { + // e.g send a mail + // console.log(`name: ${name}, action: ${action}, payload: ${JSON.stringify(payload)}`); + + // Samples: + + // name: comment, action: add, payload: {"referenceId":"fire-56a9cc5a5586879eb9cc6dd7","content":"asdfasdf asd fasd fas","userId":"tdK4e43ikjDXct7Gj","isAnonymous":false,"createdAt":"2018-03-04T14:53:40.319Z","likes":[],"dislikes":[],"replies":[],"media":{},"status":"approved","starRatings":[],"ratingScore":0,"_id":"mfAe3HL9qNofhiAnh"} + // name: reply, action: add, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: like, payload: {"replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":["tdK4e43ikjDXct7Gj"],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":1,"_id":"DyHt28rF6rTDnC5fP","ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: comment, action: like, payload: {"_id":"mfAe3HL9qNofhiAnh","ratedUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: edit, payload: {"_id":"DyHt28rF6rTDnC5fP","replyId":"teSLaz4f8RxHjptBm","content":"sas fasdfasdasdf dasd fadsfa","userId":"tdK4e43ikjDXct7Gj","createdAt":"2018-03-04T14:54:56.965Z","replies":[],"likes":[],"lastUpdatedAt":"2018-03-04T14:54:56.965Z","isAnonymous":false,"status":"approved","media":{},"ratingScore":0,"starRatings":[],"ratedUserId":"tdK4e43ikjDXct7Gj","rootUserId":"tdK4e43ikjDXct7Gj"} + // name: reply, action: remove, payload: {"_id":"DyHt28rF6rTDnC5fP","rootUserId":"tdK4e43ikjDXct7Gj"} + + // name: (comment|reply) + // action: (add|like|edit|remove) + + if (name === 'comment' && action.match(/add|like|edit/)) { + if (action === 'add') { + // Check for other users that did comments and send an email (uniq users, and not this user) + // console.log(payload.referenceId); + const query = payload.isAnonymous ? + { referenceId: payload.referenceId } : + { referenceId: payload.referenceId, userId: { $ne: payload.userId } }; + Comments.getCollection().rawCollection().distinct('userId', query).then((users) => { + // console.log(users); + const path = payload.referenceId.replace(/fire-/, 'fire/archive/'); + const fireUrl = Meteor.absoluteUrl(path); + // console.log(fireUrl); + Meteor.users.find({ _id: { $in: users } }).forEach((user) => { + const { firstName, emailAddress } = getEmailOf(user); + // console.log(JSON.stringify(user)); + if (emailAddress) { + const emailOpts = { + to: emailAddress, + subject: subjectTruncate.apply(i18n.t('Hay más información sobre un fuego')), + lang: user.lang, + template: 'new-fire-comment', + templateVars: { + applicationName: i18n.t('AppName'), + firstName, + fireUrl + } + }; + sendEmail(emailOpts).catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); + } + }); + }); + } + } + if (name === 'reply' && action.match(/add|like|edit/)) { + // Check for other previous users to this thread and send an email + // console.log('Reply op'); + } + } + }); +}); diff --git a/imports/startup/server/cron.js b/imports/startup/server/cron.js new file mode 100644 index 0000000..843e55b --- /dev/null +++ b/imports/startup/server/cron.js @@ -0,0 +1,135 @@ +/* global SyncedCron */ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import { tweetIberiaFires, tweetEuropeFires } from '/imports/api/ActiveFires/server/tweetFiresInZone'; +import { isMailServerMaster, sendEmailsFromQueue } from '/imports/startup/server/email'; +import Notifications from '/imports/api/Notifications/Notifications'; +import processNotif from '/imports/modules/server/notificationsProcess.js'; + +// https://github.com/thesaucecode/meteor-synced-cron/ + +Meteor.startup(() => { + SyncedCron.config({ + // Log job run details to console + log: Meteor.settings.private.cron.debug || false, + + // Use a custom logger function (defaults to Meteor's logging package) + // workaround to disable logging succesfully + logger: Meteor.settings.private.cron.debug ? null : () => {} + + // Name of collection to use for synchronisation and logging + // collectionName: 'cronHistory', + + // Default to localTime + // Options: 'utc', 'localtime', or specific timezones 'America/New_York' + // Will be applied to jobs with no timezone defined + // timezone: 'utc', + + /* + TTL in seconds for history records in collection to expire + NOTE: Unset to remove expiry but ensure you remove the index from + mongo by hand + + ALSO: SyncedCron can't use the `_ensureIndex` command to modify + the TTL index. The best way to modify the default value of + `collectionTTL` is to remove the index by hand (in the mongo shell + run `db.cronHistory.dropIndex({startedAt: 1})`) and re-run your + project. SyncedCron will recreate the index with the updated TTL. + */ + // collectionTTL: 172800 + }); + + if (isMailServerMaster) { + SyncedCron.add({ + name: 'Send emails', + timezone: 'Europe/Madrid', + schedule: (parser) => { + // http://bunkat.github.io/later/ + const sched = parser.text('every 10 min'); + if (sched.error !== -1) { + console.error(`Mail cron 'when' field parsed with errors: ${sched.error}`); + } + return sched; + }, + job: () => sendEmailsFromQueue() + }); + + SyncedCron.add({ + name: 'Process pending notif', + timezone: 'Europe/Madrid', + schedule: (parser) => { + // http://bunkat.github.io/later/ + const sched = parser.text('every 15 min'); + if (sched.error !== -1) { + console.error(`Mail cron 'when' field parsed with errors: ${sched.error}`); + } + return sched; + }, + job: () => { + const mobileNotif = Notifications.find({ nofitied: null, type: 'mobile' }); + mobileNotif.forEach((notif) => { + processNotif(notif); + }); + const emailNotif = Notifications.find({ emailNofitied: null, type: 'web' }); + emailNotif.forEach((notif) => { + processNotif(notif); + }); + return { mobile: mobileNotif.count(), web: emailNotif.count() }; + } + }); + } + + const esEn = Meteor.settings.private.twitter.es.enabled; + const enEn = Meteor.settings.private.twitter.en.enabled; + + if (esEn) { + SyncedCron.add({ + name: 'Check for fires stats in Spain and tweet about', + timezone: 'Europe/Madrid', + // Optionally set a positive offset if you wish to 'snooze' a schedule + // offset: 30 * 60 * 100, + // context: { + // userID: 'xyz' + // }, + schedule: (parser) => { + // this.magic = true; // Context is accesible here as this context. + // parser is a later.parse object + // return parser.text('every 2 minutes'); + // http://bunkat.github.io/later/ + const sched = parser.text(Meteor.settings.private.twitter.es.when); + if (sched.error !== -1) { + console.error(`Twitter cron 'when' field parsed with errors: ${sched.error}`); + } + return sched; + }, + job: () => tweetIberiaFires() + /* console.log('cron is working'); + * console.log(this.userID) // Context Object becomes this argument + * console.log(this.magic) / + * var numbersCrunched = CrushSomeNumbers(); + * return numbersCrunched; + return tweetFires(); */ + }); + } + + if (enEn) { + SyncedCron.add({ + name: 'Check for fires stats in Europe and tweet about', + timezone: 'Europe/Madrid', + schedule: (parser) => { + const sched = parser.text(Meteor.settings.private.twitter.en.when); + if (sched.error !== -1) { + console.error(`Twitter cron 'when' field parsed with errors: ${sched.error}`); + } + return sched; + }, + job: () => tweetEuropeFires() + }); + } + + + if (isMailServerMaster || esEn || enEn) { + SyncedCron.start(); + } +}); diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js index c38618c..42752cd 100644 --- a/imports/startup/server/email.js +++ b/imports/startup/server/email.js @@ -1,8 +1,8 @@ import { Meteor } from 'meteor/meteor'; -import { Mongo } from 'meteor/mongo'; import nodemailer from 'nodemailer'; import { MailTime } from 'meteor/ostrio:mailer'; import i18n from 'i18next'; +import isMaster from './isMaster'; const transports = []; @@ -20,38 +20,46 @@ export const hr = ``; - } */ - - function process(notif) { - if (notif.type === 'web' && !notif.emailNotified) { - const user = Meteor.users.findOne({ _id: notif.userId }); - const hasPassword = user.services && user.services.password && user.services.password.bcrypt; - - // console.log(`Has password: ${hasPassword}`); - const OAuthProfile = getOAuthProfile({ - password: hasPassword, - profile: user.profile - }, user); - const firstName = OAuthProfile ? OAuthProfile.name.first : user.profile.name.first; - const emailAddress = OAuthProfile ? OAuthProfile.email : - user && user.emails[0] && user.emails[0].verified ? user.emails[0].address : null; - if (emailAddress) { - const img = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); - // const url = imgUrl(notif.geo.coordinates[1], notif.geo.coordinates[0]); - const fireUrl = `${Meteor.absoluteUrl('fire/')}${notif.sealed}`; - // const fireHtmlUrl = `${i18n.t('Más información sobre este fuego')}`; - // TODO get _id of fire - // const fireTextUrl = `${i18n.t('Más información sobre este fuego')}:\n${fireUrl}`; - // FIXME use our map as url and static map as img - moment.locale(user.lang); - // moment user tz ? - const message = `${trim(notif.content)} (${i18n.t('fireDetectedAt', { when: dateLongFormat(notif.when) })}).`; - - // TODO Comunes Address - - const emailOpts = { - to: emailAddress, - // userName: firstName, - // sendAt: new Date(), - subject: truncate.apply(message, [50, true]), - // text: `${message}\n\n${fireTextUrl}\n\n`, - // template: '

{{appName}}

{{{html}}}', - lang: user.lang, - template: 'new-fire', - templateVars: { - applicationName: i18n.t('AppName'), - firstName, - message, - fireUrl, - img, - subsUrl: Meteor.absoluteUrl('subscriptions') - } - }; - sendEmail(emailOpts).catch((error) => { - throw new Meteor.Error('500', `${error}`); - }); - // sendMail(emailOpts, true); - Notifications.update(notif._id, { $set: { emailNotified: true, emailNotifiedAt: new Date() } }); - } - } - } - - Notifications.find().observe({ - added: function notifAdded(notif) { - process(notif); - }, - changed: function notifChanged(updatedNotif, oldNotif) { - process(updatedNotif); - } - }); }); diff --git a/imports/startup/server/prerender.js b/imports/startup/server/prerender.js new file mode 100644 index 0000000..dedee0b --- /dev/null +++ b/imports/startup/server/prerender.js @@ -0,0 +1,46 @@ +/* global WebApp */ + +import { Meteor } from 'meteor/meteor'; +import prerenderIO from 'prerender-node'; + +// NOTE: Fetch as Google needs the /?_escaped_fragment_= +// https://webmasters.stackexchange.com/questions/72819/fetch-as-google-doesnt-find-the-html-snapshot-for-my-ajax-content + +// https://forums.meteor.com/t/any-guide-for-prerender-node-and-your-own-server-on-meteor/22054/9 +const settings = Meteor.settings.PrerenderIO; + +// https://github.com/dferber90/meteor-prerender/blob/master/server/prerender.js +const token = process.env.PRERENDERIO_TOKEN || (settings && settings.token); +const protocol = process.env.PRERENDERIO_PROTOCOL || (settings && settings.protocol); +// service url (support `prerenderServiceUrl` (for historical reasons) and `serviceUrl`) +let serviceUrl = settings && (settings.prerenderServiceUrl || settings.serviceUrl); +serviceUrl = process.env.PRERENDERIO_SERVICE_URL || serviceUrl; + +Meteor.startup(() => { + /* TODO if (!__meteor_runtime_config__.ROOT_URL.match(/www|stg|app/)) return; */ + + prerenderIO.set('prerenderToken', token); + + if (serviceUrl) prerenderIO.set('prerenderServiceUrl', serviceUrl); + prerenderIO.set('prerenderToken', token); + if (protocol) prerenderIO.set('protocol', protocol); + + prerenderIO.set('beforeRender', (req, done) => { + if (Meteor.isDevelopment) console.log('\nprerender before', req.headers, '\n\n'); + /* This method is intended to be used for caching, but could be used to save analytics or anything else you need to do for each crawler request. If you return a string from beforeRender, the middleware will serve that to the crawler (with status 200) instead of making a request to the prerender service. If you return an object the middleware will look for a status and body property (defaulting to 200 and "" respectively) and serve those instead. */ + done(); + }); + + prerenderIO.set('afterRender', (err, req, prerender_res) => { + /* This method is intended to be used for caching, but could be used to save analytics or anything else you need to do for each crawler request. This method is a noop and is called after the prerender service returns HTML. */ + if (err) { + console.log('prerenderio error', err); + return; + } + if (Meteor.isDevelopment) console.log('prerender after', req.url, '\nheaders:', req.headers, '\nres complete:', prerender_res.complete, prerender_res.statusCode, prerender_res.statusMessage, '\nres headers:', prerender_res.headers, '\nres body', prerender_res); + }); + + WebApp.rawConnectHandlers.use(prerenderIO); + + // console.log('\nprerender service:', settings); +}); diff --git a/imports/startup/server/ravenLogger.js b/imports/startup/server/ravenLogger.js new file mode 100644 index 0000000..b3b83a3 --- /dev/null +++ b/imports/startup/server/ravenLogger.js @@ -0,0 +1,18 @@ +import RavenLogger from 'meteor/flowkey:raven'; +import { Meteor } from 'meteor/meteor'; + +const ravenOptions = {}; +const publicDSN = Meteor.settings.public.sentryPublicDSN; +const privateDSN = Meteor.settings.sentryPrivateDSN; +const enabled = publicDSN && privateDSN; + +const ravenLogger = enabled ? new RavenLogger({ + publicDSN, + privateDSN, + shouldCatchConsoleError: true, // default true + trackUser: true // default false +}, ravenOptions) : { log: (error) => { console.log(error); } }; + +console.log(`ravenLogger ${enabled ? 'enabled' : 'disabled'}`); + +export default ravenLogger; diff --git a/imports/startup/server/rest.js b/imports/startup/server/rest.js new file mode 100644 index 0000000..64e8172 --- /dev/null +++ b/imports/startup/server/rest.js @@ -0,0 +1 @@ +import '../../api/Rest/Rest.js'; diff --git a/imports/startup/server/sitemaps.js b/imports/startup/server/sitemaps.js index 29a62df..8026449 100644 --- a/imports/startup/server/sitemaps.js +++ b/imports/startup/server/sitemaps.js @@ -4,6 +4,8 @@ import Fires from '/imports/api/Fires/Fires'; // import Comments from '/imports/api/Comments/Comments'; +const firesMapEnabled = false; + sitemaps.add('/sitemap.xml', () => { const today = new Date(); @@ -16,25 +18,27 @@ sitemaps.add('/sitemap.xml', () => { out.push({ page: '/terms', lastmod: today }); out.push({ page: '/license', lastmod: today }); out.push({ page: '/privacy', lastmod: today }); - + out.push({ page: '/about', lastmod: today }); // When user has public page /* const users = Meteor.users.find().fetch(); - _.each(users, function(user) { - out.push({ - page: '/persona/' + user.username, - lastmod: user.updatedAt - }); + _.each(users, function(user) { + out.push({ + page: '/persona/' + user.username, + lastmod: user.updatedAt + }); }); */ - Fires.find().fetch().forEach((fire) => { - // Search the last comment of tha fire - const lastComment = Comments.getCollection().findOne({ referenceId: `fire-${fire._id}` }, { sort: { createdAt: -1 } }); - out.push({ - page: `/fire/archive/${fire._id._str}`, - lastmod: lastComment ? lastComment.createdAt : fire.updatedAt + if (firesMapEnabled) { + Fires.find({}, { limit: 100, sort: { createdAt: -1 } }).fetch().forEach((fire) => { + // Search the last comment of tha fire + const lastComment = Comments.getCollection().findOne({ referenceId: `fire-${fire._id}` }, { sort: { createdAt: -1 } }); + out.push({ + page: `/fire/archive/${fire._id._str}`, + lastmod: lastComment ? lastComment.createdAt : fire.updatedAt + }); }); - }); + } return out; }); diff --git a/imports/startup/server/subsUnion.js b/imports/startup/server/subsUnion.js new file mode 100644 index 0000000..2643359 --- /dev/null +++ b/imports/startup/server/subsUnion.js @@ -0,0 +1,123 @@ +/* eslint-disable import/no-absolute-path */ + +import { Meteor } from 'meteor/meteor'; +import Subscriptions from '/imports/api/Subscriptions/Subscriptions'; +import SiteSettings from '/imports/api/SiteSettings/SiteSettings'; +import Perlin from 'loms.perlin'; +import './leaflet-workaround'; +import L from 'leaflet'; +import calcUnion from '/imports/ui/components/Maps/SubsUnion/Unify'; +import { isMailServerMaster } from '/imports/startup/server/email'; + +// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev + +Meteor.startup(() => { + if (!isMailServerMaster) { + console.log('We only process subsUnion in master'); + return; + } + const debug = true; // Meteor.isDevelopment; + Perlin.seed(Math.random()); + + const addNoisy = (osub) => { + const sub = osub; + let lat = Math.round(sub.location.lat * 10) / 10; + let lon = Math.round(sub.location.lon * 10) / 10; + const noiseBase = Perlin.perlin2(lat, lon); + const noise = Math.abs(noiseBase / 3); + lat += noise; + lon += noise; + sub.location.lat = lat; + sub.location.lon = lon; + sub.distance += noiseBase; + return sub; + }; + + const noNoisy = sub => sub; + + const process = (isPublic) => { + const subscribers = Subscriptions.find().fetch(); + const result = calcUnion(L, subscribers, isPublic ? addNoisy : noNoisy, true); + const union = result[0]; + const bounds = result[1]; + + const publicl = isPublic ? 'public' : 'private'; + const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase()); + + if (typeof union === 'object') { + const unionSet = { + $set: { + name: `subs-${publicl}-union`, + value: JSON.stringify(union), + isPublic, + description: `${Publicl} subscriptions union`, + type: 'string' + } + }; + const boundsSet = { + $set: { + name: `subs-${publicl}-union-bounds`, + value: JSON.stringify(bounds), + isPublic, + description: `${Publicl} subscriptions union bounds`, + type: 'string' + } + }; + const sizeSet = { + $set: { + name: 'subs-union-count', + value: subscribers.length, + isPublic: false, + description: 'Subscriptions count', + type: 'number' + } + }; + // FIXME, take care of object size: + // https://stackoverflow.com/questions/10827812/what-is-the-length-maximum-for-a-string-data-type-in-mongodb-used-with-ruby + SiteSettings.upsert({ name: `subs-${publicl}-union` }, unionSet, { multi: false }); + SiteSettings.upsert({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false }); + SiteSettings.upsert({ name: 'subs-union-count' }, sizeSet, { multi: false }); + if (debug) console.log(`${Publicl} subscription union calculated`); + } else { + console.log('Subscription union failed!'); + } + }; + + // At startup, we check if it's necessary to calc subscriptions union again + const currentUnion = SiteSettings.findOne({ name: 'subs-public-union' }); + const lastSubs = Subscriptions.findOne({}, { sort: { updatedAt: -1 } }); + const countUnionSubs = SiteSettings.findOne({ name: 'subs-union-count' }); + const countSubs = Subscriptions.find({}).count(); + if (currentUnion && lastSubs) { + const lastUnionUpdated = currentUnion.updatedAt; + const lastSubsUpdated = lastSubs.updatedAt; + if (lastUnionUpdated > lastSubsUpdated || !countUnionSubs || countSubs !== countUnionSubs.value) { + console.log('Subs union outdated'); + process(true); + process(false); + } else { + console.log('Subs union up-to-date'); + } + } + + Subscriptions.find({ createdAt: { $gt: new Date() } }).observe({ + added: function newSubAdded() { // doc) { + if (debug) console.log('Subs added so recreate union'); + process(true); + process(false); + } + }); + + Subscriptions.find().observe({ + changed: function subsChanged() { // updatedDoc, oldDoc) { + if (debug) console.log('Subs changed so recreate union'); + process(true); + process(false); + }, + removed: function subsRemoved() { // oldDoc) { + if (debug) console.log('Subs removed so recreate union'); + process(true); + process(false); + } + }); +}); diff --git a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js index bff6360..6fde995 100644 --- a/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js +++ b/imports/ui/components/CenterInMyPosition/CenterInMyPosition.js @@ -5,6 +5,8 @@ import { Tracker } from 'meteor/tracker'; import { ReactiveVar } from 'meteor/reactive-var'; import { Button } from 'react-bootstrap'; import { translate } from 'react-i18next'; +import { Bert } from 'meteor/themeteorchef:bert'; + import './CenterInMyPosition.scss'; class CenterInMyPosition extends React.Component { @@ -14,7 +16,6 @@ class CenterInMyPosition extends React.Component { // only with SSL: // https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition - console.log('Center'); // https://stackoverflow.com/questions/31608579/somethings-wrong-with-my-meteor-geolocation-functions const userGeoLocation = new ReactiveVar(null); const self = this; @@ -27,8 +28,17 @@ class CenterInMyPosition extends React.Component { zoom: 11 }; self.props.onClick(viewport); - // console.log(viewport); - // self.onViewportChanged(viewport); + computation.stop(); + } + // https://developer.mozilla.org/en-US/docs/Web/API/PositionError) + const error = Geolocation.error(); + if (error) { + const cod = error.code; + // window.alert(cod); + if (cod === 1) Bert.alert(self.props.t('geo-not-perms-error'), 'danger'); + else if (cod === 2) Bert.alert(self.props.t('geo-not-avail-error'), 'danger'); + else if (cod === 3) Bert.alert(self.props.t('geo-not-timeout-error'), 'danger'); + else Bert.alert(error.message, 'danger'); computation.stop(); } }); @@ -38,8 +48,8 @@ class CenterInMyPosition extends React.Component { const { onlyIcon, t } = this.props; const msg = t('Centrar en tu ubicación'); return ( - ); } } diff --git a/imports/ui/components/DistanceSlider/DistanceSlider.js b/imports/ui/components/DistanceSlider/DistanceSlider.js index 3647c2e..f5d20f1 100644 --- a/imports/ui/components/DistanceSlider/DistanceSlider.js +++ b/imports/ui/components/DistanceSlider/DistanceSlider.js @@ -54,7 +54,7 @@ class DistanceSlider extends React.Component { render() { return (
-

¿A que distancia a la redonda quieres recibir notificaciones?

+

¿A que distancia a la redonda quieres recibir notificaciones de fuegos?

-

{this.t('AppNameFull')}

-

{this.t('general-error-title')}

- - {this.t('general-error-description')} - +

{appName}

+

{title}

+

{subTitle}

); } - return this.props.children; + return children; } } ErrorBoundary.propTypes = { - t: PropTypes.func.isRequired + appName: PropTypes.string, + title: PropTypes.string, + subTitle: PropTypes.string, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired }; ErrorBoundary.defaultProps = { + appName: 'All Against the Fire!', + title: 'Upppps: Something has gone wrong', + subTitle: 'We are investigating the problem, try again in a while' }; -export default translate([], { wait: true })(withTracker(props => ({ - // props.something -}))(ErrorBoundary)); +export default ErrorBoundary; diff --git a/imports/ui/components/ErrorBoundary/ErrorBoundary.scss b/imports/ui/components/ErrorBoundary/ErrorBoundary.scss index 9ced77e..225e4ca 100644 --- a/imports/ui/components/ErrorBoundary/ErrorBoundary.scss +++ b/imports/ui/components/ErrorBoundary/ErrorBoundary.scss @@ -2,9 +2,9 @@ background-image: url('/error-background.png'); height: 100%; min-height: 55vh; - margin-top: 40px; + margin: 40px 10px 10px 10px; } .error-boundary > div > div > h4 { - line-height: 1.5em; + line-height: 1.3em; } diff --git a/imports/ui/components/Feedback/Feedback.js b/imports/ui/components/Feedback/Feedback.js new file mode 100644 index 0000000..bbcab29 --- /dev/null +++ b/imports/ui/components/Feedback/Feedback.js @@ -0,0 +1,141 @@ +/* eslint-disable react/jsx-indent-props */ +/* eslint-disable import/no-absolute-path */ +/* eslint-disable import/no-absolute-path */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Meteor } from 'meteor/meteor'; +import { translate } from 'react-i18next'; +import { FormGroup, Button, FormControl } from 'react-bootstrap'; +import { Bert } from 'meteor/themeteorchef:bert'; +import { withTracker } from 'meteor/react-meteor-data'; +import { isHome } from '/imports/ui/components/Utils/location'; +import { testId } from '/imports/ui/components/Utils/TestUtils'; +import validate from '/imports/modules/validate'; + +import './Feedback.scss'; + +class Feedback extends Component { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.onTabClick = this.onTabClick.bind(this); + } + + componentDidMount() { + const component = this; + const { t } = this.props; + + validate(component.form, { + rules: { + email: { + required: true, + email: true + }, + feedbackText: { + required: true + } + }, + messages: { + feedbackText: { + required: t('Por favor, escribe aquí tu feedback...') + }, + email: { + required: t('Tu correo'), + email: t('¿Es correcto este correo?') + } + }, + submitHandler() { component.handleSubmit(); } + }); + } + + onTabClick() { + $('#feedback-form').toggle('slide'); + } + + handleSubmit() { + const email = this.email.value.trim(); + const feedbackText = this.feedbackText.value.trim(); + + Meteor.call('send-feedback', email, feedbackText, (error) => { + if (error) { + Bert.alert(error.reason, 'danger'); + } else { + this.form.reset(); + Bert.alert('Feedback recibido, gracias...', 'success'); + this.onTabClick(); + } + }); + } + + render() { + // console.log(`Render Feedback because isHome ${this.props.isHome}, email: '${this.props.emailAddress}'`); + const disabled = this.props.emailVerified && this.props.emailAddress; + const { t } = this.props; + return ( +
+ { !this.props.isHome && +
+
(this.formdiv = formdiv)} style={{ display: 'none' }} className="card"> +
(this.form = form)} + className="form card-body" + onSubmit={event => event.preventDefault()} + > + + (this.email = email)} + placeholder={t('Tu correo')} + key={disabled ? 'disabledEmail' : 'enabledEmail'} + disabled={disabled} + defaultValue={disabled ? this.props.emailAddress : ''} + type="email" + /> + + +