Compare commits

...

10 commits

17 changed files with 336 additions and 123 deletions

View file

@ -17,6 +17,22 @@ install pg MacOS -
Start Postgres -
brew services start postgresql
Postgres CLI -
psql postgres
init POSTGRES Database -
CREATE DATABASE drop_shopping;
create tables -
CREATE TABLE deliveries (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
slot_id INTEGER NOT NULL,
delivery_date DATE NOT NULL,
address TEXT NOT NULL,
status VARCHAR(10) NOT NULL
);
knex for migration files

View file

@ -1 +0,0 @@
{"holidays":["2023-05-01","2023-06-12","2023-09-25"]}

View file

@ -1 +0,0 @@
{"timeSlots":["08:00 - 10:00","10:00 - 12:00","12:00 - 14:00","14:00 - 16:00","16:00 - 18:00","18:00 - 20:00"]}

40
package-lock.json generated
View file

@ -13,14 +13,16 @@
"@types/express": "^4.17.17",
"axios": "^1.3.6",
"body-parser": "^1.20.2",
"date-and-time": "^3.0.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"knex": "^2.4.2",
"pg": "^8.10.0"
"knex": "^2.4.2"
},
"devDependencies": {
"@types/node": "^18.16.0",
"@types/pg": "^8.6.6",
"nodemon": "^2.0.22",
"pg": "^8.10.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.4"
}
@ -134,6 +136,17 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.0.tgz",
"integrity": "sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ=="
},
"node_modules/@types/pg": {
"version": "8.6.6",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.6.tgz",
"integrity": "sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==",
"dev": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@ -313,6 +326,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"dev": true,
"engines": {
"node": ">=4"
}
@ -432,6 +446,11 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/date-and-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.0.0.tgz",
"integrity": "sha512-uuzXp/mvv6jEMLiP5QzERSQPzHqYnv9i8NZ8BS5kYeB2sakv74EewQiCS4Ahxwq3In+9fYZhGztuDHRVzIOkFQ=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -1195,7 +1214,8 @@
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==",
"dev": true
},
"node_modules/parseurl": {
"version": "1.3.3",
@ -1228,6 +1248,7 @@
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.10.0.tgz",
"integrity": "sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==",
"dev": true,
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
@ -1258,6 +1279,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"engines": {
"node": ">=4.0.0"
}
@ -1266,6 +1288,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz",
"integrity": "sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ==",
"dev": true,
"peerDependencies": {
"pg": ">=8.0"
}
@ -1273,12 +1296,14 @@
"node_modules/pg-protocol": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz",
"integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q=="
"integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==",
"dev": true
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
@ -1294,6 +1319,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"dependencies": {
"split2": "^4.1.0"
}
@ -1314,6 +1340,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"engines": {
"node": ">=4"
}
@ -1322,6 +1349,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -1330,6 +1358,7 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -1338,6 +1367,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"dependencies": {
"xtend": "^4.0.0"
},
@ -1600,6 +1630,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"engines": {
"node": ">= 10.x"
}
@ -1870,6 +1901,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"engines": {
"node": ">=0.4"
}

View file

@ -15,13 +15,15 @@
"@types/express": "^4.17.17",
"axios": "^1.3.6",
"body-parser": "^1.20.2",
"date-and-time": "^3.0.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"knex": "^2.4.2",
"pg": "^8.10.0"
"knex": "^2.4.2"
},
"devDependencies": {
"pg": "^8.10.0",
"@types/node": "^18.16.0",
"@types/pg": "^8.6.6",
"nodemon": "^2.0.22",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.4"

View file

@ -1,7 +1,17 @@
{
"holidays": [
"2023-05-01",
"2023-06-12",
"2023-09-25"
[
{
"name": "New Year's Day",
"date": "2023-01-17",
"country": "US"
},
{
"name": "Mother's Day",
"date": "2023-09-18",
"country": "US"
},
{
"name": "Independence Day",
"date": "2023-09-17",
"country": "GB"
}
]
}

View file

@ -1,10 +1,43 @@
{
"timeSlots": [
"08:00 - 10:00",
"10:00 - 12:00",
"12:00 - 14:00",
"14:00 - 16:00",
"16:00 - 18:00",
"18:00 - 20:00"
"courier_available_timeslots": [
{
"id": 1,
"start_time": "2023-09-17 08:00:00",
"end_time": "2023-09-17 09:00:00",
"supported_postcodes": [
"W1H 1LJ",
"2222222"
]
},
{
"id": 2,
"start_time": "2023-09-17 09:00:00",
"end_time": "2023-09-17 10:00:00",
"supported_postcodes": [
"1111111",
"2222222",
"3333333"
]
},
{
"id": 3,
"start_time": "2023-09-17 09:00:00",
"end_time": "2023-09-17 10:00:00",
"supported_postcodes": [
"4444444",
"5555555",
"6666666"
]
},
{
"id": 4,
"start_time": "2023-04-26 14:00:00",
"end_time": "2023-04-26 15:00:00",
"supported_postcodes": [
"W1H 1LJ",
"5555555",
"6666666"
]
}
]
}

View file

@ -13,12 +13,14 @@ export const resolveAddress = async (searchTerm: string): Promise<Address> => {
const response = await axios.get(`https://api.geoapify.com/v1/geocode/search?text=${searchTerm}&format=json&apiKey=${GEOCODING_API_KEY}`);
if (response.data.results.length > 0) {
const result = response.data.results[0];
console.log(result)
return {
country: result.country,
street: result.street,
line1: result.address_line1,
line2: result.address_line2,
postcode: result.postcode
postcode: result.postcode,
code: result.country_code.toUpperCase()
}
} else {
throw new Error('No results found');

View file

@ -1,37 +1,208 @@
import { Request, Response } from 'express';
import dateModule from 'date-and-time';
import { resolveAddress } from './geocoding';
import { Address } from './types';
// DOME
import { Address, AvailableTimeslots, Orders } from './types';
import { randomUUID } from 'crypto';
import { getAvailableTimeSlots } from './services/timeslotsService';
import { getHolidays } from './services/holidaysService';
const dateFormat = 'YYYY-MM-DD HH:MM:SS';
// create a hashing for caching delivery slots.
const deliveriesCache = new Map();
// create a hashing for caching timeslots.
const slotsInUse = new Map();
// orders by dates caching.
const ordersByDates = new Map();
export const resolveAddressHandler = (req: Request, res: Response) => {
console.info("resolveAddressHandler called");
if (!req.body.searchTerm) {
res.status(400).json({ error: 'Missing searchTerm' });
return;
}
const address: Promise<Address> = resolveAddress(req.body.searchTerm);
address.then((result) => {
console.info("resolveAddressHandler result: ", result);
res.status(200).json(result);
})
};
export const timeslotsHandler = (req: Request, res: Response) => {
// TODO: Implement timeslots functionality
};
export const deliveriesHandler = (req: Request, res: Response) => {
// TODO: Implement deliveries functionality
return;
})
};
export const cancelDeliveryHandler = (req: Request, res: Response) => {
export const timeslotsHandler = async (req: Request, res: Response): Promise<void> => {
if (!req.body.address) {
res.status(400).json({ error: 'Missing address' });
return;
}
const address: Address = req.body.address;
const timeSlots: AvailableTimeslots[] = await availableTimeSlots(address);
const availableTimeSlotsResult: AvailableTimeslots[] = await filterOutHolidaysByCountryCode(address, timeSlots);
availableTimeSlotsResult.forEach((timeSlot, index) => {
if (slotsInUse.has(timeSlot.id) && slotsInUse.get(timeSlot.id).length >= 2) {
availableTimeSlotsResult.splice(index, 1);
}
});
res.status(200).json(availableTimeSlotsResult);
return;
};
export const deliveriesHandler = (req: Request, res: Response): void => {
const userId = req.body.userId;
const slotId = req.body.timeslotId;
// Idea: validate userId and slotId needed
if (!userId || !slotId) {
res.status(400).json({ error: 'Missing userId or slotId' });
return;
}
// Idea: check if user has already booked a delivery - is this needed?
// if (deliveriesCache.has(userId) && deliveriesCache.get(userId).slotId === slotId) {
// res.status(400).json({ error: 'User has already booked a delivery' });
// return;
// }
// Idea: check if timeslot is already full in cache
if (slotsInUse.has(slotId) && slotsInUse.get(slotId).length >= 2) {
res.status(400).json({ error: 'This timeslot is already full' });
return;
}
const deliveryId = randomUUID();
// Idea: create new delivery for user
// the date needs to be in "2023-09-18 14:00:00" format
const deliveryCreatedDate = dateModule.format(new Date(), dateFormat);
const date = deliveryCreatedDate.split(' ')[0];
const delivery = {
_id: deliveryId,
userId,
slotId,
deliveryCreatedDate: deliveryCreatedDate
};
// INFO: This is a very simple implementation of caching.
// INFO: In my opinion, transaction should be used to avoid data inconsistency.
if (slotsInUse.has(slotId)) {
slotsInUse.get(slotId).push(deliveryId);
} else {
slotsInUse.set(slotId, [deliveryId]);
}
if (ordersByDates.has(date)) {
ordersByDates.get(date).push(deliveryId);
} else {
ordersByDates.set(date, [deliveryId]);
}
deliveriesCache.set(deliveryId, delivery);
console.log("ordersByDates", ordersByDates)
console.log("deliveriesCache", deliveriesCache)
console.log("slotsInUse", slotsInUse)
res.status(200).json(delivery);
return;
};
export const cancelDeliveryHandler = (req: Request, res: Response): void => {
// TODO: Implement cancel delivery functionality
// DELETE /deliveries/:deliveryId
const deliveryId = req.params.deliveryId;
if (!deliveryId) {
res.status(400).json({ error: 'Missing deliveryId' });
return;
}
// remove from deliveriesCache and extract slotId
const delivery = deliveriesCache.get(deliveryId);
if (!delivery) {
res.status(400).json({ error: 'Delivery not found' });
return;
}
const slotId = delivery.slotId;
deliveriesCache.delete(deliveryId);
// remove from slotsInUse by slotId
const slot = slotsInUse.get(slotId);
const index = slot.indexOf(deliveryId);
if (index > -1) {
slot.splice(index, 1);
}
res.status(200).json(delivery);
return;
};
export const dailyDeliveriesHandler = (req: Request, res: Response) => {
export const dailyDeliveriesHandler = (req: Request, res: Response): void => {
// TODO: Implement daily deliveries functionality
// GET /deliveries/daily - retrieve all todays deliveries - by today in ordersByDates
const date = dateModule.format(new Date(), dateFormat).split(' ')[0];
const todaysOrders: Orders[] = [];
if (ordersByDates.has(date)) {
ordersByDates.get(date).forEach((orderId) => {
todaysOrders.push(deliveriesCache.get(orderId));
});
}
res.status(200).json(todaysOrders);
return;
};
export const weeklyDeliveriesHandler = (req: Request, res: Response) => {
export const weeklyDeliveriesHandler = (req: Request, res: Response): void => {
// TODO: Implement weekly deliveries functionality
// GET /deliveries/weekly - retrieve all week deliveries - from today to 7 days later in ordersByDates
// this array will contain 7 elements - each element will be dates in the next 7 days
// ["today", "tomorrow" ... ]
const today = new Date();
const weeklyOrders: Orders [] = [];
for (let i = 0; i < 7; i++) {
const date = dateModule.format(new Date(today.setDate(today.getDate() + i)), dateFormat).split(' ')[0];
if (ordersByDates.has(date)) {
weeklyOrders.push(deliveriesCache.get(ordersByDates.get(date)));
}
}
res.status(200).json(weeklyOrders);
return;
};
async function filterOutHolidaysByCountryCode(address: Address, availableTimeSlot: AvailableTimeslots[]): Promise<AvailableTimeslots[]> {
const countryCode = address.code;
const holidays = await getHolidays();
const filteredAvailableTimeSlot = [];
console.log(availableTimeSlot)
holidays.forEach((holiday) => {
if (holiday.country === countryCode) {
availableTimeSlot.forEach((timeSlot) => {
if (timeSlot.start_time.split(' ')[0] !== holiday.date) {
filteredAvailableTimeSlot.push(timeSlot)
}
})
}
});
return filteredAvailableTimeSlot;
}
async function availableTimeSlots(address: Address): Promise<AvailableTimeslots[]> {
const availableTimeSlot = [];
const timeslots = await getAvailableTimeSlots();
// check by postcode if any available timeslots
// the time needs to be in "2023-09-18 14:00:00" format
for (const timeslot of timeslots.courier_available_timeslots) {
if (timeslot.supported_postcodes.includes(address.postcode)) {
availableTimeSlot.push({
id: timeslot.id,
start_time: timeslot.start_time,
end_time: timeslot.end_time
});
}
}
return availableTimeSlot;
}

View file

@ -1,47 +1,13 @@
import app from './app';
import fs from 'fs';
// INFO: this is a simple in-memory cache, for demo purposes only. this app will not scale!
// INFO: SQL database is required for continuing this application!
// INFO: Used Map instead of Object for better performance.
// INFO: Making foundation for PostgresSQL database. migration files are in src/migrations
import app from './app';
const env = process.env.NODE_ENV || 'development';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log("STARTING ... ")
if (env === 'development') {
console.log("Generating mock data files")
generateMockDataFiles();
}
console.log(`Server is running on port ${PORT}`);
});
const generateMockDataFiles = () => {
if (!fs.existsSync('./data')) {
fs.mkdirSync('./data');
}
if (!fs.existsSync('./data/timeSlots.json')) {
const timeSlots = {
timeSlots: [
"08:00 - 10:00",
"10:00 - 12:00",
"12:00 - 14:00",
"14:00 - 16:00",
"16:00 - 18:00",
"18:00 - 20:00"
]
}
fs.writeFileSync('./data/timeSlots.json', JSON.stringify(timeSlots));
}
if (!fs.existsSync('./data/holidays.json')) {
const holidays = {
holidays: [
"2023-05-01",
"2023-06-12",
"2023-09-25"
]
}
fs.writeFileSync('./data/holidays.json', JSON.stringify(holidays));
}
}

View file

@ -1,13 +1,13 @@
exports.up = function(knex) {
return knex.schema.createTable('timeslots', function(table) {
table.increments('id').primary();
table.timestamp('start_time').notNullable();
table.timestamp('end_time').notNullable();
table.boolean('is_reserved').notNullable().defaultTo(false);
table.timestamps(true, true);
return knex.schema.createTable('orders', function(table) {
table.string('_id', 36).primary();
table.string('userId', 36).notNullable();
table.string('slotId', 36).notNullable();
table.string('deliveryCreatedDate', 255).notNullable();
});
};
exports.down = function(knex) {
return knex.schema.dropTable('timeslots');
return knex.schema.dropTable('orders');
};

View file

@ -4,10 +4,10 @@ import { resolveAddressHandler, timeslotsHandler, deliveriesHandler, cancelDeliv
const router = express.Router();
router.post('/resolve-address', resolveAddressHandler);
router.delete('/deliveries/:deliveryId', cancelDeliveryHandler);
router.post('/timeslots', timeslotsHandler);
router.get('/deliveries/daily', dailyDeliveriesHandler);
router.get('/deliveries/weekly', weeklyDeliveriesHandler);
router.post('/timeslots', timeslotsHandler);
router.post('/deliveries', deliveriesHandler);
router.delete('/deliveries/:id', cancelDeliveryHandler);
export default router;

View file

@ -1,30 +0,0 @@
exports.seed = async function(knex) {
// Delete all existing data from the tables
// check is table exists first
const hasTable = await knex.schema.hasTable('timeslots');
if (hasTable) {
await knex('timeslots').del();
}
// Load data from the JSON files
const holidays = require('../data/holidays.json');
const timeslots = require('../data/timeslots.json');
// Insert the holiday dates into the holidays table
for (const date of holidays) {
await knex('holidays').insert({ date });
}
// Insert the time slots into the timeslots table
for (const { date, slots } of timeslots) {
for (const slot of slots) {
await knex('timeslots').insert({
date,
start_time: slot.start_time,
end_time: slot.end_time
});
}
}
};

0
src/seeds/load-data.js Normal file
View file

View file

@ -1,6 +1,7 @@
// for demo resolving the promise immediately with mock data
// INFO: assuming this will be replaced with an API call, Will add id to the mock data
import timeSlots from '../data/timeslots.json';
export function getAvailableTimeSlots() {
return Promise.resolve(timeSlots);
}
}

View file

@ -1,2 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View file

@ -4,4 +4,18 @@ export interface Address {
line2: string;
country: string;
postcode: string;
}
code: string;
}
export interface AvailableTimeslots {
id: string;
start_time: string;
end_time: string;
}
export interface Orders {
_id: string;
userId: string;
slotId: string;
deliveryCreatedDate: string;
}