commit 08128374c1f69dd81f74cb6c8fbbb63bd1316409 Author: AndrewTrieu Date: Tue Dec 27 19:38:34 2022 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6f9a44 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/settings.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..60cac25 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Project: Shared shopping list diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40da710 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +version: "3.4" + +services: + shopping-lists: + build: shopping-lists + image: shopping-lists + restart: "no" + volumes: + - ./shopping-lists/:/app + ports: + - 7777:7777 + depends_on: + - database + - flyway + env_file: + - project.env + + database: + container_name: database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d + image: postgres:14.1 + restart: "no" + env_file: + - project.env + + flyway: + image: flyway/flyway:8.4.0-alpine + depends_on: + - database + volumes: + - .:/flyway/sql + command: -connectRetries=60 -baselineOnMigrate=true migrate + env_file: + - project.env + + e2e-playwright: + entrypoint: "/bin/true" # Prevent startup on docker-compose up + build: e2e-playwright + image: e2e-playwright + network_mode: host + depends_on: + - shopping-lists + volumes: + - ./e2e-playwright/tests:/e2e-playwright/tests \ No newline at end of file diff --git a/e2e-playwright/Dockerfile b/e2e-playwright/Dockerfile new file mode 100644 index 0000000..49903dc --- /dev/null +++ b/e2e-playwright/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/playwright:v1.24.2-focal + +COPY . /e2e-playwright + +WORKDIR /e2e-playwright + +RUN npm install +RUN npx playwright install chrome + +CMD [ "npx", "playwright", "test", "--reporter=list" ] \ No newline at end of file diff --git a/e2e-playwright/package.json b/e2e-playwright/package.json new file mode 100644 index 0000000..0c78651 --- /dev/null +++ b/e2e-playwright/package.json @@ -0,0 +1,8 @@ +{ + "name": "e2e-playwright-in-docker", + "version": "1.0.0", + "dependencies": { + "playwright": "^1.24.2", + "@playwright/test": "^1.24.2" + } +} diff --git a/e2e-playwright/playwright.config.js b/e2e-playwright/playwright.config.js new file mode 100644 index 0000000..b4ab2e3 --- /dev/null +++ b/e2e-playwright/playwright.config.js @@ -0,0 +1,20 @@ +module.exports = { + timeout: 10000, + retries: 0, + reporter: "list", + workers: 5, + use: { + baseURL: "http://localhost:7777", + headless: true, + ignoreHTTPSErrors: true, + }, + projects: [ + { + name: "e2e-headless-chrome", + use: { + browserName: "chromium", + channel: "chrome", + }, + }, + ], +}; diff --git a/e2e-playwright/tests/hello-world.spec.js b/e2e-playwright/tests/hello-world.spec.js new file mode 100644 index 0000000..9dc7ee5 --- /dev/null +++ b/e2e-playwright/tests/hello-world.spec.js @@ -0,0 +1,6 @@ +const { test, expect } = require("@playwright/test"); + +test("Server responds with the text 'Hello world!'", async ({ page }) => { + const response = await page.goto("/"); + expect(await response.text()).toBe("Hello world!"); +}); \ No newline at end of file diff --git a/flyway/sql/V1___initial_schema.sql b/flyway/sql/V1___initial_schema.sql new file mode 100644 index 0000000..3f85c2b --- /dev/null +++ b/flyway/sql/V1___initial_schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE shopping_lists ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + active BOOLEAN DEFAULT TRUE +); + +CREATE TABLE shopping_list_items ( + id SERIAL PRIMARY KEY, + shopping_list_id INTEGER REFERENCES shopping_lists(id), + name TEXT NOT NULL, + collected BOOLEAN DEFAULT FALSE +); \ No newline at end of file diff --git a/project.env b/project.env new file mode 100644 index 0000000..a81cc25 --- /dev/null +++ b/project.env @@ -0,0 +1,16 @@ +# Database configuration for PostgreSQL (running in container called "database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d") +POSTGRES_USER=username +POSTGRES_PASSWORD=password +POSTGRES_DB=database + +# Database configuration for Flyway (used for database migrations) +FLYWAY_USER=username +FLYWAY_PASSWORD=password +FLYWAY_URL=jdbc:postgresql://database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d:5432/database + +# Database configuration for Deno's PostgreSQL driver +PGUSER=username +PGPASSWORD=password +PGHOST=database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d +PGPORT=5432 +PGDATABASE=database diff --git a/shopping-lists/Dockerfile b/shopping-lists/Dockerfile new file mode 100644 index 0000000..0856e24 --- /dev/null +++ b/shopping-lists/Dockerfile @@ -0,0 +1,11 @@ +FROM denoland/deno:alpine-1.26.2 + +EXPOSE 7777 + +WORKDIR /app + +COPY . . + +RUN deno cache deps.js + +CMD [ "run", "--unstable", "--watch", "--allow-net", "--allow-read", "--allow-env", "--no-check", "app.js" ] \ No newline at end of file diff --git a/shopping-lists/app.js b/shopping-lists/app.js new file mode 100644 index 0000000..4563c8b --- /dev/null +++ b/shopping-lists/app.js @@ -0,0 +1,41 @@ +import { serve } from "./deps.js"; +import { configure } from "./deps.js"; +import * as listController from "./controllers/listController.js"; +import * as itemController from "./controllers/itemController.js"; +import * as mainController from "./controllers/mainController.js"; + +configure({ + views: `${Deno.cwd()}/views/`, +}); + +const handleRequest = async (request) => { + const url = new URL(request.url); + if (url.pathname === "/" && request.method === "GET") { + return mainController.showMain(request); + } else if (url.pathname === "/lists" && request.method === "GET") { + return await listController.viewLists(request); + } else if (url.pathname === "/lists" && request.method === "POST") { + return await listController.addList(request); + } else if (url.pathname.match("lists/[0-9]+") && request.method === "GET") { + return await listController.viewList(request); + } else if ( + url.pathname.match("lists/[0-9]+/deactivate") && + request.method === "POST" + ) { + return await listController.deactivateList(request); + } else if ( + url.pathname.match("lists/[0-9]+/items/[0-9]+/collect") && + request.method === "POST" + ) { + return await itemController.markItemCollected(request); + } else if ( + url.pathname.match("lists/[0-9]+/items") && + request.method === "POST" + ) { + return await itemController.addItemToList(request); + } else { + return new Response("Not found", { status: 404 }); + } +}; + +serve(handleRequest, { port: 7777 }); diff --git a/shopping-lists/controllers/itemController.js b/shopping-lists/controllers/itemController.js new file mode 100644 index 0000000..59b9caa --- /dev/null +++ b/shopping-lists/controllers/itemController.js @@ -0,0 +1,22 @@ +import * as itemService from "../services/itemService.js"; +import * as requestUtils from "../utils/requestUtils.js"; + +const markItemCollected = async (request) => { + const url = new URL(request.url); + const urlParts = url.pathname.split("/"); + await itemService.markCollected(urlParts[4], urlParts[2]); + + return requestUtils.redirectTo(`/lists/${urlParts[4]}`); +}; + +const addItemToList = async (request) => { + const url = new URL(request.url); + const urlParts = url.pathname.split("/"); + const formData = await request.formData(); + const name = formData.get("name"); + await itemService.addItem(urlParts[2], name); + + return requestUtils.redirectTo(`/lists/${urlParts[2]}`); +}; + +export { markItemCollected, addItemToList }; diff --git a/shopping-lists/controllers/listController.js b/shopping-lists/controllers/listController.js new file mode 100644 index 0000000..3d77d55 --- /dev/null +++ b/shopping-lists/controllers/listController.js @@ -0,0 +1,45 @@ +import { renderFile } from "../deps.js"; +import * as listService from "../services/listService.js"; +import * as requestUtils from "../utils/requestUtils.js"; + +const responseDetails = { + headers: { "Content-Type": "text/html;charset=UTF-8" }, +}; + +const addList = async (request) => { + const formData = await request.formData(); + const name = formData.get("name"); + + await listService.create(name); + + return requestUtils.redirectTo("/lists"); +}; + +const viewList = async (request) => { + const url = new URL(request.url); + const urlParts = url.pathname.split("/"); + + const data = { + items: await listService.findById(urlParts[2]), + }; + + return new Response(await renderFile("list.eta", data), responseDetails); +}; + +const viewLists = async (_request) => { + const data = { + lists: await listService.findAllLists(), + }; + + return new Response(await renderFile("lists.eta", data), responseDetails); +}; + +const deactivateList = async (request) => { + const url = new URL(request.url); + const urlParts = url.pathname.split("/"); + await listService.deactivateByID(urlParts[2]); + + return requestUtils.redirectTo("/lists"); +}; + +export { addList, viewList, viewLists, deactivateList }; diff --git a/shopping-lists/controllers/mainController.js b/shopping-lists/controllers/mainController.js new file mode 100644 index 0000000..ec7e8ca --- /dev/null +++ b/shopping-lists/controllers/mainController.js @@ -0,0 +1,17 @@ +import { renderFile } from "../deps.js"; +import * as mainService from "../services/mainService.js"; + +const responseDetails = { + headers: { "Content-Type": "text/html;charset=UTF-8" }, +}; + +const showMain = async (_request) => { + const data = { + listCount: await mainService.checkList(), + itemCount: await mainService.checkItem(), + }; + + return new Response(await renderFile("main.eta", data), responseDetails); +}; + +export { showMain }; diff --git a/shopping-lists/database/database.js b/shopping-lists/database/database.js new file mode 100644 index 0000000..c386f24 --- /dev/null +++ b/shopping-lists/database/database.js @@ -0,0 +1,38 @@ +import { Pool } from "../deps.js"; + +const CONCURRENT_CONNECTIONS = 2; +let connectionPool; +if (Deno.env.get("DATABASE_URL")) { + connectionPool = new Pool(Deno.env.get("DATABASE_URL"), CONCURRENT_CONNECTIONS); +} else { + connectionPool = new Pool({}, CONCURRENT_CONNECTIONS); +} + +const executeQuery = async (query, params) => { + const response = {}; + let client; + + try { + client = await connectionPool.connect(); + const result = await client.queryObject(query, params); + if (result.rows) { + response.rows = result.rows; + } + } catch (e) { + console.log(e); + response.error = e; + } finally { + if (client) { + try { + await client.release(); + } catch (e) { + console.log("Unable to release database connection."); + console.log(e); + } + } + } + + return response; +}; + +export { executeQuery }; diff --git a/shopping-lists/deps.js b/shopping-lists/deps.js new file mode 100644 index 0000000..87f8748 --- /dev/null +++ b/shopping-lists/deps.js @@ -0,0 +1,3 @@ +export { serve } from "https://deno.land/std@0.160.0/http/server.ts"; +export { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts"; +export { configure, renderFile } from "https://deno.land/x/eta@v1.12.3/mod.ts"; diff --git a/shopping-lists/fly.toml b/shopping-lists/fly.toml new file mode 100644 index 0000000..4755085 --- /dev/null +++ b/shopping-lists/fly.toml @@ -0,0 +1,38 @@ +# fly.toml file generated for shopper on 2022-12-27T19:33:40+02:00 + +app = "shopper" +kill_signal = "SIGINT" +kill_timeout = 5 +processes = [] + +[env] + +[experimental] + allowed_public_ports = [] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 7777 + processes = ["app"] + protocol = "tcp" + script_checks = [] + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + force_https = true + handlers = ["http"] + port = 80 + + [[services.ports]] + handlers = ["tls", "http"] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" diff --git a/shopping-lists/services/itemService.js b/shopping-lists/services/itemService.js new file mode 100644 index 0000000..4917216 --- /dev/null +++ b/shopping-lists/services/itemService.js @@ -0,0 +1,23 @@ +import { executeQuery } from "../database/database.js"; + +const markCollected = async (id, shopping_list_id) => { + await executeQuery( + "UPDATE shopping_list_items SET collected = true WHERE id = $id AND shopping_list_id = $shopping_list_id;", + { + id: id, + shopping_list_id: shopping_list_id, + } + ); +}; + +const addItem = async (shopping_list_id, name) => { + await executeQuery( + "INSERT INTO shopping_list_items (shopping_list_id, name) VALUES ($shopping_list_id, $name);", + { + shopping_list_id: shopping_list_id, + name: name, + } + ); +}; + +export { markCollected, addItem }; diff --git a/shopping-lists/services/listService.js b/shopping-lists/services/listService.js new file mode 100644 index 0000000..35a8631 --- /dev/null +++ b/shopping-lists/services/listService.js @@ -0,0 +1,41 @@ +import { executeQuery } from "../database/database.js"; + +const deactivateByID = async (id) => { + await executeQuery( + "UPDATE shopping_lists SET active = false WHERE id = $id;", + { + id: id, + } + ); +}; + +const create = async (name) => { + await executeQuery("INSERT INTO shopping_lists (name) VALUES ($name);", { + name: name, + }); +}; + +const findAllLists = async () => { + const result = await executeQuery( + "SELECT * FROM shopping_lists WHERE active = true;" + ); + + return result.rows; +}; + +const findById = async (shopping_list_id) => { + const result = await executeQuery( + "SELECT * FROM shopping_list_items WHERE shopping_list_id = $shopping_list_id;", + { + shopping_list_id: shopping_list_id, + } + ); + + if (result.rows && result.rows.length > 0) { + return result.rows[0]; + } + + return { id: 0, name: "Unknown" }; +}; + +export { deactivateByID, create, findAllLists, findById }; diff --git a/shopping-lists/services/mainService.js b/shopping-lists/services/mainService.js new file mode 100644 index 0000000..0584a98 --- /dev/null +++ b/shopping-lists/services/mainService.js @@ -0,0 +1,15 @@ +import { executeQuery } from "../database/database.js"; + +const checkList = async () => { + const result = await executeQuery("SELECT COUNT(*) FROM shopping_lists;"); + return result.rows[0].count; +}; + +const checkItem = async () => { + const result = await executeQuery( + "SELECT COUNT(*) FROM shopping_list_items;" + ); + return result.rows[0].count; +}; + +export { checkList, checkItem }; diff --git a/shopping-lists/utils/requestUtils.js b/shopping-lists/utils/requestUtils.js new file mode 100644 index 0000000..a8e5a3b --- /dev/null +++ b/shopping-lists/utils/requestUtils.js @@ -0,0 +1,10 @@ +const redirectTo = (path) => { + return new Response(`Redirecting to ${path}.`, { + status: 303, + headers: { + "Location": path, + }, + }); +}; + +export { redirectTo }; diff --git a/shopping-lists/views/layouts/layout.eta b/shopping-lists/views/layouts/layout.eta new file mode 100644 index 0000000..0be3527 --- /dev/null +++ b/shopping-lists/views/layouts/layout.eta @@ -0,0 +1,15 @@ + + + + Shared shopping lists + + + + + + +
+ <%~ it.body %> +
+ + diff --git a/shopping-lists/views/list.eta b/shopping-lists/views/list.eta new file mode 100644 index 0000000..07b8268 --- /dev/null +++ b/shopping-lists/views/list.eta @@ -0,0 +1,44 @@ +<% layout("./layouts/layout.eta") %> +

