@@ -278,6 +278,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
|
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
|
||||||
TF_VAR_aws_region: ${{ secrets.TF_VAR_AWS_REGION }}
|
TF_VAR_aws_region: ${{ secrets.TF_VAR_AWS_REGION }}
|
||||||
|
TF_VAR_db_name: ${{ secrets.TF_VAR_db_name }}
|
||||||
|
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
|
||||||
|
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
|
||||||
|
TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }}
|
||||||
|
|
||||||
terraform-apply:
|
terraform-apply:
|
||||||
name: Terraform Apply
|
name: Terraform Apply
|
||||||
@@ -309,8 +313,35 @@ jobs:
|
|||||||
- name: Terraform Init
|
- name: Terraform Init
|
||||||
run: terraform init
|
run: terraform init
|
||||||
|
|
||||||
- name: Terraform Apply
|
- name: Terraform Apply — provision RDS
|
||||||
run: terraform apply -auto-approve -lock-timeout=5m
|
run: terraform apply -auto-approve -lock-timeout=5m
|
||||||
env:
|
env:
|
||||||
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
|
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
|
||||||
TF_VAR_aws_region: ${{ secrets.TF_VAR_AWS_REGION }}
|
TF_VAR_aws_region: ${{ secrets.TF_VAR_AWS_REGION }}
|
||||||
|
TF_VAR_db_name: ${{ secrets.TF_VAR_db_name }}
|
||||||
|
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
|
||||||
|
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
|
||||||
|
TF_VAR_db_host: ${{ secrets.TF_VAR_db_host }}
|
||||||
|
|
||||||
|
- name: Capture RDS hostname
|
||||||
|
id: rds
|
||||||
|
run: |
|
||||||
|
RDS_HOST=$(terraform output -raw rds_hostname)
|
||||||
|
echo "hostname=$RDS_HOST" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Terraform Apply — wire EC2 with RDS hostname
|
||||||
|
run: terraform apply -auto-approve -lock-timeout=5m
|
||||||
|
env:
|
||||||
|
TF_VAR_aws_account_id: ${{ secrets.TF_VAR_AWS_ACCOUNT_ID }}
|
||||||
|
TF_VAR_aws_region: ${{ secrets.TF_VAR_AWS_REGION }}
|
||||||
|
TF_VAR_db_name: ${{ secrets.TF_VAR_db_name }}
|
||||||
|
TF_VAR_db_username: ${{ secrets.TF_VAR_db_username }}
|
||||||
|
TF_VAR_db_password: ${{ secrets.TF_VAR_db_password }}
|
||||||
|
TF_VAR_db_host: ${{ steps.rds.outputs.hostname }}
|
||||||
|
|
||||||
|
- name: Job summary
|
||||||
|
run: |
|
||||||
|
echo "### Terraform Apply - $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- RDS hostname: \`${{ steps.rds.outputs.hostname }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- EC2 re-provisioned with DB connection wired" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const { Pool } = require("pg");
|
||||||
|
|
||||||
|
// Connection pool — reuses connections instead of
|
||||||
|
// opening a new one for every request
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
ssl: {
|
||||||
|
rejectUnauthorized: false, // Required for AWS RDS
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the tasks table if it does not already exist.
|
||||||
|
// Runs once on server startup.
|
||||||
|
// In CI there is no real database — the error is logged
|
||||||
|
// and the server starts anyway so the health check passes.
|
||||||
|
async function initDb() {
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log("Database initialised — tasks table ready");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Database not available — running without persistent storage:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pool, initDb };
|
||||||
@@ -23,6 +23,7 @@ module.exports = [
|
|||||||
files: ["**/*.test.js"],
|
files: ["**/*.test.js"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
|
jest: "readonly",
|
||||||
describe: "readonly",
|
describe: "readonly",
|
||||||
it: "readonly",
|
it: "readonly",
|
||||||
expect: "readonly",
|
expect: "readonly",
|
||||||
|
|||||||
Generated
+153
-6
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "task-backend",
|
"name": "task-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "task-backend",
|
"name": "task-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"express": "^4.21.2"
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.13.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -2111,9 +2112,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4971,6 +4972,95 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.13.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.14.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"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==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5070,6 +5160,45 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"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==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5453,6 +5582,15 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -6197,6 +6335,15 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "task-backend",
|
"name": "task-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Backend API for scalable task platform",
|
"description": "Backend API for scalable task platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"express": "^4.21.2"
|
"express": "^4.21.2",
|
||||||
|
"pg": "^8.13.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
+79
-26
@@ -1,18 +1,16 @@
|
|||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const { pool, initDb } = require("./db");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// In-memory task store
|
// ── Routes ────────────────────────────────────
|
||||||
let tasks = [];
|
|
||||||
let nextId = 1;
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.get("/api", (req, res) => {
|
app.get("/api", (req, res) => {
|
||||||
res.json({ message: "Task API is running", version: "1.0.0" });
|
res.json({ message: "Task API is running", version: "2.0.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/health", (req, res) => {
|
app.get("/api/health", (req, res) => {
|
||||||
@@ -23,49 +21,104 @@ app.get("/api/health", (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/tasks", (req, res) => {
|
// GET /api/tasks — return all tasks, newest first
|
||||||
res.json(tasks);
|
app.get("/api/tasks", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM tasks ORDER BY created_at DESC"
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("GET /api/tasks error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch tasks" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/tasks", (req, res) => {
|
// POST /api/tasks — create a new task
|
||||||
|
app.post("/api/tasks", async (req, res) => {
|
||||||
const { title } = req.body;
|
const { title } = req.body;
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return res.status(400).json({ error: "Title is required" });
|
return res.status(400).json({ error: "Title is required" });
|
||||||
}
|
}
|
||||||
const task = { id: nextId++, title, completed: false, createdAt: new Date() };
|
try {
|
||||||
tasks.push(task);
|
const result = await pool.query(
|
||||||
res.status(201).json(task);
|
"INSERT INTO tasks (title) VALUES ($1) RETURNING *",
|
||||||
|
[title]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/tasks error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to create task" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/tasks/:id", (req, res) => {
|
// PATCH /api/tasks/:id — toggle completed status
|
||||||
const task = tasks.find((t) => t.id === parseInt(req.params.id));
|
app.patch("/api/tasks/:id", async (req, res) => {
|
||||||
if (!task) return res.status(404).json({ error: "Task not found" });
|
const { id } = req.params;
|
||||||
task.completed = !task.completed;
|
try {
|
||||||
res.json(task);
|
const result = await pool.query(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET completed = NOT completed
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ error: "Task not found" });
|
||||||
|
}
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PATCH /api/tasks/:id error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to update task" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/api/tasks/:id", (req, res) => {
|
// DELETE /api/tasks/:id — delete a task
|
||||||
const index = tasks.findIndex((t) => t.id === parseInt(req.params.id));
|
app.delete("/api/tasks/:id", async (req, res) => {
|
||||||
if (index === -1) return res.status(404).json({ error: "Task not found" });
|
const { id } = req.params;
|
||||||
tasks.splice(index, 1);
|
try {
|
||||||
res.status(204).send();
|
const result = await pool.query(
|
||||||
|
"DELETE FROM tasks WHERE id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ error: "Task not found" });
|
||||||
|
}
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("DELETE /api/tasks/:id error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete task" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler
|
// ── Error handlers ────────────────────────────
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: "Route not found" });
|
res.status(404).json({ error: "Route not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global error handler
|
|
||||||
app.use((err, req, res, _next) => {
|
app.use((err, req, res, _next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────
|
||||||
|
// initDb runs first — creates the tasks table if it
|
||||||
|
// does not exist, then starts the server.
|
||||||
|
// Wrapped in require.main check so Jest can import
|
||||||
|
// the app without starting the server or connecting
|
||||||
|
// to the database.
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
app.listen(PORT, () => {
|
initDb()
|
||||||
console.log(`Server running on port ${PORT}`);
|
.then(() => {
|
||||||
});
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to initialise database:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Mock the pg Pool before importing app so no real
|
||||||
|
// database connection is attempted during tests
|
||||||
|
jest.mock('pg', () => {
|
||||||
|
const mPool = {
|
||||||
|
query: jest.fn(),
|
||||||
|
};
|
||||||
|
return { Pool: jest.fn(() => mPool) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock initDb so the server does not try to create
|
||||||
|
// the tasks table on startup during tests
|
||||||
|
jest.mock('./db', () => ({
|
||||||
|
pool: {
|
||||||
|
query: jest.fn(),
|
||||||
|
},
|
||||||
|
initDb: jest.fn().mockResolvedValue(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { pool } = require('./db');
|
||||||
const app = require('./server');
|
const app = require('./server');
|
||||||
|
|
||||||
|
// Reset all mocks between tests so they don't bleed into each other
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Health Check', () => {
|
describe('Health Check', () => {
|
||||||
it('GET /api/health returns 200 with status ok', async () => {
|
it('GET /api/health returns 200 with status ok', async () => {
|
||||||
const res = await request(app).get('/api/health');
|
const res = await request(app).get('/api/health');
|
||||||
@@ -11,12 +36,19 @@ describe('Health Check', () => {
|
|||||||
|
|
||||||
describe('Tasks API', () => {
|
describe('Tasks API', () => {
|
||||||
it('GET /api/tasks returns empty array initially', async () => {
|
it('GET /api/tasks returns empty array initially', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
const res = await request(app).get('/api/tasks');
|
const res = await request(app).get('/api/tasks');
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(Array.isArray(res.body)).toBe(true);
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
expect(res.body).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/tasks creates a new task', async () => {
|
it('POST /api/tasks creates a new task', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 1, title: 'Test task', completed: false, created_at: new Date() }],
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/tasks')
|
.post('/api/tasks')
|
||||||
.send({ title: 'Test task' });
|
.send({ title: 'Test task' });
|
||||||
@@ -31,4 +63,36 @@ describe('Tasks API', () => {
|
|||||||
.send({});
|
.send({});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/tasks/:id toggles completed status', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 1, title: 'Test task', completed: true, created_at: new Date() }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).patch('/api/tasks/1');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/tasks/:id returns 404 when task not found', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
const res = await request(app).patch('/api/tasks/999');
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/tasks/:id deletes a task', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 1 });
|
||||||
|
|
||||||
|
const res = await request(app).delete('/api/tasks/1');
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/tasks/:id returns 404 when task not found', async () => {
|
||||||
|
pool.query.mockResolvedValueOnce({ rowCount: 0 });
|
||||||
|
|
||||||
|
const res = await request(app).delete('/api/tasks/999');
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- DB_HOST=${DB_HOST:-localhost}
|
||||||
|
- DB_PORT=${DB_PORT:-5432}
|
||||||
|
- DB_NAME=${DB_NAME:-tasksdb}
|
||||||
|
- DB_USER=${DB_USER:-postgres}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
networks:
|
networks:
|
||||||
- task-network
|
- task-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+11
-1
@@ -57,11 +57,17 @@ resource "aws_instance" "app" {
|
|||||||
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
|
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
|
||||||
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-frontend:latest
|
docker pull ${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-frontend:latest
|
||||||
|
|
||||||
# Run backend
|
# Run backend — DB credentials passed as environment variables
|
||||||
|
# Values come from Terraform variables which are sourced from GitHub Secrets
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name task-backend \
|
--name task-backend \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
|
-e DB_HOST=${var.db_host} \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=${var.db_name} \
|
||||||
|
-e DB_USER=${var.db_username} \
|
||||||
|
-e DB_PASSWORD=${var.db_password} \
|
||||||
${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
|
${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/task-backend:latest
|
||||||
|
|
||||||
# Wait for backend to be ready
|
# Wait for backend to be ready
|
||||||
@@ -92,4 +98,8 @@ resource "aws_instance" "app" {
|
|||||||
Project = var.project_name
|
Project = var.project_name
|
||||||
Environment = var.environment
|
Environment = var.environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [ami, user_data]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# RDS — PostgreSQL for Scalable Task Platform
|
||||||
|
# Week 7 | infra/terraform/rds.tf
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── DB Subnet Group ──────────────────────────
|
||||||
|
# RDS needs to know which subnets it can live in.
|
||||||
|
# We use the private subnets so it is never
|
||||||
|
# reachable from the public internet.
|
||||||
|
resource "aws_db_subnet_group" "tasks" {
|
||||||
|
name = "${var.project_name}-db-subnet-group"
|
||||||
|
subnet_ids = aws_subnet.private[*].id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-db-subnet-group"
|
||||||
|
Project = var.project_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Security Group: RDS ──────────────────────
|
||||||
|
# Only the EC2 instance security group may reach
|
||||||
|
# the database on port 5432. No public access.
|
||||||
|
resource "aws_security_group" "rds" {
|
||||||
|
name = "${var.project_name}-rds-sg"
|
||||||
|
description = "Allow PostgreSQL access from EC2 only"
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "PostgreSQL from EC2"
|
||||||
|
from_port = 5432
|
||||||
|
to_port = 5432
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.backend.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
description = "Allow all outbound"
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-rds-sg"
|
||||||
|
Project = var.project_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── RDS Parameter Group ──────────────────────
|
||||||
|
# Explicit parameter group gives you a place to
|
||||||
|
# tune Postgres settings later without replacing
|
||||||
|
# the instance.
|
||||||
|
resource "aws_db_parameter_group" "tasks" {
|
||||||
|
name = "${var.project_name}-pg16"
|
||||||
|
family = "postgres16"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-pg16"
|
||||||
|
Project = var.project_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── RDS Instance ─────────────────────────────
|
||||||
|
resource "aws_db_instance" "tasks" {
|
||||||
|
identifier = "${var.project_name}-db"
|
||||||
|
|
||||||
|
# Engine
|
||||||
|
engine = "postgres"
|
||||||
|
engine_version = "16"
|
||||||
|
instance_class = "db.t3.micro" # Free-tier eligible
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
allocated_storage = 20
|
||||||
|
max_allocated_storage = 100 # Enable storage autoscaling up to 100 GB
|
||||||
|
storage_type = "gp2"
|
||||||
|
storage_encrypted = true
|
||||||
|
|
||||||
|
# Credentials — sourced from Terraform variables
|
||||||
|
# which are populated from GitHub Secrets via the pipeline
|
||||||
|
db_name = var.db_name
|
||||||
|
username = var.db_username
|
||||||
|
password = var.db_password
|
||||||
|
|
||||||
|
# Network
|
||||||
|
db_subnet_group_name = aws_db_subnet_group.tasks.name
|
||||||
|
vpc_security_group_ids = [aws_security_group.rds.id]
|
||||||
|
publicly_accessible = false
|
||||||
|
multi_az = false # Single-AZ is fine for this stage
|
||||||
|
|
||||||
|
# Maintenance
|
||||||
|
parameter_group_name = aws_db_parameter_group.tasks.name
|
||||||
|
backup_retention_period = 7
|
||||||
|
backup_window = "03:00-04:00"
|
||||||
|
maintenance_window = "Mon:04:00-Mon:05:00"
|
||||||
|
deletion_protection = false # Set to true before production hardening (Week 10)
|
||||||
|
|
||||||
|
# Lifecycle — prevent accidental destroy on credential rotation
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [password]
|
||||||
|
}
|
||||||
|
|
||||||
|
skip_final_snapshot = true # Change to false and set final_snapshot_identifier for production
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${var.project_name}-db"
|
||||||
|
Project = var.project_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Outputs ───────────────────────────────────
|
||||||
|
# The EC2 user-data script and any future modules
|
||||||
|
# that need the DB endpoint will read these.
|
||||||
|
output "rds_endpoint" {
|
||||||
|
description = "RDS instance endpoint (host:port)"
|
||||||
|
value = aws_db_instance.tasks.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_hostname" {
|
||||||
|
description = "RDS hostname only (no port)"
|
||||||
|
value = aws_db_instance.tasks.address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_port" {
|
||||||
|
description = "RDS port"
|
||||||
|
value = aws_db_instance.tasks.port
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_db_name" {
|
||||||
|
description = "Database name"
|
||||||
|
value = aws_db_instance.tasks.db_name
|
||||||
|
}
|
||||||
@@ -37,3 +37,25 @@ variable "private_subnet_cidr" {
|
|||||||
type = string
|
type = string
|
||||||
default = "10.0.2.0/24"
|
default = "10.0.2.0/24"
|
||||||
}
|
}
|
||||||
|
variable "db_name" {
|
||||||
|
description = "PostgreSQL database name"
|
||||||
|
type = string
|
||||||
|
default = "tasksdb"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_username" {
|
||||||
|
description = "PostgreSQL master username"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_password" {
|
||||||
|
description = "PostgreSQL master password"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
variable "db_host" {
|
||||||
|
description = "RDS instance hostname — output from aws_db_instance.tasks.address"
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user