migrate from knex to prisma on backend

Co-authored-by: fred <>
Reviewed-on: #1
This commit is contained in:
fred 2025-08-15 20:52:38 +00:00
parent 24281a6322
commit 0a41568a2e
31 changed files with 1577 additions and 472 deletions

7
.gitignore vendored
View file

@ -1,6 +1,7 @@
examp_frontend/ db/
postgres/db
*/.env */.env
.env .env*
todo todo
sqldumps/ sqldumps/
logs/
dist/

View file

@ -1,154 +0,0 @@
const express = require("express");
const db = require("./db");
const port = 3000;
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
// ####### ROUTES #######
app.get("/backend/test", async (req, res) => {
console.log("test");
res.json({ env: process.env.NODE_ENV });
});
// ### GET ALL RECIPES ###
app.get("/backend/recipes", async (req, res) => {
try {
const recipes = await db("recipes").select("id", "name", "cuisine", "stars", "prep_minutes", "cook_minutes");
res.json(recipes);
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### GET ALL RECIPE_INGREDIENTS ###
app.get("/backend/recipe-ingredients", async (req, res) => {
try {
const recipe_ingredients = await db("recipe_ingredients").select("*");
res.json(recipe_ingredients);
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### GET ALL RECIPE_STEPS ###
app.get("/backend/recipe-steps", async (req, res) => {
try {
const recipe_steps = await db("recipe_steps").select("*");
res.json(recipe_steps);
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### GET RECIPE ###
app.get("/backend/recipe/:id", async (req, res) => {
const id = req.params.id;
try {
const recipeQuery = db("recipes")
.where("id", "=", id)
.select("id", "name", "author", "cuisine", "stars", "prep_minutes", "cook_minutes");
const ingredientsQuery = db
.from("recipe_ingredients")
.where("recipe_id", "=", id)
.select("raw");
const stepsQuery = db("recipe_steps")
.where("recipe_id", id)
.select("step_number", "instruction");
const [recipe, ingredients, steps] = await Promise.all([
recipeQuery,
ingredientsQuery,
stepsQuery,
]);
const result = {
details: recipe[0],
ingredients: ingredients,
steps: steps,
};
res.json(result);
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### ADD RECIPE ###
app.post("/backend/add-recipe", async (req, res) => {
if (process.env.NODE_ENV === 'demo') { return; };
const { name, author, cuisine, stars, ingredients, steps, prep_minutes, cook_minutes } = req.body;
try {
const [id] = await db("recipes").insert(
{
name: name,
author: author,
cuisine: cuisine,
prep_minutes: prep_minutes,
cook_minutes: cook_minutes,
stars: stars,
},
["id"],
);
const ingredientInserts = ingredients.map((ing) => ({
recipe_id: id.id,
raw: ing,
}));
//
await db("recipe_ingredients").insert(ingredientInserts);
const stepInserts = Object.keys(steps).map((stepNumber) => ({
recipe_id: id.id,
step_number: parseInt(stepNumber),
instruction: steps[stepNumber],
}));
await db("recipe_steps").insert(stepInserts);
res.status(200).send({ message: "Recipe added", id: id.id });
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### SET STARS ###
app.post("/backend/set-stars", async (req, res) => {
if (process.env.NODE_ENV === 'demo') { return; };
const { id, stars } = req.body;
try {
await db("recipes").where({ id: id }).update({ stars: stars });
res.status(200).send({ message: "stars updated" });
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
// ### DELETE RECIPE ###
app.delete("/backend/delete-recipe", async (req, res) => {
if (process.env.NODE_ENV === 'demo') { return; };
const { id } = req.body;
try {
await db("recipe_steps").where({ recipe_id: id }).del();
await db("recipe_ingredients").where({ recipe_id: id }).del();
await db("recipes").where({ id: id }).del();
res.status(200).send({ message: "Recipe deleted" });
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message });
}
});
app.listen(port, () => console.log(`Server has started on port: ${port}`));
process.on("SIGINT", async () => {
console.log("Closing database connection...");
await db.destroy();
process.exit(0);
});

View file

@ -1,10 +0,0 @@
const knex = require('knex');
const knexConfig = require('./knexfile.js');
const environment = process.env.NODE_ENV || 'dev';
const config = knexConfig[environment];
const db = knex(config);
module.exports = db;

View file

@ -1,60 +0,0 @@
require("dotenv").config();
module.exports = {
dev: {
client: "postgresql",
connection: {
host: "db",
port: 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
pool: {
min: 2,
max: 10,
},
migrations: {
tableName: "knex_migrations",
directory: "./migrations",
},
},
demo: {
client: "postgresql",
connection: {
host: "db",
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
pool: {
min: 2,
max: 10,
},
migrations: {
tableName: "knex_migrations",
directory: "./migrations",
},
},
production: {
client: "postgresql",
connection: {
host: "db",
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
pool: {
min: 2,
max: 10,
},
migrations: {
tableName: "knex_migrations",
directory: "./migrations",
},
},
};

View file

@ -1,21 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('recipes', function (table) {
table.increments('id').primary();
table.string('name').notNullable().unique();
table.string('cuisine').notNullable();
table.timestamps(true, true);
})
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTable('users');
};

View file

@ -1,43 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('ingredients', function (table) {
table.increments('id').primary();
table.string('name').notNullable().unique();
table.string('type');
table.string('notes');
table.timestamps(true, true);
}).
createTable('recipe_ingredients', function (table) {
table.increments('id').primary();
table.integer('recipe_id').notNullable();
table.integer('ingredient_id').notNullable();
table.string('quantity').notNullable();
table.string('unit').notNullable();
table.string('notes');
table.index(['recipe_id']);
table.index(['ingredient_id']);
table.timestamps(true, true);
}).
createTable('recipe_steps', function (table) {
table.increments('id').primary();
table.integer('recipe_id');
table.integer('step_number');
table.string('instruction');
table.unique(['recipe_id', 'step_number']);
table.index(['recipe_id'])
table.timestamps(true, true);
})
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('ingredients')
.dropTableIfExists('recipe_ingredients')
.dropTableIfExists('recipe_steps')
};

View file

@ -1,24 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.table('recipe_ingredients', table => {
table.string('raw', 255).defaultTo('');
table.integer('ingredient_id').nullable().alter();
table.string('quantity').nullable().alter();
table.string('unit').nullable().alter();
}).table('recipe_steps', table => {
table.string('instruction', 510).alter();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.table('recipe_ingredients', table => {
table.dropColumn('raw');
});
};

View file

@ -1,22 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.table('recipes', table => {
table.string('author');
table.integer('stars');
})
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.table('recipes', table => {
table.dropColumn('author');
table.dropColumn('stars')
})
};

View file

@ -1,22 +0,0 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.table('recipes', table => {
table.integer('prep_minutes');
table.integer('cook_minutes');
})
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.table('recipes', table => {
table.dropColumn('prep_minutes');
table.dropColumn('cook_minutes')
})
};

File diff suppressed because it is too large Load diff

View file

@ -3,19 +3,26 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node backendServer.js", "dev": "nodemon ./src/index.js",
"demo": "node backendServer.js", "production": "node ./src/index.js"
"production": "node backendServer.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@prisma/client": "^6.14.0",
"@types/node": "^24.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^5.1.0", "express": "^5.1.0",
"knex": "^3.1.0", "knex": "^3.1.0",
"pg": "^8.16.3" "pg": "^8.16.3",
"prisma": "^6.14.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
},
"devDependencies": {
"nodemon": "^3.1.10"
} }
} }

View file

@ -0,0 +1,93 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "public"."ingredients" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255) NOT NULL,
"type" VARCHAR(255),
"notes" VARCHAR(255),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ingredients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."knex_migrations" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255),
"batch" INTEGER,
"migration_time" TIMESTAMPTZ(6),
CONSTRAINT "knex_migrations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."knex_migrations_lock" (
"index" SERIAL NOT NULL,
"is_locked" INTEGER,
CONSTRAINT "knex_migrations_lock_pkey" PRIMARY KEY ("index")
);
-- CreateTable
CREATE TABLE "public"."recipe_ingredients" (
"id" SERIAL NOT NULL,
"recipe_id" INTEGER NOT NULL,
"ingredient_id" INTEGER,
"quantity" VARCHAR(255),
"unit" VARCHAR(255),
"notes" VARCHAR(255),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"raw" VARCHAR(255) DEFAULT '',
CONSTRAINT "recipe_ingredients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."recipe_steps" (
"id" SERIAL NOT NULL,
"recipe_id" INTEGER,
"step_number" INTEGER,
"instruction" VARCHAR(510),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "recipe_steps_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."recipes" (
"id" SERIAL NOT NULL,
"name" VARCHAR(255) NOT NULL,
"cuisine" VARCHAR(255) NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"author" VARCHAR(255),
"stars" INTEGER,
"prep_minutes" INTEGER,
"cook_minutes" INTEGER,
CONSTRAINT "recipes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ingredients_name_unique" ON "public"."ingredients"("name");
-- CreateIndex
CREATE INDEX "recipe_ingredients_ingredient_id_index" ON "public"."recipe_ingredients"("ingredient_id");
-- CreateIndex
CREATE INDEX "recipe_ingredients_recipe_id_index" ON "public"."recipe_ingredients"("recipe_id");
-- CreateIndex
CREATE INDEX "recipe_steps_recipe_id_index" ON "public"."recipe_steps"("recipe_id");
-- CreateIndex
CREATE UNIQUE INDEX "recipe_steps_recipe_id_step_number_unique" ON "public"."recipe_steps"("recipe_id", "step_number");
-- CreateIndex
CREATE UNIQUE INDEX "recipes_name_unique" ON "public"."recipes"("name");

View file

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the `knex_migrations` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `knex_migrations_lock` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "public"."knex_migrations";
-- DropTable
DROP TABLE "public"."knex_migrations_lock";

View file

@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the `ingredients` table. If the table is not empty, all the data it contains will be lost.
*/
-- AlterTable
ALTER TABLE "public"."recipe_ingredients" ADD COLUMN "recipesId" INTEGER;
-- AlterTable
ALTER TABLE "public"."recipe_steps" ADD COLUMN "recipesId" INTEGER;
-- DropTable
DROP TABLE "public"."ingredients";
-- AddForeignKey
ALTER TABLE "public"."recipe_ingredients" ADD CONSTRAINT "recipe_ingredients_recipesId_fkey" FOREIGN KEY ("recipesId") REFERENCES "public"."recipes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."recipe_steps" ADD CONSTRAINT "recipe_steps_recipesId_fkey" FOREIGN KEY ("recipesId") REFERENCES "public"."recipes"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "public"."recipe_ingredients" DROP CONSTRAINT "recipe_ingredients_recipesId_fkey";
-- DropForeignKey
ALTER TABLE "public"."recipe_steps" DROP CONSTRAINT "recipe_steps_recipesId_fkey";
-- AddForeignKey
ALTER TABLE "public"."recipe_ingredients" ADD CONSTRAINT "recipe_ingredients_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."recipe_steps" ADD CONSTRAINT "recipe_steps_recipe_id_fkey" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -0,0 +1,53 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model recipes {
id Int @id @default(autoincrement())
name String @unique(map: "recipes_name_unique") @db.VarChar(255)
cuisine String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
author String? @db.VarChar(255)
stars Int?
prep_minutes Int?
cook_minutes Int?
recipeIngredients recipe_ingredients[]
recipeSteps recipe_steps[]
}
model recipe_ingredients {
id Int @id @default(autoincrement())
recipe_id Int
ingredient_id Int?
quantity String? @db.VarChar(255)
unit String? @db.VarChar(255)
notes String? @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
raw String? @default("") @db.VarChar(255)
recipes recipes? @relation(fields: [recipe_id], references: [id])
recipesId Int?
@@index([ingredient_id], map: "recipe_ingredients_ingredient_id_index")
@@index([recipe_id], map: "recipe_ingredients_recipe_id_index")
}
model recipe_steps {
id Int @id @default(autoincrement())
recipe_id Int?
step_number Int?
instruction String? @db.VarChar(510)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
recipes recipes? @relation(fields: [recipe_id], references: [id])
recipesId Int?
@@unique([recipe_id, step_number], map: "recipe_steps_recipe_id_step_number_unique")
@@index([recipe_id], map: "recipe_steps_recipe_id_index")
}

View file

@ -0,0 +1,89 @@
const RecipeModel = require("../models/recipeModel");
const model = new RecipeModel();
exports.test = async (req, res) => {
console.log("test");
res.json({ env: process.env.NODE_ENV });
};
exports.getAllRecipes = async (req, res) => {
try {
const recipes = await model.getAllRecipes();
res.json(recipes);
} catch (error) {
res.status(500).json({
msg: "Failed to fetch all recipes",
source: "recipeController",
error: error.message,
});
}
};
exports.getRecipeById = async (req, res) => {
const id = parseInt(req.params.id, 10);
try {
const recipe = await model.findById(id);
if (recipe) {
res.json(recipe);
} else {
res.status(404).json({ msg: "Recipe not found" });
}
} catch (error) {
res.status(500).json({
msg: "Failed to fetch recipe",
source: "recipeController",
error: error.message,
});
}
};
exports.addRecipe = async (req, res) => {
if (process.env.NODE_ENV === "demo") {
return;
}
try {
const createdRecipe = await model.addRecipe(req.body);
res.status(201).json(createdRecipe);
} catch (error) {
res.status(500).json({
msg: "Failed to add recipe",
source: "recipeController",
error: error.message,
});
}
};
exports.setStars = async (req, res) => {
if (process.env.NODE_ENV === "demo") {
return;
}
const id = parseInt(req.body.id, 10);
const stars = parseInt(req.body.stars, 10);
try {
const createdRecipe = await model.setStars(id, stars);
res.status(202).json(createdRecipe);
} catch (error) {
res.status(500).json({
msg: "Failed to set stars",
source: "recipeController",
error: error.message,
});
}
};
exports.deleteRecipe = async (req, res) => {
if (process.env.NODE_ENV === "demo") {
return;
}
const id = parseInt(req.body.id, 10);
try {
await model.deleteRecipe(id);
res.json({ success: "true" });
} catch (error) {
res.status(500).json({
msg: "Failed to delete recipe",
source: "recipeController",
error: error.message,
});
}
};

41
backend/src/index.js Normal file
View file

@ -0,0 +1,41 @@
const express = require("express");
const cors = require("cors");
const { PrismaClient } = require("@prisma/client");
const appRoutes = require("./routes/appRoutes");
const app = express();
const port = process.env.PORT || 3000;
const prisma = new PrismaClient();
function setupMiddleware(app) {
app.use(cors());
app.use(express.json());
app.use("/api", appRoutes);
}
setupMiddleware(app);
// Start server
async function startServer() {
try {
app.listen(port);
console.log(`Server is running on http://localhost:${port}`);
} catch (error) {
console.error("Error starting the server:", error);
}
}
process.on("SIGINT", async () => {
try {
await prisma.$disconnect();
console.log("Prisma client disconnected");
process.exit(0);
} catch (error) {
console.error("Error disconnecting Prisma client:", error);
process.exit(1);
}
});
startServer();
module.exports = { app, prisma };

View file

@ -0,0 +1,134 @@
const { PrismaClient } = require("@prisma/client");
const Logger = require("../utils/logger.js");
const logger = new Logger();
class recipeModel {
constructor() {
this.prisma = new PrismaClient();
}
async getAllRecipes() {
try {
return await this.prisma.recipes.findMany();
} catch (err) {
console.error("Error fetching all recipies:", err);
throw new Error(err.message);
}
}
async findById(id) {
try {
const recipe = await this.prisma.recipes.findUnique({
where: { id },
include: { recipeSteps: true, recipeIngredients: true },
});
if (!recipe) {
logger.warn(`recipe with id ${id} cannot be found`);
return null;
}
const data = {
details: {
id: recipe.id,
name: recipe.name,
author: recipe.author,
cuisine: recipe.cuisine,
stars: recipe.stars,
prep_minutes: recipe.prep_minutes,
cook_minutes: recipe.cook_minutes,
},
ingredients: recipe.recipeIngredients.map((ing) => ing.raw),
steps: recipe.recipeSteps.map((step, idx) => ({
step_number: step.step_number,
instruction: step.instruction,
})),
};
return data;
} catch (err) {
console.log("Error finding recipe:", err);
logger.error("error finding recipe", err);
throw new Error(err.message);
}
}
async addRecipe(recipeData) {
const {
name,
author,
cuisine,
stars,
ingredients,
steps,
prep_minutes,
cook_minutes,
} = recipeData;
try {
const createdRecipe = await this.prisma.recipes.create({
data: {
name,
author,
cuisine,
prep_minutes,
cook_minutes,
stars,
recipeIngredients: {
create: ingredients.map((ing) => ({ raw: ing })),
},
recipeSteps: {
create: Object.keys(steps).map((stepNumber) => ({
step_number: parseInt(stepNumber),
instruction: steps[stepNumber],
})),
},
},
});
logger.info("new recipe created", {
id: createdRecipe.id,
name: createdRecipe.name,
});
return createdRecipe;
} catch (error) {
console.log(error);
logger.error("error creating recipe", err);
throw new Error("Failed to add recipe");
}
}
async setStars(id, stars) {
try {
await this.prisma.recipes.update({
where: { id },
data: { stars },
});
return { message: "stars updated" };
} catch (err) {
console.error("Error updating stars:", err);
logger.error("error setting stars", err);
throw new Error(err.message);
}
}
async deleteRecipe(id) {
try {
await this.prisma.recipe_ingredients.deleteMany({
where: { recipe_id: id },
});
await this.prisma.recipe_steps.deleteMany({
where: { recipe_id: id },
});
const deletedRecipe = await this.prisma.recipes.delete({
where: { id },
});
logger.info("recipe deleted", {
id: deletedRecipe.id,
name: deletedRecipe.name,
});
return { message: "Recipe deleted successfully" };
} catch (err) {
console.error("Error deleting recipe:", err);
logger.error("error deleting recipe", err);
throw new Error(err.message);
}
}
}
module.exports = recipeModel;

View file

@ -0,0 +1,18 @@
const express = require("express");
const recipeController = require("../controllers/recipeController");
const router = express.Router();
router.get("/test", recipeController.test);
router.get("/recipes", recipeController.getAllRecipes);
router.get("/recipe/:id", recipeController.getRecipeById);
router.post("/add-recipe", recipeController.addRecipe);
router.post("/set-stars", recipeController.setStars);
router.delete("/delete-recipe", recipeController.deleteRecipe);
module.exports = router;

View file

@ -0,0 +1,33 @@
const fs = require("fs");
class Logger {
constructor(filePath) {
this.filePath = "/logs/app.log";
}
log(level, message, params) {
const logEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
params: params,
};
fs.appendFile(this.filePath, JSON.stringify(logEntry) + "\n", (err) => {
if (err) throw err;
});
}
info(message, params = {}) {
this.log("info", message, params);
}
warn(message, params = {}) {
this.log("warn", message, params);
}
error(message, params = {}) {
this.log("error", message, params);
}
}
module.exports = Logger;

View file

@ -1,14 +0,0 @@
const db = require('./db');
async function testConnection() {
try {
await db.raw('SELECT 1+1 as result');
console.log('Database connected successfully!');
} catch (error) {
console.error('Database connection failed:', error);
} finally {
await db.destroy();
}
}
testConnection();

16
backend/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": [
"src",
"scripts"
]
}

View file

@ -12,7 +12,7 @@ services:
ports: ports:
- "${DB_PORT}:5432" - "${DB_PORT}:5432"
volumes: volumes:
- ./postgres/db:/var/lib/postgresql/data - ./db:/var/lib/postgresql/data
backend: backend:
image: recipes_backend image: recipes_backend
container_name: recipes_backend_${ID} container_name: recipes_backend_${ID}
@ -24,11 +24,13 @@ services:
- "${BACKEND_PORT}:3000" - "${BACKEND_PORT}:3000"
volumes: volumes:
- ./backend:/usr/src/app - ./backend:/usr/src/app
- ./logs:/logs
environment: environment:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- DATABASE_URL=${PRISMA_DB_URL}
frontend: frontend:
image: recipes_frontend image: recipes_frontend
container_name: recipes_frontend_${ID} container_name: recipes_frontend_${ID}
@ -40,5 +42,6 @@ services:
- "${FRONTEND_PORT}:80" - "${FRONTEND_PORT}:80"
volumes: volumes:
- ./frontend:/usr/src/app - ./frontend:/usr/src/app
- "$FRONTEND_BUILD_DIR:/usr/src/app/dist"
environment: environment:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}

View file

@ -10,5 +10,4 @@ COPY . .
EXPOSE 80 EXPOSE 80
# CMD ["npm", "run", "$NODE_ENV"]
CMD npm run $NODE_ENV CMD npm run $NODE_ENV

View file

@ -1,10 +1,8 @@
import { getRecipeIngredients } from "../services/frontendApi.js"; import { getRecipeIngredients } from "../services/frontendApi.js";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { type Ingredient } from "../types/Recipe.ts"
function RecipeIngredients() { function RecipeIngredients() {
const [recipeIngredients, setRecipeIngredients] = useState<string[]>([]);
const [recipeIngredients, setRecipeIngredients] = useState<Ingredient[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -13,36 +11,32 @@ function RecipeIngredients() {
try { try {
const recipeIngredients = await getRecipeIngredients(); const recipeIngredients = await getRecipeIngredients();
setRecipeIngredients(recipeIngredients); setRecipeIngredients(recipeIngredients);
console.log(recipeIngredients) console.log(recipeIngredients);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
setError("Failed to load recipe ingredients..."); setError("Failed to load recipe ingredients...");
console.log(error) console.log(error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadRecipeIngredients(); loadRecipeIngredients();
}, []); }, []);
console.log(recipeIngredients) console.log(recipeIngredients);
return ( return (
// should this be a string[]? only if we are only returning raw. otherwise i will need to type and return the ingredient object. This template shoudl work for steps though, so maybe setting that up is a good first step <div className="page-outer">
<div className='page-outer'>
{loading ? ( {loading ? (
<div className="loading">Loading...</div> <div className="loading">Loading...</div>
) : ( ) : (
<div className="recipe-outer bg-amber-100 p-4 md:p-8 lg:p-12"> <div className="recipe-outer bg-amber-100 p-4 md:p-8 lg:p-12">
<div className="recipes-grid grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8"> <div className="recipes-grid grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{recipeIngredients.map(ing => ( {recipeIngredients.map((ing, idx) => (
<li key={ing.id}> <li key={idx}>{ing}</li>
{ing.raw}
</li>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
) );
} }
export default RecipeIngredients export default RecipeIngredients;

View file

@ -1,17 +1,21 @@
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getRecipeById, deleteRecipe, setDBStars } from "../services/frontendApi.js"; import {
import { type Recipe, type Ingredient } from "../types/Recipe" getRecipeById,
import Modal from '../components/Modal.tsx' deleteRecipe,
import DemoModal from '../components/DemoModal.tsx' setDBStars,
import StarRating from "../components/StarRating.tsx" } from "../services/frontendApi.js";
import TimeDisplay from '../components/TimeDisplay.tsx' import { type Recipe } from "../types/Recipe";
import Modal from "../components/Modal.tsx";
import DemoModal from "../components/DemoModal.tsx";
import StarRating from "../components/StarRating.tsx";
import TimeDisplay from "../components/TimeDisplay.tsx";
function RecipePage() { function RecipePage() {
const [recipe, setRecipe] = useState<Recipe>({ const [recipe, setRecipe] = useState<Recipe>({
details: {}, details: {},
ingredients: [], ingredients: [],
steps: [] steps: [],
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -20,16 +24,21 @@ function RecipePage() {
const [showConfirmModal, setShowConfirmModal] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showDemoModal, setShowDemoModal] = useState(false); const [showDemoModal, setShowDemoModal] = useState(false);
const { id } = useParams(); const { id } = useParams();
const isWebSource = recipe && recipe.details && recipe.details.author const isWebSource =
recipe && recipe.details && recipe.details.author
? /http|com/.test(recipe.details.author) //etc ? /http|com/.test(recipe.details.author) //etc
: false; : false;
const navigate = useNavigate(); const navigate = useNavigate();
const openModal = () => { setShowConfirmModal(true) }; const openModal = () => {
const closeModal = () => { setShowConfirmModal(false) }; setShowConfirmModal(true);
};
const closeModal = () => {
setShowConfirmModal(false);
};
const confirmDelete = () => { const confirmDelete = () => {
if (process.env.NODE_ENV === 'demo') { if (process.env.NODE_ENV === "demo") {
closeModal(); closeModal();
setShowDemoModal(true); setShowDemoModal(true);
} else { } else {
@ -43,13 +52,13 @@ function RecipePage() {
try { try {
const recipe = await getRecipeById(id); const recipe = await getRecipeById(id);
if (!recipe.details) { if (!recipe.details) {
setError("Sorry, this recipe no longer exists") setError("Sorry, this recipe no longer exists");
} else { } else {
setRecipe(recipe); setRecipe(recipe);
setStars(recipe.details?.stars ?? 0) setStars(recipe.details?.stars ?? 0);
setInitialStars(recipe.details?.stars ?? 0); setInitialStars(recipe.details?.stars ?? 0);
if (process.env.NODE_ENV === 'dev') { if (process.env.NODE_ENV === "dev") {
console.log(recipe) console.log(recipe);
} }
} }
} catch (error) { } catch (error) {
@ -77,38 +86,56 @@ function RecipePage() {
const handleDelete = async (id: number | void) => { const handleDelete = async (id: number | void) => {
try { try {
await deleteRecipe(id); await deleteRecipe(id);
navigate('/') navigate("/");
} catch (error) { } catch (error) {
console.error("Error deleting recipe:", error); console.error("Error deleting recipe:", error);
} }
}; };
return ( return (
<div className="recipe page-outer"> <div className="recipe page-outer">
{loading ? ( {loading ? (
<div className="loading">Loading...</div> <div className="loading">Loading...</div>
) : error ? ( ) : error ? (
<div> <div>
<div className="error-message text-lg">{error}</div> <div className="error-message text-lg">{error}</div>
<div className="m-2"> <div className="m-2">
<Link to="/" className="ar-button bg-amber-600 text-white py-2 px-4 rounded hover:bg-amber-700"> <Link
to="/"
className="ar-button bg-amber-600 text-white py-2 px-4 rounded hover:bg-amber-700"
>
Return to Cookbook Return to Cookbook
</Link> </Link>
</div> </div>
</div> </div>
) : ( ) : (
<div className="border-b-2 border-amber-300 pb-4"> <div className="border-b-2 border-amber-300 pb-4">
<div className="recipe-card"> <div className="recipe-card">
<div className="flex relative justify-between"> <div className="flex relative justify-between">
<button onClick={() => { }} className="invisible ar-button py-1 px-1 rounded self-start">🗑</button> <button
<h3 className="text-center max-w-lg px-4 text-2xl lg:text-3xl font-bold text-amber-900">{recipe.details.name}</h3> onClick={() => {}}
<button onClick={openModal} className="ar-button bg-amber-500 text-white py-1 px-1 rounded hover:bg-amber-600 self-start">🗑</button> className="invisible ar-button py-1 px-1 rounded self-start"
>
🗑
</button>
<h3 className="text-center max-w-lg px-4 text-2xl lg:text-3xl font-bold text-amber-900">
{recipe.details.name}
</h3>
<button
onClick={openModal}
className="ar-button bg-amber-500 text-white py-1 px-1 rounded hover:bg-amber-600 self-start"
>
🗑
</button>
</div> </div>
<div className="mt-1"> <div className="mt-1">
<p className="text-amber-700 italic text-lg">{recipe.details.cuisine}</p> <p className="text-amber-700 italic text-lg">
<p>prep: <TimeDisplay minutes={recipe.details.prep_minutes ?? 0} /> | cook: <TimeDisplay minutes={recipe.details.cook_minutes ?? 0} /></p> {recipe.details.cuisine}
</p>
<p>
prep: <TimeDisplay minutes={recipe.details.prep_minutes ?? 0} />{" "}
| cook:{" "}
<TimeDisplay minutes={recipe.details.cook_minutes ?? 0} />
</p>
</div> </div>
</div> </div>
@ -118,10 +145,10 @@ function RecipePage() {
Ingredients: Ingredients:
</h4> </h4>
<ul className="space-y-2"> <ul className="space-y-2">
{recipe.ingredients.map((ingredient: Ingredient, index) => ( {recipe.ingredients.map((ingredient: string, index) => (
<li key={index} className="text-gray-700 flex items-start"> <li key={index} className="text-gray-700 flex items-start">
<span className="w-1.5 h-1.5 bg-amber-400 rounded-full mt-2 mr-3 flex-shrink-0"></span> <span className="w-1.5 h-1.5 bg-amber-400 rounded-full mt-2 mr-3 flex-shrink-0"></span>
<span className="font-medium text-left">{ingredient.raw}</span> <span className="font-medium text-left">{ingredient}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -132,12 +159,18 @@ function RecipePage() {
Instructions: Instructions:
</h4> </h4>
<ol className="space-y-3"> <ol className="space-y-3">
{recipe.steps && Object.keys(recipe.steps || {}).map((stepNumber) => ( {recipe.steps &&
<li key={stepNumber} className="text-gray-700 flex items-start"> Object.keys(recipe.steps || {}).map((stepNumber) => (
<li
key={stepNumber}
className="text-gray-700 flex items-start"
>
<span className="bg-amber-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold mr-3 mt-0.5 flex-shrink-0"> <span className="bg-amber-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold mr-3 mt-0.5 flex-shrink-0">
{recipe.steps[parseInt(stepNumber)].step_number} {recipe.steps[parseInt(stepNumber)].step_number}
</span> </span>
<span className="leading-relaxed text-left">{recipe.steps[parseInt(stepNumber)].instruction}</span> <span className="leading-relaxed text-left">
{recipe.steps[parseInt(stepNumber)].instruction}
</span>
</li> </li>
))} ))}
</ol> </ol>
@ -152,13 +185,15 @@ function RecipePage() {
<span>From the kitchen of {recipe.details.author}</span> <span>From the kitchen of {recipe.details.author}</span>
)} )}
<span> <span>
<StarRating rating={stars} onRatingChange={(newRating: number) => setStars(newRating)} /> <StarRating
rating={stars}
onRatingChange={(newRating: number) => setStars(newRating)}
/>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
) )}
}
<Modal <Modal
isOpen={showConfirmModal} isOpen={showConfirmModal}
onClose={closeModal} onClose={closeModal}
@ -173,7 +208,7 @@ function RecipePage() {
closeModal={() => setShowDemoModal(false)} closeModal={() => setShowDemoModal(false)}
/> />
)} )}
</div > </div>
); );
} }

View file

@ -1,27 +1,27 @@
const baseUrl = process.env.NODE_ENV !== 'dev' const baseUrl =
? '/' process.env.NODE_ENV !== "dev" ? "/api" : "http://localhost:3000/api";
: 'http://localhost:3000/';
export const getRecipes = async () => { export const getRecipes = async () => {
const response = await fetch(`${baseUrl}backend/recipes`); console.log(`${baseUrl}/recipes`);
const response = await fetch(`${baseUrl}/recipes`);
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const getRecipeSteps = async () => { export const getRecipeSteps = async () => {
const response = await fetch(`${baseUrl}backend/recipe-steps`); const response = await fetch(`${baseUrl}/recipe-steps`);
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const getRecipeIngredients = async () => { export const getRecipeIngredients = async () => {
const response = await fetch(`${baseUrl}backend/recipe-ingredients`); const response = await fetch(`${baseUrl}/recipe-ingredients`);
const data = await response.json(); const data = await response.json();
return data; return data;
}; };
export const getRecipeById = async (id) => { export const getRecipeById = async (id) => {
const response = await fetch(`${baseUrl}backend/recipe/${id}`); const response = await fetch(`${baseUrl}/recipe/${id}`);
const data = await response.json(); const data = await response.json();
if (!data || !data.details) { if (!data || !data.details) {
return { details: null }; return { details: null };
@ -32,7 +32,7 @@ export const getRecipeById = async (id) => {
export const addRecipe = async (recipeData) => { export const addRecipe = async (recipeData) => {
console.log(JSON.stringify(recipeData)); console.log(JSON.stringify(recipeData));
// return // return
const response = await fetch(`${baseUrl}backend/add-recipe`, { const response = await fetch(`${baseUrl}/add-recipe`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(recipeData), body: JSON.stringify(recipeData),
@ -45,7 +45,7 @@ export const addRecipe = async (recipeData) => {
export const setDBStars = async (id, stars) => { export const setDBStars = async (id, stars) => {
console.log(JSON.stringify({ id: id, stars: stars })); console.log(JSON.stringify({ id: id, stars: stars }));
// return // return
const response = await fetch(`${baseUrl}backend/set-stars`, { const response = await fetch(`${baseUrl}/set-stars`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, stars: stars }), body: JSON.stringify({ id: id, stars: stars }),
@ -58,7 +58,7 @@ export const setDBStars = async (id, stars) => {
export const deleteRecipe = async (id) => { export const deleteRecipe = async (id) => {
console.log(id); console.log(id);
// return // return
const response = await fetch(`${baseUrl}backend/delete-recipe`, { const response = await fetch(`${baseUrl}/delete-recipe`, {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }), body: JSON.stringify({ id }),

View file

@ -4,13 +4,6 @@ interface Step {
instruction: string; instruction: string;
} }
interface Ingredient {
id?: number;
name?: string;
quantity?: number;
unit?: string;
raw?: string;
}
interface Recipe { interface Recipe {
details: { details: {
id?: number; id?: number;
@ -20,8 +13,8 @@ interface Recipe {
cuisine?: string; cuisine?: string;
prep_minutes?: number; prep_minutes?: number;
cook_minutes?: number; cook_minutes?: number;
}, };
ingredients: Ingredient[], ingredients: string[];
steps: Step[]; steps: Step[];
} }
// smaller Recipe type returned by backend at /recipes for all // smaller Recipe type returned by backend at /recipes for all
@ -34,5 +27,4 @@ interface RecipeSmall {
prep_minutes: number; prep_minutes: number;
} }
export type { Recipe, Step, RecipeSmall };
export type { Recipe, Ingredient, Step, RecipeSmall }

View file

@ -1,15 +0,0 @@
services:
db:
container_name: recipe_postgres
image: docker.io/library/postgres:17
restart: always
env_file:
- .env
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
ports:
- "5432:5432"
volumes:
- ./db:/var/lib/postgresql/data