First commit

This commit is contained in:
Yusuph 2025-09-10 17:20:39 +03:00
commit 56f362d189
27 changed files with 7775 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma

11
jest.config.js Normal file
View File

@ -0,0 +1,11 @@
const { createDefaultPreset } = require("ts-jest");
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
export default {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
};

6965
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "server",
"version": "1.0.0",
"type": "module",
"main": "index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "nodemon --exec tsx src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@prisma/client": "^6.15.0",
"@types/jest": "^30.0.0",
"@types/supertest": "^6.0.3",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"jest": "^30.1.3",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lodash.merge": "^4.6.2",
"morgan": "^1.10.1",
"supertest": "^7.1.4",
"ts-jest": "^29.4.1"
},
"devDependencies": {
"@types/lodash.merge": "^4.6.9",
"@types/node": "^24.3.0",
"nodemon": "^3.1.10",
"prisma": "^6.15.0",
"ts-node": "^10.9.2",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@ -0,0 +1,95 @@
-- CreateEnum
CREATE TYPE "public"."UPDATE_STATUS" AS ENUM ('IN_PROGRESS', 'LIVE', 'DEPRECATED', 'ARCHIVED');
-- CreateTable
CREATE TABLE "public"."Post" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"content" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"authorId" TEXT NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."User" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Product" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"belongsToId" TEXT NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Update" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"body" TEXT NOT NULL,
"status" "public"."UPDATE_STATUS" NOT NULL DEFAULT 'IN_PROGRESS',
"version" TEXT,
"asset" TEXT NOT NULL,
"productId" TEXT NOT NULL,
CONSTRAINT "Update_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."UpdatePoint" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT NOT NULL,
"updateId" TEXT NOT NULL,
CONSTRAINT "UpdatePoint_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."_UpdateToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_UpdateToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "public"."User"("username");
-- CreateIndex
CREATE INDEX "_UpdateToUser_B_index" ON "public"."_UpdateToUser"("B");
-- AddForeignKey
ALTER TABLE "public"."Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Product" ADD CONSTRAINT "Product_belongsToId_fkey" FOREIGN KEY ("belongsToId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Update" ADD CONSTRAINT "Update_productId_fkey" FOREIGN KEY ("productId") REFERENCES "public"."Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."UpdatePoint" ADD CONSTRAINT "UpdatePoint_updateId_fkey" FOREIGN KEY ("updateId") REFERENCES "public"."Update"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_UpdateToUser" ADD CONSTRAINT "_UpdateToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."Update"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_UpdateToUser" ADD CONSTRAINT "_UpdateToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."User"("id") ON DELETE CASCADE 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"

82
prisma/schema.prisma Normal file
View File

@ -0,0 +1,82 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String @db.VarChar(255)
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
}
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
username String @unique
password String
updates Update[]
Post Post[]
Product Product[]
}
model Product {
id String @id @default(uuid())
createdAt DateTime @default(now())
name String
belongsTo User @relation(fields: [belongsToId], references: [id])
belongsToId String
updates Update[]
}
enum UPDATE_STATUS {
IN_PROGRESS
LIVE
DEPRECATED
ARCHIVED
}
model Update {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime
title String @db.VarChar(255)
body String
status UPDATE_STATUS @default(IN_PROGRESS)
version String?
asset String
productId String
product Product @relation(fields: [productId], references: [id])
updatePoints UpdatePoint[]
User User[]
}
model UpdatePoint {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime
name String @db.VarChar(255)
description String
updateId String
update Update @relation(fields: [updateId], references: [id])
}

20
src/api.req.http Normal file
View File

@ -0,0 +1,20 @@
POST http://localhost:3000/user
Content-Type: application/json
{
"username": "Piddo",
"password": "123456789"
}
POST http://localhost:3000/signin
Content-Type: application/json
{
"username": "Piddo",
"password": "123456789"
}
GET http://localhost:3000/health
GET http://localhost:3000/api/product
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEwZGQ1MmUzLWY2NjEtNGRkNy1hNjIzLTFkNzY0YjQwMmY0YSIsInVzZXJuYW1lIjoiUGlkZG8iLCJpYXQiOjE3NTY5MTA5NTd9.rtktVNT5uofPuSJ5nkz6J19iCRnC_qQ35oCuN5VgfBI

50
src/api.request.http Normal file
View File

