First commit

This commit is contained in:
AndrewTrieu
2022-12-27 19:38:34 +02:00
commit 08128374c1
25 changed files with 523 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.vscode/settings.json

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Project: Shared shopping list

43
docker-compose.yml Normal file
View File

@@ -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

10
e2e-playwright/Dockerfile Normal file
View File

@@ -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" ]

View File

@@ -0,0 +1,8 @@
{
"name": "e2e-playwright-in-docker",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.24.2",
"@playwright/test": "^1.24.2"
}
}

View File

@@ -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",
},
},
],
};

View File

@@ -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!");
});

View File

@@ -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
);

16
project.env Normal file
View File

@@ -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

11
shopping-lists/Dockerfile Normal file
View File

@@ -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" ]

41
shopping-lists/app.js Normal file
View File

@@ -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 });

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

3
shopping-lists/deps.js Normal file
View File

@@ -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";

38
shopping-lists/fly.toml Normal file
View File

@@ -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"

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -0,0 +1,10 @@
const redirectTo = (path) => {
return new Response(`Redirecting to ${path}.`, {
status: 303,
headers: {
"Location": path,
},
});
};
export { redirectTo };

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<title>Shared shopping lists</title>
<link rel="stylesheet" href="https://unpkg.com/papercss@1.8.1/dist/paper.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta charset="utf-8">
</head>
<body>
<div class="container paper">
<%~ it.body %>
</div>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<% layout("./layouts/layout.eta") %>
<h1><%= it.list.name %></h1>
<h2>Add an item</h2>
<form method="POST" action="/lists/<%= it.list.id %>/items">
Name: <input type="text" name="name" />
<input type="submit" value="Add!" />
</form>
<h2>Active shopping lists</h2>
<table>
<tr>
<th>Items</th>
<th>Collected</th>
</tr>
<%
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) => {
%>
<tr>
<th>
<% if (item.collected) { %>
<del><%= item.name %></del>
<% } else { %>
<%= item.name %>
<% } %>
</th>
<th>
<form method="POST" action="/lists/<%= item.shopping_list_id %>/items/<%= item.id %>/collect">
<input type="submit" value="Mark collected!" />
</form>
</th>
</tr>
<% }); %>
</table>
<a href="/lists">Shopping lists</a>

View File

@@ -0,0 +1,24 @@
<% layout("./layouts/layout.eta") %>
<h1>Lists</h1>
<h2>Add a shopping list</h2>
<form method="POST">
Name: <input type="text" name="name" />
<input type="submit" value="Create!" />
</form>
<h2>Active shopping lists</h2>
<ul>
<% it.lists.forEach((list) => { %>
<li>
<a href="/lists/<%= list.id %>"><%= list.name %></a>
<form method="POST" action="/lists/<%= list.id %>/deactivate">
<input type="submit" value="Deactivate list!" />
</form>
</li>
<% }); %>
</ul>
<a href="/">Main page</a>

View File

@@ -0,0 +1,19 @@
<% layout("./layouts/layout.eta") %>
<h1>Main page</h1>
<ul>
<% if (it.listCount !== 0) { %>
<li>Shopping lists: <%= it.listCount %></li>
<% } else { %>
<li>No shopping lists yet.</li>
<% } %>
<% if (it.itemCount !== 0) { %>
<li>Shopping list items: <%= it.itemCount %></li>
<% } else { %>
<li>No items yet.</li>
<% } %>
</ul>
<% if (it.listCount !== 0) { %>
<a href="/lists">Lists</a>
<% } %>