Merge branch 'master' into 'checkup-email'

# Conflicts:
#   app/config/appConfig.js
#   app/services/notificationService.js
This commit is contained in:
Naida Vatric
2020-01-31 21:59:26 +00:00
26 changed files with 972 additions and 195 deletions

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.ejs

View File

@@ -7,7 +7,42 @@ const PRICE_SLIDER_OPTIONS_SALE = {
step: 1000, step: 1000,
connect: true connect: true
}; };
const FLAT_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [800000]
},
step: 5000,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_SALE = {
start: [50000, 150000],
range: {
min: [0],
max: [1500000]
},
step: 10000,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_SALE = {
start: [15000, 50000],
range: {
min: [0],
max: [2000000]
},
step: 2000,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_SALE = {
start: [40000, 80000],
range: {
min: [0],
max: [2000000]
},
step: 10000,
connect: true
};
const PRICE_SLIDER_OPTIONS_RENT = { const PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 500], start: [300, 500],
range: { range: {
@@ -17,18 +52,62 @@ const PRICE_SLIDER_OPTIONS_RENT = {
step: 50, step: 50,
connect: true connect: true
}; };
const FLAT_PRICE_SLIDER_OPTIONS_RENT = {
start: [300, 600],
range: {
min: [0],
max: [4000]
},
step: 100,
connect: true
};
const HOUSE_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [10000]
},
step: 100,
connect: true
};
const OFFICE_PRICE_SLIDER_OPTIONS_RENT = {
start: [200, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
const LAND_PRICE_SLIDER_OPTIONS_RENT = {
start: [500, 1000],
range: {
min: [0],
max: [20000]
},
step: 100,
connect: true
};
//This will be used for Flats, Apartments, Houses //This will be used for Flats, Apartments, Houses
const HOME_SIZE_SLIDER_OPTIONS = { const HOME_SIZE_SLIDER_OPTIONS = {
start: [30, 75], start: [30, 75],
range: { range: {
min: [0], min: [0],
max: [400] max: [500]
}, },
step: 5, step: 5,
connect: true connect: true
}; };
const OFFICE_SIZE_SLIDER_OPTIONS = {
start: [30, 150],
range: {
min: [0],
max: [1200]
},
step: 10,
connect: true
};
const GARDEN_SIZE_SLIDER_OPTIONS = { const GARDEN_SIZE_SLIDER_OPTIONS = {
start: [100, 1000], start: [100, 1000],
range: { range: {
@@ -111,8 +190,8 @@ const AD_CATEGORY = {
hasNumberOfRoom: true, hasNumberOfRoom: true,
hasNumberOfFloors: false, hasNumberOfFloors: false,
hasFloorProp: true, hasFloorProp: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
}, },
HOUSE: { HOUSE: {
@@ -126,8 +205,8 @@ const AD_CATEGORY = {
hasNumberOfRoom: true, hasNumberOfRoom: true,
hasNumberOfFloors: true, hasNumberOfFloors: true,
hasFloorProp: false, hasFloorProp: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
}, },
@@ -142,9 +221,9 @@ const AD_CATEGORY = {
hasNumberOfRoom: true, hasNumberOfRoom: true,
hasNumberOfFloors: false, hasNumberOfFloors: false,
hasFloorProp: true, hasFloorProp: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: OFFICE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: OFFICE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS sizeSliderOptions: OFFICE_SIZE_SLIDER_OPTIONS
}, },
LAND: { LAND: {
id: "LAND", id: "LAND",
@@ -157,8 +236,8 @@ const AD_CATEGORY = {
hasNumberOfRoom: false, hasNumberOfRoom: false,
hasNumberOfFloors: false, hasNumberOfFloors: false,
hasFloorProp: false, hasFloorProp: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: LAND_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: LAND_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS sizeSliderOptions: LAND_SIZE_SLIDER_OPTIONS
}, },
APARTMENT: { APARTMENT: {
@@ -172,8 +251,8 @@ const AD_CATEGORY = {
hasNumberOfRoom: true, hasNumberOfRoom: true,
hasNumberOfFloors: false, hasNumberOfFloors: false,
hasFloorProp: true, hasFloorProp: true,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: FLAT_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: FLAT_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS
}, },
GARAGE: { GARAGE: {
@@ -202,8 +281,8 @@ const AD_CATEGORY = {
hasNumberOfRoom: true, hasNumberOfRoom: true,
hasNumberOfFloors: true, hasNumberOfFloors: true,
hasFloorProp: false, hasFloorProp: false,
priceSliderOptionsSale: PRICE_SLIDER_OPTIONS_SALE, priceSliderOptionsSale: HOUSE_PRICE_SLIDER_OPTIONS_SALE,
priceSliderOptionsRent: PRICE_SLIDER_OPTIONS_RENT, priceSliderOptionsRent: HOUSE_PRICE_SLIDER_OPTIONS_RENT,
sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS, sizeSliderOptions: HOME_SIZE_SLIDER_OPTIONS,
gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS gardenSizeSliderOptions: GARDEN_SIZE_SLIDER_OPTIONS
} }
@@ -216,7 +295,8 @@ const AD_STATUS = {
STATUS_DELETED: 4, STATUS_DELETED: 4,
STATUS_URGENT: 5, STATUS_URGENT: 5,
STATUS_DISCOUNTED: 6, STATUS_DISCOUNTED: 6,
STATUS_RENTED: 7 STATUS_RENTED: 7,
STATUS_VIP: 8
}; };
const AD_AGENCY = { const AD_AGENCY = {

View File

@@ -34,6 +34,11 @@ const PRINT_CRAWLER_DEBUG = process.env.PRINT_CRAWLER_DEBUG_INFO || 0;
const API_MAP_KEY = process.env.API_MAP_KEY || ""; const API_MAP_KEY = process.env.API_MAP_KEY || "";
const PROSTOR_LOGIN = {
EMAIL: process.env.PROSTOR_LOGIN_EMAIL,
PASSWORD: process.env.PROSTOR_LOGIN_PASS
};
module.exports = { module.exports = {
APP_PORT, APP_PORT,
APP_URL, APP_URL,
@@ -45,5 +50,6 @@ module.exports = {
MAX_REAL_ESTATES_IN_FIRST_EMAIL, MAX_REAL_ESTATES_IN_FIRST_EMAIL,
PRINT_CRAWLER_DEBUG, PRINT_CRAWLER_DEBUG,
API_MAP_KEY, API_MAP_KEY,
CHECK_UP_DAYS CHECK_UP_DAYS,
PROSTOR_LOGIN
}; };

View File

@@ -35,7 +35,8 @@ const getFilters = async (req, res) => {
balcony, balcony,
elevator, elevator,
newBuilding, newBuilding,
accessRoadType accessRoadType,
includeWithoutPrice
} = searchRequest; } = searchRequest;
const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT; const category = AD_CATEGORY[realEstateType] || AD_CATEGORY.FLAT;
@@ -115,7 +116,8 @@ const getFilters = async (req, res) => {
advancedSegmentSelectFilterValues, advancedSegmentSelectFilterValues,
advancedRangeFilterObjects, advancedRangeFilterObjects,
advancedRangeFilterValues, advancedRangeFilterValues,
includeIncompleteAds includeIncompleteAds,
includeWithoutPrice
}); });
}; };
@@ -191,6 +193,7 @@ const postFilters = async (req, res) => {
}); });
const includeIncompleteAds = req.body.includeIncompleteAds === "on"; const includeIncompleteAds = req.body.includeIncompleteAds === "on";
const includeWithoutPrice = req.body.includeWithoutPrice === "on";
const balcony = req.body.balcony === "on"; const balcony = req.body.balcony === "on";
const elevator = req.body.elevator === "on"; const elevator = req.body.elevator === "on";
@@ -217,6 +220,7 @@ const postFilters = async (req, res) => {
searchRequest.newBuilding = newBuilding; searchRequest.newBuilding = newBuilding;
searchRequest.includeIncompleteAds = includeIncompleteAds; searchRequest.includeIncompleteAds = includeIncompleteAds;
searchRequest.includeWithoutPrice = includeWithoutPrice;
searchRequest.accessRoadType = accessRoadType; searchRequest.accessRoadType = accessRoadType;

View File

@@ -2,13 +2,14 @@
const { const {
findRealEstatesForSearchRequest findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch"); } = require("../helpers/db/searchRequestMatch");
const { AD_STATUS } = require("../common/enums");
const getRealEstates = async (req, res) => { const getRealEstates = async (req, res) => {
const searchRequestId = req.params["searchRequestId"] || ""; const searchRequestId = req.params["searchRequestId"] || "";
const realEstates = await findRealEstatesForSearchRequest(searchRequestId); const realEstates = await findRealEstatesForSearchRequest(searchRequestId);
const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage"; const title = "Nekretnine koje odgovaraju Vašim uslovima pretrage";
res.render("realEstates", { realEstates, title }); res.render("realEstates", { realEstates, title, AD_STATUS });
}; };
module.exports = { module.exports = {

View File

@@ -1,9 +1,11 @@
const { getRealEstateById } = require("../helpers/db/realEstate"); const { getRealEstateById } = require("../helpers/db/realEstate");
const { AD_STATUS } = require("../common/enums");
const getRedirect = async (req, res) => { const getRedirect = async (req, res) => {
const id = req.params.id || null; const id = req.params.id || null;
let error = false; let error = false;
let redirectUrl = undefined; let redirectUrl = undefined;
let vipAd = undefined;
if (!id) { if (!id) {
error = true; error = true;
} else { } else {
@@ -13,6 +15,7 @@ const getRedirect = async (req, res) => {
error = true; error = true;
} else { } else {
redirectUrl = realEstate.url; redirectUrl = realEstate.url;
vipAd = realEstate.adStatus === AD_STATUS.STATUS_VIP;
} }
} catch (e) { } catch (e) {
error = true; error = true;
@@ -24,7 +27,7 @@ const getRedirect = async (req, res) => {
res.render("notFound", { title }); res.render("notFound", { title });
} else { } else {
const title = "Preusmjeravanje"; const title = "Preusmjeravanje";
res.render("redirect", { title, redirectUrl }); res.render("redirect", { title, redirectUrl, vipAd });
} }
}; };

View File

@@ -1,6 +1,7 @@
const moment = require("moment"); const moment = require("moment");
const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate"); const { bulkUpsertRealEstates } = require("../../helpers/db/realEstate");
const { bulkUpsertPriceHistory } = require("../../helpers/db/priceHistory");
class PostgresSaver { class PostgresSaver {
connect() { connect() {
@@ -11,6 +12,21 @@ class PostgresSaver {
async save(results) { async save(results) {
const savedRecords = await bulkUpsertRealEstates(results); const savedRecords = await bulkUpsertRealEstates(results);
//Extruding data for price history table
const resultPrices = savedRecords.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
const savedPrices = await bulkUpsertPriceHistory(resultPrices);
if (Array.isArray(savedRecords)) { if (Array.isArray(savedRecords)) {
const newRealEstates = []; const newRealEstates = [];

View File

@@ -3,6 +3,7 @@
const fetch = require("node-fetch"); const fetch = require("node-fetch");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
const FormData = require("form-data");
const { const {
AD_TYPE, AD_TYPE,
@@ -16,7 +17,8 @@ const {
const { const {
PRINT_CRAWLER_DEBUG, PRINT_CRAWLER_DEBUG,
DEFAULT_TIMEZONE DEFAULT_TIMEZONE,
PROSTOR_LOGIN
} = require("../../config/appConfig"); } = require("../../config/appConfig");
const { PROSTOR_FORCE_CRAWL } = require("../specificConfigs/prostor"); const { PROSTOR_FORCE_CRAWL } = require("../specificConfigs/prostor");
@@ -60,13 +62,16 @@ class ProstorCrawler {
async crawl() { async crawl() {
const crawlAdCategories = this.crawlerAdCategories; const crawlAdCategories = this.crawlerAdCategories;
//We need session cookie to use login privileges
const prostorCookie = await this.getCookies();
//New tag to check if crawler loged in
const login = await this.loginForScraping(PROSTOR_LOGIN, prostorCookie);
const newRealEstates = []; const newRealEstates = [];
//Crawl only if login was successful
if (crawlAdCategories) { if (crawlAdCategories && login) {
const indexGenerators = []; const indexGenerators = [];
for (const adCategory of crawlAdCategories) { for (const adCategory of crawlAdCategories) {
indexGenerators.push(this.categoryIndexer(adCategory)); indexGenerators.push(this.categoryIndexer(adCategory, prostorCookie));
} }
let done = false; let done = false;
@@ -119,13 +124,14 @@ class ProstorCrawler {
return newRealEstates; return newRealEstates;
} }
async *categoryIndexer(adCategory) { async *categoryIndexer(adCategory, prostorCookie) {
const urlAdTypePart = PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes]; const urlAdTypePart = PROSTOR_ENUMS.PROSTOR_AD_TYPE[this.crawlerAdTypes];
const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory]; const urlCategoryPart = PROSTOR_ENUMS.PROSTOR_AD_CATEGORY[adCategory];
if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) { if (urlAdTypePart !== undefined && urlCategoryPart !== undefined) {
const urlPageToCrawl = `${this.baseUrl}?remove_sold=0${urlAdTypePart}${urlCategoryPart}`; const urlPageToCrawl = `${this.baseUrl}?remove_sold=0${urlAdTypePart}${urlCategoryPart}`;
const listOfAllRealEstates = await this.extractRealEstates( const listOfAllRealEstates = await this.extractRealEstates(
urlPageToCrawl urlPageToCrawl,
prostorCookie
); );
let elementToStartIndexFrom = 0; let elementToStartIndexFrom = 0;
@@ -139,7 +145,8 @@ class ProstorCrawler {
elementToStartIndexFrom += realEstatesForSinglePage.length; elementToStartIndexFrom += realEstatesForSinglePage.length;
const singlePageResults = await this.indexSinglePage( const singlePageResults = await this.indexSinglePage(
realEstatesForSinglePage realEstatesForSinglePage,
prostorCookie
); );
const filteredSinglePageResults = singlePageResults.filter( const filteredSinglePageResults = singlePageResults.filter(
@@ -163,10 +170,10 @@ class ProstorCrawler {
} }
} }
async indexSinglePage(realEstatesList) { async indexSinglePage(realEstatesList, prostorCookie) {
const asyncActions = []; const asyncActions = [];
for (const realEstate of realEstatesList) { for (const realEstate of realEstatesList) {
asyncActions.push(this.scrapeAd(realEstate)); asyncActions.push(this.scrapeAd(realEstate, prostorCookie));
} }
try { try {
@@ -180,12 +187,25 @@ class ProstorCrawler {
} }
} }
async scrapeAd(realEstate) { async scrapeAd(realEstate, prostorCookie) {
const { lat, lng, property_name, price, size, link, status } = realEstate; const { lat, lng, property_name, price, size, link, status } = realEstate;
//Status information is given already in realestate list
//For VIP Ads status ='' canot be used, but no VIP ads are crawled
//We will make "fake" vip ad for RE that have size=55
//It is weird because yesterday it said 'VIP ponuda' ???
const adStatus =
size === "55"
? ProstorCrawler.getStatusId("VIP ponuda")
: ProstorCrawler.getStatusId(status);
const url = `https://prostor.ba${link}`; const url = `https://prostor.ba${link}`;
// console.log("[PROSTOR] Scraping : ", url); // console.log("[PROSTOR] Scraping : ", url);
try { try {
const adPageSource = await fetch(url); const adPageSource = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await adPageSource.text(); const body = await adPageSource.text();
const $ = cheerio.load(body); const $ = cheerio.load(body);
@@ -330,7 +350,6 @@ class ProstorCrawler {
furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id; furnishingType = FURNISHING_TYPE.NOT_FURNISHED.id;
} }
const adStatus = ProstorCrawler.getStatusId(status);
const title = property_name; const title = property_name;
const parsedPrice = parseFloat(price.replace(/\./g, "")) || null; const parsedPrice = parseFloat(price.replace(/\./g, "")) || null;
const parsedArea = parseFloat(size); const parsedArea = parseFloat(size);
@@ -408,13 +427,15 @@ class ProstorCrawler {
} }
} }
async extractRealEstates(url) { async extractRealEstates(url, prostorCookie) {
if (PRINT_CRAWLER_DEBUG) { if (PRINT_CRAWLER_DEBUG) {
console.log("[PROSTOR] Index page : ", url); console.log("[PROSTOR] Index page : ", url);
} }
try { try {
const res = await fetch(url); const res = await fetch(url, {
headers: { Cookie: prostorCookie }
});
const body = await res.text(); const body = await res.text();
const $ = cheerio.load(body); const $ = cheerio.load(body);
@@ -548,6 +569,8 @@ class ProstorCrawler {
return AD_STATUS.STATUS_SOLD; return AD_STATUS.STATUS_SOLD;
case "Iznajmljeno": case "Iznajmljeno":
return AD_STATUS.STATUS_RENTED; return AD_STATUS.STATUS_RENTED;
case "VIP ponuda":
return AD_STATUS.STATUS_VIP;
default: default:
console.log("[PROSTOR] Unknown AD_STATUS : [", statusText, "]"); console.log("[PROSTOR] Unknown AD_STATUS : [", statusText, "]");
return AD_STATUS.STATUS_NORMAL; return AD_STATUS.STATUS_NORMAL;
@@ -569,6 +592,51 @@ class ProstorCrawler {
return savers[0].save(results); return savers[0].save(results);
//so that we can use some sequelize options and information when data is inserted //so that we can use some sequelize options and information when data is inserted
} }
async loginForScraping(PROSTOR_LOGIN, prostorCookie) {
let formData = new FormData();
formData.append("email", PROSTOR_LOGIN.EMAIL);
formData.append("password", PROSTOR_LOGIN.PASSWORD);
return fetch("https://prostor.ba/moj-prostor/prijava", {
method: "POST",
body: formData,
headers: { Cookie: prostorCookie }
})
.then(page => {
return page.text();
})
.then(resp => {
const $ = cheerio.load(resp);
if (
$("h1")
.text()
.indexOf("Dobrodošli") !== -1
) {
console.log("[PROSTOR]: Crawler loged in!");
return true;
} else {
console.log("[PROSTOR]: Crawler login failed - wrong credentials!");
return false;
}
})
.catch(err => {
console.log("[PROSTOR]: Crawler login error ", err);
});
}
async getCookies() {
const getResponse = await fetch("https://prostor.ba/moj-prostor/prijava", {
headers: { Cookie: "" }
});
const raw = getResponse.headers.raw()["set-cookie"];
const cookie = raw
.map(datastring => {
const data = datastring.split(";");
const cookieData = data[0];
return cookieData;
})
.join(";");
return cookie;
}
} }
module.exports = ProstorCrawler; module.exports = ProstorCrawler;

View File

@@ -0,0 +1,20 @@
"use strict";
const db = require("../../models/index");
const sequelize = require("sequelize");
const bulkUpsertPriceHistory = async priceHistoryData => {
try {
const order = [["realEstateId", "desc"]];
return await db.PriceHistory.bulkCreate(priceHistoryData, {
order,
ignoreDuplicates: true
});
} catch (e) {
console.log("Error bulk upserting priceHistory : ", e);
}
};
module.exports = {
bulkUpsertPriceHistory
};

View File

@@ -2,6 +2,8 @@
const db = require("../../models/index"); const db = require("../../models/index");
const sequelize = require("sequelize"); const sequelize = require("sequelize");
const Op = sequelize.Op; const Op = sequelize.Op;
const { AD_CATEGORY } = require("../../common/enums");
const bulkUpsertRealEstates = async realEstateData => { const bulkUpsertRealEstates = async realEstateData => {
try { try {
const fieldsToUpdateIfDuplicate = [ const fieldsToUpdateIfDuplicate = [
@@ -96,12 +98,16 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
floorMin, floorMin,
floorMax, floorMax,
includeIncompleteAds, includeIncompleteAds,
includeWithoutPrice,
balcony, balcony,
elevator, elevator,
newBuilding, newBuilding,
accessRoadType accessRoadType
} = searchRequest; } = searchRequest;
//Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType];
const longitudeColumn = sequelize.col("locationLong"); const longitudeColumn = sequelize.col("locationLong");
const latitudeColumn = sequelize.col("locationLat"); const latitudeColumn = sequelize.col("locationLat");
@@ -134,15 +140,6 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const query = { const query = {
adType, adType,
realEstateType, realEstateType,
price: {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
},
area: { area: {
[Op.lte]: sizeMax, [Op.lte]: sizeMax,
[Op.gte]: sizeMin [Op.gte]: sizeMin
@@ -154,15 +151,6 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
const queryIncludeIncomplete = { const queryIncludeIncomplete = {
adType, adType,
realEstateType, realEstateType,
price: {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
},
area: { area: {
[Op.or]: { [Op.or]: {
[Op.and]: { [Op.and]: {
@@ -175,8 +163,49 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
[Op.and]: geoSearchQueryPart [Op.and]: geoSearchQueryPart
}; };
//Every other attribute is checked separately and included in query only if it is defined //Is user unchecked includeWithoutPrice FALSE then it shouldn't return null values of price
if (gardenSizeMax && gardenSizeMin) { //If not then null values are accepted (this is DEFAULT)
//includeIncpompleteAds does not have effect on price query
if (includeWithoutPrice) {
query.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
queryIncludeIncomplete.price = {
[Op.or]: {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
},
[Op.is]: null
}
};
} else {
query.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
queryIncludeIncomplete.price = {
[Op.and]: {
[Op.lte]: priceMax,
[Op.gte]: priceMin
}
};
}
//Every other attribute is checked separately and included in query only if it is defined for real estate type
if (
realEstateTypeObject.hasGardenSize &&
gardenSizeMax != null &&
gardenSizeMin != null
) {
query.gardenSize = { query.gardenSize = {
[Op.lte]: gardenSizeMax, [Op.lte]: gardenSizeMax,
[Op.gte]: gardenSizeMin [Op.gte]: gardenSizeMin
@@ -192,7 +221,11 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
}; };
} }
if (numberOfRoomsMin && numberOfRoomsMax) { if (
realEstateTypeObject.hasNumberOfRoom &&
numberOfRoomsMin != null &&
numberOfRoomsMax != null
) {
query.numberOfRooms = { query.numberOfRooms = {
[Op.lte]: numberOfRoomsMax, [Op.lte]: numberOfRoomsMax,
[Op.gte]: numberOfRoomsMin [Op.gte]: numberOfRoomsMin
@@ -208,7 +241,11 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
}; };
} }
if (numberOfFloorsMin && numberOfFloorsMax) { if (
realEstateTypeObject.hasNumberOfFloors &&
numberOfFloorsMin != null &&
numberOfFloorsMax != null
) {
query.numberOfFloors = { query.numberOfFloors = {
[Op.lte]: numberOfFloorsMax, [Op.lte]: numberOfFloorsMax,
[Op.gte]: numberOfFloorsMin [Op.gte]: numberOfFloorsMin
@@ -224,7 +261,11 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
}; };
} }
if (floorMin && floorMax) { if (
realEstateTypeObject.hasFloorProp &&
floorMin != null &&
floorMax != null
) {
query.floor = { query.floor = {
[Op.lte]: floorMax, [Op.lte]: floorMax,
[Op.gte]: floorMin [Op.gte]: floorMin
@@ -239,8 +280,10 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
} }
}; };
} }
//Logic for balcony, newBuilding and elevator from users side
if (balcony) { //If true is checked, then I want characteristic to be true but,
//if it is not checked, then I dont care - it can be null or false or true
if (realEstateTypeObject.hasBalconyProp && balcony === true) {
query.balcony = { query.balcony = {
[Op.eq]: balcony [Op.eq]: balcony
}; };
@@ -252,7 +295,7 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
}; };
} }
if (newBuilding) { if (realEstateTypeObject.hasNewBuildingProp && newBuilding === true) {
query.newBuilding = { query.newBuilding = {
[Op.eq]: newBuilding [Op.eq]: newBuilding
}; };
@@ -264,7 +307,7 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
}; };
} }
if (elevator) { if (realEstateTypeObject.hasElevatorProp && elevator === true) {
query.elevator = { query.elevator = {
[Op.eq]: elevator [Op.eq]: elevator
}; };
@@ -275,7 +318,8 @@ const findRealEstatesForSearchRequest = async (searchRequest, maxResults) => {
} }
}; };
} }
//If user wants 'ANY' road type acces then it is not included in query -
//returns every road type and null values
if (accessRoadType !== "ANY") { if (accessRoadType !== "ANY") {
query.accessRoadType = { query.accessRoadType = {
[Op.eq]: accessRoadType [Op.eq]: accessRoadType

View File

@@ -49,128 +49,390 @@ const findSearchRequestsForRealEstate = async realEstate => {
const geoSearchQueryPart = sequelize.where(contains, true); const geoSearchQueryPart = sequelize.where(contains, true);
//General query contains only attributes that are defined for every RealEstate - not null
const query = {
adType,
realEstateType,
subscribed: true,
[Op.and]: geoSearchQueryPart
};
//Needed for defining which attribute should exist or not //Needed for defining which attribute should exist or not
const realEstateTypeObject = AD_CATEGORY[realEstateType]; const realEstateTypeObject = AD_CATEGORY[realEstateType];
//Needed to decide on including incomplete RealEstates data
// ?? Needed to decide on including incomplete RealEstates data
let checkForIncompleteWanted = false; let checkForIncompleteWanted = false;
//Attributes are checked separately and included in query only if defined //Attributes are checked separately to make different query parts
//Price and area should be defined for every property
if (price) { //If real estate price is number then it searches for req that have priceMin and priceMax
query.priceMin = { //If real estate price is null it searches for req that accept ads without price
[Op.lte]: price //User always defines price and area (sliders) - not null in search req
let priceQuery = {};
if (price != null) {
priceQuery = {
[Op.and]: [
{
priceMin: {
[Op.lte]: price
}
},
{
priceMax: {
[Op.gte]: price
}
}
]
}; };
query.priceMax = { } else {
[Op.gte]: price priceQuery = {
includeWithoutPrice: {
[Op.eq]: true
}
}; };
} }
if (area) { let areaQuery = {};
query.sizeMin = { if (area != null) {
[Op.lte]: area areaQuery = {
}; [Op.and]: [
query.sizeMax = { {
[Op.gte]: area sizeMin: {
[Op.lte]: area
}
},
{
sizeMax: {
[Op.gte]: area
}
}
]
}; };
} else { } else {
checkForIncompleteWanted = true; checkForIncompleteWanted = true;
} }
//Other attributes can be defined or not depending on RealEstate type //Other attributes can be defined or not depending on RealEstate type
if (gardenSize) { //we check what to include in query based on real estate type object
query.gardenSizeMin = { let gardenSizeQuery = {};
[Op.lte]: gardenSize if (realEstateTypeObject.hasGardenSize) {
}; if (gardenSize != null) {
query.gardenSizeMax = { gardenSizeQuery = {
[Op.gte]: gardenSize [Op.and]: [
}; {
} else if (realEstateTypeObject.hasGardenSize) { gardenSizeMin: {
checkForIncompleteWanted = true; [Op.lte]: gardenSize
}
},
{
gardenSizeMax: {
[Op.gte]: gardenSize
}
}
]
};
} else {
checkForIncompleteWanted = true;
}
} }
if (numberOfRooms) { let numberOfRoomsQuery = {};
query.numberOfRoomsMin = { if (realEstateTypeObject.hasNumberOfRoom) {
[Op.lte]: numberOfRooms if (numberOfRooms != null) {
}; //If real estate has defined number of rooms ex. 3 it returns req
query.numberOfRoomsMax = { // that accepts 3 rooms or ones that don't have defined number - null
[Op.gte]: numberOfRooms //Ex. they didnt choose advanced filters at all
}; numberOfRoomsQuery = {
} else if (realEstateTypeObject.hasNumberOfRoom) { [Op.and]: [
checkForIncompleteWanted = true; {
numberOfRoomsMin: {
[Op.or]: {
[Op.lte]: numberOfRooms,
[Op.is]: null
}
}
},
{
numberOfRoomsMax: {
[Op.or]: {
[Op.gte]: numberOfRooms,
[Op.is]: null
}
}
}
]
};
} else {
// If real estate dont have defined number of rooms ex. null
//It returns requests that didn't choose number of rooms - also null
//Or ones that picked some values but also picked to includeIncomplete ads
numberOfRoomsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfRoomsMin: {
[Op.is]: null
}
},
{
numberOfRoomsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
}
//Same logic for number of Floors and floors
let numberOfFloorsQuery = {};
if (realEstateTypeObject.hasNumberOfFloors) {
if (numberOfFloors != null) {
numberOfFloorsQuery = {
[Op.and]: [
{
numberOfFloorsMin: {
[Op.or]: {
[Op.lte]: numberOfFloors,
[Op.is]: null
}
}
},
{
numberOfFloorsMax: {
[Op.or]: {
[Op.gte]: numberOfFloors,
[Op.is]: null
}
}
}
]
};
} else {
numberOfFloorsQuery = {
[Op.or]: [
{
[Op.and]: [
{
numberOfFloorsMin: {
[Op.is]: null
}
},
{
numberOfFloorsMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
}
let floorQuery = {};
if (realEstateTypeObject.hasFloorProp) {
if (floor != null) {
floorQuery = {
[Op.and]: [
{
floorMin: {
[Op.or]: {
[Op.lte]: floor,
[Op.is]: null
}
}
},
{
floorMax: {
[Op.or]: {
[Op.gte]: floor,
[Op.is]: null
}
}
}
]
};
} else {
floorQuery = {
[Op.or]: [
{
[Op.and]: [
{
floorMin: {
[Op.is]: null
}
},
{
floorMax: {
[Op.is]: null
}
}
]
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
};
}
} }
if (numberOfFloors) { //Logic for balcony, newBuilding and elevator
query.numberOfFloorsMin = { //If user dont check checkbox for ex. elevator it does not mean he only wants no elevator
[Op.lte]: numberOfFloors //If real estate characteristic =true find all req, one that wants charachertistic or dont care - dont need query
}; //If real estate characteristic = false, find all req exept for ones that wants characteristic to be true
query.numberOfFloorsMax = { //If real estate characteristic = null, dont know if true or false, find req that dont care or want char and want incomplete ads
[Op.gte]: numberOfFloors let balconyQuery = {};
}; if (realEstateTypeObject.hasBalconyProp && balcony !== true) {
} else if (realEstateTypeObject.hasNumberOfFloors) { if (balcony === false) {
checkForIncompleteWanted = true; balconyQuery = {
balcony: {
[Op.ne]: true
}
};
} else if (balcony === null) {
balconyQuery = {
[Op.or]: [
{
balcony: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
balcony: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
} }
let newBuildingQuery = {};
if (floor) { if (realEstateTypeObject.hasNewBuildingProp && newBuilding !== true) {
query.floorMin = { if (newBuilding === false) {
[Op.lte]: floor newBuildingQuery = {
}; newBuilding: {
query.floorMax = { [Op.ne]: true
[Op.gte]: floor }
}; };
} else if (realEstateTypeObject.hasFloorProp) { } else if (newBuilding === null) {
checkForIncompleteWanted = true; newBuildingQuery = {
[Op.or]: [
{
newBuilding: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
newBuilding: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
} }
let elevatorQuery = {};
if (realEstateTypeObject.hasElevatorProp && elevator !== true) {
if (elevator === false) {
elevatorQuery = {
elevator: {
[Op.ne]: true
}
};
} else if (elevator === null) {
elevatorQuery = {
[Op.or]: [
{
elevator: {
[Op.ne]: true
}
},
{
[Op.and]: [
{
elevator: {
[Op.eq]: true
}
},
{
includeIncompleteAds: {
[Op.eq]: true
}
}
]
}
]
};
}
}
//General query consists of each individual query
const query = {
adType,
realEstateType,
subscribed: true,
[Op.and]: [
geoSearchQueryPart,
priceQuery,
areaQuery,
gardenSizeQuery,
numberOfRoomsQuery,
numberOfFloorsQuery,
floorQuery,
balconyQuery,
newBuildingQuery,
elevatorQuery
]
};
if (accessRoadType) { //AccessRoadType is defined - should exists for each ad and estate type
if (accessRoadType != null) {
query.accessRoadType = { query.accessRoadType = {
[Op.or]: { [Op.or]: {
[Op.eq]: "ANY", [Op.like]: "ANY",
[Op.eq]: accessRoadType [Op.eq]: accessRoadType
} }
}; };
} else if (realEstateTypeObject.hasAccesRoadType) { } else {
checkForIncompleteWanted = true; //Null values are returned for user request that wanted ANY acces road type
} query.accessRoadType = {
[Op.eq]: "ANY"
if (balcony) {
query.balcony = {
[Op.eq]: balcony
}; };
} else if (realEstateTypeObject.hasBalconyProp) {
checkForIncompleteWanted = true;
} }
//Tag to check if incomplete ads are accepted in query
if (newBuilding) {
query.newBuilding = {
[Op.eq]: newBuilding
};
} else if (realEstateTypeObject.hasNewBuildingProp) {
checkForIncompleteWanted = true;
}
if (elevator) {
query.elevator = {
[Op.eq]: elevator
};
} else if (realEstateTypeObject.hasElevatorProp) {
checkForIncompleteWanted = true;
}
//If one of the attributes that exists for property type is null
//we include in query to check if incomplete real estates are accepted
if (checkForIncompleteWanted) { if (checkForIncompleteWanted) {
query.includeIncompleteAds = { query.includeIncompleteAds = {
[Op.eq]: true [Op.eq]: true
}; };
} }
return await db.SearchRequest.findAll({ where: query });
return await db.SearchRequest.findAll({
where: query
});
}; };
module.exports = { module.exports = {

View File

@@ -1,10 +1,11 @@
"use strict"; "use strict";
const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig"); const { MAX_REAL_ESTATES_IN_EMAIL, APP_URL } = require("../config/appConfig");
const { AD_CATEGORY } = require("../common/enums"); const { AD_CATEGORY, AD_TYPE, EMAIL_FREQUENCY } = require("../common/enums");
const generateEmailFooter = searchRequestId => { const generateEmailFooter = (searchRequestId, emailFrequencyTitle) => {
return `<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div> return ` <div>Trenutno ste prijavljeni da obavještenja o novim nekretninama primate <strong>${emailFrequencyTitle.toLowerCase()} </strong>.</div>
<div>Ako želite prestati dobijati obavještenja za ovu pretragu, <a href="${APP_URL}/odjava/${searchRequestId}">odjavite ovdje</a></div>
<div>Ako želite pogledati ili promijeniti uslove za ovu pretragu, <a href="${APP_URL}/pregled/${searchRequestId}">pogledajte ovdje</a></div> <div>Ako želite pogledati ili promijeniti uslove za ovu pretragu, <a href="${APP_URL}/pregled/${searchRequestId}">pogledajte ovdje</a></div>
<br/> <br/>
<strong>Vaš,<br/>Kivi tim</strong>`; <strong>Vaš,<br/>Kivi tim</strong>`;
@@ -23,17 +24,24 @@ const generateRealEstateLinks = realEstates => {
const generateNotificationEmail = ( const generateNotificationEmail = (
realEstates, realEstates,
searchRequestId, searchRequestId,
noAllRealEstates,
dailyNotification = false dailyNotification = false
) => { ) => {
const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL; const truncateList = realEstates.length > MAX_REAL_ESTATES_IN_EMAIL;
const realEstatesToShow = truncateList const realEstatesToShow = truncateList
? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL) ? realEstates.slice(0, MAX_REAL_ESTATES_IN_EMAIL)
: realEstates; : realEstates;
const allRealEstatesLink = `${APP_URL}/nekretnine/${searchRequestId}`; const allRealEstatesLink = `${APP_URL}/nekretnine/${searchRequestId}`;
const emailFrequencyTitle = dailyNotification
? EMAIL_FREQUENCY.DAILY.title
: EMAIL_FREQUENCY.ASAP.title;
const realEstateLinks = generateRealEstateLinks(realEstatesToShow); const realEstateLinks = generateRealEstateLinks(realEstatesToShow);
const moreRealEstates = `<div>Kompletan spisak nekretnina možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`; const moreRealEstates = `<div>Kompletan spisak nekretnina (${noAllRealEstates}) možete pogledati na <a href="${allRealEstatesLink}">listi nekretnina</a><div>`;
const emailFooter = generateEmailFooter(searchRequestId); const emailFooter = generateEmailFooter(searchRequestId, emailFrequencyTitle);
const asapMessageBody = const asapMessageBody =
realEstates.length > 1 realEstates.length > 1
? "Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi" ? "Pronašli smo nekretnine koje odgovaraju Vašoj pretrazi"
@@ -59,6 +67,28 @@ const generateNotificationEmail = (
const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => { const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
const realEstateType = AD_CATEGORY[searchRequest.realEstateType]; const realEstateType = AD_CATEGORY[searchRequest.realEstateType];
let adTypeTitle = "";
switch (searchRequest.adType) {
case AD_TYPE.AD_TYPE_SALE.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_SALE.title;
break;
case AD_TYPE.AD_TYPE_RENT.stringId:
adTypeTitle = AD_TYPE.AD_TYPE_RENT.title;
break;
default:
adTypeTitle = "-";
break;
}
let emailFrequencyTitle;
switch (searchRequest.emailFrequency) {
case EMAIL_FREQUENCY.ASAP.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.ASAP.title;
break;
case EMAIL_FREQUENCY.DAILY.stringId:
emailFrequencyTitle = EMAIL_FREQUENCY.DAILY.title;
break;
}
const { const {
id, id,
gardenSizeMin, gardenSizeMin,
@@ -70,6 +100,7 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
} = searchRequest; } = searchRequest;
const realEstateLinks = generateRealEstateLinks(matchingRealEstates); const realEstateLinks = generateRealEstateLinks(matchingRealEstates);
const instantRealEstatesText = `<br/> const instantRealEstatesText = `<br/>
<div> <div>
U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/> U međuvremenu pogledajte neke od nedavno objavljenih nekretnina koje odgovaraju Vašim uslovima pretrage :<br/>
@@ -80,13 +111,14 @@ const generateNewSearchRequestEmail = (searchRequest, matchingRealEstates) => {
? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>` ? `<div><strong>Kvadratura okućnice: Od ${gardenSizeMin} do ${gardenSizeMax} m2</strong></div>`
: ``; : ``;
const emailFooter = generateEmailFooter(id); const emailFooter = generateEmailFooter(id, emailFrequencyTitle);
return `<h3>Zdravo</h3> return `<h3>Zdravo</h3>
<div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</div> <div>Naručili ste da Vam javimo ako se nekretnina sa navedenim uslovima pojavi u oglasima:</div>
<br/> <br/>
<div> <div>
<div><strong>Tip nekretnine: </strong>${realEstateType.title}</div> <div><strong>Tip nekretnine: </strong>${realEstateType.title}</div>
<div><strong>Vrsta oglasa: </strong>${adTypeTitle}</div>
<div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div> <div><strong>Kvadratura nekretnine:</strong> Od ${sizeMin} do ${sizeMax} m2</div>
${gardenSize} ${gardenSize}
<div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div> <div><strong>Cijena:</strong> ${priceMin} do ${priceMax} KM</div>

View File

@@ -0,0 +1,42 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
const tableFields = {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
allowNull: false,
primaryKey: true
},
realEstateId: {
type: Sequelize.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: Sequelize.REAL,
unique: "uniquePriceRealEstate"
},
createdAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
},
updatedAt: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal("NOW()")
}
};
return queryInterface.createTable("PriceHistory", tableFields);
},
down: queryInterface => {
return queryInterface.dropTable("PriceHistory", {});
}
};

View File

@@ -0,0 +1,10 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) =>
queryInterface.addConstraint("PriceHistory", ["realEstateId", "price"], {
type: "unique",
name: "uniquePriceRealEstate"
}),
down: queryInterface =>
queryInterface.removeConstraint("PriceHistory", "uniquePriceRealEstate")
};

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.addColumn("SearchRequests", "includeWithoutPrice", {
type: Sequelize.BOOLEAN,
defaultValue: true
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.removeColumn("SearchRequests", "includeWithoutPrice");
}
};

View File

@@ -0,0 +1,44 @@
"use strict";
module.exports = (sequalize, DataTypes) => {
const PriceHistory = sequalize.define(
"PriceHistory",
{
id: {
type: DataTypes.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
realEstateId: {
type: DataTypes.BIGINT,
allowNull: false,
unique: "uniquePriceRealEstate",
references: {
model: "RealEstates",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "SET NULL"
},
price: {
type: DataTypes.REAL,
unique: "uniquePriceRealEstate"
}
},
{
freezeTableName: true
}
);
PriceHistory.associate = models => {
PriceHistory.hasMany(models.RealEstate, {
foreignKey: "id",
sourceKey: "realEstateId",
targetKey: "id",
as: "realEstates"
});
};
return PriceHistory;
};

View File

@@ -15,7 +15,15 @@ module.exports = (sequelize, DataTypes) => {
allowNull: false, allowNull: false,
defaultValue: { defaultValue: {
type: "Polygon", type: "Polygon",
coordinates: [[[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]], coordinates: [
[
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
]
],
crs: { type: "name", properties: { name: "EPSG:4326" } } crs: { type: "name", properties: { name: "EPSG:4326" } }
} }
}, },
@@ -71,6 +79,7 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.TEXT type: DataTypes.TEXT
}, },
includeIncompleteAds: DataTypes.BOOLEAN, includeIncompleteAds: DataTypes.BOOLEAN,
includeWithoutPrice: DataTypes.BOOLEAN,
balcony: DataTypes.BOOLEAN, balcony: DataTypes.BOOLEAN,
elevator: DataTypes.BOOLEAN, elevator: DataTypes.BOOLEAN,
newBuilding: DataTypes.BOOLEAN, newBuilding: DataTypes.BOOLEAN,

View File

@@ -154,3 +154,7 @@ h3 {
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.estates-link {
color: rgba(0, 0, 0, 0.87);
}

View File

@@ -0,0 +1,34 @@
"use strict";
const { RealEstate } = require("../models");
module.exports = {
async up(queryInterface, Sequelize) {
//Reading initial data from RealEstate table in db
const realEstateInitialData = await RealEstate.findAll();
//Extruding data for table PriceHistory
const priceHistoryInitialData = realEstateInitialData.map(realEstate => {
//Null values canot be recognized by ignore duplicates in sequalize
//Value price = 0 indicates 'cijena na upit'
const priceTmp =
realEstate.dataValues.price === null ? 0 : realEstate.dataValues.price;
return {
realEstateId: realEstate.dataValues.id,
price: priceTmp,
createdAt: realEstate.dataValues.createdAt,
updatedAt: realEstate.dataValues.updatedAt
};
});
return queryInterface.bulkInsert(
"PriceHistory",
priceHistoryInitialData,
{}
);
},
async down(queryInterface, Sequelize) {
return queryInterface.bulkDelete("PriceHistory", null, {});
}
};

View File

@@ -11,7 +11,8 @@ const {
} = require("../helpers/emailContentGenerator"); } = require("../helpers/emailContentGenerator");
const { const {
findNotNotifiedMatches, findNotNotifiedMatches,
findAllRequestsForCheckUp findAllRequestsForCheckUp,
findRealEstatesForSearchRequest
} = require("../helpers/db/searchRequestMatch"); } = require("../helpers/db/searchRequestMatch");
const { sendEmail } = require("../services/emailService"); const { sendEmail } = require("../services/emailService");
@@ -43,10 +44,18 @@ const notifyMatches = async (matches, dailyNotification = false) => {
const { email, subscribed } = searchRequest; const { email, subscribed } = searchRequest;
if (notifyNow && subscribed) { if (notifyNow && subscribed) {
const allMatchingRealEstates = matches[id].realEstates || []; const allMatchingRealEstates = matches[id].realEstates || [];
//Variable allMatchingRealEstates are real estates that are "new" on the market
//the ones that we notify user in this moment, not all that already exists in db
//New variable allRealEstates are all real estates that exists in db for search req
const allRealEstates = await findRealEstatesForSearchRequest(id);
const noAllRealEstates = allRealEstates.length;
if (allMatchingRealEstates.length > 0) { if (allMatchingRealEstates.length > 0) {
const emailContent = generateNotificationEmail( const emailContent = generateNotificationEmail(
allMatchingRealEstates, allMatchingRealEstates,
id, id,
noAllRealEstates,
dailyNotification dailyNotification
); );
const emailSubject = generateEmailSubject( const emailSubject = generateEmailSubject(

View File

@@ -1,13 +1,29 @@
<div class="row center-align"> <div class="row center-align">
<ul class="collection with-header"> <ul class="collection with-header">
<% for(const realEstate of realEstates) { %> <% for(const realEstate of realEstates) { %>
<li class="collection-item"> <li class="collection-item">
<div><%= realEstate.title %> <% if(realEstate.adStatus === AD_STATUS.STATUS_VIP) {%>
<a href="<%= realEstate.url %>" class="kivi-color secondary-content"> <div>
<% //This needs to do redirecting instead of direct link to realestate
%>
<a href="/redirect/<%= realEstate.id %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i> <i class="material-icons">send</i>
</a> </div>
</div> </a>
</li> </div>
<% } %> <%} else { %>
</ul> <div>
</div> <a href="<%= realEstate.url %>" class="estates-link">
<%= realEstate.title %>
<div class="kivi-color secondary-content">
<i class="material-icons">send</i>
</div>
</a>
</div>
<% }%>
</li>
<% } %>
</ul>
</div>

View File

@@ -1,26 +1,49 @@
<br><br> <br /><br />
<div class="center"> <div class="center">
<div class="preloader-wrapper big active center"> <div class="preloader-wrapper big active center">
<div class="kivi-spinner-color spinner-layer spinner-green-only"> <div class="kivi-spinner-color spinner-layer spinner-green-only">
<div class="circle-clipper left"> <div class="circle-clipper left">
<div class="circle"></div> <div class="circle"></div>
</div><div class="gap-patch"> </div>
<div class="circle"></div> <div class="gap-patch">
</div><div class="circle-clipper right"> <div class="circle"></div>
<div class="circle"></div> </div>
</div> <div class="circle-clipper right">
</div> <div class="circle"></div>
</div>
</div> </div>
</div>
</div> </div>
<br> <br />
<% if(vipAd) { %>
<div class="center"> <div class="center">
<h6> <h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl">Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a> Ovaj oglas zahtijeva da budete član
</h6> <a href="https://prostor.ba/" rel="noreferrer">Prostor.ba</a>.
<br />
<br />
<a href="https://prostor.ba/moj-prostor/prijava" rel="noreferrer"
>Ulogujte se</a
>
ili napravite
<a href="https://prostor.ba/moj-prostor/registracija" rel="noreferrer"
>novi račun</a
>, a potom otvorite <a href="<%= redirectUrl %>" rel="noreferrer">oglas</a>.
</h6>
</div> </div>
<% } else { %>
<div class="center">
<h6>
<a href="<%= redirectUrl %>" rel="noreferrer" id="realEstateUrl"
>Kliknite ovdje ako Vas web preglednik ne preusmjeri automatski</a
>
</h6>
</div>
<% }%>
<script> <script>
window.onload = function() { window.onload = function() {
document.getElementById('realEstateUrl').click(); document.getElementById("realEstateUrl").click();
} };
</script> </script>

View File

@@ -18,6 +18,15 @@
</div> </div>
</div> </div>
<br />
<p class="distinguished">
<label class="checkbox-label">
<input type="checkbox" class="filled-in" name="includeWithoutPrice"
checked
>
<span>Uključi i oglase bez cijene</span>
</label>
</p>
<br /> <br />
<div class="row center-align"> <div class="row center-align">

View File

@@ -52,6 +52,8 @@ PROSTOR_CRAWLER_AD_CATEGORIES=comma separated list of enum names of categories t
PROSTOR_IGNORED_USERNAMES=!!! This is not used for prostor crawler !!! PROSTOR_IGNORED_USERNAMES=!!! This is not used for prostor crawler !!!
PROSTOR_DELAY_BETWEEN_PAGES=!!! This is not used for prostor crawler !!! PROSTOR_DELAY_BETWEEN_PAGES=!!! This is not used for prostor crawler !!!
PROSTOR_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found PROSTOR_FORCE_CRAWL=Non-zero value will force crawler to crawl all pages without stopping when known real estate is found
PROSTOR_LOGIN_EMAIL=Email of valid Prostor.ba account for crawling purposes
PROSTOR_LOGIN_PASS=Password of valid Prostor.ba account for crawling purposes
#==AKTIDO== #==AKTIDO==
AKTIDO_MAX_PAGES=Restrict crawler to this number of pages AKTIDO_MAX_PAGES=Restrict crawler to this number of pages
AKTIDO_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved AKTIDO_MAX_RESULTS_PER_PAGE=Only this number or less results from one page will be scraped and saved

30
package-lock.json generated
View File

@@ -1346,13 +1346,23 @@
"integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
}, },
"form-data": { "form-data": {
"version": "2.3.3", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"requires": { "requires": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.6", "combined-stream": "^1.0.8",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
},
"dependencies": {
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
}
} }
}, },
"forwarded": { "forwarded": {
@@ -3430,6 +3440,18 @@
"tough-cookie": "~2.4.3", "tough-cookie": "~2.4.3",
"tunnel-agent": "^0.6.0", "tunnel-agent": "^0.6.0",
"uuid": "^3.3.2" "uuid": "^3.3.2"
},
"dependencies": {
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
}
} }
}, },
"require-directory": { "require-directory": {

View File

@@ -8,6 +8,7 @@
"start": "node ./index.js", "start": "node ./index.js",
"start-mon": "nodemon ./index.js", "start-mon": "nodemon ./index.js",
"migrate": "cd app && npx sequelize db:migrate", "migrate": "cd app && npx sequelize db:migrate",
"seed": "cd app && npx sequelize db:seed:all",
"setup": "docker build -t marketalerts . && docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts && sleep 10 && npm run migrate", "setup": "docker build -t marketalerts . && docker run -e POSTGRES_USER=docker -e POSTGRES_PASSWORD=docker -e POSTGRES_DB=marketalerts --name pg_marketalerts -d -p 5432:5432 marketalerts && sleep 10 && npm run migrate",
"docker-start": "docker start pg_marketalerts", "docker-start": "docker start pg_marketalerts",
"docker-stop": "docker stop pg_marketalerts", "docker-stop": "docker stop pg_marketalerts",
@@ -40,6 +41,7 @@
"express": "^4.16.4", "express": "^4.16.4",
"express-ejs-layouts": "^2.5.0", "express-ejs-layouts": "^2.5.0",
"express-layout": "^0.1.0", "express-layout": "^0.1.0",
"form-data": "^3.0.0",
"html-to-text": "^5.1.1", "html-to-text": "^5.1.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-timezone": "^0.5.26", "moment-timezone": "^0.5.26",