@ -0,0 +1,50 @@
GET http://localhost:3000/api/product
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAyNDAzNGRjLTVjZmEtNDk2Mi1iOTA5LTU2MmUwY2M0OGIyZCIsInVzZXJuYW1lIjoiRXBocmFpbSIsImlhdCI6MTc1NjkwODI5NH0.gLosNyt2gISTlHLO2jRtE1a07lgHJukJ34i7jHOwkAM
POST http://localhost:3000/user
Content-Type: application/json
{
"username": "Y",
"password": "t@no2500"
}
POST http://localhost:3000/user
Content-Type: application/json
{
"username": "Paschal",
"password": "M@no2500pasc"
}
POST http://localhost:3000/user
Content-Type: application/json
{
"username": "Ephraim",
"password": "M@no474pasc"
}
POST http://localhost:3000/signin
Content-Type: application/json
{
"username": "kiddoh",
"password": "M@no2500"
}
POST http://localhost:3000/signin
Content-Type: application/json
{
"username": "Paschal",
"password": "M@no2500pasc"
}
POST http://localhost:3000/signin
Content-Type: application/json
{
"username": "Ephraim",
"password": "M@no474pasc"
}

24
src/config/index.ts Normal file
View File

@ -0,0 +1,24 @@
import merge from "lodash.merge"
const stage = process.env.STAGE || "local";
let envConfig;
if(stage=== "production"){
envConfig = require("./prod").default;
}
else if( stage==="staging") {
envConfig = require("./local").default;
}
else{
envConfig = require("./local").default;
}
const defaultConfig = {
stage,
dbUrl: process.env.DATABASE_URL,
jwtSecret:process.env.JWT_SECRET,
logging:false,
}
export default merge(defaultConfig, envConfig);

0
src/config/local.ts Normal file
View File

0
src/config/prod.ts Normal file
View File

0
src/config/staging.ts Normal file
View File

5
src/db.ts Normal file
View File

@ -0,0 +1,5 @@
import { PrismaClient } from "./generated/prisma";
const prisma = new PrismaClient();
export default prisma;

46
src/handlers/post.ts Normal file
View File

@ -0,0 +1,46 @@
import prisma from "../db";
import { Request, Response } from "express";
export const getPosts = async (req:Request, res:Response) => {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
include: { Post: true },
});
res.json({data: user.Post});
}
export const getPostById = async (req:Request, res:Response) => {
const post = await prisma.post.findUnique({
where: {id: req.params.id, authorId: req.user.id},
});
res.json({data: post});
}
export const createPost = async (req:Request, res:Response) => {
const post = await prisma.post.create({
data: {
title: req.body.title,
content: req.body.content,
authorId: req.user.id,
}
});
res.json({data: post});
}
export const updatePost = async (req:Request, res:Response) => {
const post = await prisma.post.update({
where: {id: req.params.id, authorId: req.user.id},
data: {
title: req.body.title,
content: req.body.content,
}
});
res.json({data: post});
}
export const deletePost = async (req:Request, res:Response) => {
const post = await prisma.post.delete({
where: {id: req.params.id, authorId: req.user.id},
});
res.json({data: post});
}

48
src/handlers/product.ts Normal file
View File

@ -0,0 +1,48 @@
import prisma from "../db";
import { Request, Response } from "express";
export const getProducts = async (req:Request, res:Response) => {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
include: { Product: true },
});
res.json({data: user.Product});
}
export const getProductById = async (req:Request, res:Response) => {
const product = await prisma.product.findUnique({
where: {id: req.params.id,belongsToId: req.user.id},
});
res.json({data: product});
}
export const createProduct = async (req:Request, res:Response) => {
const product = await prisma.product.create({
data: {
name: req.body.name,
belongsToId: req.user.id,
}
});
res.json({data: product});
}
export const updateProduct = async (req:Request, res:Response) => {
const product = await prisma.product.update({
where: {id: req.params.id, belongsToId: req.user.id},
data: {
name: req.body.name,
}
});
res.json({data: product});
}
export const deleteProduct = async (req:Request, res:Response) => {
const product = await prisma.product.delete({
where: {id: req.params.id, belongsToId: req.user.id},
});
res.json({data: product});
}

82
src/handlers/update.ts Normal file
View File

