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 56a40d5..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,18 +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 -chimp --watch --ddp=http://localhost:3000 --path=cucumber +node_modules/.bin/chimp --watch --ddp=http://localhost:3000 --path=cucumber # and -chimp --ddp=http://localhost:3000 --path=cucumber +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*. @@ -42,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/client/main.html b/client/main.html index 5f2bc53..a2728e0 100644 --- a/client/main.html +++ b/client/main.html @@ -1,7 +1,7 @@ - All against 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 index d1ffed5..3bbb113 100644 --- a/cucumber/features/feedback.feature +++ b/cucumber/features/feedback.feature @@ -1,4 +1,3 @@ -@watch Feature: Allow users to give feedback As a user (authenticated or not) @@ -10,6 +9,7 @@ Feature: Allow users to give feedback # 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 diff --git a/cucumber/features/pages.feature b/cucumber/features/pages.feature index 1c17a30..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 | @@ -10,8 +13,9 @@ Feature: Test all secundary pages | 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 | @@ -21,10 +25,27 @@ Feature: Test all secundary pages | 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 index f25a3b3..ffd78be 100644 --- a/cucumber/features/steps_definitions/feedback.js +++ b/cucumber/features/steps_definitions/feedback.js @@ -24,6 +24,7 @@ module.exports = function doSteps() { 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'); diff --git a/cucumber/features/steps_definitions/hooks.js b/cucumber/features/steps_definitions/hooks.js index 9e3e611..64b405a 100644 --- a/cucumber/features/steps_definitions/hooks.js +++ b/cucumber/features/steps_definitions/hooks.js @@ -2,17 +2,50 @@ (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 }); 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 38e61b0..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,10 +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.waitForVisible('#react-root', 10000); - client.waitForText('#react-root', content); + processPageUrl(i); } callback(); }); @@ -77,7 +74,7 @@ module.exports = function () { // 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.waitForText('#react-root', pages[i][1]); + client.waitUntilText('#react-root', pages[i][1]); } callback(); }); diff --git a/designs/icons-tcef.svg b/designs/icons-tcef.svg index 8bfd7f1..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" @@ -448,11 +449,18 @@ inkscape:connector-curvature="0" /> + + 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/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 c6de899..03878b3 100644 --- a/imports/api/ActiveFires/server/publications.js +++ b/imports/api/ActiveFires/server/publications.js @@ -31,12 +31,12 @@ const activefires = (northEastLng, northEastLat, southWestLng, southWestLat, wit lat: 1, lon: 1, when: 1, - scan: 1 + scan: 1, + track: 1 } }); - // console.log(JSON.stringify(result)); - // 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); @@ -58,3 +58,8 @@ Meteor.publish('activefiresmyloc', function activeInMyLoc(northEastLng, northEas 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/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 659c2d4..a79ae1a 100644 --- a/imports/api/FalsePositives/server/publications.js +++ b/imports/api/FalsePositives/server/publications.js @@ -5,7 +5,8 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { NumberBetween } from '/imports/modules/server/other-checks'; -import L from 'leaflet-headless'; +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'; @@ -17,12 +18,26 @@ Meteor.publish('falsePositivesTotal', function total() { }); export const firesUnion = (fires) => { - const group = new L.FeatureGroup(); - const remap = fires.fetch().map(function remap(doc) { - // default scan: 1 for neightbor alerts - return { location: { lat: doc.lat, lon: doc.lon }, distance: doc.scan || 1 }; + 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(remap, group, sub => sub); + 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; }; 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 96d6a85..3862beb 100644 --- a/imports/api/Fires/server/publications.js +++ b/imports/api/Fires/server/publications.js @@ -12,6 +12,7 @@ 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'; @@ -132,32 +133,46 @@ Meteor.publish('fireFromId', function fireFromId(_id) { } }); -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/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/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 618ed21..cf927e0 100644 --- a/imports/api/Subscriptions/methods.js +++ b/imports/api/Subscriptions/methods.js @@ -10,30 +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'; - const newDoc = { - owner: this.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); - } + return subscriptionsInsert(doc, this.userId, 'web'); }, 'subscriptions.update': function subscriptionsUpdate(doc) { check(doc, { @@ -52,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/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/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 717f19f..6f02058 100644 --- a/imports/api/Utility/server/files.js +++ b/imports/api/Utility/server/files.js @@ -1,5 +1,7 @@ +/* eslint-disable import/no-absolute-path */ + import fs from 'fs'; -import getFallbackLang from '/imports/startup/server/accounts/lang-fallback'; +import getFallbackLang from '/imports/modules/lang-fallback'; const as = f => `assets/app/${f}`; 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/startup/server/accounts/lang-fallback.js b/imports/modules/lang-fallback.js similarity index 69% rename from imports/startup/server/accounts/lang-fallback.js rename to imports/modules/lang-fallback.js index 67fbb66..d912347 100644 --- a/imports/startup/server/accounts/lang-fallback.js +++ b/imports/modules/lang-fallback.js @@ -1,5 +1,6 @@ const getFallbackLang = (lang) => { - if (lang && (lang === 'ast' || lang === 'gl' || lang === 'eu' || lang === 'ca' || lang.match(/^es-/))) { + if (lang && (lang === 'ast' || lang === 'gl' || lang === 'eu' || + lang === 'es' || lang === 'ca' || lang.match(/^es-/))) { return 'es'; } return 'en'; 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/i18n.js b/imports/startup/client/i18n.js index 2fdc6b4..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', @@ -61,6 +61,14 @@ if (sendMissing && Meteor.isDevelopment) { }; } +function setT9(lang) { + if (lang === 'gl') { + T9n.setLanguage('es'); + } else { + T9n.setLanguage(lang); + } +} + i18n.use(backend) .use(LngDetector) .use(Cache) @@ -75,7 +83,7 @@ i18n.use(backend) // document.title = t('AppName'); // Accounts translation // https://github.com/softwarerero/meteor-accounts-t9n - T9n.setLanguage(i18n.language); + setT9(i18n.language); // console.log(T9n.get('error.accounts.User not found')); moment.locale(i18n.language); @@ -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 6cff432..147cf4f 100644 --- a/imports/startup/client/index.js +++ b/imports/startup/client/index.js @@ -1,6 +1,8 @@ +/* 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'; @@ -9,4 +11,9 @@ import '../../ui/stylesheets/app.scss'; 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 Meteor.startup(() => render(, document.getElementById('react-root'))); +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 { 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 728ec88..7b9ab18 100644 --- a/imports/startup/common/comments.js +++ b/imports/startup/common/comments.js @@ -3,7 +3,8 @@ // 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 diff --git a/imports/startup/common/i18n.js b/imports/startup/common/i18n.js index 9be09fd..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,10 @@ 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!! @@ -35,7 +40,7 @@ const i18nOpts = { return value; } }, - 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 383b8c0..566acc6 100644 --- a/imports/startup/server/accounts/on-create-user.js +++ b/imports/startup/server/accounts/on-create-user.js @@ -1,7 +1,9 @@ +/* eslint-disable import/no-absolute-path */ + import { Accounts } from 'meteor/accounts-base'; -import sendWelcomeEmail from '../../../api/Users/server/send-welcome-email'; -import getOAuthProfile from '../../../modules/get-oauth-profile'; -import getFallbackLang from './lang-fallback'; +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; 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/ravenLogger.js b/imports/startup/server/ravenLogger.js index d722dcf..b3b83a3 100644 --- a/imports/startup/server/ravenLogger.js +++ b/imports/startup/server/ravenLogger.js @@ -2,12 +2,17 @@ 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 = new RavenLogger({ - publicDSN: Meteor.settings.public.sentryPublicDSN, - privateDSN: Meteor.settings.sentryPrivateDSN, - shouldCatchConsoleError: true, // default - trackUser: true // default -}, ravenOptions); +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 c1fe573..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(); @@ -20,21 +22,23 @@ sitemaps.add('/sitemap.xml', () => { // 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({}, { 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 + 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 index d527f50..2643359 100644 --- a/imports/startup/server/subsUnion.js +++ b/imports/startup/server/subsUnion.js @@ -4,12 +4,19 @@ 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 L from 'leaflet-headless'; +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) => { @@ -26,59 +33,91 @@ Meteor.startup(() => { return sub; }; - const process = () => { - const group = new L.FeatureGroup(); - const result = calcUnion(Subscriptions.find().fetch(), group, addNoisy); + 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-public-union', + name: `subs-${publicl}-union`, value: JSON.stringify(union), - isPublic: true, - description: 'Public subscriptions union', + isPublic, + description: `${Publicl} subscriptions union`, type: 'string' } }; const boundsSet = { $set: { - name: 'subs-public-union-bounds', + name: `subs-${publicl}-union-bounds`, value: JSON.stringify(bounds), - isPublic: true, - description: 'Public subscriptions union 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-public-union' }, unionSet, { multi: false }); - SiteSettings.upsert({ name: 'subs-public-union-bounds' }, boundsSet, { multi: false }); - if (Meteor.isDevelopment) console.log('Subscription union calculated'); + 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 - process(); + // 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 (Meteor.isDevelopment) console.log('Subs added so recreate union'); - process(); + if (debug) console.log('Subs added so recreate union'); + process(true); + process(false); } }); Subscriptions.find().observe({ changed: function subsChanged() { // updatedDoc, oldDoc) { - if (Meteor.isDevelopment) console.log('Subs changed so recreate union'); - process(); + if (debug) console.log('Subs changed so recreate union'); + process(true); + process(false); }, removed: function subsRemoved() { // oldDoc) { - if (Meteor.isDevelopment) console.log('Subs removed so recreate union'); - process(); + if (debug) console.log('Subs removed so recreate union'); + process(true); + process(false); } }); }); diff --git a/imports/ui/components/ErrorBoundary/ErrorBoundary.js b/imports/ui/components/ErrorBoundary/ErrorBoundary.js index a6676ec..f57fe79 100644 --- a/imports/ui/components/ErrorBoundary/ErrorBoundary.js +++ b/imports/ui/components/ErrorBoundary/ErrorBoundary.js @@ -58,7 +58,7 @@ ErrorBoundary.propTypes = { }; ErrorBoundary.defaultProps = { - appName: 'All Against Fire!', + appName: 'All Against the Fire!', title: 'Upppps: Something has gone wrong', subTitle: 'We are investigating the problem, try again in a while' }; diff --git a/imports/ui/components/FromNow/FromNow.js b/imports/ui/components/FromNow/FromNow.js index b540097..945b13d 100644 --- a/imports/ui/components/FromNow/FromNow.js +++ b/imports/ui/components/FromNow/FromNow.js @@ -5,6 +5,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { withTracker } from 'meteor/react-meteor-data'; import Chronos from '/imports/ui/components/Chronos/Chronos'; +import { translate } from 'react-i18next'; +import { dateLongFormat } from '/imports/api/Common/dates'; + +import './FromNow.scss'; class FromNow extends Component { constructor(props) { @@ -27,26 +31,39 @@ class FromNow extends Component { return !(nextState.when === this.state.when); } + thatIs() { + return this.props.t('esDecirTalDia', { date: dateLongFormat(this.props.whenDate) }); + } + render() { console.log(`Render from now ${this.state.when}`); return ( - {this.state.when} + {this.state.when} ); } } FromNow.propTypes = { - when: PropTypes.string + t: PropTypes.func.isRequired, + when: PropTypes.string, + whenDate: PropTypes.instanceOf(Date) }; FromNow.defaultProps = { }; -const FromNowContainer = withTracker((props) => { +/* + * const FromNowContainer = withTracker((props) => { + * + * })(FromNow); + * + * export default FromNowContainer; + * */ + +export default translate([], { wait: true })(withTracker((props) => { const whenDate = props.when; return { - when: whenDate ? Chronos.moment(whenDate).fromNow() : null + when: whenDate ? Chronos.moment(whenDate).fromNow() : null, + whenDate }; -})(FromNow); - -export default FromNowContainer; +})(FromNow)); diff --git a/imports/ui/components/FromNow/FromNow.scss b/imports/ui/components/FromNow/FromNow.scss new file mode 100644 index 0000000..d74bb16 --- /dev/null +++ b/imports/ui/components/FromNow/FromNow.scss @@ -0,0 +1,4 @@ +.from-now { + border-bottom: 1px gray dashed; + cursor: pointer; +} diff --git a/imports/ui/components/Loading/LoadingBar.js b/imports/ui/components/Loading/LoadingBar.js index 943e9a5..5dab00c 100644 --- a/imports/ui/components/Loading/LoadingBar.js +++ b/imports/ui/components/Loading/LoadingBar.js @@ -2,7 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Line } from 'react-progressbar.js'; +import { Meteor } from 'meteor/meteor'; +import { Line } from 'react-progress-bar.js'; import './LoadingBar.scss'; @@ -10,7 +11,7 @@ import './LoadingBar.scss'; const LoadingBar = ({ progress }) => (
diff --git a/imports/ui/components/Maps/DefMapLayers.js b/imports/ui/components/Maps/DefMapLayers.js index 0db329c..6e3ed56 100644 --- a/imports/ui/components/Maps/DefMapLayers.js +++ b/imports/ui/components/Maps/DefMapLayers.js @@ -35,7 +35,8 @@ class DefMapLayers extends Component { ); const osmlayer = ( @@ -56,9 +57,9 @@ class DefMapLayers extends Component { } { this.state.gkey && - - - } + + + } { this.state.gkey && @@ -72,6 +73,7 @@ DefMapLayers.propTypes = { t: PropTypes.func.isRequired, gray: PropTypes.bool, osmcolor: PropTypes.bool, + terrain: PropTypes.bool, satellite: PropTypes.bool }; @@ -79,6 +81,7 @@ DefMapLayers.propTypes = { DefMapLayers.defaultProps = { gray: false, osmcolor: false, + terrain: false, satellite: false }; diff --git a/imports/ui/components/Maps/FireCircleMark.js b/imports/ui/components/Maps/FireCircleMark.js index e4a3460..80b9826 100644 --- a/imports/ui/components/Maps/FireCircleMark.js +++ b/imports/ui/components/Maps/FireCircleMark.js @@ -1,10 +1,10 @@ import React, { Component } from 'react'; -import { Circle } from 'react-leaflet'; +import { GeoJSON } from 'react-leaflet'; import PropTypes from 'prop-types'; +import { rectangleAround } from 'map-common-utils'; import { onMarkClick } from './MarkListeners'; import FirePopup from './FirePopup'; - class FireCircleMark extends Component { constructor(props) { super(props); @@ -22,22 +22,26 @@ class FireCircleMark extends Component { lat, lon, scan, + track, nasa, id, history, when, t } = this.props; + const rect = rectangleAround({ lat, lon }, track, track); return ( - + - + ); + /* */ } } FireCircleMark.propTypes = { scan: PropTypes.number.isRequired, + track: PropTypes.number.isRequired, lat: PropTypes.number.isRequired, lon: PropTypes.number.isRequired, nasa: PropTypes.bool.isRequired, diff --git a/imports/ui/components/Maps/FireIconMark.js b/imports/ui/components/Maps/FireIconMark.js index 7199aa4..ab24da4 100644 --- a/imports/ui/components/Maps/FireIconMark.js +++ b/imports/ui/components/Maps/FireIconMark.js @@ -3,8 +3,8 @@ import React, { Component, Fragment } from 'react'; import { CircleMarker, Marker, Tooltip } from 'react-leaflet'; import PropTypes from 'prop-types'; import { fireIconS, fireIconM, fireIconL, nFireIcon, industryIcon, regIndustryIcon } from '/imports/ui/components/Maps/Icons'; -import { onMarkClick } from './MarkListeners'; import { translate } from 'react-i18next'; +import { onMarkClick } from './MarkListeners'; import FirePopup from './FirePopup'; class FireIconMark extends Component { @@ -30,16 +30,7 @@ class FireIconMark extends Component { render() { const { - lat, - lon, - scan, - nasa, - id, - history, - falsePositives, - industries, - when, - t + lat, lon, scan, track, nasa, id, history, falsePositives, industries, neighbour, when, t } = this.props; return (
@@ -78,9 +69,11 @@ FireIconMark.propTypes = { lat: PropTypes.number.isRequired, lon: PropTypes.number.isRequired, scan: PropTypes.number, + track: PropTypes.number, nasa: PropTypes.bool, falsePositives: PropTypes.bool.isRequired, industries: PropTypes.bool.isRequired, + neighbour: PropTypes.bool.isRequired, id: PropTypes.object.isRequired, history: PropTypes.object.isRequired, when: PropTypes.instanceOf(Date), diff --git a/imports/ui/components/Maps/FireList.js b/imports/ui/components/Maps/FireList.js index a5979d7..da88cf9 100644 --- a/imports/ui/components/Maps/FireList.js +++ b/imports/ui/components/Maps/FireList.js @@ -9,14 +9,14 @@ import FirePixel from '/imports/ui/components/Maps/FirePixel'; export default function FireList(props) { const { - fires, scale, useMarkers, nasa, t, history, falsePositives, industries + fires, scale, useMarkers, nasa, t, history, falsePositives, industries, neighbour } = props; const useMarks = useMarkers && scale; const usePixel = !nasa || !scale; /* console.log(`Using marks: ${useMarks}, using pixels: ${usePixel}`); */ let items; if (useMarks) { - items = fires.map(({ _id, ...otherProps }) => ()); + items = fires.map(({ _id, ...otherProps }) => ()); } else if (usePixel && !falsePositives && !industries) { items = fires.map(({ _id, ...otherProps }) => ()); } else if (!falsePositives && !industries) { @@ -32,6 +32,7 @@ FireList.propTypes = { nasa: PropTypes.bool.isRequired, falsePositives: PropTypes.bool.isRequired, industries: PropTypes.bool.isRequired, + neighbour: PropTypes.bool.isRequired, history: PropTypes.object.isRequired, t: PropTypes.func.isRequired }; diff --git a/imports/ui/components/Maps/FireListUnion.js b/imports/ui/components/Maps/FireListUnion.js new file mode 100644 index 0000000..3b094c0 --- /dev/null +++ b/imports/ui/components/Maps/FireListUnion.js @@ -0,0 +1,22 @@ +/* eslint-disable import/no-absolute-path */ +/* eslint-disable react/jsx-indent-props */ +/* eslint-disable react/jsx-indent */ +import React from 'react'; +import PropTypes from 'prop-types'; +import FirePolygonMark from '/imports/ui/components/Maps/FirePolygonMark'; + +export default function FireListUnion(props) { + const { + firesUnion, nasa, t, history + } = props; + /* console.log(`Using marks: ${useMarks}, using pixels: ${usePixel}`); */ + const items = firesUnion.map(({ _id, ...otherProps }) => ()); + return (
{items}
); +} + +FireListUnion.propTypes = { + firesUnion: PropTypes.array.isRequired, + nasa: PropTypes.bool.isRequired, + history: PropTypes.object.isRequired, + t: PropTypes.func.isRequired +}; diff --git a/imports/ui/components/Maps/FirePixel.js b/imports/ui/components/Maps/FirePixel.js index c81b547..4efcabe 100644 --- a/imports/ui/components/Maps/FirePixel.js +++ b/imports/ui/components/Maps/FirePixel.js @@ -3,31 +3,32 @@ import React from 'react'; import { CircleMarker } from 'react-leaflet'; import PropTypes from 'prop-types'; -import FirePopup from './FirePopup'; import { translate } from 'react-i18next'; +import FirePopup from './FirePopup'; /* Less acurate (1 pixel per fire) but faster */ const FirePixel = ({ - lat, lon, nasa, id, when, t, history + lat, lon, nasa, id, when, t, history, track }) => ( ); - FirePixel.propTypes = { lat: PropTypes.number.isRequired, lon: PropTypes.number.isRequired, id: PropTypes.object.isRequired, history: PropTypes.object.isRequired, + scan: PropTypes.number, + track: PropTypes.number, when: PropTypes.instanceOf(Date), nasa: PropTypes.bool.isRequired, t: PropTypes.func.isRequired diff --git a/imports/ui/components/Maps/FirePolygonMark.js b/imports/ui/components/Maps/FirePolygonMark.js new file mode 100644 index 0000000..92e3640 --- /dev/null +++ b/imports/ui/components/Maps/FirePolygonMark.js @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import { GeoJSON } from 'react-leaflet'; +import PropTypes from 'prop-types'; +import { onMarkClick } from './MarkListeners'; +import FirePopup from './FirePopup'; + +class FirePolygonMark extends Component { + constructor(props) { + super(props); + this.t = props.t; + this.onClick = this.onClick.bind(this); + } + + onClick() { + const { history, nasa, id } = this.props; + onMarkClick(history, nasa, id); + } + + render() { + const { + nasa, + id, + shape, + centerid, + history, + when, + t + } = this.props; + const lon = centerid.coordinates[0]; + const lat = centerid.coordinates[1]; + return ( + + ); + /* */ + } +} + +FirePolygonMark.propTypes = { + id: PropTypes.object.isRequired, + nasa: PropTypes.bool.isRequired, + shape: PropTypes.object.isRequired, + centerid: PropTypes.object.isRequired, + history: PropTypes.object.isRequired, + when: PropTypes.instanceOf(Date).isRequired, + t: PropTypes.func.isRequired +}; + +export default FirePolygonMark; diff --git a/imports/ui/components/Maps/Icons.js b/imports/ui/components/Maps/Icons.js index 95adf71..eec46fb 100644 --- a/imports/ui/components/Maps/Icons.js +++ b/imports/ui/components/Maps/Icons.js @@ -13,21 +13,21 @@ export const fireIcon = new Leaflet.Icon({ }); export const fireIconL = new Leaflet.Icon({ - iconUrl: '/fire-marker-l.png', - iconSize: [16, 24], - iconAnchor: [8, 26] + iconUrl: '/fire-marker-l.png?v2', + iconSize: [28, 28], + iconAnchor: [14, 30] }); export const fireIconM = new Leaflet.Icon({ - iconUrl: '/fire-marker-m.png', - iconSize: [12, 17], - iconAnchor: [6, 19] + iconUrl: '/fire-marker-m.png?v2', + iconSize: [28, 28], + iconAnchor: [14, 30] }); export const fireIconS = new Leaflet.Icon({ - iconUrl: '/fire-marker-s.png', - iconSize: [8, 12], - iconAnchor: [4, 14] + iconUrl: '/fire-marker-s.png?v2', + iconSize: [28, 28], + iconAnchor: [14, 30] }); export const industryIcon = new Leaflet.Icon({ diff --git a/imports/ui/components/Maps/SubsUnion/SubsUnion.js b/imports/ui/components/Maps/SubsUnion/SubsUnion.js index dab127a..41a4d81 100644 --- a/imports/ui/components/Maps/SubsUnion/SubsUnion.js +++ b/imports/ui/components/Maps/SubsUnion/SubsUnion.js @@ -33,8 +33,7 @@ const subsUnion = (union, options) => { options.map.leafletElement.fitBounds(L.latLngBounds(bounds._northEast, bounds._southWest)); } } else if (options.subs.length > 0) { - const unionGroup = new L.FeatureGroup(); - const result = calcUnion(options.subs, unionGroup, sub => sub); + const result = calcUnion(L, options.subs, sub => sub, true); const unionJson = result[0]; const bounds = result[1]; diff --git a/imports/ui/components/Maps/SubsUnion/Unify.js b/imports/ui/components/Maps/SubsUnion/Unify.js index 46586b7..873dcbf 100644 --- a/imports/ui/components/Maps/SubsUnion/Unify.js +++ b/imports/ui/components/Maps/SubsUnion/Unify.js @@ -1,48 +1,9 @@ -import { check } from 'meteor/check'; -import LGeo from 'leaflet-geodesy'; -import tunion from '@turf/union'; -import ttrunc from '@turf/truncate'; +import { calcUnion as calcUnionWoBounds } from 'map-common-utils'; -// https://stackoverflow.com/questions/35394577/leaflet-js-union-merge-circles -const truncOptions = { precision: 6, coordinates: 2 }; - -const unify = (polyList) => { - let unionTemp; - for (let i = 0; i < polyList.length; i += 1) { - const pol = polyList[i].toGeoJSON(); - const cleanPol = ttrunc(pol, truncOptions); - if (i === 0) { - unionTemp = cleanPol; - } else { - unionTemp = ttrunc(tunion(unionTemp, cleanPol), truncOptions); - } - } - return unionTemp; -}; - -const calcUnion = (subs, group, decorated) => { - const unionGroup = group; - const copts = { - parts: 144 - }; - subs.forEach((osub) => { - try { - if (osub.location && osub.location.lat && osub.location.lon && osub.distance) { - check(osub.location.lon, Number); - check(osub.location.lat, Number); - check(osub.distance, Number); - const dsub = decorated(osub); - const circle = LGeo.circle([dsub.location.lat, dsub.location.lon], dsub.distance * 1000, copts); - circle.addTo(unionGroup); - } else { - console.info(`Wrong subscription ${JSON.stringify(osub)}`); - } - } catch (e) { - console.error(e, `Wrong subscription trying to make union ${JSON.stringify(osub)}`); - } - }); - const unionJson = unify(unionGroup.getLayers()); - return [unionJson, unionGroup.getBounds()]; +const calcUnion = (L, subs, decorated, typeCircle) => { + const unionJson = calcUnionWoBounds(subs, decorated, typeCircle); + // return [unionJson, L.geoJSON(unionJson).getBounds()]; + return [unionJson, unionJson === null ? null : L.geoJSON(unionJson).getBounds()]; }; export default calcUnion; diff --git a/imports/ui/components/NotificationsObserver/NotificationsObserver.js b/imports/ui/components/NotificationsObserver/NotificationsObserver.js index e68b1ef..ac374b5 100644 --- a/imports/ui/components/NotificationsObserver/NotificationsObserver.js +++ b/imports/ui/components/NotificationsObserver/NotificationsObserver.js @@ -12,7 +12,7 @@ import { trim } from './util.js'; function process(notif) { // No already notified if (Push.Permission.has()) { - if (!notif.webNotified) { + if (!notif.notified && notif.type === 'web') { Push.create(i18n.t('AppName'), { body: `${trim(notif.content)} (${i18n.t('fireDetected', { when: dateFromNow(notif.when) })})`, icon: '/n-fire-marker.png', @@ -47,7 +47,7 @@ Meteor.startup(() => { if (Meteor.userId()) { Meteor.subscribe('mynotifications'); // Check for notifications not processed at startup - Notifications.find({ webNotified: null }).forEach((notif) => { + Notifications.find({ notified: null, type: 'web' }).forEach((notif) => { process(notif); }); } diff --git a/imports/ui/components/Reconnect/Reconnect.js b/imports/ui/components/Reconnect/Reconnect.js index 19bbfb5..6c12778 100644 --- a/imports/ui/components/Reconnect/Reconnect.js +++ b/imports/ui/components/Reconnect/Reconnect.js @@ -4,13 +4,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { translate } from 'react-i18next'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; +/* import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; */ import Blaze from 'meteor/gadicc:blaze-react-component'; const Reconnect = ({ t, authenticated }) => (
- { !authenticated && + { true && /* !authenticated && */ ( /* https://react.i18next.com/components/i18nextprovider.html */
{props.i18nReady.get() && - - - - - - { !props.loading && -
- - - {i18n.t('AppName')} - - - - - - + + + + + + { !props.loading && +
+ + + {i18n.t('AppName')} + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - -
- - - {props.i18nReady.get() && } -
} -
-
-
-
-
} + + + +
+ + + {props.i18nReady.get() && } +
} +
+
+
+
+
}
); @@ -183,7 +186,9 @@ export default withTracker(() => { const loading = !Roles.subscription.ready() || !i18nReady.get(); const name = user && user.profile && user.profile.name && getUserName(user.profile.name); const emailAddress = user && user.emails && user.emails[0].address; - // console.log(`i18n ready?: ${i18nReady.get()}`); + Meteor.autorun(() => { + console.log(`i18n ready?: ${i18nReady.get()}`); + }); return { loading, loggingIn, diff --git a/imports/ui/pages/Fires/Fires.js b/imports/ui/pages/Fires/Fires.js index e5bf45f..2647992 100644 --- a/imports/ui/pages/Fires/Fires.js +++ b/imports/ui/pages/Fires/Fires.js @@ -10,14 +10,15 @@ import { Row, Col, Alert, FormGroup } from 'react-bootstrap'; import { Meteor } from 'meteor/meteor'; import { Bert } from 'meteor/themeteorchef:bert'; import { Helmet } from 'react-helmet'; -import { Map, Circle } from 'react-leaflet'; +import { Map, GeoJSON } from 'react-leaflet'; +import { rectangleAround } from 'map-common-utils'; import Blaze from 'meteor/gadicc:blaze-react-component'; import DefMapLayers from '/imports/ui/components/Maps/DefMapLayers'; import NotFound from '/imports/ui/pages/NotFound/NotFound'; import FiresCollection from '/imports/api/Fires/Fires'; import FireList from '/imports/ui/components/Maps/FireList'; import FromNow from '/imports/ui/components/FromNow/FromNow'; -import { dateLongFormat } from '/imports/api/Common/dates'; +import { dateLongFormat, dateYYYYMMDD } from '/imports/api/Common/dates'; import '/imports/startup/client/comments'; import FalsePositiveTypes from '/imports/api/FalsePositives/FalsePositiveTypes'; import FalsePositivesCollection, { falsePositivesRemap } from '/imports/api/FalsePositives/FalsePositives'; @@ -52,7 +53,7 @@ class Fire extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - return !(nextState.when === this.state.when && nextState.loading === this.state.loading && this.state.notfound === nextState.notfound); + return !(nextState.when === this.state.when && nextState.loading === this.state.loading && this.state.notfound === nextState.notfound && nextState.nasaLink === this.state.nasaLink); } onTypeSelect(key) { @@ -67,7 +68,34 @@ class Fire extends React.Component { }); } - handleLeafletLoad(circle) { + handleLeafletMapLoad(map) { + if (map && map.leafletElement) { + const lmap = map.leafletElement; + const bounds = lmap.getBounds(); + const ne = bounds.getNorthEast(); + const sw = bounds.getSouthWest(); + // console.log(`${sw},${ne}`); + // const nasaLinkBase = 'https://worldview.earthdata.nasa.gov/?p=geographic&l=VIIRS_SNPP_CorrectedReflectance_TrueColor(hidden),MODIS_Aqua_CorrectedReflectance_TrueColor(hidden),MODIS_Terra_CorrectedReflectance_TrueColor,MODIS_Fires_All,MODIS_Fires_Aqua,VIIRS_SNPP_Fires_375m_Night,VIIRS_SNPP_Fires_375m_Day,MODIS_Fires_Terra,Reference_Labels(hidden),Reference_Features(hidden),Coastlines&t=' + const nasaLinkBase = 'https://worldview.earthdata.nasa.gov/?p=geographic&l=' + + 'VIIRS_SNPP_CorrectedReflectance_TrueColor,' + + 'MODIS_Aqua_CorrectedReflectance_TrueColor,' + + 'MODIS_Terra_CorrectedReflectance_TrueColor,' + + 'MODIS_Terra_Data_No_Data(hidden),' + + 'MODIS_Fires_All,' + + 'MODIS_Fires_Aqua,' + + 'VIIRS_SNPP_Fires_375m_Night,' + + 'VIIRS_SNPP_Fires_375m_Day,' + + 'MODIS_Fires_Terra,' + + 'Reference_Labels,' + + 'Reference_Features,' + + 'Coastlines' + + '&t='; + const nasaZoom = 5; + this.setState({ nasaLink: `${nasaLinkBase}${this.dateYYYYMMDD}&z=3&v=${sw.lng},${sw.lat},${ne.lng},${ne.lat}&ab=off&as=${this.dateYYYYMMDD}&ae=${this.dateYYYYMMDD}&av=${nasaZoom}&al=true` }); + } + } + + handleLeafletCircleLoad(circle) { if (this.fireMap && this.fireMap.leafletElement && circle && circle.leafletElement) { this.fireMap.leafletElement.fitBounds(circle.leafletElement.getBounds()); } @@ -83,12 +111,14 @@ class Fire extends React.Component { * console.log(`Not found fire: ${notfound}`); */ if (fire && fire.when) { this.dateLongFormat = dateLongFormat(fire.when); + this.dateYYYYMMDD = dateYYYYMMDD(fire.when); this.title = fire.address ? t('Información adicional sobre fuego detectado en {{where}} el {{when}}', { where: fire.address, when: this.dateLongFormat }) : t('Información adicional sobre fuego detectado el {{when}}', { when: this.dateLongFormat }); } - - return (fire && !loading ? ( + const ready = fire && !loading; + const rect = ready ? rectangleAround({ lat: fire.lat, lon: fire.lon }, fire.track, fire.track) : null; + return (ready ? (
{t('AppName')}: {t('Información adicional sobre fuego')} @@ -98,7 +128,10 @@ class Fire extends React.Component {

{this.title}

{ this.fireMap = map; }} + ref={(map) => { + this.fireMap = map; + this.handleLeafletMapLoad(map); + }} animate sleep={false} center={[fire.lat, fire.lon]} @@ -106,14 +139,13 @@ class Fire extends React.Component { zoom={13} > - { this.circle = circle; this.handleLeafletCircleLoad(circle); }} + data={rect} color="red" - fillColor="red" - fillOpacity={0.0} - interactive={false} - radius={fire.scan ? fire.scan * 500 : 300} - ref={(circle) => { this.circle = circle; this.handleLeafletLoad(circle); }} + stroke + width="1" + fillOpacity="0.0" /> @@ -141,7 +175,10 @@ class Fire extends React.Component {

{t('Coordenadas:')} {fire.lat}, {fire.lon}

{(fire.type === 'modis' || fire.type === 'viirs') && -

Fuego detectado por satélites de la NASA

+ +

Fuego detectado por satélites de la NASA +

+
} {(fire.type === 'vecinal') &&

Fuego notificado por uno de nuestros usuarios/as

@@ -242,7 +279,7 @@ const FireContainer = withTracker(({ match }) => { } else { console.log('Seems a fire from enc hash'); fromHash = true; - subscription = Meteor.subscribe('fireFromHash', id); + subscription = Meteor.subscribe('fireFromHash', id, match.params); } // console.log(`Type of '${fireType}' fire, active: ${active}, archive: ${archive}, fromHash: ${fromHash}`); diff --git a/imports/ui/pages/FiresMap/FireStats.js b/imports/ui/pages/FiresMap/FireStats.js new file mode 100644 index 0000000..a4742ef --- /dev/null +++ b/imports/ui/pages/FiresMap/FireStats.js @@ -0,0 +1,60 @@ +/* eslint-disable react/jsx-indent-props */ +/* eslint-disable import/no-absolute-path */ +/* eslint-disable import/no-absolute-path */ + +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { translate, Trans } from 'react-i18next'; +import FromNow from '/imports/ui/components/FromNow/FromNow'; + +class FireStats extends Component { + constructor(props) { + super(props); + this.state = { + lastFireDetected: props.lastFireDetected, + lastCheck: props.lastCheck, + loadingAll: props.loadingAll + }; + } + + componentWillReceiveProps(nextProps) { + if (this.props.lastCheck !== nextProps.lastCheck || + this.props.lastFireDetected !== nextProps.lastFireDetected || + this.props.loadingAll !== nextProps.loadingAll + ) { + this.setState({ + lastFireDetected: nextProps.lastFireDetected, + lastCheck: nextProps.lastCheck, + loadingAll: nextProps.loadingAll + }); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return !(nextState.lastCheck === this.state.lastCheck && + nextState.lastFireDetected === this.state.lastFireDetected && + nextState.loadingAll === this.state.loadingAll); + } + + render() { + if (this.state.loadingAll) return (
Actualizando...
); + if (this.state.lastCheck && !this.state.lastFireDetected) { + return (
Actualizado .
); + } + if (this.state.lastCheck && this.state.lastFireDetected) { + return (
Actualizado , último fuego detectado .
); + } + return null; + } +} + +FireStats.propTypes = { + lastFireDetected: PropTypes.object, + loadingAll: PropTypes.bool.isRequired, + lastCheck: PropTypes.instanceOf(Date) +}; + +FireStats.defaultProps = { +}; + +export default translate([], { wait: true })(FireStats); diff --git a/imports/ui/pages/FiresMap/FiresMap.js b/imports/ui/pages/FiresMap/FiresMap.js index cf7a9d8..e797427 100644 --- a/imports/ui/pages/FiresMap/FiresMap.js +++ b/imports/ui/pages/FiresMap/FiresMap.js @@ -23,10 +23,11 @@ import 'leaflet-sleep/Leaflet.Sleep.js'; import geolocation from '/imports/startup/client/geolocation'; import CenterInMyPosition from '/imports/ui/components/CenterInMyPosition/CenterInMyPosition'; import FireList from '/imports/ui/components/Maps/FireList'; +import FireListUnion from '/imports/ui/components/Maps/FireListUnion'; import subsUnion from '/imports/ui/components/Maps/SubsUnion/SubsUnion'; import DefMapLayers from '/imports/ui/components/Maps/DefMapLayers'; -import FromNow from '/imports/ui/components/FromNow/FromNow'; import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires'; +import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion'; import FireAlertsCollection from '/imports/api/FireAlerts/FireAlerts'; import IndustriesCollection, { industriesRemap } from '/imports/api/Industries/Industries'; import FalsePositivesCollection, { falsePositivesRemap } from '/imports/api/FalsePositives/FalsePositives'; @@ -37,16 +38,17 @@ import ShareIt from '/imports/ui/components/ShareIt/ShareIt'; import FullScreenMap from '/imports/ui/components/Maps/FullScreenMap'; import LocationAutocomplete from '/imports/ui/components/LocationAutocomplete/LocationAutocomplete'; import Gkeys from '/imports/startup/client/Gkeys'; - +import FireStats from './FireStats'; import './FiresMap.scss'; const MAXZOOM = 6; const MAXZOOMREACTIVE = 6; const DEFZOOM = 8; const zoom = new ReactiveVar(DEFZOOM); -const center = new ReactiveVar([0, 0]); +// https://en.wikipedia.org/wiki/Geographical_midpoint_of_Europe +const center = new ReactiveVar([53.5775, 3.106111]); const mapSize = new ReactiveVar(); -const marks = new ReactiveVar(false); +const marks = new ReactiveVar(true); const showUnion = new ReactiveVar(true); // Remove map in subscription @@ -82,19 +84,6 @@ class FiresMap extends React.Component { }); } - /* shouldComponentUpdate(nextProps, nextState) { - * const notMoving = !nextState.moving; - * const markersChanged = this.state.useMarkers !== nextState.useMarkers; - * const unionChanged = this.state.showSubsUnion !== nextState.showSubsUnion; - * const otherViewport = this.state.viewport !== nextState.viewport; - * // const init = nextState.viewport.center === [0, 0]; - * // console.log(notMoving ? 'Not moving map' : 'Moving map'); - * // console.log(otherViewport ? 'Other viewport' : 'Not other viewport'); - * console.log(`${otherViewport ? 'OTHER' : 'Not other'} viewport ${nextState.viewport.center} zoom: ${nextState.viewport.zoom}`); - * return this.state.init || (notMoving && otherViewport && this.state.moved) || unionChanged || markersChanged; - * } - */ - shouldComponentUpdate(nextProps, nextState) { const notMoving = !nextState.moving; return notMoving; @@ -152,7 +141,7 @@ class FiresMap extends React.Component { } centerOnUserLocation(viewport) { - this.setState({ viewport }); + this.setState({ viewport: { center: viewport.center, zoom: 10 } }); } useMarkers(use) { @@ -196,11 +185,15 @@ class FiresMap extends React.Component { render() { const { t } = this.props; - console.log(`Rendering ${this.props.loading ? 'loading' : 'LOADED'}, zoom ${this.state.viewport.zoom}, map ${this.props.activefires.length + this.props.firealerts.length} of ${this.props.activefirestotal} total. False positives: ${this.props.falsePositives.length}. Reactive ${this.state.viewport.zoom >= MAXZOOMREACTIVE}`); + const loading = this.props.loading || !this.props.subsready; + console.log(`Rendering ${loading ? 'loading' : 'LOADED'}, zoom ${this.state.viewport.zoom}, map ${this.props.activefires.length + this.props.firealerts.length} of ${this.props.activefirestotal} total. False positives: ${this.props.falsePositives.length}. Reactive ${this.state.viewport.zoom >= MAXZOOMREACTIVE}`); const title = `${t('AppName')}: ${t('Fuegos activos')}`; + if (Meteor.isDevelopment && this.props.activefires.length === 1) console.log(`Active fire: ${JSON.stringify(this.props.activefires[0])}`); + if (Meteor.isDevelopment) console.log(`Active fires total: ${this.props.activefires.length}`); + if (Meteor.isDevelopment) console.log(`Active fires union total: ${this.props.activefiresunion.length}`); if (Meteor.isDevelopment) console.log(`False positives total: ${this.props.falsePositives.length}`); - + if (Meteor.isDevelopment) console.log(`Fire alerts total: ${this.props.firealerts.length}`); if (Meteor.isDevelopment) console.log(`Industries total: ${this.props.industries.length}`); if (!this.state.init) { @@ -218,50 +211,51 @@ class FiresMap extends React.Component { {title}
} -

Fuegos activos

- -
-

- { (this.props.activefires.length + this.props.firealerts.length) === 0 ? - No hay fuegos activos en esta zona del mapa. {{ countTotal: this.props.activefirestotal }} fuegos activos en el mundo. Actualizado . : - En rojo, {{ count: this.props.activefires.length + this.props.firealerts.length }} fuegos activos. {{ countTotal: this.props.activefirestotal }} fuegos activos en el mundo. Actualizado . - } -

- {isNotHomeAndMobile() && this.props.firealerts.length > 0 && -

En naranja, los fuegos notificados por nuestros usuarios/as recientemente.

} - {isNotHomeAndMobile() && this.props.firealerts.length === 0 && !isAnyMobile && +

Fuegos activos

+ + + {isNotHomeAndMobile() && + + this.onAutocompleteChange(value)} + /> + this.setShowSubsUnion(e.target.checked)}> + Resaltar en verde el área vigilada por nuestros usuarios/as (*) + + {(this.state.viewport.zoom >= MAXZOOM) && + this.useMarkers(e.target.checked)}> + Resaltar los fuegos con un marcador + } + } + + +

+ { (this.props.activefires.length + this.props.firealerts.length) === 0 ? + No hay fuegos activos en esta zona del mapa. {{ countTotal: this.props.activefirestotal }} fuegos activos en el mundo. : + En rojo, {{ count: this.props.activefiresunion.length + this.props.firealerts.length }} fuegos activos. {{ countTotal: this.props.activefirestotal }} fuegos activos en el mundo. + } +

+ {isNotHomeAndMobile() && this.props.firealerts.length > 0 && +

En naranja, los fuegos notificados por nuestros usuarios/as recientemente.

} + { /* disabled */ false && isNotHomeAndMobile() && this.props.firealerts.length === 0 && !isAnyMobile &&

No hay fuegos notificados recientemente por nuestros usuarios/as en esta zona.

} - - - {isNotHomeAndMobile() && - - this.onAutocompleteChange(value)} - /> - this.setShowSubsUnion(e.target.checked)}> - Resaltar en verde el área vigilada por nuestros usuarios/as (*) - - {(this.state.viewport.zoom >= MAXZOOM) && - this.useMarkers(e.target.checked)}> - Resaltar los fuegos con un marcador - } - } -

- { this.state.viewport.zoom >= MAXZOOMREACTIVE ? - Los fuegos activos se actualizan en tiempo cuasi real. : - Haga zoom en una zona de su interés si quiere que los fuegos se actualicen en tiempo real. - } - -

- - - {this.props.loading || !this.props.subsready ? - - : ''} + +

+ { this.state.viewport.zoom >= MAXZOOMREACTIVE ? + Los fuegos activos se actualizan en tiempo cuasi real. : + Haga zoom en una zona de su interés si quiere que los fuegos se actualicen en tiempo real. + } + +

+ + + {loading ? + + : ''} {/* https://github.com/CliffCloud/Leaflet.Sleep */} { @@ -300,8 +294,17 @@ class FiresMap extends React.Component { useMarkers={this.state.useMarkers} nasa={false} falsePositives + neighbour={false} industries={false} /> + { Meteor.isDevelopment && + + } = MAXZOOM} useMarkers={this.state.useMarkers} nasa={false} + neighbour={false} falsePositives={false} industries /> @@ -319,6 +323,7 @@ class FiresMap extends React.Component { scale={this.state.viewport.zoom >= MAXZOOM} useMarkers={this.state.useMarkers} nasa + neighbour={false} falsePositives={false} industries={false} /> @@ -326,9 +331,10 @@ class FiresMap extends React.Component { t={t} history={this.props.history} fires={this.props.firealerts} - scale={false} + scale={this.state.viewport.zoom >= MAXZOOM} useMarkers={this.state.useMarkers} nasa={false} + neighbour falsePositives={false} industries={false} /> @@ -361,11 +367,14 @@ FiresMap.propTypes = { userSubs: PropTypes.string, userSubsBounds: PropTypes.string, activefires: PropTypes.arrayOf(PropTypes.object).isRequired, + activefiresunion: PropTypes.arrayOf(PropTypes.object).isRequired, + lastFireDetected: PropTypes.object, firealerts: PropTypes.arrayOf(PropTypes.object).isRequired, falsePositives: PropTypes.arrayOf(PropTypes.object).isRequired, industries: PropTypes.arrayOf(PropTypes.object).isRequired, lastCheck: PropTypes.instanceOf(Date), activefirestotal: PropTypes.number.isRequired, + activefiresuniontotal: PropTypes.number.isRequired, center: PropTypes.arrayOf(PropTypes.number), zoom: PropTypes.number, marks: PropTypes.bool.isRequired, @@ -382,6 +391,7 @@ export default translate([], { wait: true })(withTracker(() => { // console.log(`With industries: ${withIndustries}`); let subscription; + let subscriptionUnion; let alertSubscription; const centerStored = store.get('firesmap_center'); @@ -396,6 +406,8 @@ export default translate([], { wait: true })(withTracker(() => { if (typeof showUnionStored === 'boolean') { showUnion.set(showUnionStored); } + // Disable, because this increase the number of fires by one + // Meteor.subscribe('lastFireDetected'); Meteor.autorun(() => { if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) { center.set(centerStored || geolocation.get()); @@ -411,6 +423,14 @@ export default translate([], { wait: true })(withTracker(() => { mapSize.get()[1].lat, marks.get() && zoom.get() >= MAXZOOM ); + subscriptionUnion = Meteor.subscribe( + 'activefiresunionmyloc', + mapSize.get()[0].lng, + mapSize.get()[0].lat, + mapSize.get()[1].lng, + mapSize.get()[1].lat, + false + ); alertSubscription = Meteor.subscribe( 'fireAlerts', mapSize.get()[0].lng, @@ -419,18 +439,19 @@ export default translate([], { wait: true })(withTracker(() => { mapSize.get()[1].lat ); /* if (withIndustries) { - Meteor.subscribe( - 'industriesMyloc', - mapSize.get()[0].lng, - mapSize.get()[0].lat, - mapSize.get()[1].lng, - mapSize.get()[1].lat - ); - } */ + Meteor.subscribe( + 'industriesMyloc', + mapSize.get()[0].lng, + mapSize.get()[0].lat, + mapSize.get()[1].lng, + mapSize.get()[1].lat + ); + } */ } }); Meteor.subscribe('activefirestotal'); + Meteor.subscribe('activefiresuniontotal'); const settingsSubs = Meteor.subscribe('settings'); const lastCheck = SiteSettings.findOne({ name: 'last-fire-check' }); @@ -439,19 +460,23 @@ export default translate([], { wait: true })(withTracker(() => { const fireAlerts = FireAlertsCollection.find().fetch(); const falsePositives = FalsePositivesCollection.find().fetch().map(falsePositivesRemap); const industries = IndustriesCollection.find().fetch().map(industriesRemap); + const lastFireDetected = ActiveFiresCollection.findOne({}, { sort: { when: -1 } }); return { - loading: !subscription ? true : !(subscription.ready() && settingsSubs.ready() && alertSubscription.ready()), + loading: Meteor.status().status !== 'connected' || !subscription ? true : !(subscription.ready() && settingsSubs.ready() && alertSubscription.ready() && settingsSubs.ready() && subscriptionUnion.ready()), userSubs: userSubs ? userSubs.value : null, userSubsBounds: userSubs ? userSubsBounds.value : null, subsready: settingsSubs.ready(), // Not reactive query depending on zoom level activefires: ActiveFiresCollection.find({}, { reactive: zoom.get() >= MAXZOOMREACTIVE }).fetch(), + activefiresunion: ActiveFiresUnion.find({}, { reactive: zoom.get() >= MAXZOOMREACTIVE }).fetch(), activefirestotal: Counter.get('countActiveFires') + fireAlerts.length, + activefiresuniontotal: Counter.get('countActiveFiresUnion') + fireAlerts.length, firealerts: fireAlerts, falsePositives, industries, lastCheck: lastCheck ? lastCheck.value : null, + lastFireDetected, center: center.get(), marks: marks.get(), showUnion: showUnion.get(), diff --git a/imports/ui/pages/Index/Index-custom.scss b/imports/ui/pages/Index/Index-custom.scss index 7273007..8e089c1 100644 --- a/imports/ui/pages/Index/Index-custom.scss +++ b/imports/ui/pages/Index/Index-custom.scss @@ -187,28 +187,72 @@ transform: translate3d(0, 0, 0); } -.carousel-item-1 { - background-image: url('/home-slide1.jpg') +.no-webp .carousel-item-1 { + background-image: url('/images/home-slide1.jpg') } -.carousel-item-2 { - background-image: url('/home-slide2.jpg') +.no-webp .carousel-item-2.lazy { + background-image: url('/images/home-slide2.jpg') } -.carousel-item-3 { - background-image: url('/home-slide3.jpg') +.no-webp .carousel-item-3.lazy { + background-image: url('/images/home-slide3.jpg') } -.carousel-item-4 { - background-image: url('/home-slide4.jpg') +.no-webp .carousel-item-4.lazy { + background-image: url('/images/home-slide4.jpg') } -.carousel-snd-item-1 { - background-image: url('/home-slide-snd-1.jpg') +.no-webp .carousel-snd-item-1 { + background-image: url('/images/home-slide-snd-1.jpg') } -.carousel-snd-item-2 { - background-image: url('/home-slide-snd-2.jpg') +.no-webp .carousel-snd-item-2.lazy { + background-image: url('/images/home-slide-snd-2.jpg') +} + +.webp .carousel-item-1 { + background-image: url('/images/home-slide1.webp') +} + +.webp .carousel-item-2.lazy { + background-image: url('/images/home-slide2.webp') +} + +.webp .carousel-item-3.lazy { + background-image: url('/images/home-slide3.webp') +} + +.webp .carousel-item-4.lazy { + background-image: url('/images/home-slide4.webp') +} + +/* .webp .carousel-item-1 { + background-image: url('/images/home-slide1-blur.webp') +} */ + +.webp .carousel-item-2 { + background-image: url('/images/home-slide2-blur.webp') +} + +.webp .carousel-item-3 { + background-image: url('/images/home-slide3-blur.webp') +} + +.webp .carousel-item-4 { + background-image: url('/images/home-slide4-blur.webp') +} + +.webp .carousel-snd-item-1 { + background-image: url('/images/home-slide-snd-1.webp') +} + +.webp .carousel-snd-item-2 { + background-image: url('/images/home-slide-snd-2-blur.webp') +} + +.webp .carousel-snd-item-2.lazy { + background-image: url('/images/home-slide-snd-2.webp') } .participe-btn { @@ -225,28 +269,52 @@ -moz-background-size: cover; background-size: cover; -o-background-size: cover; - background-image: url('/home-fires.jpg'); +} + +.no-webp .bg-image-full { + background-image: url('/images/home-fires.jpg'); +} + +.webp .bg-image-full { + background-image: url('/images/home-fires.webp'); } @media only screen and (max-width: 767px) { - .bg-image-full { - background-image: url('/home-fires-800.jpg'); + .no-webp .bg-image-full { + background-image: url('/images/home-fires-800.jpg'); + } + + .webp .bg-image-full { + background-image: url('/images/home-fires-800.webp'); } } @media only screen and (max-width: 400px) { - .bg-image-full { - background-image: url('/home-fires-400.jpg'); + .no-webp .bg-image-full { + background-image: url('/images/home-fires-400.jpg'); + } + + .webp .bg-image-full { + background-image: url('/images/home-fires-400.webp'); } } @media only screen and (max-width: 1000px) { - .bg-image-full { - background-image: url('/home-fires-1000.jpg'); } + .no-webp .bg-image-full { + background-image: url('/images/home-fires-1000.jpg'); + } + + .webp .bg-image-full { + background-image: url('/images/home-fires-1000.webp'); + } } -.device[data-device=iPhone6][data-orientation=portrait][data-color=white] { - background-image: url(/mobile.png); +.nowebp .device[data-device=iPhone6][data-orientation=portrait][data-color=white] { + background-image: url(/images/mobile.png); +} + +.webp .device[data-device=iPhone6][data-orientation=portrait][data-color=white] { + background-image: url(/images/mobile.webp); } .full-width .page-header { // section titles diff --git a/imports/ui/pages/Index/Index.js b/imports/ui/pages/Index/Index.js index 10e50c8..9986fba 100644 --- a/imports/ui/pages/Index/Index.js +++ b/imports/ui/pages/Index/Index.js @@ -2,6 +2,7 @@ /* eslint-disable react/jsx-indent-props */ /* eslint-disable key-spacing */ /* eslint-env jquery */ +import 'simple-line-icons/css/simple-line-icons.css'; import React, { Component } from 'react'; import { translate, Trans } from 'react-i18next'; import { Helmet } from 'react-helmet'; @@ -10,6 +11,7 @@ import PropTypes from 'prop-types'; // import { Link } from 'react-router-dom'; // https://www.npmjs.com/package/react-resize-detector import ReactResizeDetector from 'react-resize-detector'; +import MobileStoreButton from 'react-mobile-store-button'; import _ from 'lodash'; import 'html5-device-mockups/dist/device-mockups.min.css'; import 'bootstrap-carousel-swipe/carousel-swipe'; @@ -17,9 +19,8 @@ import { isAnyMobile } from '/imports/ui/components/Utils/isMobile'; import SubscriptionEditor from '/imports/ui/components/SubscriptionEditor/SubscriptionEditor'; import SubscriptionsMap from '/imports/ui/pages/Subscriptions/SubscriptionsMap'; import ShareIt from '/imports/ui/components/ShareIt/ShareIt'; +import getFallbackLang from '/imports/modules/lang-fallback'; import FiresMap from '../FiresMap/FiresMap'; - -import 'simple-line-icons/css/simple-line-icons.css'; import './Index.scss'; import './Index-custom.scss'; @@ -34,11 +35,22 @@ class Index extends Component { self.scaleHeader(); }, 250); this.onResize = this.onResize.bind(this); + this.getBotUrl = this.getBotUrl.bind(this); } componentDidMount() { - $('#carouselMainIndicators').carousel(); - $('#carouselSndIndicators').carousel(); + const car1 = $('#carouselMainIndicators'); + const car2 = $('#carouselSndIndicators'); + car1.carousel(); + car2.carousel(); + /* _.defer(() => { + $('#firsthomeslide').addClass('lazy'); + }); */ + const loadLazy = (ev) => { + ev.relatedTarget.classList.add('lazy'); + }; + car1.on('slide.bs.carousel', loadLazy); + car2.on('slide.bs.carousel', loadLazy); } onResize() { @@ -57,6 +69,14 @@ class Index extends Component { return (
); } + getBotUrl() { + const fallbackLng = getFallbackLang(this.props.i18n.language); + console.log(`language: ${this.props.i18n.language}, fallback: ${fallbackLng}`); + return fallbackLng === 'es' ? + 'https://t.me/TodosContraElFuego_bot' : + 'https://t.me/AllAgainstTheFire_bot'; + } + scaleHeader() { const scalable = document.getElementById('tcefh1'); const margin = 10; @@ -69,7 +89,7 @@ class Index extends Component { } handleBtnClick() { - window.open('https://t.me/TodosContraElFuego_bot', '_blank'); + window.open(this.getBotUrl(), '_blank'); } gotoParticipe() { @@ -82,6 +102,7 @@ class Index extends Component { render() { const { t } = this.props; const title = `${t('AppName')}: ${t('Inicio')}`; + const androidUrl = 'https://play.google.com/store/apps/details?id=org.comunes.fires'; return (
@@ -106,7 +127,7 @@ class Index extends Component {
-
+

Siempre alerta a los fuegos en nuestro vecindario

{/* {this.props.t('Participa')} */} +
+ +
@@ -250,7 +274,7 @@ class Index extends Component {
{/* Demo image for screen mockup, you can put an image here, some HTML, an animation, video, or anything else! */} - +
{}} role="button" onClick={() => this.handleBtnClick()} />
@@ -260,7 +284,7 @@ class Index extends Component {
- +

Telegram

@@ -308,6 +332,7 @@ class Index extends Component { Index.propTypes = { history: PropTypes.object.isRequired, + i18n: PropTypes.object.isRequired, t: PropTypes.func.isRequired }; diff --git a/imports/ui/pages/Index/Index.scss b/imports/ui/pages/Index/Index.scss index 3fb18b4..527aa93 100644 --- a/imports/ui/pages/Index/Index.scss +++ b/imports/ui/pages/Index/Index.scss @@ -77,3 +77,7 @@ } } } + +.android-btn > div { + height: 50px !important; +} diff --git a/imports/ui/pages/Profile/Profile.js b/imports/ui/pages/Profile/Profile.js index 665e9d7..6d9b24b 100644 --- a/imports/ui/pages/Profile/Profile.js +++ b/imports/ui/pages/Profile/Profile.js @@ -9,15 +9,17 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Bert } from 'meteor/themeteorchef:bert'; import { testId } from '/imports/ui/components/Utils/TestUtils'; -import Col from '../../components/Col/Col'; -import { withTracker } from 'meteor/react-meteor-data'; -import InputHint from '../../components/InputHint/InputHint'; -import validate from '../../../modules/validate'; import { translate } from 'react-i18next'; import { T9n } from 'meteor-accounts-t9n'; +import { withTracker } from 'meteor/react-meteor-data'; +import Col from '../../components/Col/Col'; +import InputHint from '../../components/InputHint/InputHint'; +import validate from '../../../modules/validate'; import './Profile.scss'; +// List of languages: https://github.com/i18next/i18next/issues/1068 + class Profile extends React.Component { constructor(props) { super(props); @@ -155,6 +157,7 @@ class Profile extends React.Component { renderPasswordUser(loading, user) { const { t, i18n } = this.props; + const enabledLangs = ['en', 'es', 'gl']; const langName = { en: 'English', es: 'Español', gl: 'Galego', ast: 'Asturianu', ca: 'Català' }; @@ -202,7 +205,7 @@ class Profile extends React.Component { {langName[i18n.language]}
- {i18n.languages.map(lang => ( + {enabledLangs.map(lang => (
+ + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + +
+ Hey, {{firstName}}! +
+ There is more information about a fire to which you made comments: +
+ +
+ Thanks!
+ {{applicationName}} Team +
+
+
+
+ + + diff --git a/private/email-templates/new-fire-comment-en.txt b/private/email-templates/new-fire-comment-en.txt new file mode 100644 index 0000000..459f81a --- /dev/null +++ b/private/email-templates/new-fire-comment-en.txt @@ -0,0 +1,8 @@ +Hey, {{firstName}}! + +There is more information about a fire to which you made comments: + +{{fireUrl}} + +Thanks! +{{applicationName}} Team diff --git a/private/email-templates/new-fire-comment-es.html b/private/email-templates/new-fire-comment-es.html new file mode 100644 index 0000000..389b637 --- /dev/null +++ b/private/email-templates/new-fire-comment-es.html @@ -0,0 +1,317 @@ + + + + + +[{{applicationName}}] Hay más información sobre un fuego + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + +
+ Hola, {{firstName}}! +
+ Hay más información sobre un fuego al que hiciste comentarios: +
+ +
+ Gracias!
+ El equipo de {{applicationName}} +
+
+
+
+ + + diff --git a/private/email-templates/new-fire-comment-es.txt b/private/email-templates/new-fire-comment-es.txt new file mode 100644 index 0000000..8028274 --- /dev/null +++ b/private/email-templates/new-fire-comment-es.txt @@ -0,0 +1,8 @@ +Hola, {{firstName}}! + +Hay más información sobre un fuego al que hiciste comentarios: + +{{fireUrl}} + +Gracias! +El equipo de {{applicationName}} diff --git a/private/email-templates/new-fire-en.html b/private/email-templates/new-fire-en.html index b0f7fcb..1ca0246 100644 --- a/private/email-templates/new-fire-en.html +++ b/private/email-templates/new-fire-en.html @@ -274,7 +274,7 @@
- +
diff --git a/private/email-templates/new-fire-en.txt b/private/email-templates/new-fire-en.txt index 1b00d23..d8ec41f 100644 --- a/private/email-templates/new-fire-en.txt +++ b/private/email-templates/new-fire-en.txt @@ -2,7 +2,7 @@ Hey, {{firstName}}! {{message}} -[More information about this fire]({{fireUrl}}) +More information about this fire: {{fireUrl}} Isn't it a forest fire? In the previous link you can tell us what kind of fire it is and help us improve our notifications. diff --git a/private/email-templates/new-fire-es.html b/private/email-templates/new-fire-es.html index e254a8a..6129aeb 100644 --- a/private/email-templates/new-fire-es.html +++ b/private/email-templates/new-fire-es.html @@ -274,7 +274,7 @@
- +
diff --git a/private/email-templates/new-fire-es.txt b/private/email-templates/new-fire-es.txt index 36bb3b9..3e0568f 100644 --- a/private/email-templates/new-fire-es.txt +++ b/private/email-templates/new-fire-es.txt @@ -2,7 +2,7 @@ Hola, {{firstName}}! {{message}} -[Más información sobre este fuego]({{fireUrl}}) +Más información sobre este fuego: {{fireUrl}} ¿No es un fuego forestal? En el enlace anterior puedes indicarnos de que tipo de fuego se trata y ayudarnos así a mejorar nuestras notificaciones. diff --git a/private/email-templates/reset-password-en.txt b/private/email-templates/reset-password-en.txt index 5519476..ba4333d 100644 --- a/private/email-templates/reset-password-en.txt +++ b/private/email-templates/reset-password-en.txt @@ -4,7 +4,7 @@ A password reset has been requested for the account related to this email addres To reset the password, visit the following link: -[Reset Password]({{resetUrl}}) +{{resetUrl}} Thanks! {{applicationName}} Team diff --git a/private/email-templates/reset-password-es.txt b/private/email-templates/reset-password-es.txt index 9996d66..ef9b3c3 100644 --- a/private/email-templates/reset-password-es.txt +++ b/private/email-templates/reset-password-es.txt @@ -4,7 +4,7 @@ Se ha solicitado un reseteo de contraseña para la cuenta asociada a este correo Para resetear la contraseña, visita el siguiente enlace: -[Resetea la contraseña]({{resetUrl}}) +{{resetUrl}} Gracias! El equipo de {{applicationName}} diff --git a/private/email-templates/verify-email-en.txt b/private/email-templates/verify-email-en.txt index cd22d81..cc228c7 100644 --- a/private/email-templates/verify-email-en.txt +++ b/private/email-templates/verify-email-en.txt @@ -2,7 +2,7 @@ Hey, {{firstName}}! Can you do us a favor and verify your email address? -[Verify Email Address]({{verifyUrl}}) +Verify Email Address: {{verifyUrl}} Thanks! {{applicationName}} Team diff --git a/private/email-templates/verify-email-es.txt b/private/email-templates/verify-email-es.txt index 361f14b..b5ec1ae 100644 --- a/private/email-templates/verify-email-es.txt +++ b/private/email-templates/verify-email-es.txt @@ -2,7 +2,7 @@ Hola, {{firstName}}! ¿Nos puedes hacer un favor y verificar tu correo electrónico? -[Verificar correo electrónico]({{verifyUrl}}) +Verificar correo electrónico: {{verifyUrl}} Gracias! El equipo de {{applicationName}} diff --git a/private/email-templates/welcome-en.txt b/private/email-templates/welcome-en.txt index 6dcb0d9..47bfd8e 100644 --- a/private/email-templates/welcome-en.txt +++ b/private/email-templates/welcome-en.txt @@ -4,7 +4,7 @@ Welcome to {{applicationName}}. We thank you for collaborating with us. Ready to get started? -[View Your Subscriptions to Fires]({{welcomeUrl}}) +View Your Subscriptions to Fires: {{welcomeUrl}} Thanks, {{applicationName}} Team diff --git a/private/email-templates/welcome-es.txt b/private/email-templates/welcome-es.txt index 99dabed..1db2fca 100644 --- a/private/email-templates/welcome-es.txt +++ b/private/email-templates/welcome-es.txt @@ -4,7 +4,7 @@ Bienvenid@ a {{applicationName}}. Te agradecemos que colabores con nosotr@s. ¿Preparada para empezar? -[Ver tus subscripciones a fuegos]({{welcomeUrl}}) +Ver tus subscripciones a fuegos: {{welcomeUrl}} Gracias, El equipo de {{applicationName}} diff --git a/private/pages/privacy-en.md b/private/pages/privacy-en.md index 94602bc..4c42f76 100644 --- a/private/pages/privacy-en.md +++ b/private/pages/privacy-en.md @@ -1,97 +1,156 @@ -Your privacy is important to us. +# PRIVACY POLICY # -Comunes Association built the 'All Against Fire' app and this SERVICE is provided by Comunes Association at no cost and is intended for use as is. +*Last updated July 12, 2018* -This page is used to inform website visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our Service. +**In Short**: *Your privacy is important to us. We do not do business with your data, we do not share it with third parties and we only worry about the fires and how to strengthen neighborhoods against them.* -If you choose to use our Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that we collect is used for providing and improving the Service. We will not use or share your information with anyone except as described in this Privacy Policy. +Thank you for choosing to be part of our community 'All Against the Fires' at Comunes Association (“we”, “us”, or “our”). We are committed to protecting your personal information and your right to privacy. If you have any questions or concerns about our policy, or our practices with regards to your personal information, please contact us at info@comunes.org. -The terms used in this Privacy Policy have the same meanings as in our [Terms and Conditions](/terms), which is accessible at 'All Against Fire' unless otherwise defined in this Privacy Policy. +When you visit our website [fires.comunes.org](https://fires.comunes.org), mobile application, and use our services, you trust us with your personal information. We take your privacy very seriously. In this privacy notice, we describe our privacy policy. We seek to explain to you in the clearest way possible what information we collect, how we use it and what rights you have in relation to it. We hope you take some time to read through it carefully, as it is important. If there are any terms in this privacy policy that you do not agree with, please discontinue use of our Sites or Apps and our services. -**Information Collection and Use** +This privacy policy applies to all information collected through our website (such as [fires.comunes.org](https://fires.comunes.org)), mobile application (the "**Apps**"), and/or any related services, or events (we refer to them collectively in this privacy policy as the "**Sites**"). -For a better experience, while using our Service, we may require you to provide us with certain personally identifiable information, including but not limited to name, email, language, and the telegram username (of our Telegram users). The information that we request is will be retained by us and used as described in this privacy policy. +**Please read this privacy policy carefully as it will help you make informed decisions about sharing your personal information with us.** -In addition, the app may collect certain information automatically, including, but not limited to, the type of device you use, the IP address of your device, your mobile operating system, the type of Internet browsers you use, and information about the way you use the app. -We may use the information we collect in the following ways: -* To personalize your experience and to allow us to deliver the type of content in which you are most interested. -* To send periodic emails regarding our services. +## 1. WHAT INFORMATION DO WE COLLECT? ## -The app does use third party services that may collect information used to identify you. +### Information automatically collected ### -Links to privacy policy of third party service providers used by the app: +**In Short:** *Some information – such as IP address and/or browser and device characteristics – is collected +automatically when you visit our Sites or Apps.* -* [OpenStreetMap](https://wiki.openstreetmap.org/wiki/Privacy_Policy/) -* [Google Maps](https://developers.google.com/maps/terms?hl=es#3-privacy-and-personal-information) +We automatically collect certain information when you visit, use or navigate the Sites or Apps. This information does not reveal your specific identity (like your name or contact information) but may include device and usage information, such as your IP address, browser and device characteristics, operating system, language preferences, referring URLs, device name, country, location, information about how and when you use our Sites or Apps and other technical information. This information is primarily needed to maintain the security and operation of our Sites or Apps, and for our internal analytics and reporting purposes. Like many businesses, we also collect information through cookies and similar technologies. -We have not enabled Google AdSense. +### Information collected through our Apps ### -**Does the app collect precise real time location information of the device?** +**In Short:** *We may collect information regarding your geo-location, push notifications, when you use our apps.* -When you visit the app, we may use GPS technology (or other similar technology) to determine your current location in order to determine the city you are located within and/or display a location map of local fires and/or provide alerts of future fires in your zone. We **will not** share your current location with other users or partners. +If you use our Apps, we may also collect the following information: -If you do not want us to use your location for the purposes set forth above, you should turn off the location services for the mobile app located in your account settings or in your mobile phone settings and/or within the app and/or when asked by your browser. +- *Geo-Location Information.* We may request at some point access or permission to location-based information from your mobile device to provide location-based services. If you wish to change our access or permissions, you may do so in your device's settings. +- *Push Notifications.* We may request to send you push notifications regarding your account or the mobile application. If you wish to opt-out from receiving these types of communications, you may turn them off in your device's settings. -**How do we protect your information?** -We do not use vulnerability scanning and/or scanning to PCI standards. We only provide articles and information. We never ask for credit card numbers. We do not use Malware Scanning. +## 2. HOW DO WE USE YOUR INFORMATION? ## -Your personal information is contained behind secured networks and is only accessible by a limited number of persons who have special access rights to such systems, and are required to keep the information confidential. In addition, all sensitive information you supply is encrypted via Secure Socket Layer (SSL) technology. +**In Short:** *We process your information for purposes based on legitimate interests, the fulfillment of our contract with you, compliance with our legal obligations, and/or your consent.* -We implement a variety of security measures when a user enters, submits, or accesses their information to maintain the safety of your personal information. +We use personal information collected via our Sites or Apps for a variety of purposes described below. We process your personal information for these purposes in order to enter into or perform a contract with you ("Contractual"), with your consent ("Consent"), and/or for compliance with our legal obligations ("Legal Reasons"). We indicate the specific processing grounds we rely on next to each purpose listed below. -**Log Data** +We use the information we collect or receive: -We want to inform you that whenever you use our Service, in a case of an error in the app we collect data and information (through third party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing our Service, the time and date of your use of the Service, and other statistics. +- **To facilitate account creation and logon process** with your Consent. If you choose to link your account with us to a third party account \*(such as your Google or Telegram account), we use the information you allowed us to collect from those third parties to facilitate account creation and logon process. +- **Request Feedback**. We may use your information to request feedback and to contact you about your use of our Sites or Apps. +- **To enable user-to-user communications** with your Consent. We may use your information in order to enable user-to-user communications with each user's consent. +- **To enforce our terms, conditions and policies**. +- **To respond to legal requests and prevent harm** for Legal Reasons. If we receive a subpoena or other legal request, we may need to inspect the data we hold to determine how to respond. -**Do we use 'cookies'?** -Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow) that enables the site's or service provider's systems to recognize your browser and capture and remember certain information. For instance, we use cookies to help us remember and process your prefered language. They are also used to help us understand your preferences based on previous or current site activity, which enables us to provide you with improved services. We also use cookies to help us compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. +## 3. WILL YOUR INFORMATION BE SHARED WITH ANYONE? ## -We use cookies to understand and save user's preferences for future visits. +**In Short:** *We only share information with your consent, to comply with laws or to protect your rights.* -You can choose to have your computer warn you each time a cookie is being sent, or you can choose to turn off all cookies. You do this through your browser settings. Since browser is a little different, look at your browser's Help Menu to learn the correct way to modify your cookies. +We only share and disclose your information in the following situations: -If you turn cookies off, some of the features that make your site experience more efficient may not function properly. +- **Compliance with Laws**. We may disclose your information where we are legally required to do so in order to comply with applicable law, governmental requests, a judicial proceeding, court order, or legal process, such as in response to a court order or a subpoena (including in response to public authorities to meet national security or law enforcement requirements). +- **Vital Interests and Legal Rights**. We may disclose your information where we believe it is necessary to investigate, prevent, or take action regarding potential violations of our policies, suspected fraud, situations involving potential threats to the safety of any person and illegal activities, or as evidence in litigation in which we are involved. +- **With your Consent**. We may disclose your personal information for any other purpose with your consent. -**Service Providers** -We may employ third-party companies and individuals to facilitate our Service. +## 4. DO WE USE COOKIES AND OTHER TRACKING TECHNOLOGIES? ## -**Third-party disclosure** +**In Short:** *We may use cookies and other tracking technologies to collect and store your information.* -We do not sell, trade, or otherwise transfer to outside parties your Personally Identifiable Information. +We may use cookies to access or store information about account login and your user preferences (like your prefered language). -**Security** -We value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and we cannot guarantee its absolute security. +## 5. DO WE USE GOOGLE MAPS? ## -**Links to Other Sites** +**In Short:** *Yes, we use Google Maps for the purpose of providing better service.* -We do not include or offer third-party products or services on our website but this Service may contain links to other sites in user comments. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. +This website, mobile application, or Telegram application uses Google Maps APIs. You may find the Google Maps APIs Terms of Service [here](https://developers.google.com/maps/terms). To better understand Google's Privacy Policy, please refer to this [link](https://policies.google.com/privacy). -**Children’s Privacy** +By using our Maps API Implementation, you agree to be bound by Google's Terms of Service. You agree to allow us to obtain or cache your location. You may revoke your consent at anytime. We use information about location in conjunction with data from other data providers. -These Services do not address anyone under the age of 13. We do not knowingly collect personally identifiable information from children under 13. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to do necessary actions. -**Fair Information Practices** +## 6. HOW LONG DO WE KEEP YOUR INFORMATION? ## -The Fair Information Practices Principles form the backbone of privacy law in the United States and the concepts they include have played a significant role in the development of data protection laws around the globe. Understanding the Fair Information Practice Principles and how they should be implemented is critical to comply with the various privacy laws that protect personal information. +**In Short:** *We keep your information for as long as necessary to fulfill the purposes outlined in this privacy policy unless otherwise required by law.* -In order to be in line with Fair Information Practices we will take the following responsive action, should a data breach occur: -* We will notify you via email, within 7 business days +We will only keep your personal information for as long as it is necessary for the purposes set out in this privacy policy, unless a longer retention period is required or permitted by law (such as tax, accounting or other legal requirements). No purpose in this policy will require us keeping your personal information for longer than the period of time in which users have an account with us. -We also agree to the Individual Redress Principle which requires that individuals have the right to legally pursue enforceable rights against data collectors and processors who fail to adhere to the law. This principle requires not only that individuals have enforceable rights against data users, but also that individuals have recourse to courts or government agencies to investigate and/or prosecute non-compliance by data processors. +When we have no ongoing legitimate need to process your personal information, we will either delete or anonymize it, or, if this is not possible (for example, because your personal information has been stored in backup archives), then we will securely store your personal information and isolate it from any further processing until deletion is possible. -**Changes to This Privacy Policy** -We may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately after they are posted on this page. +## 7.HOW DO WE KEEP YOUR INFORMATION SAFE? ## -**Your Consent** +**In Short:** *We aim to protect your personal information through a system of organizational and technical security measures.* -By using the app, you are consenting to our processing of your information as set forth in this Privacy Policy now and as amended by us. "Processing," means using cookies on a computer/hand held device or using or touching information in any way, including, but not limited to, collecting, storing, deleting, using, combining and disclosing information, all of which activities will take place in Spain. If you reside outside Spain your information will be transferred, processed and stored there under Spain privacy standards. +We have implemented appropriate technical and organizational security measures designed to protect the security of any personal information we process. However, please also remember that we cannot guarantee that the internet itself is 100% secure. Although we will do our best to protect your personal information, transmission of personal information to and from our Sites or Apps is at your own risk. You should only access the services within a secure environment. -**Contact Us** -If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us: info AT comunes DOT org. +## 8. DO WE COLLECT INFORMATION FROM MINORS? ## + +**In Short:** *We do not knowingly collect data from or market to children under 18 years of age.* + +We do not knowingly solicit data from or market to children under 18 years of age. By using the Sites or Apps, you represent that you are at least 18 or that you are the parent or guardian of such a minor and consent to such minor dependent's use of the Sites or Apps. If we learn that personal information from users less than 18 years of age has been collected, we will deactivate the account and take reasonable measures to promptly delete such data from our records. If you become aware of any data we have collected from children under age 18, please contact us at info@comunes.org. + + +## 9. WHAT ARE YOUR PRIVACY RIGHTS? ## + +**In Short:** *In some regions, such as the European Economic Area, you have rights that allow you greater access to and control over your personal information. You may review, change, or terminate your account at any time.* + +In some regions (like the European Economic Area), you have certain rights under applicable data protection laws. These may include the right (i) to request access and obtain a copy of your personal information, (ii) to request rectification or erasure; (iii) to restrict the processing of your personal information; and (iv) if applicable, to data portability. In certain circumstances, you may also have the right to object to the processing of your personal information. To make such a request, please use the contact details provided below. We will consider and act upon any request in accordance with applicable data protection laws. + +If we are relying on your consent to process your personal information, you have the right to withdraw your consent at any time. Please note however that this will not affect the lawfulness of the processing before its withdrawal. + +If you are resident in the European Economic Area and you believe we are unlawfully processing your personal information, you also have the right to complain to your local data protection supervisory authority. You can find their contact details here: [http://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm](http://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm) + +### Account Information ### + +If you would at any time like to review or change the information in your account or terminate your account, you can: + +- Log into your account settings and update your user account. + +Upon your request to terminate your account, we will deactivate or delete your account and information from our active databases. However, some information may be retained in our files to prevent fraud, troubleshoot problems, assist with any investigations, enforce our Terms of Use and/or comply with legal requirements. + +**Cookies and similar technologies:** Most Web browsers are set to accept cookies by default. If you prefer, you can usually choose to set your browser to remove cookies and to reject cookies. If you choose to remove cookies or reject cookies, this could affect certain features or services of our Sites or Apps. + +**Opting out of email notifications:** You can unsubscribe from our notification emails at any time by clicking on the unsubscribe link in the emails that we send or by contacting us using the details provided below. You will then be removed from these emails – however, we will still need to send you service-related emails that are necessary for the administration and use of your account. To otherwise opt-out, you may: + +- Note your preferences when you register an account with the site. +- Access your account settings and update preferences. + + +## 10. DO CALIFORNIA RESIDENTS HAVE SPECIFIC PRIVACY RIGHTS? ## + +**In Short:** *Yes, if you are a resident of California, you are granted specific rights regarding access to your personal information.* + +California Civil Code Section 1798.83, also known as the "Shine The Light" law, permits our users who are California residents to request and obtain from us, once a year and free of charge, information about categories of personal information (if any) we disclosed to third parties for direct marketing purposes and the names and addresses of all third parties with which we shared personal information in the immediately preceding calendar year. If you are a California resident and would like to make such a request, please submit your request in writing to us using the contact information provided below. + +If you are under 18 years of age, reside in California, and have a registered account with the Sites or Apps, you have the right to request removal of unwanted data that you publicly post on the Sites or Apps. To request removal of such data, please contact us using the contact information provided below, and include the email address associated with your account and a statement that you reside in California. We will make sure the data is not publicly displayed on the Sites or Apps, but please be aware that the data may not be completely or comprehensively removed from our systems. + + +## 11. DO WE MAKE UPDATES TO THIS POLICY? ## + +**In Short:** *Yes, we will update this policy as necessary to stay compliant with relevant laws.* + +We may update this privacy policy from time to time. The updated version will be indicated by an updated "Revised" date and the updated version will be effective as soon as it is accessible. If we make material changes to this privacy policy, we may notify you either by prominently posting a notice of such changes or by directly sending you a notification. We encourage you to review this privacy policy frequently to be informed of how we are protecting your information. + + +## 12. HOW CAN YOU CONTACT US ABOUT THIS POLICY? ## + +If you have questions or comments about this policy, you may email us at info@comunes.org or by post to: + +ASOCIACIÓN COMUNES +C/ COLÓN 21, 5º DCHA, ESC DCHA +03001 ALICANTE +SPAIN + +If you have any further questions or comments about us or our policies, email us at info@comunes.org or by post to: + +ASOCIACIÓN COMUNES +C/ COLÓN 21, 5º DCHA, ESC DCHA +03001 ALICANTE +SPAIN diff --git a/private/pages/privacy-es.md b/private/pages/privacy-es.md index c494ca4..20d63d4 100644 --- a/private/pages/privacy-es.md +++ b/private/pages/privacy-es.md @@ -1,97 +1,155 @@ -Su privacidad es importante para nosotros. +# POLÍTICA DE PRIVACIDAD # -La Asociación Comunes ha creado esta aplicación 'Tod@s contra el fuego' y este SERVICIO que se provee por la Asociación de Comunes sin costo y está destinado a ser usado tal cual. +*Última actualización 12 de julio de 2018* -Esta página se utiliza para informar a los visitantes del sitio web sobre nuestras políticas con la recopilación, el uso y la divulgación de información personal si alguien decide utilizar nuestro Servicio. +**En resumen**: *Su privacidad es importante para nosotros/as. No hacemos negocios con sus datos, no los compartimos con terceros y solo nos preocupan los incendios y cómo fortalecer vecindarios contra ellos.* -Si elige usar nuestro Servicio, acepta la recopilación y el uso de la información en relación con esta política. La información personal que recopilamos se usa para proporcionar y mejorar el servicio. No usaremos ni compartiremos su información con nadie, excepto según se describe en esta Política de privacidad. +Gracias por elegir ser parte de nuestra comunidad 'Tod@s contra los Fuegos' en la Asociación de Comunes ("nosotras/os" o "nos"). Nos comprometemos a proteger su información personal y su derecho a la privacidad. Si tiene alguna pregunta o inquietud sobre nuestra política o nuestras prácticas con respecto a su información personal, contáctenos a info@comunes.org. -Los términos utilizados en esta Política de privacidad tienen los mismos significados que en nuestros [Términos y condiciones](/terms), a los que se puede acceder en "Tod@s contra el fuego" a menos que se defina lo contrario en esta Política de privacidad. +Cuando visita nuestro sitio web [fuegos.comunes.org](https://fuegos.comunes.org), la aplicación móvil y utiliza nuestros servicios, confía en nosotras/os con su información personal. Nos tomamos su privacidad muy en serio. En este aviso de privacidad, describimos nuestra política de privacidad. Buscamos explicarte de la manera más clara posible qué información recopilamos, cómo la utilizamos y qué derechos tienes en relación con ella. Esperamos que se tome un tiempo para leer detenidamente, ya que es importante. Si hay términos en esta política de privacidad con los que no está de acuerdo, interrumpa el uso de nuestros Sitios o Aplicaciones y nuestros servicios. -**Recopilación y uso de información** +Esta política de privacidad se aplica a toda la información recopilada a través de nuestro sitio web (como [fuegos.comunes.org](https://fuegos.comunes.org)), la aplicación móvil (las "**Aplicaciones**") y/o cualquier servicio relacionado o eventos (nos referimos a ellos colectivamente en esta política de privacidad como los "**Sitios**"). -Para una mejor experiencia, al utilizar nuestro Servicio, podemos solicitarle que nos brinde cierta información de identificación personal, que incluye, entre otros, nombre, correo electrónico, idioma y el nombre de usuario del telegrama (de nuestros usuarios de Telegram). La información que solicitamos será retenida por nosotros y utilizada como se describe en esta política de privacidad. +**Lea atentamente esta política de privacidad, ya que le ayudará a tomar decisiones informadas sobre cómo compartir su información personal con nosotras/os.** -Además, la aplicación puede recopilar cierta información automáticamente, que incluye, entre otros, el tipo de dispositivo que usa, la dirección IP de su dispositivo, su sistema operativo móvil, el tipo de navegador de Internet que utiliza e información sobre la forma que usas la aplicación. -Podemos usar la información que recopilamos de las siguientes maneras: -* Para personalizar su experiencia y permitirnos ofrecer el tipo de contenido que más le interesa. -* Para enviar correos electrónicos periódicos con respecto a nuestros servicios. +## 1. ¿QUÉ INFORMACIÓN RECOPILAMOS? ## -La aplicación utiliza servicios de terceros que pueden recopilar información utilizada para identificarlo. +### Información recopilada automáticamente ### -Enlaces a la política de privacidad de proveedores de servicios de terceros utilizados por la aplicación: +**En resumen:** *Recopilamos información automáticamente, como la dirección IP y/o las características del navegador y del dispositivo, cuando visita nuestros Sitios o Aplicaciones.* -* [OpenStreetMap](https://wiki.openstreetmap.org/wiki/Privacy_Policy/) -* [Google Maps](https://developers.google.com/maps/terms?hl=es#3-privacy-and-personal-information) +Recopilamos cierta información automáticamente cuando visita, utiliza o navega por nuestros Sitios o Aplicaciones. Esta información no revela su identidad específica (como su nombre o información de contacto) pero puede incluir información de uso y dispositivo, como su dirección IP, características del navegador y del dispositivo, sistema operativo, preferencias de idioma, URL de referencia, nombre del dispositivo, país, ubicación , información sobre cómo y cuándo utiliza nuestros sitios o aplicaciones y otra información técnica. Esta información es principalmente necesaria para mantener la seguridad y el funcionamiento de nuestros sitios o aplicaciones, y para nuestros fines de análisis internos e informes. Al igual que muchas empresas, también recopilamos información a través de cookies y tecnologías similares. -No hemos habilitado Google AdSense. +### Información recopilada a través de nuestras aplicaciones ### -**¿La aplicación recopila información precisa sobre la ubicación en tiempo real del dispositivo?** +**En resumen:** *Podemos recopilar información sobre su ubicación geográfica, notificaciones push, cuando usa nuestras aplicaciones.* -Cuando visita la aplicación, podemos usar tecnología GPS (u otra tecnología similar) para determinar su ubicación actual a fin de determinar la ciudad en la que se encuentra y/o mostrar un mapa de ubicación de incendios locales y/o proporcionar alertas de incendios futuros en tu zona. **No** compartiremos su ubicación actual con otros usuarios o socios. +Si usa nuestras aplicaciones, también podemos recopilar la siguiente información: -Si no desea que usemos su ubicación para los fines establecidos anteriormente, debe desactivar los servicios de ubicación para la aplicación móvil ubicados en la configuración de su cuenta o en la configuración de su teléfono móvil y/o dentro de la aplicación y/o cuando se le solicite por tu navegador. +- *Información de geolocalización.* Podemos solicitar en algún momento acceso o permiso a la información basada en la ubicación desde su dispositivo móvil para proporcionar servicios basados ​​en la ubicación. Si desea cambiar nuestro acceso o permisos, puede hacerlo en la configuración de su dispositivo. +- *Notificaciones Push.* Podemos solicitar el envío de notificaciones push con respecto a su cuenta o la aplicación móvil. Si desea optar por no recibir este tipo de comunicaciones, puede desactivarlas en la configuración de su dispositivo. -**¿Cómo protegemos tu información?** -No utilizamos el escaneo y/o escaneo de vulnerabilidades a estándares PCI. Solo proporcionamos artículos e información. Nunca pedimos números de tarjetas de crédito. No utilizamos el Malware Scanning. +## 2. ¿CÓMO USAMOS SU INFORMACIÓN? ## -Su información personal está almacenada detrás de redes seguras y solo es accesible para un número limitado de personas que tienen derechos especiales de acceso a dichos sistemas, y se les exige mantener la confidencialidad de la información. Además, toda la información sensible que usted suministra se cifra a través de la tecnología Secure Socket Layer (SSL). +**En resumen:** *Procesamos su información para fines basados ​​en intereses legítimos, el cumplimiento de nuestro contrato con usted, el cumplimiento de nuestras obligaciones legales y/o su consentimiento.* -Implementamos una variedad de medidas de seguridad cuando un usuario ingresa, envía o accede a su información para mantener la seguridad de su información personal. +Usamos información personal recopilada a través de nuestros Sitios o Aplicaciones para una variedad de propósitos que se describen a continuación. Procesamos su información personal para estos fines a fin de celebrar o realizar un contrato con usted ("Contractual"), con su consentimiento ("Consentimiento"), y/o para cumplir con nuestras obligaciones legales ("Razones legales"). Indicamos los motivos de procesamiento específicos en los que confiamos junto a cada uno de los propósitos enumerados a continuación. -**Dato de registro** +Usamos la información que recopilamos o recibimos: -Queremos informarle que cada vez que utiliza nuestro Servicio, en caso de error en la aplicación, recopilamos datos e información (a través de productos de terceros) en su teléfono, llamados Datos de registro. Este registro de datos puede incluir información como la dirección del protocolo de Internet ("IP"), el nombre del dispositivo, la versión del sistema operativo, la configuración de la aplicación al utilizar nuestro servicio, la hora y fecha de uso del servicio y otras estadísticas. +- **Para facilitar la creación de la cuenta y el proceso de inicio de sesión** con su consentimiento. Si elige vincular su cuenta con nosotras/os a una cuenta de terceros \*(como su cuenta de Google o Telegram), usamos la información que nos permitió recopilar de esos terceros para facilitar la creación de la cuenta y el proceso de inicio de sesión. +- **Solicitud de comentarios**. Podemos utilizar su información para solicitar comentarios y para comunicarnos con usted sobre su uso de nuestros Sitios o Aplicaciones. +- **Para habilitar las comunicaciones de usuario a usuario** con su consentimiento. Podemos usar su información para permitir las comunicaciones de usuario a usuario con el consentimiento de cada usuario. +- **Para hacer cumplir nuestros términos, condiciones y políticas**. +- **Para responder a solicitudes legales y evitar daños** por razones legales. Si recibimos una citación u otra solicitud legal, es posible que tengamos que inspeccionar los datos que tenemos para determinar cómo responder. -**¿Usamos 'cookies'?** -Sí. Las cookies son pequeños archivos que un sitio o su proveedor de servicios transfiere al disco duro de su computadora a través de su navegador web (si lo permite) que permite que los sistemas del sitio o del proveedor de servicios reconozcan su navegador y capturen y recuerden cierta información. Por ejemplo, usamos cookies para ayudarnos a recordar y procesar su idioma preferido. También se utilizan para ayudarnos a comprender sus preferencias en función de la actividad del sitio anterior o actual, lo que nos permite brindarle mejores servicios. También utilizamos cookies para ayudarnos a recopilar datos agregados sobre el tráfico del sitio y la interacción del sitio para que podamos ofrecer mejores experiencias y herramientas del sitio en el futuro. +## 3. ¿SU INFORMACIÓN SERÁ COMPARTIDA CON ALGUIEN? ## -Usamos cookies para comprender y guardar las preferencias del usuario para futuras visitas. +**En resumen:** *Solo compartimos información con su consentimiento, para cumplir con las leyes o proteger sus derechos* -Puede optar por que su computadora lo advierta cada vez que se envía una cookie, o puede optar por desactivar todas las cookies. Lo haces a través de la configuración de tu navegador. Dado que el navegador es un poco diferente, consulte el Menú de Ayuda de su navegador para conocer la forma correcta de modificar sus cookies. +Solo compartimos y divulgamos su información en las siguientes situaciones: -Si desactiva las cookies, es posible que algunas de las funciones que hacen que su sitio sea más eficiente no funcionen correctamente. +- **De acuerdo con las leyes**. Podemos divulgar su información cuando se nos requiera legalmente para cumplir con la ley aplicable, las solicitudes gubernamentales, un procedimiento judicial, una orden judicial o un proceso legal, como por ejemplo en respuesta a una orden judicial o una citación judicial (incluso en respuesta a las autoridades públicas para cumplir con los requisitos de seguridad nacional o cumplimiento de la ley). +- **Intereses vitales y derechos legales**. Podemos divulgar su información cuando creemos que es necesario investigar, prevenir o tomar medidas con respecto a posibles violaciones de nuestras políticas, sospecha de fraude, situaciones que involucren amenazas potenciales a la seguridad de cualquier persona y actividades ilegales, o como evidencia en un litigio en el cual estamos involucrados. +- **Con su consentimiento**. Podemos divulgar su información personal para cualquier otro propósito con su consentimiento. -**Proveedores de servicio** -Podemos emplear compañías e individuos de terceros para facilitar nuestro Servicio. +## 4. ¿USAMOS COOKIES Y OTRAS TECNOLOGÍAS DE SEGUIMIENTO? ## -**Divulgación de terceros** +**En resumen:** *Podemos utilizar cookies y otras tecnologías de seguimiento para recopilar y almacenar su información.* -No vendemos, comercializamos ni transferimos a terceros su información de identificación personal. +Podemos usar cookies para acceder o almacenar información sobre el inicio de sesión de la cuenta y sus preferencias de usuario (como su idioma preferido). -**Seguridad** -Valoramos su confianza al proporcionarnos su información personal, por lo tanto, nos esforzamos por utilizar medios comercialmente aceptables para protegerla. Pero recuerde que ningún método de transmisión a través de Internet o método de almacenamiento electrónico es 100% seguro y confiable, y no podemos garantizar su seguridad absoluta. +## 5. ¿USAMOS GOOGLE MAPS? ## -**Enlaces a otros sitios** +**En resumen:** *Sí, utilizamos Google Maps con el fin de proporcionar un mejor servicio.* -No incluimos ni ofrecemos productos o servicios de terceros en nuestro sitio web, pero este Servicio puede contener enlaces a otros sitios en los comentarios de los usuarios. Si hace clic en un enlace de un tercero, se lo dirigirá a ese sitio. Tenga en cuenta que estos sitios externos no son operados por nosotros. Por lo tanto, le recomendamos encarecidamente que revise la Política de privacidad de estos sitios web. No tenemos control ni asumimos ninguna responsabilidad por el contenido, las políticas de privacidad o las prácticas de sitios o servicios de terceros. +Este sitio web, aplicación móvil o aplicación Telegram utiliza las API de Google Maps. Puede encontrar las Condiciones del servicio de las API de Google Maps [aquí](https://developers.google.com/maps/terms). Para comprender mejor la Política de privacidad de Google, consulte este [enlace](https://policies.google.com/privacy). -**Privacidad de los niños** +Al utilizar nuestra implementación de Maps API, usted acepta estar sujeto a los Términos de Servicio de Google. Usted acepta permitirnos obtener o almacenar en caché su ubicación. Puede revocar su consentimiento en cualquier momento. Usamos información sobre la ubicación junto con datos de otros proveedores de datos. -Estos servicios no se dirigen a personas menores de 13 años. No recopilamos deliberadamente información personal identificable de niños menores de 13 años. En el caso de que descubramos que un niño menor de 13 años nos ha proporcionado información personal, la borramos inmediatamente de nuestros servidores. Si usted es un padre o tutor y sabe que su hijo nos ha proporcionado información personal, comuníquese con nosotros para que podamos hacer las acciones necesarias. -**Prácticas justas de información** +## 6. ¿CUÁNTO TIEMPO CONSERVAMOS SU INFORMACIÓN? ## -Los principios de prácticas justas de información (del inglés 'Fair Information Practices') forman la columna vertebral de la ley de privacidad en los Estados Unidos y los conceptos que incluyen han jugado un papel importante en el desarrollo de las leyes de protección de datos en todo el mundo. Comprender los principios prácticos de información justa y cómo deben implementarse es fundamental para cumplir con las diversas leyes de privacidad que protegen la información personal. +**En resumen:** *Conservamos su información el tiempo que sea necesario para cumplir con los fines descritos en esta política de privacidad, a menos que la ley así lo requiera.* -Para estar en línea con las prácticas de información justas, tomaremos la siguiente medida de respuesta, en caso de que se produzca una violación de datos: -* Le notificaremos por correo electrónico dentro de los 7 días hábiles +Solo conservaremos su información personal durante el tiempo que sea necesario para los fines establecidos en esta política de privacidad, a menos que la ley exija o permita un período de retención más largo (como impuestos, contabilidad u otros requisitos legales). Ningún propósito de esta política nos obligará a conservar su información personal por un período mayor al que el usuario tenga una cuenta con nosotras/os. -También aceptamos el principio de reparación individual que exige que las personas tengan derecho a buscar legalmente derechos exigibles contra los recolectores de datos y procesadores que no cumplan con la ley. Este principio requiere no solo que los individuos tengan derechos exigibles contra los usuarios de los datos, sino también que los individuos recurran a los tribunales o agencias gubernamentales para investigar y/o enjuiciar el incumplimiento por parte de los procesadores de datos. +Cuando no tenemos una necesidad legítima continua de procesar su información personal, la borraremos o la anonimizamos, o, si esto no es posible (por ejemplo, porque su información personal ha sido almacenada en archivos de respaldo), almacenaremos de manera segura su información personal y aislarlo de cualquier procesamiento posterior hasta que la eliminación sea posible. -**Cambios a esta política de privacidad** -Es posible que actualicemos nuestra política de privacidad de vez en cuando. Por lo tanto, se recomienda revisar esta página periódicamente para cualquier cambio. Le notificaremos de cualquier cambio publicando la nueva política de privacidad en esta página. Estos cambios entran en vigencia inmediatamente después de que se publiquen en esta página. +## 7. ¿CÓMO MANTENEMOS SU INFORMACIÓN SEGURA? ## -**Tu consentimiento** +**En resumen:** *Nuestro objetivo es proteger su información personal a través de un sistema de medidas de seguridad técnicas y organizativas.* -Al utilizar la aplicación, usted está dando su consentimiento para que procesemos su información según lo establecido en esta política de privacidad ahora y en la forma en que nosotros lo modifiquemos. "Procesamiento" significa usar cookies en una computadora/dispositivo de mano o usar o tocar información de cualquier manera, incluyendo, pero no limitado a, recolectar, almacenar, eliminar, usar, combinar y divulgar información, todas las cuales se llevarán a cabo en España. Si reside fuera de España, su información será transferida, procesada y almacenada allí según los estándares de privacidad de España. +Hemos implementado medidas de seguridad organizacionales y técnicas adecuadas diseñadas para proteger la seguridad de cualquier información personal que procesamos. Sin embargo, recuerde que no podemos garantizar que Internet sea 100% seguro. Aunque haremos nuestro mejor esfuerzo para proteger su información personal, la transmisión de información personal hacia y desde nuestros Sitios o Aplicaciones es bajo su propio riesgo. Solo debe acceder a los servicios dentro de un entorno seguro. -**Contáctenos** -Si tiene alguna pregunta o sugerencia sobre nuestra Política de privacidad, no dude en ponerse en contacto con nosotros: info ARROBA comunes PUNTO org. +## 8. ¿RECOPILAMOS INFORMACIÓN DE MENORES DE EDAD? ## + +**En resumen:** *No recopilamos deliberadamente ni comercializamos datos de niños menores de 18 años.* + +No solicitamos intencionalmente ni comercializamos datos de niños menores de 18 años. Al utilizar los Sitios o Aplicaciones, usted declara que tiene al menos 18 años o que es el padre o tutor de dicho menor y acepta el uso de los Sitios o Aplicaciones por parte de dicho menor dependiente. Si descubrimos que se ha recopilado información personal de usuarios menores de 18 años, desactivaremos la cuenta y tomaremos medidas razonables para eliminar dichos datos de inmediato de nuestros registros. Si toma conocimiento de cualquier información que hayamos recopilado de niños menores de 18 años, contáctenos a info@comunes.org. + + +## 9. ¿CUÁLES SON SUS DERECHOS DE PRIVACIDAD? ## + +**En resumen:** *En algunas regiones, como el Unión Europea, tiene derechos que le permiten un mayor acceso y control sobre su información personal. Puede revisar, cambiar o cancelar su cuenta en cualquier momento.* + +En algunas regiones (como la Unión Europea), usted tiene ciertos derechos bajo las leyes de protección de datos aplicables. Estos pueden incluir el derecho (i) de solicitar acceso y obtener una copia de su información personal, (ii) solicitar una rectificación o cancelación; (iii) restringir el procesamiento de su información personal; y (iv) si corresponde, a la portabilidad de datos. En determinadas circunstancias, también puede tener derecho a oponerse al procesamiento de su información personal. Para realizar dicha solicitud, utilice los datos de contacto que se proporcionan a continuación. Consideraremos y actuaremos en cualquier solicitud de acuerdo con las leyes de protección de datos aplicables. + +Si confiamos en su consentimiento para procesar su información personal, tiene derecho a retirar su consentimiento en cualquier momento. Sin embargo, tenga en cuenta que esto no afectará la legalidad del procesamiento antes de su retirada. + +Si usted reside en la Unión Europea y considera que estamos procesando ilegalmente su información personal, también tiene derecho a presentar una queja ante la autoridad de supervisión de protección de datos local. Puede encontrar sus datos de contacto aquí: [http://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm](http://ec.europa.eu/justice/data-protection/bodies/authorities/index_en.htm) + +### Información de la cuenta ### + +Si en algún momento desea revisar o cambiar la información en su cuenta o cancelar su cuenta, puede: + +- Inicie sesión en la configuración de su cuenta y actualice su cuenta de usuario. + +Tras su solicitud para cancelar su cuenta, desactivaremos o eliminaremos su cuenta y la información de nuestras bases de datos activas. Sin embargo, parte de la información se puede conservar en nuestros archivos para evitar fraudes, solucionar problemas, ayudar con cualquier investigación, hacer cumplir nuestros Términos de uso y/o cumplir con los requisitos legales. + +**Cookies y tecnologías similares:** La mayoría de los navegadores web están configurados para aceptar cookies por defecto. Si lo prefiere, generalmente puede elegir configurar su navegador para que elimine las cookies y rechace las cookies. Si elige eliminar las cookies o rechazar las cookies, esto podría afectar ciertas funciones o servicios de nuestros sitios o aplicaciones. + +**Optar por no recibir notificaciones por correo electrónico:** Puede darse de baja de nuestros correos electrónicos de notificación en cualquier momento haciendo clic en el enlace para darse de baja en los correos electrónicos que le enviamos o poniéndose en contacto con nosotras/os utilizando los detalles que se proporcionan a continuación. A continuación, se le eliminará de estos correos electrónicos; sin embargo, aún así le enviaremos correos electrónicos relacionados con el servicio que son necesarios para la administración y el uso de su cuenta. Para optar por no participar, usted puede: + +- Tener en cuenta sus preferencias cuando registra una cuenta en el sitio. +- Acceder a la configuración de tu cuenta y actualizar tus preferencias. + + +## 10. ¿LOS RESIDENTES DE CALIFORNIA TIENEN DERECHOS DE PRIVACIDAD ESPECÍFICOS? ## + +**En resumen:** *Sí, si usted es residente de California, se le otorgan derechos específicos con respecto al acceso a su información personal.* + +La Sección 1798.83 del Código Civil de California, también conocida como la ley "Shine The Light", permite a nuestros usuarios que son residentes de California solicitar y obtener de nosotras/os, una vez al año y sin cargo, información sobre categorías de información personal (si corresponde) que divulgado a terceros para fines de marketing directo y los nombres y direcciones de todos los terceros con quienes compartimos información personal en el año calendario inmediatamente anterior. Si usted es residente de California y desea realizar dicha solicitud, envíe su solicitud por escrito a nosotras/os utilizando la información de contacto que se proporciona a continuación. + +Si eres menor de 18 años, resides en California y tienes una cuenta registrada en los Sitios o Aplicaciones, tienes el derecho de solicitar la eliminación de datos no deseados que publiques en los Sitios o Aplicaciones. Para solicitar la eliminación de dichos datos, contáctenos utilizando la información de contacto que se proporciona a continuación, e incluya la dirección de correo electrónico asociada a su cuenta y una declaración de que reside en California. Nos aseguraremos de que los datos no se muestren públicamente en los Sitios o Aplicaciones, pero tenga en cuenta que es posible que los datos no se eliminen completa o completamente de nuestros sistemas. + + +## 11. ¿HACEMOS ACTUALIZACIONES DE ESTA POLÍTICA? ## + +**En resumen:** *Sí, actualizaremos esta política según sea necesario para cumplir con las leyes pertinentes.* + +Podemos actualizar esta política de privacidad de vez en cuando. La versión actualizada se indicará con una fecha actualizada "Revisada" y la versión actualizada entrará en vigencia tan pronto como sea accesible. Si realizamos cambios sustanciales a esta política de privacidad, podemos notificarlo publicando de manera prominente un aviso de dichos cambios o enviándole directamente una notificación. Le recomendamos que revise esta política de privacidad con frecuencia para recibir información sobre cómo protegemos su información. + + +## 12. ¿CÓMO PUEDE CONTACTARNOS SOBRE ESTA POLÍTICA? ## + +Si tiene preguntas o comentarios sobre esta política, puede enviarnos un correo electrónico a info@comunes.org o por correo postal a: + +ASOCIACIÓN COMUNES +C/ COLÓN 21, 5º DCHA, ESC DCHA +03001 ALICANTE +ESPAÑA + +Si tiene más preguntas o comentarios sobre nosotras/os o nuestras políticas, envíenos un correo electrónico a info@comunes.org o por correo postal a: + +ASOCIACIÓN COMUNES +C/ COLÓN 21, 5º DCHA, ESC DCHA +03001 ALICANTE +ESPAÑA diff --git a/public/fire-marker-l.png b/public/fire-marker-l.png index 0a20859..ea5a5f3 100644 Binary files a/public/fire-marker-l.png and b/public/fire-marker-l.png differ diff --git a/public/fire-marker-m.png b/public/fire-marker-m.png index e6325f0..972273d 100644 Binary files a/public/fire-marker-m.png and b/public/fire-marker-m.png differ diff --git a/public/fire-marker-s.png b/public/fire-marker-s.png index 954e689..c2a1113 100644 Binary files a/public/fire-marker-s.png and b/public/fire-marker-s.png differ diff --git a/public/home-fires-1000.jpg b/public/images/home-fires-1000.jpg similarity index 100% rename from public/home-fires-1000.jpg rename to public/images/home-fires-1000.jpg diff --git a/public/images/home-fires-1000.webp b/public/images/home-fires-1000.webp new file mode 100644 index 0000000..850bac3 Binary files /dev/null and b/public/images/home-fires-1000.webp differ diff --git a/public/home-fires-400.jpg b/public/images/home-fires-400.jpg similarity index 100% rename from public/home-fires-400.jpg rename to public/images/home-fires-400.jpg diff --git a/public/images/home-fires-400.webp b/public/images/home-fires-400.webp new file mode 100644 index 0000000..a258508 Binary files /dev/null and b/public/images/home-fires-400.webp differ diff --git a/public/home-fires-800.jpg b/public/images/home-fires-800.jpg similarity index 100% rename from public/home-fires-800.jpg rename to public/images/home-fires-800.jpg diff --git a/public/images/home-fires-800.webp b/public/images/home-fires-800.webp new file mode 100644 index 0000000..431d0b4 Binary files /dev/null and b/public/images/home-fires-800.webp differ diff --git a/public/home-fires.jpg b/public/images/home-fires.jpg similarity index 100% rename from public/home-fires.jpg rename to public/images/home-fires.jpg diff --git a/public/images/home-fires.webp b/public/images/home-fires.webp new file mode 100644 index 0000000..db4075b Binary files /dev/null and b/public/images/home-fires.webp differ diff --git a/public/images/home-slide-snd-1-blur.webp b/public/images/home-slide-snd-1-blur.webp new file mode 100644 index 0000000..b1109c3 Binary files /dev/null and b/public/images/home-slide-snd-1-blur.webp differ diff --git a/public/home-slide-snd-1.jpg b/public/images/home-slide-snd-1.jpg similarity index 100% rename from public/home-slide-snd-1.jpg rename to public/images/home-slide-snd-1.jpg diff --git a/public/images/home-slide-snd-1.webp b/public/images/home-slide-snd-1.webp new file mode 100644 index 0000000..700d804 Binary files /dev/null and b/public/images/home-slide-snd-1.webp differ diff --git a/public/images/home-slide-snd-2-blur.webp b/public/images/home-slide-snd-2-blur.webp new file mode 100644 index 0000000..739355d Binary files /dev/null and b/public/images/home-slide-snd-2-blur.webp differ diff --git a/public/home-slide-snd-2.jpg b/public/images/home-slide-snd-2.jpg similarity index 100% rename from public/home-slide-snd-2.jpg rename to public/images/home-slide-snd-2.jpg diff --git a/public/images/home-slide-snd-2.webp b/public/images/home-slide-snd-2.webp new file mode 100644 index 0000000..312d394 Binary files /dev/null and b/public/images/home-slide-snd-2.webp differ diff --git a/public/images/home-slide1-blur.webp b/public/images/home-slide1-blur.webp new file mode 100644 index 0000000..024541b Binary files /dev/null and b/public/images/home-slide1-blur.webp differ diff --git a/public/home-slide1.jpg b/public/images/home-slide1.jpg similarity index 100% rename from public/home-slide1.jpg rename to public/images/home-slide1.jpg diff --git a/public/images/home-slide1.webp b/public/images/home-slide1.webp new file mode 100644 index 0000000..2737e88 Binary files /dev/null and b/public/images/home-slide1.webp differ diff --git a/public/images/home-slide2-blur.webp b/public/images/home-slide2-blur.webp new file mode 100644 index 0000000..a12e123 Binary files /dev/null and b/public/images/home-slide2-blur.webp differ diff --git a/public/home-slide2.jpg b/public/images/home-slide2.jpg similarity index 100% rename from public/home-slide2.jpg rename to public/images/home-slide2.jpg diff --git a/public/images/home-slide2.webp b/public/images/home-slide2.webp new file mode 100644 index 0000000..c697c16 Binary files /dev/null and b/public/images/home-slide2.webp differ diff --git a/public/images/home-slide3-blur.webp b/public/images/home-slide3-blur.webp new file mode 100644 index 0000000..1b49784 Binary files /dev/null and b/public/images/home-slide3-blur.webp differ diff --git a/public/home-slide3.jpg b/public/images/home-slide3.jpg similarity index 100% rename from public/home-slide3.jpg rename to public/images/home-slide3.jpg diff --git a/public/images/home-slide3.webp b/public/images/home-slide3.webp new file mode 100644 index 0000000..b0e917c Binary files /dev/null and b/public/images/home-slide3.webp differ diff --git a/public/images/home-slide4-blur.webp b/public/images/home-slide4-blur.webp new file mode 100644 index 0000000..a38511d Binary files /dev/null and b/public/images/home-slide4-blur.webp differ diff --git a/public/home-slide4.jpg b/public/images/home-slide4.jpg similarity index 100% rename from public/home-slide4.jpg rename to public/images/home-slide4.jpg diff --git a/public/images/home-slide4.webp b/public/images/home-slide4.webp new file mode 100644 index 0000000..838ae49 Binary files /dev/null and b/public/images/home-slide4.webp differ diff --git a/public/mobile.png b/public/images/mobile.png similarity index 100% rename from public/mobile.png rename to public/images/mobile.png diff --git a/public/images/mobile.webp b/public/images/mobile.webp new file mode 100644 index 0000000..65af639 Binary files /dev/null and b/public/images/mobile.webp differ diff --git a/public/telegram-screen.png b/public/images/telegram-screen.png similarity index 100% rename from public/telegram-screen.png rename to public/images/telegram-screen.png diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9a7d412..6ddd231 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1,6 +1,6 @@ { - "AppName": "All Against Fire", - "AppNameFull": "All Against Fire!", + "AppName": "All Against the Fire", + "AppNameFull": "All Against the Fire!", "AppDescrip": "Crowdsourcing against wildfires", "AppDescripLong": "We use different sources of data to notify you of active fires in your areas of interest", "OrgName": "Comunes", @@ -76,7 +76,7 @@ "Marked in red <1><0>{{count,number}} active fires. <3><0>{{countTotal,number}} active fires worldwide.", "activeNeigFireInMapCount": "In Orange, the fires recently reported by our users.", "Centrar en tu ubicación": "Center on your location", - "Resaltar en verde el área vigilada por nuestros usuarios/as": "The area monitored by our users is highlighted in green", + "Resaltar en verde el área vigilada por nuestros usuarios/as": "Highlight in green the area monitored by our users", "Resaltar los fuegos con un marcador": "Highlight the fires with a marker", "mapPrivacy": "<0>In order to preserve the privacy of our users, the reflected data are randomly altered and are only indicative.", "Nuevas notificaciones de {{app}}": "New notifications of {{app}}", @@ -168,6 +168,7 @@ "Zonas vigiladas": "Monitored areas", "En verde, las zonas vigiladas por nuestros usuari@s actualmente": "In green, the areas monitored by our users currently", "Actualizado <1>.": "Updated <1>.", + "Actualizado <1>, último fuego detectado <3>.": "Updated <1>, last fire detected <3>.", "Información adicional sobre fuego": "Additional information about fire", "CO2emisions": "Did you know that wildfires <1>produce as much CO² as cars and <3>about ⅕ of all our carbon emissions?", "Ayúdanos a combatir el cambio climático y a proteger el medioambiente": "Help us fight climate change and protect the environment", @@ -185,7 +186,7 @@ "¿No es un fuego forestal?": "Isn't that a forest fire?", "Regístrate o inicia sesión para aportar información sobre este fuego": "Sign up or login to provide information about this fire", "Fuego no encontrado": "Fire not found", - "Es una industria (fuente: nuestros usuarios/as)": "It's an industry (source: our users)", + "Es una industria": "It's an industry", "Es una quema controlada": "It's a controlled burning", "Parece una falsa alarma": "It seems a false alarm", "Fuegos activos en el mundo actualizados en tiempo real": "Active fires in the world updated in real time", @@ -225,6 +226,8 @@ "noActiveNeigFireInMap": "There are no fires recently reported by our users in this area.", "Es una industria (fuente: registro oficial)": "It's an industry (source: oficial register)", + "Es una industria (fuente: nuestros usuarios/as)": + "It's an industry (source: our users):", "Pantalla completa": "Fullscreen", "Salir de pantalla completa": @@ -243,5 +246,10 @@ "Cancelar": "Cancel", "Nota: Las nubes pueden entorpecer la detección de fuegos activos.": - "Note: Cloud cover might obscure active fire detections." + "Note: Cloud cover might obscure active fire detections.", + "Hay más información sobre un fuego": + "There is more information about a fire", + "esDecirTalDia": "That is on {{date}}", + "Actualizando...": "Updating...", + "Alerta de fuego": "Alert of fire" } diff --git a/public/locales/es/common.json b/public/locales/es/common.json index a2f8abc..d46328a 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -238,6 +238,7 @@ "Zonas vigiladas": "Zonas vigiladas", "En verde, las zonas vigiladas por nuestros usuari@s actualmente": "En verde, las zonas vigiladas por nuestros usuari@s actualmente", "Actualizado <1>.": "Actualizado <1>.", + "Actualizado <1>, último fuego detectado <3>.": "Actualizado <1>, último fuego detectado <3>.", "Tomamos nota, ¡gracias por colaborar!": "Tomamos nota, ¡gracias por colaborar!", "Indícanos de que tipo de fuego se trata y ayúdanos así a mejorar nuestras notificaciones:": "Indícanos de que tipo de fuego se trata y ayúdanos así a mejorar nuestras notificaciones:", @@ -246,8 +247,8 @@ "Regístrate o inicia sesión para aportar información sobre este fuego", "Fuego no encontrado": "Fuego no encontrado", - "Es una industria (fuente: nuestros usuarios/as)": - "Es una industria (fuente: nuestros usuarios/as)", + "Es una industria": + "Es una industria", "Es una quema controlada": "Es una quema controlada", "Parece una falsa alarma": @@ -310,6 +311,8 @@ "noActiveNeigFireInMap": "No hay fuegos notificados recientemente por nuestros usuarios/as en esta zona.", "Es una industria (fuente: registro oficial)": "Es una industria (fuente: registro oficial)", + "Es una industria (fuente: nuestros usuarios/as)": + "Es una industria (fuente: nuestros usuarios/as):", "Pantalla completa": "Pantalla completa", "Salir de pantalla completa": @@ -328,5 +331,10 @@ "Cancelar": "Cancelar", "Nota: Las nubes pueden entorpecer la detección de fuegos activos.": - "Nota: Las nubes pueden entorpecer la detección de fuegos activos." + "Nota: Las nubes pueden entorpecer la detección de fuegos activos.", + "Hay más información sobre un fuego": + "Hay más información sobre un fuego", + "esDecirTalDia": "Es decir el {{date}}", + "Actualizando...": "Actualizando...", + "Alerta de fuego": "Alerta de fuego" } diff --git a/public/locales/gl/common.json b/public/locales/gl/common.json index 0967ef4..bea98c9 100644 --- a/public/locales/gl/common.json +++ b/public/locales/gl/common.json @@ -1 +1,223 @@ -{} +{ + "fireDetected": "lume detectado {{when}}", + "fireDetectedAt": "lume detectado o {{when}}", + "Sí": "Si", + "No": "Non", + "AppName": "Tod@s contra o Lume", + "AppNameFull": "Tod@s contra o Lume!", + "AppDescrip": "Colaboración masiva contra os incendios", + "AppDescripLong": "Empregamos diferentes fontes de datos para notificarche os lumes activos nas túas zonas de interese", + "OrgName": "Comúns", + "OrgNameFull": "Asociación Comúns", + "Privacidad": "Privacidade", + "Términos": "Termos", + "Términos de Servicio": "Termos do Servizo", + "Inicio": "Inicio", + "Licencia": "Licencia", + "Cerrar sesión": "Pechar sesión", + "Registrarse": "Rexistrarse", + "Regístrate": "Rexístrate", + "Iniciar sesión": "Iniciar sesión", + "o regístrate con un correo": "ou rexístrate cun correo", + "o con un correo": "ou cun correo", + "Nombre": "Nome", + "Apellidos": "Apelidos", + "Correo electrónico": "Correo electrónico", + "Contraseña": "Contrasinal", + "¿Ya tienes un cuenta?": "Xa tes unha conta?", + "Usa al menos seis caracteres.": "Emprega polo menos seis caracteres.", + "Iniciar sesión con Google": "Iniciar sesión con Google", + "¿Olvidaste tu contraseña?": "Esqueciches o teu contrasinal?", + "¿No tienes una cuenta?": "Non tes unha conta?", + "Introduce tu correo abajo para recibir un enlace para resetear tu contraseña.": "Introduce o teu correo abaixo para recibir un enlace para resetear o teu contrasinal.", + "Recupera tu contraseña": "Recupera o teu contrasinal", + "¿Recuerdas tu contraseña?": "Lembras o teu contrasinal?", + "Necesitamos un correo aquí.": "Necesitamos un correo aquí.", + "¿Es este correo correcto?": "É iste correo correcto?", + "Necesitamos una contraseña aquí.": "Necesitamos un contrasinal aquí.", + "Bienvenid@ de nuevo": "Benvid@ de novo", + "Guardar perfíl": "Gardar perfil", + "Contraseña actual": "Contrasinal actual", + "Nueva contraseña": "Novo contrasinal", + "Editar perfíl": "Editar perfil", + "Editar perfíl en": "Editar perfil en", + "¡Perfíl actualizado!": "Perfil actualizado!", + "¿Cuál es tu nombre?": "Cal é o teu nome?", + "¿Cuál es tu apellido?": "Cal é o teu apelido?", + "Necesito tu contraseña si la quieres cambiar.": "Necesito o teu contrasinal se o queres cambiar.", + "Necesito tu nueva contraseña si la quieres cambiar.": "Necesito o teu novo contrasinal se o queres cambiar.", + "¿Es correcto este correo?": "É correcto iste correo?", + "verifyEmail": "Ei! pódesnos verificar o teu correo electrónico ({{email}})?", + "Reenviar email de verificación": "Reenviar email de verificación", + "checkVerificationEmail": "Comproba no teu correo {{email}} o enlace de verificación!", + "checkResetEmail": "Comproba no teu correo {{email}} o enlace de reseteo!", + "¡Listo, gracias!": "Listo, grazas!", + "Por favor, inténtalo otra vez.": "Por favor, téntao de novo.", + "Verificando...": "Verificando...", + "Introduce una nueva contraseña, por favor.": "Introduce un novo contrasinal, por favor.", + "Repite tu nueva contraseña, por favor.": "Repite o teu novo contrasinal, por favor.", + "Mmmm, tus contraseñas no coinciden. Inténtalo otra vez": "Mmmm, os teus contrasinais non coinciden. Téntao de novo", + "Para resetear tu contraseña, introduce una nueva debajo. Iniciarás la sesión con la nueva contraseña.": "Para resetear o teu contrasinal, introduce un novo debaixo. Iniciarás a sesión co novo contrasinal.", + "Resetea tu contraseña": "Resetea o teu contrasinal", + "Repite la nueva contraseña": "Repite o novo contrasinal", + "Resetea la contraseña y entra": "Resetea o contrasinal e entra", + "Uso de Cookies": "Emprego de Cookies", + "Utilizamos cookies para asegurar un mejor uso de nuestra web. Si continúas navegando, consideramos que aceptas su uso": "Empregamos cookies para asegurar un mellor uso da nosa web. Se continúas a navegar, consideramos que aceptas o seu uso", + "Participa": "Participa", + "activeFires": "Lumes activos", + "Fuegos activos": "Lumes activos", + "noActiveFireInMapCount": "Non hai lumes activos nesta zona do mapa. <1><0>{{countTotal}} lumes activos no mundo.", + "activeFireInMapCount": "En vermello, <1><0>{{count,number}} lumes activos. <3><0>{{countTotal,number}} lumes activos no mundo.", + "activeNeigFireInMapCount": "En laranxa, os lumes notificados polas/os nosas/os usuarias/os recentemente.", + "Centrar en tu ubicación": "Centrar na túa ubicación", + "Resaltar en verde el área vigilada por nuestros usuarios/as": "Resaltar en verde a área vixiada por as/os nosas/os usuarias/os", + "Resaltar los fuegos con un marcador": "Resaltar os lumes cun marcador", + "mapPrivacy": "<0>Para preservar a privacidade das/os nosas/os usuarias/os, os datos reflectidos están aleatoriamente alterados e son só orientativos.", + "Nuevas notificaciones de {{app}}": "Novas notificacións de {{app}}", + "Imágenes capturadas por los satélites de la NASA muestran el humo de grandes incendios que se extienden sobre el Océano Pacífico. La actividad del fuego está delineada en rojo.": "Imaxes capturadas polos satélites da NASA amosan o fume de grandes incendios que se estenden sobre o Océano Pacífico. A actividade do lume esta deliñada en vermello.", + "Todavía sin subscriptiones": "Aínda sen subscricións", + "Suscripción añadida": "Suscrición engadida", + "Suscripción actualizada": "Suscrición actualizada", + "Desuscrito": "Desuscrita", + "Mis zonas": "As miñas zonas", + "Nueva zona": "Nova zona", + "Suscribirme a fuegos en este radio": "Suscribirme a lumes neste radio", + "Suscribirme a este radio": "Suscribirme a iste radio", + "Créditos": "Créditos", + "Escribe aquí un lugar": "Escribe aquí un lugar", + "Indícanos la posición de la zona a vigilar (por ej. tu pueblo, una calle, etc):": "Indícanos a posición da zona a vixiar (por ex. a túa aldea, unha rúa, etc):", + "También puedes seleccionar la zona en el mapa arrastrando el puntero naranja.": "Tamén podes seleccionar a zona no mapa arrastrando o punteiro laranxa.", + "¿A que distancia a la redonda quieres recibir notificaciones de fuegos?": "A que distancia darredor queres recibir notificacións de lumes?", + "Pulsa para activar": "Preme para activar", + "Arrastrar para seleccionar otro punto": "Arrastrar para seleccionar outro punto", + "Mapa gris de OpenStreetMap": "Mapa gris de OpenStreetMap", + "Mapa color de OpenStreetMap": "Mapa cor de OpenStreetMap", + "Mapa de carreteras de Google": "Mapa de estradas de Google", + "Mapa de terreno de Google": "Mapa de terreo de Google", + "Mapa de satélite de Google": "Mapa de satélite de Google", + "Zona añadida": "Zona engadida", + "Añadir zona": "Engadir zona", + "Editar": "Editar", + "Suscripciones a alertas de fuegos en zonas de mi interés": "Suscricións a alertas de lumes en zonas do meu interese", + "En verde, áreas de las que recibirás alertas de fuegos": "En verde, áreas das que recibirás alertas de lumes", + "Terminar": "Rematar", + "Pulsa para borrar": "Preme para borrar", + "Pulsa aquí para borrar la zona": "Preme aquí para borrar a zona", + "Los fuegos activos se actualizan en tiempo cuasi real.": "Os lumes activos actualízanse en tempo cuasi real.", + "Somos muchos ojos": "Somos moitos ollos", + "Usa nuestro bot de Telegram para estar al tanto de los fuegos en tus área": "Emprega o noso bot do Telegram para estar ó tanto dos lumes na túa área", + "Otros dispositivos": "Outros dispositivos", + "support-us-home": "Estamos a desenvolver as nosas ferramentas para outros dispositivos. Podes <1>contribuír a facelo posible.", + "Software Libre": "Software Libre", + "Suscríbete a alertas de fuegos": "Suscríbete a alertas de lumes", + "Gracias por Participar": "Grazas por Participar", + "También puedes seguirnos en la web": "Tamén nos podes seguir na web", + "Participar": "Participar", + "Dejarás de recibir notificaciones de fuegos en esa área ¿Estás seguro/a? ": "Deixarás de recibir notificacións de lumes nesa área. Estás segura/o? ", + "Sobre los datos y imágenes usados": "Sobre os datos e imaxes empregados", + "dev-with-us-home": "Todo o noso traballo é <1>sofware libre. <3>Traductoræs e <5>desenvolvedoræs sempre benvid@s.", + "Política de Privacidad": "Política de Privacidade", + "No estás suscrito a fuegos en ninguna zona": "Non tes suscrición a lumes en ningunha zona", + "Iniciar sesión con Telegram": "Iniciar sesión con Telegram", + "Idioma": "Idioma", + "Comentarios": "Comentarios", + "Guardar": "Gardar", + "Responder": "Responder", + "Borrar": "Borrar", + "Añadir un comentario": "Engadir un comentario", + "Añadir una respuesta": "Engadir unha resposta", + "Añadir comentario": "Engadir comentario", + "Necesitas iniciar sesión para": "Precisas iniciar a sesión para", + "añadir comentarios": "engadir comentarios", + "puntuar comentarios": "puntuar comentarios", + "responder": "responder", + "Más comentarios": "Máis comentarios", + "Elige un lugar": "Escolle un lugar", + "Elige un radio de vigilancia": "Escolle un radio de vixiancia", + "Recibe alertas de fuegos en esa zona": "Recibe alertas de lumes nesa zona", + "Alerta cuando hay un fuego": "Alerta cando hai un lume", + "Anterior": "Anterior", + "Siguiente": "Seguinte", + "Siempre alerta a los fuegos en nuestro vecindario": "Alerta sempre dos lumes na nosa veciñanza", + "Información adicional sobre fuego detectado en {{where}} el {{when}}": "Información adicional sobre lume detectado en {{where}} o {{when}}", + "Información adicional sobre fuego detectado el {{when}}": "Información adicional sobre lume detectado o {{when}}", + "Coordenadas:": "Coordenadas:", + "Fuego detectado por satélites de la NASA <1>": "Lume detectado por satélites da NASA <1>", + "Puedes añadir un comentario si tienes información adicional sobre este fuego.": "Podes engadir un comentario se tes información adicional sobre este lume", + "Por ejemplo:": "Por exemplo:", + "si conoces esta zona y cómo acceder al fuego (esto puede de ser de ayuda para apagarlo si sigue activo o para investigarlo en un futuro)": "se coñeces esta zona e como acceder ó lume (isto pode ser de moita axuda para apagalo se segue activo ou para investigalo nun futuro)", + "si conoces el motivo por el que comenzó el fuego": "se coñeces o motivo polo que empezou o lume", + "si quieres denunciar algún tipo de ilegalidad, incluso anónimamente": "se queres denunciar algún tipo de ilegalidade, incluso anonimamente", + "o cualquier otra información": "ou calquera outra información", + "Haga zoom en una zona de su interés si quiere que los fuegos se actualicen en tiempo real.": "Faga zoom nunha zona do seu interese se quere que os lumes se actualicen en tempo real.", + "Notificaciones": "Notificacións", + "Recibe nuestras notificaciones de fuegos por correo o en tu navegador": "Recibe as nosas notificacións de lumes por correo ou no teu navegador", + "Fuego notificado por uno de nuestros usuarios/as <1>": "Lume notificado por un/ha das/os nosas/os usuarias/os", + "No recibirás notificaciones de fuegos en este equipo, solo por correo": "Non recibirás notificacións de lumes neste equipo, só por correo", + "not-found": "Upppps: Esta páxina non existe", + "general-error-title": "Upppps: Algo saiu mal", + "general-error-description": "Estamos a investigar o problema, volve tentalo nun cachiño", + "Más información sobre este fuego": "Máis información sobre este lume", + "Última actualización, {{when}}": "Última actualización, {{when}}", + "termsAccept": "Acepto as <1>condicións de servizo deste sitio", + "Bienvenid@!": "Benvid@!", + "welcome": "Benvid@", + "Has iniciado sesión con {{service}} usando la dirección de correo {{email}}.": "Iniciaches a sesión con {{service}} usando a dirección de correo {{email}}.", + "Verifica tu dirección de correo": "Verifica a túa dirección de correo", + "Zonas vigiladas": "Zonas vixiladas", + "En verde, las zonas vigiladas por nuestros usuari@s actualmente": "En verde, as zonas vixiladas polas/os nosas/os usuarias/os actualmente", + "Actualizado <1>, último fuego detectado <3>.": "Actualizado <1>, último lume detectado <3>.", + "Tomamos nota, ¡gracias por colaborar!": "Tomamos nota, grazas por colaborar!", + "Indícanos de que tipo de fuego se trata y ayúdanos así a mejorar nuestras notificaciones:": "Indícanos de que tipo de lume se trata e axúdanos así a mellorar as nosas notificacións:", + "¿No es un fuego forestal?": "Non é un lume forestal?", + "Regístrate o inicia sesión para aportar información sobre este fuego": "Rexístrate ou inicia sesión para aportar información sobre este lume", + "Fuego no encontrado": "Lume non atopado", + "Es una industria": "É unha industria", + "Es una quema controlada": "É unha queima controlada", + "Parece una falsa alarma": "Parece unha falsa alarma", + "Fuegos activos en el mundo actualizados en tiempo real": "Lumes activos no mundo actualizados en tempo real", + "Información adicional sobre fuego": "Información adicional sobre o lume", + "CO2emisions": "Sabías que os lumes <1>producen tanto CO² como os coches e <3>aproximadamente ⅕ de tódalas nosas emisións ?", + "Ayúdanos a combatir el cambio climático y a proteger el medioambiente": "Axúdanos a combatir o cambio climático e a protexer o medioambiente", + "Fuego en La Tuna, Los Ángeles, Estados Unidos, septiembre de 2017": "Lume en La Tuna, Los Ángeles, Estados Unidos, setembro do 2017", + "Polución en la ciudad de Almaty, Kazakhstan, enero de 2014": "Polución na cidade de Almaty, Kazakhstan, xaneiro do 2014", + "Fuente": "Fonte", + "NASA": "NASA", + "nuestros usuarios/as": "as nosas/os usuarias/os", + "Pulsa para más información": "Preme para máis información", + "Detectado": "Detectado", + "Zonas vigiladas por nuestros usuari@s actualmente": "Zonas vixiadas pol@s nos@s usuari@s actualmente", + "Iniciar sesión con Facebook": "Iniciar sesión con Facebook", + "Elige un tipo": "Escolle un tipo", + "Puedes participar en las traducciones": "Podes participar nas traducións", + "Aceptar": "Aceptar", + "Por favor, escribe aquí tu feedback...": "Por favor, escribe aquí o teu feedback...", + "Feedback": "Feedback", + "Enviar": "Enviar", + "Tu correo": "O teu correo", + "Desconectado del servidor, reconectando en %delay% segundos.": "Desconectando di servidor, reconectando en %delay% segundos.", + "Desconectado del servidor, reconectando...": "Desconectado do servidor, reconectando...", + "Reintentar ahora": "Reintentar agora", + "Feedback recibido, gracias...": "Feedback recibido, grazas...", + "Parece que este fuego no es un fuego forestal.": "Parece que este lume non é un lume forestal.", + "Sobre nosotr@s": "Sobre nós", + "Nosotr@s": "Nós", + "Sobre '{{appName}}'": "Sobre '{{appName}}'", + "¿Vecindarios vigilando y combatiendo fuegos? ¿de qué va todo esto?": "Veciñanzas vixiando e combatendo lumes? de vai todo isto?", + "noActiveNeigFireInMap": "Non hai lumes notificados recentemente polas/os nosas/os usuarias/os nesta zona.", + "Es una industria (fuente: registro oficial)": "É unha industria (fonte: rexistro oficial)", + "Es una industria (fuente: nuestros usuarios/as)": "É unha industria (fonte as/os nosas/os usuarias/os):", + "Pantalla completa": "Pantalla completa", + "Salir de pantalla completa": "Saír da pantalla completa", + "Escribe un lugar para centrar el mapa": "Escribe un lugar para centrar o mapa", + "geo-not-perms-error": "Upppps: parece que non temos permiso para coñecer a túa ubicación", + "geo-not-avail-error": "Upppps: parece que a túa ubicación non está dispoñible", + "geo-not-timeout-error": "Upppps: o teu dispositivo está a tardar demasiado en coñecer a túa ubicación", + "Estás subscrito a una zona muy grande": "Suscribícheste a unha zona grande de máis", + "Lugar no encontrado": "Lugar non atopado", + "on-already-subscribed": "Upppps: Xa te suscribiras a esta zona antes", + "Cancelar": "Cancelar", + "Nota: Las nubes pueden entorpecer la detección de fuegos activos.": "Nota: As nubes poden entorpecer a detección de lumes activos.", + "Hay más información sobre un fuego": "Hai máis información sobre un lume", + "esDecirTalDia": "É dicir o {{date}}" +} diff --git a/settings-development-sample.json b/settings-development-sample.json index cec962e..3bbb1c7 100644 --- a/settings-development-sample.json +++ b/settings-development-sample.json @@ -1,6 +1,11 @@ { + "PrerenderIO": { + "serviceUrl": "http://localhost:8080/", + "token": "yourtoken" + }, "gmaps": { - "key": "" + "key": "", + "serverKey": "" }, "piwik": { "url": "", @@ -16,24 +21,42 @@ "testMailer": false, "debugMailer": false, "MAIL_URL": "", + "// isMailServer": "See: https://github.com/VeliovGroup/Mail-Time", + "isMailServer": true, + "fireIconUrl": "https://testfuegos.comunes.org/fire-marker.png", "OAuth": { - "facebook": { - "appId": "", - "secret": "", - "loginStyle": "popup" - }, "google": { "clientId": "", "secret": "", "loginStyle": "popup" - }, - "github": { - "clientId": "", - "secret": "", - "loginStyle": "popup" } }, + "fcmApiToken": "", "proxies_count": 0, - "ironPassword": "SomePasswordAlLeast32LongYouKnowHowToCount" + "ironPassword": "SomePasswordAlLeast32LongYouKnowHowToCount", + "internalApiToken": "someSimilarPasswordForUseOurAPIinternally", + "cron": { + "debug": false + }, + "twitter": { + "es": { + "enabled": true, + "comment": "using later.js", + "when": "at 9:15 am also at 3:15 pm also at 10:00 pm", + "consumer_key": "...", + "consumer_secret": "...", + "access_token": "...", + "access_token_secret": "..." + }, + "en": { + "enabled": true, + "comment": "using later.js", + "when": "at 9:15 am also at 3:15 pm also at 10:00 pm", + "consumer_key": "...", + "consumer_secret": "...", + "access_token": "...", + "access_token_secret": "..." + } + } } } diff --git a/test/activeFiresUnion.test.js b/test/activeFiresUnion.test.js new file mode 100644 index 0000000..1a1a52e --- /dev/null +++ b/test/activeFiresUnion.test.js @@ -0,0 +1,29 @@ +/* eslint-env mocha */ +/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable import/no-absolute-path */ + +import { chai } from 'meteor/practicalmeteor:chai'; +import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion'; + +const centerid = { type: 'Point', coordinates: [-5.905956029891968, 43.55727622097011] }; +const shape = { type: 'Polygon', coordinates: [[[-5.9084, 43.5558], [-5.906, 43.5558], [-5.906, 43.5566], [-5.9036, 43.5566], [-5.9036, 43.5583], [-5.9061, 43.5583], [-5.9061, 43.558], [-5.9084, 43.558], [-5.9084, 43.5558]]] }; +const fireUnion = { centerid, shape, when: Date('2018-05-02T16:11:04.617Z') }; + +describe('activeFireUnion insert', () => { + it('should insert fire union', () => { + // Remove previous test register + const alreadyInserted = ActiveFiresUnion.findOne({ centerid }); + if (alreadyInserted) { + ActiveFiresUnion.remove(alreadyInserted._id); + } + + const insertedId = ActiveFiresUnion.insert(fireUnion); + ActiveFiresUnion.schema.validate(fireUnion); + + const inserted = ActiveFiresUnion.findOne(insertedId); + delete inserted._id; + chai.expect(fireUnion).to.deep.equal(inserted); + ActiveFiresUnion.remove(insertedId); + chai.expect(ActiveFiresUnion.find({ _id: inserted }).count()).equal(0); + }); +}); diff --git a/test/email.test.js b/test/email.test.js new file mode 100644 index 0000000..0377368 --- /dev/null +++ b/test/email.test.js @@ -0,0 +1,27 @@ +/* eslint-env mocha */ +/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable import/no-absolute-path */ + +import { chai } from 'meteor/practicalmeteor:chai'; +import { subjectTruncate } from '/imports/modules/server/send-email'; + +describe('check email utilities', () => { + it('small words', async () => { + chai.expect('something').equal(subjectTruncate.apply('something', [50, true])); + }); + + it('big sentences', async () => { + chai.expect('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam eget sed.') + .equal(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam eget sed.')); + }); + + it('big sentences space break', async () => { + chai.expect('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi...') + .equal(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis cras amet.')); + }); + + it('big sentences no break', async () => { + chai.expect('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis...') + .equal(subjectTruncate.apply('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi mollis cras amet.', [70, false])); + }); +}); diff --git a/test/encode.test.js b/test/encode.test.js index b02dc25..e7ff306 100644 --- a/test/encode.test.js +++ b/test/encode.test.js @@ -5,8 +5,43 @@ import { chai } from 'meteor/practicalmeteor:chai'; import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires'; import urlEnc from '/imports/modules/url-encode'; +import seeder from '@cleverbeagle/seeder'; import { Accounts } from 'meteor/accounts-base'; +const firesSeed = () => ({ + collection: ActiveFiresCollection, + environments: ['development', 'staging'], + noLimit: true, + modelCount: 5, + model(dataIndex) { + return { + ourid: { + type: 'Point', + coordinates: [ + 149.751 + dataIndex, + -10.021 + dataIndex + ] + }, + type: 'modis', + lat: -10.021 + dataIndex, + lon: 149.751 + dataIndex, + updatedAt: Date('2018-05-02T16:11:04.617Z'), + acq_date: '2018-05-02', + acq_time: '00:05', + scan: 1.5, + track: 1.2, + satellite: 'T', + confidence: 57, + version: '6.0NRT', + frp: 7.8, + daynight: 'D', + brightness: 304.3, + bright_t31: 283.4, + when: Date('2018-05-01T22:05:00.000Z'), + createdAt: Date('2018-05-02T16:11:04.617Z') + }; + } +}); async function encAndDec(obj) { delete obj._id; @@ -22,21 +57,23 @@ async function encAndDec(obj) { } describe('url encoding', () => { - it('should encrypt and dcrypt basic objects', async () => { + it('should encrypt and dcrypt basic objects', async (done) => { const obj = { lat: 2 }; const sealed = await urlEnc.encrypt(obj); const unsealed = await urlEnc.decrypt(sealed); chai.expect(unsealed).to.deep.equal(obj); + done(); }); - it('should encrypt and dcrypt objects with date', async () => { + it('should encrypt and dcrypt objects with date', async (done) => { const obj = { lat: 40.234503, lon: -3.350386, when: (new Date()).toString() }; const sealed = await urlEnc.encrypt(obj); const unsealed = await urlEnc.decrypt(sealed); chai.expect(unsealed).to.deep.equal(obj); + done(); }); - it('should encrypt complete objects', async () => { + it('should encrypt complete objects', async (done) => { const obj = { ourid: { type: 'Point', @@ -68,21 +105,26 @@ describe('url encoding', () => { chai.expect(unsealed).to.deep.equal(obj); chai.expect(sealed).to.equal(encodeURI(sealed)); chai.expect(sealed).to.not.equal(sealed2); + chai.expect(sealed).to.not.include('/'); + chai.expect(sealed).to.not.include('%'); + done(); }); // This fails because Date is return as String (not as Date) and because _id - it('should encrypt and dcrypt collection objects', async () => { + it('should encrypt and dcrypt collection objects', async (done) => { + seeder(ActiveFiresCollection, firesSeed()); const obj = ActiveFiresCollection.findOne(); encAndDec(obj); + done(); }); it('should decrypt node-red enc objects', async () => { - const sealed = 'Fe26.2**750f04110aef0f20d1093aa3f2a54dac3d7d5cc86e864e2c7d71b0ead88bc5b9*rX653dosl4BiM2L4fmZiiA*xuKj8XojCaS38R6X1EXkXBWjOidpnB0EVgGbdod_4vACHSl1w61hgXkU6SOq8HdURakJKCyIqWrsrdGt6DI6bl1xgfZ4ejlRMV_Keu0-WV0BVqrxbw0NlMN9KPexRDsy15yZou4ExU1yE36PBsfZIYysUrIsXwiBz3D5KvDaW0BnAXZ4sUR5Kyvk8g75QQmBth_LVHaEWE_OMarQUXDvFZ33R2_vM_i89QSj-wzwG5v-lbYrimE_5SMhEngRJFjihtQ_LfQlkH0wrpe5SUIdNL-DzsRxswvY7RIuMKHVLWSy8So66PxCuVJKa-DGvclX7tj7NO9RgNidfqP8U0izTpoV7dJB44Bwi4NOwLKGwNO9NG7jt-KGb2P6FeLTJYq8Mt0qqNsYXWUMpuhgXlKc2SuKT0avh-kYdovFO3YztGg5dz_Asu5hGZtkzRG6oGrvZxU8j7VDDNSQLLHo67Skmqg5_0e6Bp0gqpz13bmCSVvM_IpOkJLIgkRkGAvFoPy-woBqWiBU3NICo0z9X35WJ8j2xFY5niidGPXkP_uo5JbpGkmLH1tL06UUqjXZ5GS7gVhi8ii0vZt5zgaM4z0g4Q**96aa408156b0952f0d90e7c6d3960c06595543ffcfd642274138631b51b03383*BRnu9clkqVeKeXfWVe8i_Dh6TgqstVgV9HafoiLKxks'; + const sealed = 'Fe26.2**7ae64199b871901d16a6ba031198c57b43b4a9231e15b851ff80014a8de230ca*RkB3_Uu_oJnTefyzB-Ocqg*2Z9rXYf--GxLJnYESHvdK5rC6lOMlSbMJ6YQyR2Mgpl57iBYopAHPWRLNTBgNkbUHFYa0IlEjdyeEz5VvLDtVvlSr1Sam-Cx8GUai4mharZO5-Wkze0dTr7M56WofSC9jpkv3kANrpsrit3HASE_E-5Z1JVYQVQqehw-VbSZTorrYj0GCJiAgXE5I0iY4boXRDYCv7fm_pzcuAbHlnNkKT1GbMFEPjyVViP4mVMRI5PDUhcWb0f62goMX4UnYLZXum4oYOIr84DG8AxqIdW2L94yslt0EZkD3yVhvKEGoauMpy6ry2illpSgaMaj4sU3AKWIjfAsJXvM6bHwbNh6G1_BJfCapuu3KijBBQPQMAnhYuwLAlY56WN-Y2efNIBJkp__kO6ak2nFqu8PufpxE-cv0uW7x6GWUtCpnFO2SeUJWVVMddUgJjLiM8Hkdl1v9huB0WdIvsoPEV0ZOwHZaC3jtdKFSO7Xe6LdqTXjXmdBMwtOv-HefyaB8LnEN6dAeKWwJuDu7g03QFTryDIiuwtpQ8mcLWTJGHcxoaluNhUESOBULkWSv_E1vOM1_fFv**1e41101b27c5da58bc11177448e91d88d7801bf911088a895844db0c542dfa7c*dPlOhtXJcyR47ezuL7CEgC2Z8d6C1asNOiqZmhD5TXY'; const unsealed = await urlEnc.decrypt(sealed); // console.log(unsealed); }); - it('should encrypt and dcrypt Accounts hashes', async () => { + it('should encrypt and dcrypt Accounts hashes', async (done) => { const token = Accounts._generateStampedLoginToken(); const obj = Accounts._hashStampedToken(token); console.log(obj); @@ -91,12 +133,15 @@ describe('url encoding', () => { const w = unsealed.when; unsealed.when = new Date(w); chai.expect(unsealed).to.deep.equal(obj); + done(); }); // limit 0 for no limit and test everything (and increase timeout) - it('should encrypt and dcrypt all collection objects', async () => { + it('should encrypt and dcrypt all collection objects', async (done) => { + seeder(ActiveFiresCollection, firesSeed()); ActiveFiresCollection.find({}, { limit: 1000 }).fetch().forEach((obj) => { encAndDec(obj); }); + done(); }).timeout(5000); }); diff --git a/test/rest.test.js b/test/rest.test.js new file mode 100644 index 0000000..d365ab2 --- /dev/null +++ b/test/rest.test.js @@ -0,0 +1,293 @@ +/* eslint-disable import/no-absolute-path */ +/* eslint-env mocha */ +/* eslint-disable func-names, prefer-arrow-callback */ + +import { chai } from 'meteor/practicalmeteor:chai'; +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; + +const testPort = process.env.TEST_PORT; +function url(path) { + return `http://127.0.0.1:${testPort}/${path}`; +} + +describe('basic api v1 returns', () => { + it('should return uptime', async done => + HTTP.get(url('api/v1/status/uptime'), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).to.equal(200); + chai.expect(typeof result.data.ms).to.equal('number'); + done(); + })); + + it('should return last-fire', async done => + HTTP.get(url('api/v1/status/active-fires-count'), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(200); + chai.expect(typeof result.data.total).to.equal('number'); + done(); + })); + + const token = Meteor.settings.private.internalApiToken; + + it('should return fires in some location', async done => + HTTP.get(url(`api/v1/fires-in/${token}/38.736946/-9.142685/100`), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(200); + chai.expect(typeof result.data.total).to.equal('number'); + chai.expect(typeof result.data.real).to.equal('number'); + done(); + })); + + it('should return full fires in some location', async done => + HTTP.get(url(`api/v1/fires-in-full/${token}/38.736946/-9.142685/100`), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(200); + chai.expect(typeof result.data.total).to.equal('number'); + chai.expect(typeof result.data.real).to.equal('number'); + chai.expect(typeof result.data.falsePos).to.equal('object'); + chai.expect(typeof result.data.industries).to.equal('object'); + chai.expect(typeof result.data.fires).to.equal('object'); + done(); + })); + + it('should not return full fires with some wrong token', async done => + HTTP.get(url('api/v1/fires-in-full/token/38.736946/-9.142685/100'), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(401); + done(); + })); + + it('should not return fires with some wrong token', async done => + HTTP.get(url('api/v1/fires-in/token/38.736946/-9.142685/100'), { + data: { + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(401); + done(); + })); + + it('should not return fires with some wrong distance', async done => + HTTP.get(url(`api/v1/fires-in-full/${token}/38.736946/-9.142685/1100`), (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(400); + done(); + })); + + const mobileToken = 'user-mobile-firebase-token'; + let testUserId; + let returnedSubsId; + const objId = new Meteor.Collection.ObjectID(); + + it('should create mobile users', async (done) => { + // this are removed in the test database but we are testing in dev/ci database + + // Delete the test account if it's still present + // Meteor.users.remove({ fireBaseToken: mobileToken }); + + HTTP.post(url('api/v1/mobile/users'), { + data: { + token, + mobileToken, + lang: 'en' + } + }, (error, result) => { + chai.expect(error, null); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + chai.expect(result.statusCode).equal(200); + chai.expect(jsendResult.data.upsertResult.numberAffected).to.equal(1); + chai.expect(jsendResult.data.mobileToken).to.equal(mobileToken); + chai.expect(jsendResult.data.lang).to.equal('en'); + chai.expect(typeof jsendResult.data.username).to.equal('string'); + testUserId = jsendResult.data.userId; + chai.expect(typeof testUserId).to.equal('string'); + done(); + }); + }); + + it('should not create mobile users with wrong token', async (done) => { + // this are removed in the test database but we are testing in dev/ci database + // Delete the test account if it's still present + // Meteor.users.remove({ fireBaseToken: mobileToken }); + + HTTP.post(url('api/v1/mobile/users'), { + data: { + token: 'wrong token', + mobileToken, + lang: 'en' + } + }, (error, result) => { + chai.expect(error, null); + chai.expect(result.statusCode).equal(401); + done(); + }); + }); + + function addSubs(done) { + // this are removed in the test database but we are testing in dev/ci database + // Remove previous subs of this user + // Subscriptions.remove({ owner: testUserId }); + // chai.expect(Subscriptions.find({ owner: testUserId }).count()).to.equal(0); + HTTP.post(url('api/v1/mobile/subscriptions'), { + data: { + token, + mobileToken, + id: objId._str, + lat: 3.106111, + lon: 53.5775, + distance: 100 + } + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + returnedSubsId = jsendResult.data.subsId; + chai.expect(returnedSubsId.toString()).equal(objId._str); + done(); + }); + } + + it('should create mobile user subscriptions', async (done) => { + addSubs(done); + }); + + it('should not create mobile user subscriptions with wrong token', async (done) => { + HTTP.post(url('api/v1/mobile/subscriptions'), { + data: { + token: 'wrongOne', + mobileToken, + id: objId._str, + lat: 3.106111, + lon: 53.5775, + distance: 100 + } + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 401) console.log(result); + chai.expect(result.statusCode).equal(401); + done(); + }); + }); + + it('should get all mobile user subscriptions', async (done) => { + HTTP.get(url(`api/v1/mobile/subscriptions/all/${token}/${mobileToken}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + chai.expect(typeof jsendResult.data.subscriptions).to.equal('object'); + chai.expect(typeof jsendResult.data.count).to.equal('number'); + if (jsendResult.data.count !== 1) console.log(result); + chai.expect(jsendResult.data.count).to.equal(1); + // console.log(JSON.stringify(jsendResult.data.subscriptions)); + done(); + }); + }); + + + it('should remove mobile user subscriptions', async (done) => { + // this are removed in the test database but we are testing in dev/ci database + + HTTP.del(url(`api/v1/mobile/subscriptions/${token}/${mobileToken}/${returnedSubsId}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + done(); + }); + }); + + it('should get all mobile user subscriptions after deletion', async (done) => { + HTTP.get(url(`api/v1/mobile/subscriptions/all/${token}/${mobileToken}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + chai.expect(typeof jsendResult.data.subscriptions).to.equal('object'); + chai.expect(typeof jsendResult.data.count).to.equal('number'); + if (jsendResult.data.count !== 0) console.log(result); + chai.expect(jsendResult.data.count).to.equal(0); + // console.log(JSON.stringify(jsendResult.data.subscriptions)); + done(); + }); + }); + + it('should not remove mobile user subscriptions with wrong token', async (done) => { + HTTP.del(url(`api/v1/mobile/subscriptions/wrongOne/${mobileToken}/${returnedSubsId}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 401) console.log(result); + chai.expect(result.statusCode).equal(401); + done(); + }); + }); + + it('should not get mobile user subscriptions with wrong token', async (done) => { + HTTP.get(url(`api/v1/mobile/subscriptions/all/wrongOne/${mobileToken}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 401) console.log(result); + chai.expect(result.statusCode).equal(401); + done(); + }); + }); + + it('should get all mobile user subscriptions', async (done) => { + HTTP.get(url(`api/v1/mobile/subscriptions/all/${token}/${mobileToken}`), { + }, (error, result) => { + chai.expect(error, null); + // console.log(result); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + chai.expect(typeof jsendResult.data.subscriptions).to.equal('object'); + chai.expect(typeof jsendResult.data.count).to.equal('number'); + if (jsendResult.data.count !== 0) console.log(result); + chai.expect(jsendResult.data.count).to.equal(0); + done(); + }); + }); + + it('should del all mobile user subscriptions', async (done) => { + // Add subs + addSubs(() => { + HTTP.del(url(`api/v1/mobile/subscriptions/all/${token}/${mobileToken}`), { + }, (error, result) => { + chai.expect(error, null); + if (result.statusCode !== 200) console.log(result); + chai.expect(result.statusCode).equal(200); + const jsendResult = result.data; + chai.expect(jsendResult.status).equal('success'); + chai.expect(typeof jsendResult.data.count).to.equal('number'); + chai.expect(jsendResult.data.count).to.be.at.most(1); + done(); + }); + }); + }); +}); diff --git a/test/siteSettings.test.js b/test/siteSettings.test.js index 64b8ef1..2a347fa 100644 --- a/test/siteSettings.test.js +++ b/test/siteSettings.test.js @@ -5,6 +5,10 @@ import { chai } from 'meteor/practicalmeteor:chai'; import SiteSettings from '/imports/api/SiteSettings/SiteSettings'; import SiteSettingsTypes from '/imports/api/SiteSettings/SiteSettingsTypes'; +import { resetDatabase } from 'meteor/xolvio:cleaner'; + +resetDatabase('siteSettings'); +SiteSettings._ensureIndex({ name: 1 }, { unique: 1 }); const setting = { name: 'site-test', @@ -35,12 +39,12 @@ describe('site settings store', () => { chai.expect(SiteSettings.find({ _id: inserted }).count()).equal(0); }); - it('should not insert twice', () => { + it('should not be inserted twice', () => { const id = SiteSettings.insert(setting); // If this fails manual check that the setting it's not already in the db. chai.expect(() => { SiteSettings.insert(setting); - }).to.throw('E11000 duplicate key error collection: fuegos.siteSettings index: name_1 dup key: { : "site-test" }'); + }).to.throw(); const inserted = SiteSettings.find(id).count(); chai.expect(inserted).equal(1); SiteSettings.remove(id); diff --git a/test/tweets.test.js b/test/tweets.test.js new file mode 100644 index 0000000..e2282ca --- /dev/null +++ b/test/tweets.test.js @@ -0,0 +1,51 @@ +/* eslint-env mocha */ +/* eslint-disable func-names, prefer-arrow-callback */ +/* eslint-disable import/no-absolute-path */ + +import { chai } from 'meteor/practicalmeteor:chai'; +import { composeIberiaTweet } from '/imports/api/ActiveFires/server/tweetFiresInZone'; + +const tr = (stats) => { + const keys = Object.keys(stats); + const newStats = []; + keys.map((key) => { + newStats[key] = { count: stats[key] }; + }); + return newStats; +}; + +describe('compose tweets of fires', () => { + it('No fires', async () => { + chai.expect('').to.deep.equal(composeIberiaTweet(0, { })); + }); + + it('a fire in a zone', async () => { + chai.expect('Un #fuego activo en #Asturias').to.deep.equal(composeIberiaTweet(1, tr({ Asturias: 1 }))); + }); + + it('some fires in a zone', async () => { + chai.expect('4 #fuegos activos en #Asturias').to.deep.equal(composeIberiaTweet(4, tr({ Asturias: 4 }))); + }); + + it('some fires in two zones', async () => { + chai.expect('Un #fuego activo en #Asturias y otros 4 en #Madrid').to.deep.equal(composeIberiaTweet(5, tr({ Asturias: 1, Madrid: 4 }))); + }); + + it('some fires in some zones', async () => { + chai.expect('3 #fuegos activos en #Catalunya, otro en #Asturias y otros 4 en #Madrid').to.deep.equal(composeIberiaTweet(8, tr({ Catalunya: 3, Asturias: 1, Madrid: 4 }))); + }); + + it('other fires in some zones', async () => { + chai.expect('Un #fuego activo en #Catalunya, otro en #Asturias y otros 4 en #Madrid').to.deep.equal(composeIberiaTweet(6, tr({ Catalunya: 1, Asturias: 1, Madrid: 4 }))); + }); + + it('other fires in two zones', async () => { + chai.expect('Un #fuego activo en #Catalunya y otro en #Asturias').to.deep.equal(composeIberiaTweet(2, tr({ Catalunya: 1, Asturias: 1 }))); + }); + + it('many fires in many zones', async () => { + chai.expect('3 #fuegos activos en #Cataluña, otro en #Cantabria, otros 4 en #Andalucia, otro en #Galicia y otros 2 en #Madrid').to.deep.equal(composeIberiaTweet(11, tr({ + Cataluña: 3, Cantabria: 1, Andalucia: 4, Galicia: 1, Madrid: 2 + }))); + }); +});