<%= it.list.name %>

+ +

Add an item

+ +
+ Name: + +
+ +

Active shopping lists

+ + + + + + + <% + it.items.sort((a, b) => { + if (a.collected === b.collected) { + return a.name.localeCompare(b.name); + } + return a.collected ? 1 : -1; + }); + it.items.forEach((item) => { + %> + + + + + <% }); %> +
ItemsCollected
+ <% if (item.collected) { %> + <%= item.name %> + <% } else { %> + <%= item.name %> + <% } %> + +
+ +
+
+ +Shopping lists diff --git a/shopping-lists/views/lists.eta b/shopping-lists/views/lists.eta new file mode 100644 index 0000000..d7a7359 --- /dev/null +++ b/shopping-lists/views/lists.eta @@ -0,0 +1,24 @@ +<% layout("./layouts/layout.eta") %> +

Lists

+ +

Add a shopping list

+ +
+ Name: + +
+ +

Active shopping lists

+ + + +Main page diff --git a/shopping-lists/views/main.eta b/shopping-lists/views/main.eta new file mode 100644 index 0000000..bf5a68c --- /dev/null +++ b/shopping-lists/views/main.eta @@ -0,0 +1,19 @@ +<% layout("./layouts/layout.eta") %> +

Main page

+ + + +<% if (it.listCount !== 0) { %> + Lists +<% } %> \ No newline at end of file