@ -0,0 +1,82 @@
import prisma from "../db";
import { Request, Response } from "express";
export const getUpdates = async (req:Request, res:Response) => {
const products = await prisma.product.findMany({
where: {belongsToId: req.user.id},
include: {updates: true}
});
const updates = products.reduce((allUpdates, product) => {
return [...allUpdates, ...product.updates]
}, []);
res.json({data: updates});
}
export const getUpdateById = async (req:Request, res:Response) => {
const update = await prisma.update.findUnique({
where: {id: req.params.id}
});
res.json({data: update});
}
export const createUpdate = async (req:Request, res:Response) => {
const product = await prisma.product.findUnique({
where: {id: req.body.productId}
});
if (!product || product.belongsToId !== req.user.id) {
return res.status(401).json({message: "Unauthorized"});
}
const update = await prisma.update.create({
data: req.body
});
res.json({data: update});
}
export const updateUpdate = async (req:Request, res:Response) => {
const products = await prisma.product.findMany({
where: {belongsToId: req.user.id},
include: {updates: true}
});
const updates = products.reduce((allUpdates, product) => {
return [...allUpdates, ...product.updates]
}, []);
const match = updates.find(update => update.id === req.params.id);
if (!match) {
return res.status(401).json({message: "Unauthorized"});
}
const updatedUpdate = await prisma.update.update({
where: {id: req.params.id},
data: req.body
});
res.json({data: updatedUpdate});
}
export const deleteUpdate = async (req:Request, res:Response) => {
const products = await prisma.product.findMany({
where: {belongsToId: req.user.id},
include: {updates: true}
});
const updates = products.reduce((allUpdates, product) => {
return [...allUpdates, ...product.updates]
}, []);
const match = updates.find(update => update.id === req.params.id);
if (!match) {
return res.status(401).json({message: "Unauthorized"});
}
const deleted = await prisma.update.delete({
where: {id: req.params.id}
});
res.json({data: deleted});
}

View File

@ -0,0 +1,68 @@
import prisma from "../db";
import { Request, Response } from "express";
export const getUpdatePoints = async (req:Request, res:Response) => {
const update = await prisma.update.findUnique({
where: {id: req.body.updateId},
include: {updatePoints: true}
});
res.json({data: update.updatePoints});
}
export const getUpdatePointById = async (req:Request, res:Response) => {
const updatePoint = await prisma.updatePoint.findUnique({
where: {id: req.params.id}
});
res.json({data: updatePoint});
}
export const createUpdatePoint = async (req:Request, res:Response) => {
const update = await prisma.update.findUnique({
where: {id: req.body.updateId},
include: {product: true}
});
if (!update || update.product.belongsToId !== req.user.id) {
return res.status(401).json({message: "Unauthorized"});
}
const updatePoint = await prisma.updatePoint.create({
data: req.body
});
res.json({data: updatePoint});
}
export const updateUpdatePoint = async (req:Request, res:Response) => {
const updatePoint = await prisma.updatePoint.findUnique({
where: {id: req.params.id},
include: {update: {include: {product: true}}}
});
if (!updatePoint || updatePoint.update.product.belongsToId !== req.user.id) {
return res.status(401).json({message: "Unauthorized"});
}
const updatedUpdatePoint = await prisma.updatePoint.update({
where: {id: req.params.id},
data: req.body
});
res.json({data: updatedUpdatePoint});
}
export const deleteUpdatePoint = async (req:Request, res:Response) => {
const updatePoint = await prisma.updatePoint.findUnique({
where: {id: req.params.id},
include: {update: {include: {product: true}}}
});
if (!updatePoint || updatePoint.update.product.belongsToId !== req.user.id) {
return res.status(401).json({message: "Unauthorized"});
}
const deleted = await prisma.updatePoint.delete({
where: {id: req.params.id}
});
res.json({data: deleted});
}

37
src/handlers/user.ts Normal file
View File

@ -0,0 +1,37 @@
import prisma from "../db";
import { comparePasswords, createJWT, hashPassword } from "../module/auth";
export const createNewUser = async (req, res) => {
const hash = await hashPassword(req.body.password);
const user = await prisma.user.create({
data: {
username: req.body.username,
password: hash,
}
});
const token = createJWT(user);
res.json({ user, token });
}
export const signIn = async (req, res) => {
const user = await prisma.user.findUnique({
where: {
username: req.body.username,
}
});
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
const isValid = await comparePasswords(req.body.password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid password' });
}
const token = createJWT(user);
res.status(200).json({ token });
};

8
src/index.ts Normal file
View File

@ -0,0 +1,8 @@
import app from './server';
import dotenv from 'dotenv';
dotenv.config();
app.listen(process.env.PORT || 3000, () => {
console.log(`Server is running on port ${process.env.PORT || 3000}`);
});

18
src/module/auth.ts Normal file
View File

@ -0,0 +1,18 @@
import jwt from 'jsonwebtoken';
import * as bcrypt from 'bcrypt';
export const createJWT = (user) => {
const token = jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_SECRET,
);
return token;
}
export const comparePasswords = (password: string, hash: string) => {
return bcrypt.compare(password, hash);
};
export const hashPassword = (password: string) => {
return bcrypt.hash(password, 10);
}

View File

@ -0,0 +1,32 @@
import { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
export const protect = (req: Request, res: Response, next: NextFunction) => {
const bearer = req.headers.authorization;
if(!bearer){
return res.status(401).json({message: 'Unauthorized'});
}
const [, token] = bearer.split(" ");
if(!token){
console.log("No token found");
return res.status(401).json({message: 'Unauthorized'});
}
const jwtSecret = process.env.JWT_SECRET;
if(!jwtSecret){
console.log("JWT_SECRET not defined in environment variables");
return res.status(500).json({message: 'Internal server error'});
}
try {
const payload = jwt.verify(token, jwtSecret);
req.user = payload;
console.log(payload);
next();
} catch (error) {
console.log("JWT verification failed:", error);
return res.status(401).json({message: 'Unauthorized'});
}
}

View File

@ -0,0 +1,7 @@
import { NextFunction, Request, Response } from 'express';
export const healthCheck = (req: Request, res: Response, next: NextFunction) => {
// res.status(200).json({status: 'OK', message: 'Server is healthy'});
console.log(new Date(), 'Health check endpoint hit');
next();
}

View File

@ -0,0 +1,48 @@
import { NextFunction, Request, Response} from 'express';
import { body, validationResult } from "express-validator";
export const validateRequestUpdate =[
body("title").isString().isLength({min: 2, max: 255}),
body("body").isString().isLength({min: 2}),
body("version").optional().isString(),
body("asset").isString().isLength({min:1, max:5}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()){
return res.status(400).json({errors: errors.array()});
}
next();
}];
export const validateRequestProduct =[
body("name").isString().isLength({min: 2}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()){
return res.status(400).json({errors: errors.array()});
}
next();
}];
export const validateRequestPost = [
body("title").isString().isLength({min: 2, max: 255}),
body("content").optional().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()){
return res.status(400).json({errors: errors.array()});
}
next();
}
];
export const validateRequestUpdatePoint =[
body("name").isString().isLength({min: 2, max: 255}),
body("description").isString().isLength({min: 2}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()){
return res.status(400).json({errors: errors.array()});
}
next();
}];

40
src/router.ts Normal file
View File

@ -0,0 +1,40 @@
import { Router } from "express";
import { body, validationResult } from "express-validator";
import { getProducts, getProductById, createProduct, updateProduct, deleteProduct } from "./handlers/product";
import { getPosts, getPostById, createPost, updatePost, deletePost } from "./handlers/post";
import { getUpdates, getUpdateById, createUpdate, updateUpdate, deleteUpdate } from "./handlers/update";
import { getUpdatePoints, getUpdatePointById, createUpdatePoint, updateUpdatePoint, deleteUpdatePoint } from "./handlers/updatepoint";
import { validateRequestProduct, validateRequestUpdate, validateRequestUpdatePoint, validateRequestPost } from "./module/validateMiddleware";
const router = Router();
router.get("/product", getProducts);
router.get("/product/:id", getProductById);
router.post("/product",validateRequestProduct, createProduct);
router.put("/product/:id",validateRequestProduct,updateProduct);
router.delete("/product/:id", deleteProduct);
router.get("/update", getUpdates);
router.get("/update/:id", getUpdateById);
router.post("/update",validateRequestUpdate, createUpdate);
router.put("/update/:id",validateRequestUpdate, updateUpdate);
router.delete("/update/:id", deleteUpdate);
router.get("/updatepoint", getUpdatePoints);
router.get("/updatepoint/:id", getUpdatePointById);
router.post("/updatepoint", validateRequestUpdatePoint, createUpdatePoint);
router.put("/updatepoint/:id", validateRequestUpdatePoint, updateUpdatePoint);
router.delete("/updatepoint/:id", deleteUpdatePoint);
router.get("/post", getPosts);
router.get("/post/:id", getPostById);
router.post("/post", validateRequestPost, createPost);
router.put("/post/:id", validateRequestPost, updatePost);
router.delete("/post/:id", deletePost);
export default router;

31
src/server.ts Normal file
View File

@ -0,0 +1,31 @@
import cors from 'cors';
import express from 'express';
import morgan from 'morgan';
import { createNewUser, signIn } from './handlers/user';
import { protect } from './module/authMiddleware';
import { healthCheck } from './module/healthMiddleware';
import router from './router';
const app = express();
// app.get("/", (req, res) => {
// console.log('Request received');
// res.status(200);
// res.json({ message: 'Hello, Dropping Zone' });
// });
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.get('/health',healthCheck, (req, res) => {
res.json({"ping": "pong"});
});
app.use('/api', protect, router);
app.post('/user', createNewUser)
app.post('/signin', signIn)
export default app;

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"sourceMap": true,
"outDir": "dist",
"strict": false,
"lib":["esnext"],
"esModuleInterop": true,
}
}