Initial commit

This commit is contained in:
Mohit Nagar
2025-10-02 18:23:29 +05:30
committed by GitHub
commit 02401a3e9d
59 changed files with 11270 additions and 0 deletions

1
.eslintignore Normal file
View File

@@ -0,0 +1 @@
src/public/

44
.eslintrc.json Normal file
View File

@@ -0,0 +1,44 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:node/recommended"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/explicit-member-accessibility": "warn",
"@typescript-eslint/no-misused-promises": 0,
"@typescript-eslint/no-floating-promises": 0,
"max-len": [
"warn",
{
"code": 80
}
],
"comma-dangle": ["warn", "always-multiline"],
"no-console": 1,
"no-extra-boolean-cast": 0,
"semi": 1,
"indent": ["warn", 2],
"quotes": ["warn", "single"],
"node/no-process-env": 1,
"node/no-unsupported-features/es-syntax": [
"error",
{ "ignores" : ["modules"] }
],
"node/no-missing-import": 0,
"node/no-unpublished-import": 0
},
"settings": {
"node": {
"tryExtensions": [".js", ".json", ".node", ".ts"]
}
}
}

138
.gitignore vendored Normal file
View File

@@ -0,0 +1,138 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
**/node_modules
dist/
**/**/*.log
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
/generated/prisma
lib

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -0,0 +1,142 @@
{
"info": {
"name": "Aragon Kanban API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Postman collection for Users, Boards, and Tasks API"
},
"item": [
{
"name": "Users",
"item": [
{
"name": "Get All Users",
"request": {
"method": "GET",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/users", "host": ["{{baseUrl}}"], "path": ["users"] }
}
},
{
"name": "Add User",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"user\": {\n \"id\": 1,\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n }\n}" },
"url": { "raw": "{{baseUrl}}/users", "host": ["{{baseUrl}}"], "path": ["users"] }
}
},
{
"name": "Update User",
"request": {
"method": "PUT",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"user\": {\n \"id\": 1,\n \"name\": \"Jane Doe\",\n \"email\": \"jane@example.com\"\n }\n}" },
"url": { "raw": "{{baseUrl}}/users", "host": ["{{baseUrl}}"], "path": ["users"] }
}
},
{
"name": "Delete User",
"request": {
"method": "DELETE",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/users/1", "host": ["{{baseUrl}}"], "path": ["users", "1"] }
}
}
]
},
{
"name": "Boards",
"item": [
{
"name": "Get All Boards for User",
"request": {
"method": "GET",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/boards/user/1", "host": ["{{baseUrl}}"], "path": ["boards", "user", "1"] }
}
},
{
"name": "Get Board by ID",
"request": {
"method": "GET",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/boards/user/1/123", "host": ["{{baseUrl}}"], "path": ["boards", "user", "1", "123"] }
}
},
{
"name": "Add Board",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"name\": \"Project Board\"\n}" },
"url": { "raw": "{{baseUrl}}/boards/user/1", "host": ["{{baseUrl}}"], "path": ["boards", "user", "1"] }
}
},
{
"name": "Update Board",
"request": {
"method": "PUT",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"name\": \"Updated Board Name\"\n}" },
"url": { "raw": "{{baseUrl}}/boards/user/1/123", "host": ["{{baseUrl}}"], "path": ["boards", "user", "1", "123"] }
}
},
{
"name": "Delete Board",
"request": {
"method": "DELETE",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/boards/user/1/123", "host": ["{{baseUrl}}"], "path": ["boards", "user", "1", "123"] }
}
}
]
},
{
"name": "Tasks",
"item": [
{
"name": "Get All Tasks for Board",
"request": {
"method": "GET",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/tasks/board/123", "host": ["{{baseUrl}}"], "path": ["tasks", "board", "123"] }
}
},
{
"name": "Get Task by ID",
"request": {
"method": "GET",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/tasks/board/123/456", "host": ["{{baseUrl}}"], "path": ["tasks", "board", "123", "456"] }
}
},
{
"name": "Add Task",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"title\": \"New Task\",\n \"description\": \"Task details\",\n \"status\": \"todo\"\n}" },
"url": { "raw": "{{baseUrl}}/tasks/board/123", "host": ["{{baseUrl}}"], "path": ["tasks", "board", "123"] }
}
},
{
"name": "Update Task",
"request": {
"method": "PUT",
"header": [{"key": "Content-Type", "value": "application/json"}, {"key": "Authorization", "value": "Bearer testtoken"}],
"body": { "mode": "raw", "raw": "{\n \"title\": \"Updated Task\",\n \"description\": \"Updated details\",\n \"status\": \"done\"\n}" },
"url": { "raw": "{{baseUrl}}/tasks/board/123/456", "host": ["{{baseUrl}}"], "path": ["tasks", "board", "123", "456"] }
}
},
{
"name": "Delete Task",
"request": {
"method": "DELETE",
"header": [{"key": "Authorization", "value": "Bearer testtoken"}],
"url": { "raw": "{{baseUrl}}/tasks/board/123/456", "host": ["{{baseUrl}}"], "path": ["tasks", "board", "123", "456"] }
}
}
]
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 ReStruct Corporate Advantage
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Seer
Armco Search and Insights APIs, analytics endpoint, search results retriever, data enrichers, adapters
## About
This project was created with [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript).
## Available Scripts
### `npm run dev`
Run the server in development mode.
### `npm test`
Run all unit-tests with hot-reloading.
### `npm test -- --testFile="name of test file" (i.e. --testFile=Users).`
Run a single unit-test.
### `npm run test:no-reloading`
Run all unit-tests without hot-reloading.
### `npm run lint`
Check for linting errors.
### `npm run build`
Build the project for production.
### `npm start`
Run the production build (Must be built first).
### `npm start -- --env="name of env file" (default is production).`
Run production build with a different env file.
## Additional Notes
- If `npm run dev` gives you issues with bcrypt on MacOS you may need to run: `npm rebuild bcrypt --build-from-source`.

64
build.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Remove old files, copy front-end ones.
*/
import fs from 'fs-extra';
import logger from 'jet-logger';
import childProcess from 'child_process';
/**
* Start
*/
(async () => {
try {
// Remove current build
await remove('./dist/');
// Copy front-end files
await copy('./src/public', './dist/public');
await copy('./src/views', './dist/views');
// Copy back-end files
await exec('tsc --build tsconfig.prod.json', './');
} catch (err) {
logger.err(err);
}
})();
/**
* Remove file
*/
function remove(loc: string): Promise<void> {
return new Promise((res, rej) => {
return fs.remove(loc, (err) => {
return (!!err ? rej(err) : res());
});
});
}
/**
* Copy file.
*/
function copy(src: string, dest: string): Promise<void> {
return new Promise((res, rej) => {
return fs.copy(src, dest, (err) => {
return (!!err ? rej(err) : res());
});
});
}
/**
* Do command line command.
*/
function exec(cmd: string, loc: string): Promise<void> {
return new Promise((res, rej) => {
return childProcess.exec(cmd, {cwd: loc}, (err, stdout, stderr) => {
if (!!stdout) {
logger.info(stdout);
}
if (!!stderr) {
logger.warn(stderr);
}
return (!!err ? rej(err) : res());
});
});
}

24
env/development.env vendored Normal file
View File

@@ -0,0 +1,24 @@
## Environment ##
NODE_ENV=development
## Server ##
PORT=3000
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

25
env/production.env vendored Normal file
View File

@@ -0,0 +1,25 @@
## Environment ##
NODE_ENV=production
## Server ##
PORT=8081
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=FILE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
# SECURE_COOKIE 'false' here for demo-ing. But ideally should be true.
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

24
env/test.env vendored Normal file
View File

@@ -0,0 +1,24 @@
## Environment ##
NODE_ENV=test
## Server ##
PORT=4000
HOST=localhost
## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
## Authentication ##
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

7965
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "ara-kanban-service",
"version": "0.0.0",
"scripts": {
"build": "npx ts-node build.ts",
"lint": "npx eslint --ext .ts src/",
"lint:tests": "npx eslint --ext .ts spec/",
"start": "node -r module-alias/register ./dist --env=production",
"dev": "PORT=5000 nodemon",
"test": "nodemon --config ./spec/nodemon.json",
"test:no-reloading": "npx ts-node --files -r tsconfig-paths/register ./spec"
},
"nodemonConfig": {
"watch": [
"src"
],
"ext": "ts, html",
"ignore": [
"src/public"
],
"exec": "./node_modules/.bin/ts-node --files -r tsconfig-paths/register ./src"
},
"_moduleAliases": {
"@src": "dist"
},
"engines": {
"node": ">=8.10.0"
},
"dependencies": {
"@prisma/client": "^6.16.2",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"helmet": "^7.2.0",
"inserturlparams": "^1.0.1",
"jet-logger": "^1.3.1",
"jet-validator": "^1.1.1",
"jsonfile": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"module-alias": "^2.2.3",
"morgan": "^1.10.0",
"prisma": "^6.16.2",
"ts-command-line-args": "^2.5.1"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.17",
"@types/find": "^0.2.1",
"@types/fs-extra": "^11.0.1",
"@types/jasmine": "^4.3.5",
"@types/jsonfile": "^6.1.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.4",
"@types/node": "^20.4.2",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.44.0",
"eslint-plugin-node": "^11.1.0",
"find": "^0.3.0",
"fs-extra": "^11.1.1",
"jasmine": "^5.0.2",
"nodemon": "^3.0.1",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,68 @@
-- CreateEnum
CREATE TYPE "public"."UserRole" AS ENUM ('Standard', 'Admin');
-- CreateEnum
CREATE TYPE "public"."Status" AS ENUM ('todo', 'in_progress', 'done');
-- CreateTable
CREATE TABLE "public"."User" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"pwdHash" TEXT,
"role" "public"."UserRole" NOT NULL DEFAULT 'Standard',
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Board" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Board_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."TaskList" (
"id" SERIAL NOT NULL,
"boardId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TaskList_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Task" (
"id" SERIAL NOT NULL,
"boardId" INTEGER NOT NULL,
"taskListId" INTEGER,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "public"."Status" NOT NULL DEFAULT 'todo',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");
-- AddForeignKey
ALTER TABLE "public"."Board" ADD CONSTRAINT "Board_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."TaskList" ADD CONSTRAINT "TaskList_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "public"."Board"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Task" ADD CONSTRAINT "Task_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "public"."Board"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Task" ADD CONSTRAINT "Task_taskListId_fkey" FOREIGN KEY ("taskListId") REFERENCES "public"."TaskList"("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"

70
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,70 @@
// 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 = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
pwdHash String?
role UserRole @default(Standard)
boards Board[]
}
model Board {
id Int @id @default(autoincrement())
userId Int
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
tasks Task[]
taskLists TaskList[]
}
model TaskList {
id Int @id @default(autoincrement())
boardId Int
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id])
tasks Task[]
}
model Task {
id Int @id @default(autoincrement())
boardId Int
taskListId Int?
title String
description String?
status Status @default(todo)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id])
taskList TaskList? @relation(fields: [taskListId], references: [id])
}
enum UserRole {
Standard
Admin
}
enum Status {
todo
in_progress
done
}

77
spec/index.ts Normal file
View File

@@ -0,0 +1,77 @@
import dotenv from 'dotenv';
import find from 'find';
import Jasmine from 'jasmine';
import { parse } from 'ts-command-line-args';
import logger from 'jet-logger';
// **** Types **** //
interface IArgs {
testFile: string;
}
// **** Setup **** //
// ** Init ** //
// NOTE: MUST BE FIRST!! Load env vars
const result2 = dotenv.config({
path: './env/test.env',
});
if (result2.error) {
throw result2.error;
}
// Setup command line options.
const args = parse<IArgs>({
testFile: {
type: String,
defaultValue: '',
},
});
// ** Start Jasmine ** //
// Init Jasmine
const jasmine = new Jasmine();
jasmine.exitOnCompletion = false;
// Set location of test files
jasmine.loadConfig({
random: true,
spec_dir: 'spec',
spec_files: [
'./tests/**/*.spec.ts',
],
stopSpecOnExpectationFailure: false,
});
// Run all or a single unit-test
let execResp: Promise<jasmine.JasmineDoneInfo> | undefined;
if (args.testFile) {
const testFile = args.testFile;
find.file(testFile + '.spec.ts', './spec', (files: string[]) => {
if (files.length === 1) {
jasmine.execute([files[0]]);
} else {
logger.err('Test file not found!');
}
});
} else {
execResp = jasmine.execute();
}
// Wait for tests to finish
(async () => {
if (!!execResp) {
const info = await execResp;
if (info.overallStatus === 'passed') {
logger.info('All tests have passed :)');
} else {
logger.err('At least one test has failed :(');
}
}
})();

6
spec/nodemon.json Normal file
View File

@@ -0,0 +1,6 @@
{
"watch": ["spec"],
"ext": "spec.ts",
"ignore": ["spec/support"],
"exec": "./node_modules/.bin/ts-node --files -r tsconfig-paths/register ./spec"
}

11
spec/support/jasmine.json Normal file
View File

@@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.ts"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": true
}

1
spec/support/types.ts Normal file
View File

@@ -0,0 +1 @@
export type TReqBody = string | object | undefined;

213
spec/tests/users.spec.ts Normal file
View File

@@ -0,0 +1,213 @@
import supertest, { SuperTest, Test, Response } from 'supertest';
import { defaultErrMsg as ValidatorErr } from 'jet-validator';
import insertUrlParams from 'inserturlparams';
import app from '@src/server';
import UserRepo from '@src/repos/user.repo';
import User from '@src/models/user.model';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { USER_NOT_FOUND_ERR } from '@src/services/UserService';
import FullPaths from '@src/routes/constants/FullPaths';
import { TReqBody } from 'spec/support/types';
// **** Variables **** //
// Paths
const {
Get,
Add,
Update,
Delete,
} = FullPaths.Users;
// StatusCodes
const {
OK,
CREATED,
NOT_FOUND,
BAD_REQUEST,
} = HttpStatusCodes;
// Dummy users for GET req
const DummyGetAllUsers = [
User.new('Sean Maxwell', 'sean.maxwell@gmail.com'),
User.new('John Smith', 'john.smith@gmail.com'),
User.new('Gordan Freeman', 'gordan.freeman@gmail.com'),
] as const;
// Dummy update user
const DummyUserData = {
user: User.new('Gordan Freeman', 'gordan.freeman@gmail.com'),
} as const;
// **** Tests **** //
describe('UserRouter', () => {
let agent: SuperTest<Test>;
// Run before all tests
beforeAll((done) => {
agent = supertest.agent(app);
done();
});
// ** Get all users ** //
describe(`"GET:${Get}"`, () => {
const callApi = () => agent.get(Get);
// Success
it('should return a JSON object with all the users and a status code ' +
`of "${OK}" if the request was successful.`, (done) => {
// Add spy
spyOn(UserRepo, 'getAll').and.resolveTo([...DummyGetAllUsers]);
// Call API
callApi()
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
for (let i = 0; i < res.body.users.length; i++) {
const user = res.body.users[i];
expect(user).toEqual(DummyGetAllUsers[i]);
}
done();
});
});
});
// Test add user
describe(`"POST:${Add}"`, () => {
const callApi = (reqBody: TReqBody) =>
agent
.post(Add)
.type('form').send(reqBody);
// Test add user success
it(`should return a status code of "${CREATED}" if the request was ` +
'successful.', (done) => {
// Spy
spyOn(UserRepo, 'add').and.resolveTo();
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(CREATED);
expect(res.body.error).toBeUndefined();
done();
});
});
// Missing param
it('should return a JSON object with an error message of ' +
`"${ValidatorErr}" and a status code of "${BAD_REQUEST}" if the user ` +
'param was missing.', (done) => {
// Call api
callApi({})
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
});
// ** Update users ** //
describe(`"PUT:${Update}"`, () => {
const callApi = (reqBody: TReqBody) =>
agent
.put(Update)
.type('form').send(reqBody);
// Success
it(`should return a status code of "${OK}" if the request was successful.`,
(done) => {
// Setup spies
spyOn(UserRepo, 'update').and.resolveTo();
spyOn(UserRepo, 'persists').and.resolveTo(true);
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
expect(res.body.error).toBeUndefined();
done();
});
});
// Param missing
it('should return a JSON object with an error message of ' +
`"${ValidatorErr}" and a status code of "${BAD_REQUEST}" if the user ` +
'param was missing.', (done) => {
// Call api
callApi({})
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
// User not found
it('should return a JSON object with the error message of ' +
`"${USER_NOT_FOUND_ERR}" and a status code of "${NOT_FOUND}" if the id ` +
'was not found.', (done) => {
// Call api
callApi(DummyUserData)
.end((_: Error, res: Response) => {
expect(res.status).toBe(NOT_FOUND);
expect(res.body.error).toBe(USER_NOT_FOUND_ERR);
done();
});
});
});
// ** Delete user ** //
describe(`"DELETE:${Delete}"`, () => {
const callApi = (id: number) =>
agent
.delete(insertUrlParams(Delete, { id }));
// Success
it(`should return a status code of "${OK}" if the request was successful.`,
(done) => {
// Setup spies
spyOn(UserRepo, 'delete').and.resolveTo();
spyOn(UserRepo, 'persists').and.resolveTo(true);
// Call api
callApi(5)
.end((_: Error, res: Response) => {
expect(res.status).toBe(OK);
expect(res.body.error).toBeUndefined();
done();
});
});
// User not found
it('should return a JSON object with the error message of ' +
`"${USER_NOT_FOUND_ERR}" and a status code of "${NOT_FOUND}" if the id ` +
'was not found.', (done) => {
callApi(-1)
.end((_: Error, res: Response) => {
expect(res.status).toBe(NOT_FOUND);
expect(res.body.error).toBe(USER_NOT_FOUND_ERR);
done();
});
});
// Invalid param
it(`should return a status code of "${BAD_REQUEST}" and return an error ` +
`message of "${ValidatorErr}" if the id was not a valid number`, (done) => {
callApi('horse' as unknown as number)
.end((_: Error, res: Response) => {
expect(res.status).toBe(BAD_REQUEST);
expect(res.body.error).toBe(ValidatorErr);
done();
});
});
});
});

14
spec/types/supertest/index.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import { IUser } from '@src/models/user.model';
import 'supertest';
declare module 'supertest' {
export interface Response {
headers: Record<string, string[]>;
body: {
error: string;
users: IUser[];
};
}
}

30
src/constants/EnvVars.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Environments variables declared here.
*/
import { NodeEnvs } from './misc';
/* eslint-disable node/no-process-env */
export default {
NodeEnv: (process.env.NODE_ENV as NodeEnvs ?? ''),
Port: (process.env.PORT ?? 0),
CookieProps: {
Key: 'ExpressGeneratorTs',
Secret: (process.env.COOKIE_SECRET ?? ''),
// Casing to match express cookie options
Options: {
httpOnly: true,
signed: true,
path: (process.env.COOKIE_PATH ?? ''),
maxAge: Number(process.env.COOKIE_EXP ?? 0),
domain: (process.env.COOKIE_DOMAIN ?? ''),
secure: (process.env.SECURE_COOKIE === 'true'),
},
},
Jwt: {
Secret: (process.env.JWT_SECRET ?? ''),
Exp: (process.env.COOKIE_EXP && process.env.COOKIE_EXP !== '' ? process.env.COOKIE_EXP : '1h'), // exp at the same time as the cookie
},
} as const;

View File

@@ -0,0 +1,386 @@
/* eslint-disable max-len */
/**
* This file was copied from here: https://gist.github.com/scokmen/f813c904ef79022e84ab2409574d1b45
*/
/**
* Hypertext Transfer Protocol (HTTP) response status codes.
* @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
*/
enum HttpStatusCodes {
/**
* The server has received the request headers and the client should proceed to send the request body
* (in the case of a request for which a body needs to be sent; for example, a POST request).
* Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient.
* To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request
* and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued.
*/
CONTINUE = 100,
/**
* The requester has asked the server to switch protocols and the server has agreed to do so.
*/
SWITCHING_PROTOCOLS = 101,
/**
* A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request.
* This code indicates that the server has received and is processing the request, but no response is available yet.
* This prevents the client from timing out and assuming the request was lost.
*/
PROCESSING = 102,
/**
* Standard response for successful HTTP requests.
* The actual response will depend on the request method used.
* In a GET request, the response will contain an entity corresponding to the requested resource.
* In a POST request, the response will contain an entity describing or containing the result of the action.
*/
OK = 200,
/**
* The request has been fulfilled, resulting in the creation of a new resource.
*/
CREATED = 201,
/**
* The request has been accepted for processing, but the processing has not been completed.
* The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
*/
ACCEPTED = 202,
/**
* SINCE HTTP/1.1
* The server is a transforming proxy that received a 200 OK from its origin,
* but is returning a modified version of the origin's response.
*/
NON_AUTHORITATIVE_INFORMATION = 203,
/**
* The server successfully processed the request and is not returning any content.
*/
NO_CONTENT = 204,
/**
* The server successfully processed the request, but is not returning any content.
* Unlike a 204 response, this response requires that the requester reset the document view.
*/
RESET_CONTENT = 205,
/**
* The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
* The range header is used by HTTP clients to enable resuming of interrupted downloads,
* or split a download into multiple simultaneous streams.
*/
PARTIAL_CONTENT = 206,
/**
* The message body that follows is an XML message and can contain a number of separate response codes,
* depending on how many sub-requests were made.
*/
MULTI_STATUS = 207,
/**
* The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response,
* and are not being included again.
*/
ALREADY_REPORTED = 208,
/**
* The server has fulfilled a request for the resource,
* and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
*/
IM_USED = 226,
/**
* Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation).
* For example, this code could be used to present multiple video format options,
* to list files with different filename extensions, or to suggest word-sense disambiguation.
*/
MULTIPLE_CHOICES = 300,
/**
* This and all future requests should be directed to the given URI.
*/
MOVED_PERMANENTLY = 301,
/**
* This is an example of industry practice contradicting the standard.
* The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect
* (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302
* with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307
* to distinguish between the two behaviours. However, some Web applications and frameworks
* use the 302 status code as if it were the 303.
*/
FOUND = 302,
/**
* SINCE HTTP/1.1
* The response to the request can be found under another URI using a GET method.
* When received in response to a POST (or PUT/DELETE), the client should presume that
* the server has received the data and should issue a redirect with a separate GET message.
*/
SEE_OTHER = 303,
/**
* Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match.
* In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
*/
NOT_MODIFIED = 304,
/**
* SINCE HTTP/1.1
* The requested resource is available only through a proxy, the address for which is provided in the response.
* Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.
*/
USE_PROXY = 305,
/**
* No longer used. Originally meant "Subsequent requests should use the specified proxy."
*/
SWITCH_PROXY = 306,
/**
* SINCE HTTP/1.1
* In this case, the request should be repeated with another URI; however, future requests should still use the original URI.
* In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request.
* For example, a POST request should be repeated using another POST request.
*/
TEMPORARY_REDIRECT = 307,
/**
* The request and all future requests should be repeated using another URI.
* 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change.
* So, for example, submitting a form to a permanently redirected resource may continue smoothly.
*/
PERMANENT_REDIRECT = 308,
/**
* The server cannot or will not process the request due to an apparent client error
* (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing).
*/
BAD_REQUEST = 400,
/**
* Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet
* been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the
* requested resource. See Basic access authentication and Digest access authentication. 401 semantically means
* "unauthenticated",i.e. the user does not have the necessary credentials.
*/
UNAUTHORIZED = 401,
/**
* Reserved for future use. The original intention was that this code might be used as part of some form of digital
* cash or micro payment scheme, but that has not happened, and this code is not usually used.
* Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.
*/
PAYMENT_REQUIRED = 402,
/**
* The request was valid, but the server is refusing action.
* The user might not have the necessary permissions for a resource.
*/
FORBIDDEN = 403,
/**
* The requested resource could not be found but may be available in the future.
* Subsequent requests by the client are permissible.
*/
NOT_FOUND = 404,
/**
* A request method is not supported for the requested resource;
* for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
*/
METHOD_NOT_ALLOWED = 405,
/**
* The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
*/
NOT_ACCEPTABLE = 406,
/**
* The client must first authenticate itself with the proxy.
*/
PROXY_AUTHENTICATION_REQUIRED = 407,
/**
* The server timed out waiting for the request.
* According to HTTP specifications:
* "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time."
*/
REQUEST_TIMEOUT = 408,
/**
* Indicates that the request could not be processed because of conflict in the request,
* such as an edit conflict between multiple simultaneous updates.
*/
CONFLICT = 409,
/**
* Indicates that the resource requested is no longer available and will not be available again.
* This should be used when a resource has been intentionally removed and the resource should be purged.
* Upon receiving a 410 status code, the client should not request the resource in the future.
* Clients such as search engines should remove the resource from their indices.
* Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
*/
GONE = 410,
/**
* The request did not specify the length of its content, which is required by the requested resource.
*/
LENGTH_REQUIRED = 411,
/**
* The server does not meet one of the preconditions that the requester put on the request.
*/
PRECONDITION_FAILED = 412,
/**
* The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".
*/
PAYLOAD_TOO_LARGE = 413,
/**
* The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request,
* in which case it should be converted to a POST request.
* Called "Request-URI Too Long" previously.
*/
URI_TOO_LONG = 414,
/**
* The request entity has a media type which the server or resource does not support.
* For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
*/
UNSUPPORTED_MEDIA_TYPE = 415,
/**
* The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
* For example, if the client asked for a part of the file that lies beyond the end of the file.
* Called "Requested Range Not Satisfiable" previously.
*/
RANGE_NOT_SATISFIABLE = 416,
/**
* The server cannot meet the requirements of the Expect request-header field.
*/
EXPECTATION_FAILED = 417,
/**
* This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol,
* and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by
* teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com.
*/
I_AM_A_TEAPOT = 418,
/**
* The request was directed at a server that is not able to produce a response (for example because a connection reuse).
*/
MISDIRECTED_REQUEST = 421,
/**
* The request was well-formed but was unable to be followed due to semantic errors.
*/
UNPROCESSABLE_ENTITY = 422,
/**
* The resource that is being accessed is locked.
*/
LOCKED = 423,
/**
* The request failed due to failure of a previous request (e.g., a PROPPATCH).
*/
FAILED_DEPENDENCY = 424,
/**
* The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.
*/
UPGRADE_REQUIRED = 426,
/**
* The origin server requires the request to be conditional.
* Intended to prevent "the 'lost update' problem, where a client
* GETs a resource's state, modifies it, and PUTs it back to the server,
* when meanwhile a third party has modified the state on the server, leading to a conflict."
*/
PRECONDITION_REQUIRED = 428,
/**
* The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
*/
TOO_MANY_REQUESTS = 429,
/**
* The server is unwilling to process the request because either an individual header field,
* or all the header fields collectively, are too large.
*/
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
/**
* A server operator has received a legal demand to deny access to a resource or to a set of resources
* that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451.
*/
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
/**
* A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
*/
INTERNAL_SERVER_ERROR = 500,
/**
* The server either does not recognize the request method, or it lacks the ability to fulfill the request.
* Usually this implies future availability (e.g., a new feature of a web-service API).
*/
NOT_IMPLEMENTED = 501,
/**
* The server was acting as a gateway or proxy and received an invalid response from the upstream server.
*/
BAD_GATEWAY = 502,
/**
* The server is currently unavailable (because it is overloaded or down for maintenance).
* Generally, this is a temporary state.
*/
SERVICE_UNAVAILABLE = 503,
/**
* The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
*/
GATEWAY_TIMEOUT = 504,
/**
* The server does not support the HTTP protocol version used in the request
*/
HTTP_VERSION_NOT_SUPPORTED = 505,
/**
* Transparent content negotiation for the request results in a circular reference.
*/
VARIANT_ALSO_NEGOTIATES = 506,
/**
* The server is unable to store the representation needed to complete the request.
*/
INSUFFICIENT_STORAGE = 507,
/**
* The server detected an infinite loop while processing the request.
*/
LOOP_DETECTED = 508,
/**
* Further extensions to the request are required for the server to fulfill it.
*/
NOT_EXTENDED = 510,
/**
* The client needs to authenticate to gain network access.
* Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used
* to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).
*/
NETWORK_AUTHENTICATION_REQUIRED = 511
}
export default HttpStatusCodes;

5
src/constants/misc.ts Normal file
View File

@@ -0,0 +1,5 @@
export enum NodeEnvs {
Dev = 'development',
Test = 'test',
Production = 'production'
}

View File

@@ -0,0 +1,103 @@
import { Request, Response } from 'express';
import BoardRepo from '@src/repos/board.repo';
import { IBoard } from '@src/models/board.model';
class BoardController {
// Get all boards for a user
public getBoardsByUserId = async (req: Request, res: Response) => {
console.log('board.controller.ts getBoardsByUserId called with userId:', req.params.userId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const userId = +req.params.userId;
const boards = await BoardRepo.getBoardsByUserId(userId);
return res.json({ boards });
} catch (err) {
console.error('board.controller.ts getBoardsByUserId error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Get single board by userId and boardId
public getBoardByUserId = async (req: Request, res: Response) => {
console.log('board.controller.ts getBoardByUserId called with userId:', req.params.userId, 'boardId:', req.params.boardId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const userId = +req.params.userId;
const boardId = +req.params.boardId;
const board = await BoardRepo.getBoardByUserId(userId, boardId);
return res.json({ board });
} catch (err) {
console.error('board.controller.ts getBoardByUserId error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Create board for a user
public createBoard = async (req: Request, res: Response) => {
console.log('board.controller.ts createBoard called with userId:', req.params.userId, 'body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const boardData = req.body as IBoard;
if (!boardData.name) {
return res.status(400).json({ error: 'Board name required' });
}
const userId = +req.params.userId;
const board = await BoardRepo.createBoard(userId, boardData);
return res.status(201).json({ board });
} catch (err) {
console.error('board.controller.ts createBoard error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Update board for a user
public updateBoard = async (req: Request, res: Response) => {
console.log('board.controller.ts updateBoard called with userId:', req.params.userId, 'boardId:', req.params.boardId, 'body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const boardData = req.body as IBoard;
if (!boardData.name) {
return res.status(400).json({ error: 'Board name required' });
}
const userId = +req.params.userId;
const boardId = +req.params.boardId;
const board = await BoardRepo.updateBoard(userId, boardId, boardData);
return res.json({ board });
} catch (err) {
console.error('board.controller.ts updateBoard error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Delete board for a user
public deleteBoard = async (req: Request, res: Response) => {
console.log('board.controller.ts deleteBoard called with userId:', req.params.userId, 'boardId:', req.params.boardId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const userId = +req.params.userId;
const boardId = +req.params.boardId;
await BoardRepo.deleteBoard(userId, boardId);
return res.status(204).end();
} catch (err) {
console.error('board.controller.ts deleteBoard error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
}
export default new BoardController();

View File

@@ -0,0 +1,105 @@
import { Request, Response } from 'express';
import TaskRepo from '@src/repos/task.repo';
import { ITask } from '@src/models/task.model';
class TaskController {
// Get all tasks for a board
public getTasksByBoardId = async (req: Request, res: Response) => {
console.log('task.controller.ts getTasksByBoardId called with boardId:', req.params.boardId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const boardId = +req.params.boardId;
const tasks = await TaskRepo.getTasksByBoardId(boardId);
return res.json({ tasks });
} catch (err) {
console.error('task.controller.ts getTasksByBoardId error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Get single task by boardId and taskId
public getTaskByBoardId = async (req: Request, res: Response) => {
console.log('task.controller.ts getTaskByBoardId called with boardId:', req.params.boardId, 'taskId:', req.params.taskId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const boardId = +req.params.boardId;
const taskId = +req.params.taskId;
const task = await TaskRepo.getTaskByBoardId(boardId, taskId);
return res.json({ task });
} catch (err) {
console.error('task.controller.ts getTaskByBoardId error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Create task for a board
public createTask = async (req: Request, res: Response) => {
console.log('task.controller.ts createTask called with boardId:', req.params.boardId, 'body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
// Dummy form validation
const taskData = req.body as ITask;
if (!taskData.title) {
return res.status(400).json({ error: 'Task title required' });
}
const boardId = +req.params.boardId;
const task = await TaskRepo.createTask(boardId, taskData);
return res.status(201).json({ task });
} catch (err) {
console.error('task.controller.ts createTask error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Update task for a board
public updateTask = async (req: Request, res: Response) => {
console.log('task.controller.ts updateTask called with boardId:', req.params.boardId, 'taskId:', req.params.taskId, 'body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
// Dummy form validation
const taskData = req.body as ITask;
if (!taskData.title) {
return res.status(400).json({ error: 'Task title required' });
}
const boardId = +req.params.boardId;
const taskId = +req.params.taskId;
const task = await TaskRepo.updateTask(boardId, taskId, taskData);
return res.json({ task });
} catch (err) {
console.error('task.controller.ts updateTask error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
// Delete task for a board
public deleteTask = async (req: Request, res: Response) => {
console.log('task.controller.ts deleteTask called with boardId:', req.params.boardId, 'taskId:', req.params.taskId);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const boardId = +req.params.boardId;
const taskId = +req.params.taskId;
await TaskRepo.deleteTask(boardId, taskId);
return res.status(204).end();
} catch (err) {
console.error('task.controller.ts deleteTask error:', err);
return res.status(500).json({ error: 'Internal server error',
details: String(err) });
}
};
}
export default new TaskController();

View File

@@ -0,0 +1,90 @@
import { Request, Response } from 'express';
import TaskListRepo from '@src/repos/taskList.repo';
import { ITaskList } from '@src/models/taskList.model';
// Extend ITaskList to include boardId for controller usage
type TaskListInput = ITaskList & { boardId: number };
class TaskListController {
public getAll = async (_: Request, res: Response) => {
console.log('taskList.controller.ts getAll called');
try {
const lists = await TaskListRepo.getAll();
return res.json({ lists });
} catch (err) {
console.error('taskList.controller.ts getAll error:', err);
return res.status(500).json({
error: 'Internal server error',
details: String(err),
});
}
};
public getById = async (req: Request, res: Response) => {
console.log('taskList.controller.ts getById called with id:', req.params.id);
try {
const id = +req.params.id;
const list = await TaskListRepo.getById(id);
return res.json({ list });
} catch (err) {
console.error('taskList.controller.ts getById error:', err);
return res.status(500).json({
error: 'Internal server error',
details: String(err),
});
}
};
public create = async (req: Request, res: Response) => {
console.log('taskList.controller.ts create called with boardId:', req.body.boardId, 'body:', req.body);
try {
const data = req.body as TaskListInput;
if (!data.name) {
return res.status(400).json({ error: 'Name required' });
}
if (!data.boardId) {
return res.status(400).json({ error: 'boardId required' });
}
const list = await TaskListRepo.create(Number(data.boardId), data);
return res.status(201).json({ list });
} catch (err) {
console.error('taskList.controller.ts create error:', err);
return res.status(500).json({
error: 'Internal server error',
details: String(err),
});
}
};
public update = async (req: Request, res: Response) => {
console.log('taskList.controller.ts update called with id:', req.params.id, 'body:', req.body);
try {
const id = +req.params.id;
const data = req.body as Partial<ITaskList>;
const list = await TaskListRepo.update(id, data);
return res.json({ list });
} catch (err) {
console.error('taskList.controller.ts update error:', err);
return res.status(500).json({
error: 'Internal server error',
details: String(err),
});
}
};
public remove = async (req: Request, res: Response) => {
console.log('taskList.controller.ts remove called with id:', req.params.id);
try {
const id = +req.params.id;
const ok = await TaskListRepo.remove(id);
return res.json({ ok });
} catch (err) {
console.error('taskList.controller.ts remove error:', err);
return res.status(500).json({
error: 'Internal server error',
details: String(err),
});
}
};
}
export default new TaskListController();

View File

@@ -0,0 +1,66 @@
import { Request, Response } from 'express';
import UserRepo from '@src/repos/user.repo';
import { IUser } from '@src/models/user.model';
class UserController {
public getAll = async (req: Request, res: Response) => {
console.log('user.controller.ts getAll called');
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const users = await UserRepo.getAll();
return res.status(200).json({ users });
} catch (err) {
console.error('user.controller.ts getAll error:', err);
return res.status(500).json({ error: 'Internal server error', details: String(err) });
}
};
public add = async (req: Request, res: Response) => {
console.log('user.controller.ts add called with body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const user = req.body.user as IUser;
await UserRepo.add(user);
return res.status(201).end();
} catch (err) {
console.error('user.controller.ts add error:', err);
return res.status(500).json({ error: 'Internal server error', details: String(err) });
}
};
public update = async (req: Request, res: Response) => {
console.log('user.controller.ts update called with body:', req.body);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const user = req.body.user as IUser;
await UserRepo.update(user);
return res.status(200).end();
} catch (err) {
console.error('user.controller.ts update error:', err);
return res.status(500).json({ error: 'Internal server error', details: String(err) });
}
};
public delete = async (req: Request, res: Response) => {
console.log('user.controller.ts delete called with id:', req.params.id);
try {
if (req.headers.authorization !== 'Bearer testtoken') {
return res.status(401).json({ error: 'Unauthorized' });
}
const id = +req.params.id;
await UserRepo.delete(id);
return res.status(200).end();
} catch (err) {
console.error('user.controller.ts delete error:', err);
return res.status(500).json({ error: 'Internal server error', details: String(err) });
}
};
}
export default new UserController();

13
src/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import './pre-start'; // Must be the first import
import logger from 'jet-logger';
import EnvVars from '@src/constants/EnvVars';
import server from './server';
// **** Run **** //
const SERVER_START_MSG = ('Express server started on port: ' +
EnvVars.Port.toString());
server.listen(EnvVars.Port, () => logger.info(SERVER_START_MSG));

80
src/models/board.model.ts Normal file
View File

@@ -0,0 +1,80 @@
// **** Variables **** //
const INVALID_CONSTRUCTOR_PARAM = 'nameOrObj arg must be a string or an object with the appropriate board keys.';
// **** Types **** //
export interface IBoard {
id: number;
userId: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
// **** Functions **** //
/**
* Create new Board.
*/
function new_(
userId?: number,
name?: string,
description?: string,
createdAt?: string,
updatedAt?: string,
id?: number,
): IBoard {
const now = new Date().toISOString();
return {
id: (id ?? -1),
userId: (userId ?? -1),
name: (name ?? ''),
description,
createdAt: (createdAt ?? now),
updatedAt: (updatedAt ?? now),
};
}
/**
* Get board instance from object.
*/
function from(param: object): IBoard {
if (!isBoard(param)) {
throw new Error(INVALID_CONSTRUCTOR_PARAM);
}
const p = param as IBoard;
return new_(p.userId, p.name, p.description, p.createdAt, p.updatedAt, p.id);
}
/**
* See if the param meets criteria to be a board.
*/
function isBoard(arg: unknown): boolean {
return (
!!arg &&
typeof arg === 'object' &&
'id' in arg &&
'userId' in arg &&
'name' in arg &&
'createdAt' in arg &&
'updatedAt' in arg
);
}
// **** Export default **** //
export default {
new: new_,
from,
isBoard,
} as const;
export interface IBoard {
id: number;
userId: number;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}

86
src/models/task.model.ts Normal file
View File

@@ -0,0 +1,86 @@
// **** Variables **** //
const INVALID_CONSTRUCTOR_PARAM = 'titleOrObj arg must be a string or an \
object with the appropriate task keys.';
// **** Types **** //
export interface ITask {
id: number;
boardId: number;
title: string;
description?: string;
status: 'todo' | 'in-progress' | 'done';
createdAt: string;
updatedAt: string;
}
// **** Functions **** //
/**
* Create new Task.
*/
function new_(
boardId?: number,
title?: string,
status?: 'todo' | 'in-progress' | 'done',
description?: string,
createdAt?: string,
updatedAt?: string,
id?: number,
): ITask {
const now = new Date().toISOString();
return {
id: (id ?? -1),
boardId: (boardId ?? -1),
title: (title ?? ''),
status: (status ?? 'todo'),
description,
createdAt: (createdAt ?? now),
updatedAt: (updatedAt ?? now),
};
}
/**
* Get task instance from object.
*/
function from(param: object): ITask {
if (!isTask(param)) {
throw new Error(INVALID_CONSTRUCTOR_PARAM);
}
const p = param as ITask;
return new_(p.boardId, p.title, p.status, p.description, p.createdAt, p.updatedAt, p.id);
}
/**
* See if the param meets criteria to be a task.
*/
function isTask(arg: unknown): boolean {
return (
!!arg &&
typeof arg === 'object' &&
'id' in arg &&
'boardId' in arg &&
'title' in arg &&
'status' in arg &&
'createdAt' in arg &&
'updatedAt' in arg
);
}
// **** Export default **** //
export default {
new: new_,
from,
isTask,
} as const;
export interface ITask {
id: number;
boardId: number;
title: string;
description?: string;
status: 'todo' | 'in-progress' | 'done';
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,6 @@
export interface ITaskList {
id: number;
name: string;
createdAt?: string;
updatedAt?: string;
}

85
src/models/user.model.ts Normal file
View File

@@ -0,0 +1,85 @@
// **** Variables **** //
const INVALID_CONSTRUCTOR_PARAM = 'nameOrObj arg must a string or an ' +
'object with the appropriate user keys.';
export enum UserRoles {
Standard,
Admin,
}
// **** Types **** //
export interface IUser {
id: number;
name: string;
email: string;
pwdHash?: string;
role?: UserRoles;
}
export interface ISessionUser {
id: number;
email: string;
name: string;
role: IUser['role'];
}
// **** Functions **** //
/**
* Create new User.
*/
function new_(
name?: string,
email?: string,
role?: UserRoles,
pwdHash?: string,
id?: number, // id last cause usually set by db
): IUser {
return {
id: (id ?? -1),
name: (name ?? ''),
email: (email ?? ''),
role: (role ?? UserRoles.Standard),
pwdHash: (pwdHash ?? ''),
};
}
/**
* Get user instance from object.
*/
function from(param: object): IUser {
// Check is user
if (!isUser(param)) {
throw new Error(INVALID_CONSTRUCTOR_PARAM);
}
// Get user instance
const p = param as IUser;
return new_(p.name, p.email, p.role, p.pwdHash, p.id);
}
/**
* See if the param meets criteria to be a user.
*/
function isUser(arg: unknown): boolean {
return (
!!arg &&
typeof arg === 'object' &&
'id' in arg &&
'email' in arg &&
'name' in arg &&
'role' in arg
);
}
// **** Export default **** //
export default {
new: new_,
from,
isUser,
} as const;

17
src/other/classes.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Miscellaneous shared classes go here.
*/
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
/**
* Error with status code and message
*/
export class RouteError extends Error {
status: HttpStatusCodes;
constructor(status: HttpStatusCodes, message: string) {
super(message);
this.status = status;
}
}

4
src/other/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export type Immutable<T> = {
readonly [K in keyof T]: Immutable<T[K]>;
};

37
src/pre-start.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Pre-start is where we want to place things that must run BEFORE the express
* server is started. This is useful for environment variables, command-line
* arguments, and cron-jobs.
*/
// NOTE: DO NOT IMPORT ANY SOURCE CODE HERE
import path from 'path';
import dotenv from 'dotenv';
import { parse } from 'ts-command-line-args';
// **** Types **** //
interface IArgs {
env: string;
}
// **** Setup **** //
// Command line arguments
const args = parse<IArgs>({
env: {
type: String,
defaultValue: 'development',
alias: 'e',
},
});
// Set the env file
const result2 = dotenv.config({
path: path.join(__dirname, `../env/${args.env}.env`),
});
if (result2.error) {
throw result2.error;
}

View File

@@ -0,0 +1,24 @@
var Http = (() => {
// Setup request for json
var getOptions = (verb, data) => {
var options = {
dataType: 'json',
method: verb,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
};
if (data) {
options.body = JSON.stringify(data);
}
return options;
};
// Set Http methods
return {
get: (path) => fetch(path, getOptions('GET')),
post: (path, data) => fetch(path, getOptions('POST', data)),
put: (path, data) => fetch(path, getOptions('PUT', data)),
delete: (path) => fetch(path, getOptions('DELETE')),
};
})();

164
src/public/scripts/users.js Normal file
View File

@@ -0,0 +1,164 @@
// ***** Start **** //
displayUsers();
// ***** Fetch and display users **** //
/**
* Call api
*/
function displayUsers() {
Http
.get('/api/users/all')
.then(resp => resp.json())
.then((resp) => {
var allUsers = resp.users;
// Empty the anchor
var allUsersAnchor = document.getElementById('all-users-anchor');
allUsersAnchor.innerHTML = '';
// Append users to anchor
allUsers.forEach((user) => {
allUsersAnchor.innerHTML += getUserDisplayEle(user);
});
});
}
/**
* Get user display element
*/
function getUserDisplayEle(user) {
return (
`<div class="user-display-ele">
<div class="normal-view">
<div>Name: ${user.name}</div>
<div>Email: ${user.email}</div>
<button class="edit-user-btn" data-user-id="${user.id}" data-user-role="${user.role}">
Edit
</button>
<button class="delete-user-btn" data-user-id="${user.id}">
Delete
</button>
</div>
<div class="edit-view">
<div>
Name: <input class="name-edit-input" value="${user.name}">
</div>
<div>
Email: <input class="email-edit-input" value="${user.email}">
</div>
<button class="submit-edit-btn" data-user-id="${user.id}">
Submit
</button>
<button class="cancel-edit-btn" data-user-id="${user.id}">
Cancel
</button>
</div>
</div>`
);
}
// **** Add, Edit, and Delete Users **** //
// Setup event listener for button click
document.addEventListener('click', function (event) {
event.preventDefault();
var ele = event.target;
if (ele.matches('#add-user-btn')) {
addUser();
} else if (ele.matches('.edit-user-btn')) {
showEditView(ele.parentNode.parentNode);
} else if (ele.matches('.cancel-edit-btn')) {
cancelEdit(ele.parentNode.parentNode);
} else if (ele.matches('.submit-edit-btn')) {
submitEdit(ele);
} else if (ele.matches('.delete-user-btn')) {
deleteUser(ele);
} else if (ele.matches('#logout-btn')) {
logoutUser();
}
}, false);
/**
* Add a new user.
*/
function addUser() {
var nameInput = document.getElementById('name-input');
var emailInput = document.getElementById('email-input');
var data = {
user: {
id: -1,
name: nameInput.value,
email: emailInput.value,
role: 0,
},
};
// Call api
Http
.post('/api/users/add', data)
.then(() => displayUsers());
}
/**
* Show edit view.
*/
function showEditView(userEle) {
var normalView = userEle.getElementsByClassName('normal-view')[0];
var editView = userEle.getElementsByClassName('edit-view')[0];
normalView.style.display = 'none';
editView.style.display = 'block';
}
/**
* Cancel edit.
*/
function cancelEdit(userEle) {
var normalView = userEle.getElementsByClassName('normal-view')[0];
var editView = userEle.getElementsByClassName('edit-view')[0];
normalView.style.display = 'block';
editView.style.display = 'none';
}
/**
* Submit edit.
*/
function submitEdit(ele) {
var userEle = ele.parentNode.parentNode;
var nameInput = userEle.getElementsByClassName('name-edit-input')[0];
var emailInput = userEle.getElementsByClassName('email-edit-input')[0];
var id = ele.getAttribute('data-user-id');
var role = ele.getAttribute('data-user-role');
var data = {
user: {
id: Number(id),
name: nameInput.value,
email: emailInput.value,
role: Number(role),
},
};
Http
.put('/api/users/update', data)
.then(() => displayUsers());
}
/**
* Delete a user
*/
function deleteUser(ele) {
var id = ele.getAttribute('data-user-id');
Http
.delete('/api/users/delete/' + id)
.then(() => displayUsers());
}
// **** Logout **** //
function logoutUser() {
Http
.get('/api/auth/logout')
.then(() => window.location.href = '/');
}

53
src/repos/BoardRepo.ts Normal file
View File

@@ -0,0 +1,53 @@
import { IBoard } from '@src/models/board.model';
import orm from './MockOrm';
async function getBoardsByUserId(userId: number): Promise<IBoard[]> {
const db = await orm.openDb();
return db.boards.filter((board: IBoard) => board.userId === userId);
}
async function getBoardByUserId(userId: number, boardId: number): Promise<IBoard | null> {
const db = await orm.openDb();
return db.boards.find((board: IBoard) => board.userId === userId && board.id === boardId) || null;
}
async function createBoard(userId: number, board: IBoard): Promise<IBoard> {
const db = await orm.openDb();
board.id = Date.now();
board.userId = userId;
board.createdAt = new Date().toISOString();
board.updatedAt = board.createdAt;
db.boards.push(board);
await orm.saveDb(db);
return board;
}
async function updateBoard(userId: number, boardId: number, boardData: Partial<IBoard>): Promise<IBoard | null> {
const db = await orm.openDb();
const board = db.boards.find((b: IBoard) => b.userId === userId && b.id === boardId);
if (board) {
Object.assign(board, boardData, { updatedAt: new Date().toISOString() });
await orm.saveDb(db);
return board;
}
return null;
}
async function deleteBoard(userId: number, boardId: number): Promise<boolean> {
const db = await orm.openDb();
const idx = db.boards.findIndex((b: IBoard) => b.userId === userId && b.id === boardId);
if (idx !== -1) {
db.boards.splice(idx, 1);
await orm.saveDb(db);
return true;
}
return false;
}
export default {
getBoardsByUserId,
getBoardByUserId,
createBoard,
updateBoard,
deleteBoard,
};

48
src/repos/MockOrm.ts Normal file
View File

@@ -0,0 +1,48 @@
import jsonfile from 'jsonfile';
import { IUser } from '@src/models/user.model';
import { IBoard } from '@src/models/board.model';
import { ITask } from '@src/models/task.model';
import { ITaskList } from '@src/models/taskList.model';
// **** Variables **** //
const DB_FILE_NAME = 'database.json';
// **** Types **** //
interface IDb {
users: IUser[];
boards: IBoard[];
tasks: ITask[];
taskLists: ITaskList[];
}
// **** Functions **** //
/**
* Fetch the json from the file.
*/
function openDb(): Promise<IDb> {
return jsonfile.readFile(__dirname + '/' + DB_FILE_NAME) as Promise<IDb>;
}
/**
* Update the file.
*/
function saveDb(db: IDb): Promise<void> {
return jsonfile.writeFile((__dirname + '/' + DB_FILE_NAME), db);
}
// **** Export default **** //
export default {
openDb,
saveDb,
} as const;

94
src/repos/board.repo.ts Normal file
View File

@@ -0,0 +1,94 @@
import { IBoard } from '@src/models/board.model';
import { PrismaClient } from '../../generated/prisma';
const prisma = new PrismaClient();
async function getBoardsByUserId(userId: number): Promise<IBoard[]> {
console.log('board.repo.ts getBoardsByUserId called with userId:', userId);
const boards = await prisma.board.findMany({ where: { userId } });
const mappedBoards = boards.map(b => ({
...b,
description: b.description === null ? undefined : b.description,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
console.log('board.repo.ts getBoardsByUserId result:', mappedBoards);
return mappedBoards;
}
async function getBoardByUserId(
userId: number,
boardId: number,
): Promise<IBoard | null> {
console.log('board.repo.ts getBoardByUserId called with userId:', userId, 'boardId:', boardId);
const b = await prisma.board.findFirst({ where: { userId, id: boardId } });
if (!b) return null;
const result = {
...b,
description: b.description === null ? undefined : b.description,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
};
console.log('board.repo.ts getBoardByUserId result:', result);
return result;
}
async function createBoard(userId: number, board: IBoard): Promise<IBoard> {
console.log('board.repo.ts createBoard called with userId:', userId, 'board:', board);
const b = await prisma.board.create({
data: {
userId,
name: board.name,
description: board.description,
},
});
const result = {
...b,
description: b.description === null ? undefined : b.description,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
};
console.log('board.repo.ts createBoard result:', result);
return result;
}
async function updateBoard(
userId: number,
boardId: number,
boardData: Partial<IBoard>,
): Promise<IBoard | null> {
console.log('board.repo.ts updateBoard called with userId:', userId, 'boardId:', boardId, 'boardData:', boardData);
const updated = await prisma.board.updateMany({
where: { id: boardId, userId },
data: { ...boardData },
});
if (updated.count > 0) {
const b = await prisma.board.findFirst({ where: { id: boardId, userId } });
if (!b) return null;
const result = {
...b,
description: b.description === null ? undefined : b.description,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
};
console.log('board.repo.ts updateBoard result:', result);
return result;
}
return null;
}
async function deleteBoard(userId: number, boardId: number): Promise<boolean> {
console.log('board.repo.ts deleteBoard called with userId:', userId, 'boardId:', boardId);
const deleted = await prisma.board.deleteMany({
where: { id: boardId, userId },
});
console.log('board.repo.ts deleteBoard completed for userId:', userId, 'boardId:', boardId);
return deleted.count > 0;
}
export default {
getBoardsByUserId,
getBoardByUserId,
createBoard,
updateBoard,
deleteBoard,
};

1
src/repos/database.json Normal file
View File

@@ -0,0 +1 @@
{"users":[{"name":"Sean Maxwell","email":"sean.maxwell@gmail.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":1,"id":159123164363},{"name":"Gordan Freeman","email":"gordan.freeman@halflife.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":0,"id":906524522143},{"name":"John Smith","email":"jsmith@yahoo.com","pwdHash":"$2b$12$1mE2OI9hMS/rgH9Mi0s85OM2V5gzm7aF3gJIWH1y0S1MqVBueyjsy","role":0,"id":357437875835},{"id":75800032258,"name":"asdf","email":"asdfasdf","role":0}],"boards":[{"name":"Project Board","id":1758960982352,"userId":1,"createdAt":"2025-09-27T08:16:22.352Z","updatedAt":"2025-09-27T08:16:22.352Z"},{"name":"asfdsadff","id":1758972779232,"userId":1,"createdAt":"2025-09-27T11:32:59.232Z","updatedAt":"2025-09-27T11:32:59.232Z"},{"name":"Marketing Plan","id":1758977959686,"userId":1,"createdAt":"2025-09-27T12:59:19.686Z","updatedAt":"2025-09-27T12:59:19.686Z"}],"tasks":[{"title":"New Task","description":"Task details","status":"todo","id":1758961254717,"boardId":1758960982352,"createdAt":"2025-09-27T08:20:54.717Z","updatedAt":"2025-09-27T08:20:54.717Z"},{"title":"New Task 2","description":"Task details 2","status":"todo","id":1758961267429,"boardId":1758960982352,"createdAt":"2025-09-27T08:21:07.429Z","updatedAt":"2025-09-27T08:21:07.429Z"},{"title":"New Task 3","description":"Dummy details","status":"in-progress","id":1758977469970,"boardId":1758960982352,"createdAt":"2025-09-27T12:51:09.970Z","updatedAt":"2025-09-27T12:51:09.970Z"},{"title":"New Task 4","description":"Dummy still","status":"done","id":1758977546230,"boardId":1758960982352,"createdAt":"2025-09-27T12:52:26.230Z","updatedAt":"2025-09-27T12:52:26.230Z"},{"title":"NT 5","description":"","status":"in-progress","id":1758977665176,"boardId":1758960982352,"createdAt":"2025-09-27T12:54:25.176Z","updatedAt":"2025-09-27T12:54:25.176Z"}]}

96
src/repos/task.repo.ts Normal file
View File

@@ -0,0 +1,96 @@
import { ITask } from '@src/models/task.model';
import { PrismaClient } from '../../generated/prisma';
const prisma = new PrismaClient();
async function getTasksByBoardId(boardId: number): Promise<ITask[]> {
const tasks = await prisma.task.findMany({ where: { boardId } });
return tasks.map(t => ({
...t,
description: t.description === null ? undefined : t.description,
status: t.status === 'in_progress' ? 'in-progress' : t.status,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
}));
}
async function getTaskByBoardId(boardId: number, taskId: number):
Promise<ITask | null> {
const t = await prisma.task.findFirst({ where: { boardId, id: taskId } });
if (!t) return null;
return {
...t,
description: t.description === null ? undefined : t.description,
status: t.status === 'in_progress' ? 'in-progress' : t.status,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
};
}
async function createTask(boardId: number, task: ITask): Promise<ITask> {
const prismaStatus = task.status === 'in-progress' ? 'in_progress' :
task.status;
console.log('task.repo.ts createTask called with task:', task);
const t = await prisma.task.create({
data: {
boardId,
title: task.title,
description: task.description,
status: prismaStatus,
},
});
console.log('task.repo.ts createTask result:', t);
return {
...t,
description: t.description === null ? undefined : t.description,
status: t.status === 'in_progress' ? 'in-progress' : t.status,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
};
}
async function updateTask(
boardId: number,
taskId: number,
taskData: Partial<ITask>,
): Promise<ITask | null> {
console.log('task.repo.ts updateTask called with taskId:', taskId, 'and taskData:', taskData);
const updateData: Partial<ITask> = { ...taskData };
// Remove id if present
delete updateData.id;
// Map status to Prisma enum
if (updateData.status === 'in-progress') updateData.status = 'in-progress';
const updated = await prisma.task.updateMany({
where: { id: taskId, boardId },
data: updateData as number,
});
if (updated.count > 0) {
const t = await prisma.task.findFirst({ where: { id: taskId, boardId } });
if (!t) return null;
console.log('task.repo.ts updateTask result:', t);
return {
...t,
description: t.description === null ? undefined : t.description,
status: t.status === 'in_progress' ? 'in-progress' : t.status,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
};
}
return null;
}
async function deleteTask(boardId: number, taskId: number): Promise<boolean> {
console.log('task.repo.ts deleteTask called with taskId:', taskId);
const deleted = await prisma.task.deleteMany({
where: { id: taskId, boardId },
});
console.log('task.repo.ts deleteTask completed for taskId:', taskId);
return deleted.count > 0;
}
export default {
getTasksByBoardId,
getTaskByBoardId,
createTask,
updateTask,
deleteTask,
};

View File

@@ -0,0 +1,77 @@
import { ITaskList } from '@src/models/taskList.model';
import { PrismaClient } from '../../generated/prisma';
const prisma = new PrismaClient();
async function getAll(): Promise<ITaskList[]> {
console.log('taskList.repo.ts getAll called');
const lists = await prisma.taskList.findMany();
const mappedLists = lists.map(l => ({
...l,
createdAt: l.createdAt.toISOString(),
updatedAt: l.updatedAt.toISOString(),
}));
console.log('taskList.repo.ts getAll result:', mappedLists);
return mappedLists;
}
async function getById(id: number): Promise<ITaskList | null> {
console.log('taskList.repo.ts getById called with id:', id);
const l = await prisma.taskList.findUnique({ where: { id } });
if (!l) return null;
const result = {
...l,
createdAt: l.createdAt.toISOString(),
updatedAt: l.updatedAt.toISOString(),
};
console.log('taskList.repo.ts getById result:', result);
return result;
}
async function create(boardId: number, list: ITaskList): Promise<ITaskList> {
console.log('taskList.repo.ts create called with boardId:', boardId, 'list:', list);
const l = await prisma.taskList.create({
data: {
name: list.name,
boardId,
},
});
const result = {
...l,
createdAt: l.createdAt.toISOString(),
updatedAt: l.updatedAt.toISOString(),
};
console.log('taskList.repo.ts create result:', result);
return result;
}
async function update(
id: number,
data: Partial<ITaskList>,
): Promise<ITaskList | null> {
console.log('taskList.repo.ts update called with id:', id, 'data:', data);
const updated = await prisma.taskList.updateMany({
where: { id },
data: { ...data },
});
if (updated.count > 0) {
const l = await prisma.taskList.findUnique({ where: { id } });
if (!l) return null;
const result = {
...l,
createdAt: l.createdAt.toISOString(),
updatedAt: l.updatedAt.toISOString(),
};
console.log('taskList.repo.ts update result:', result);
return result;
}
return null;
}
async function remove(id: number): Promise<boolean> {
console.log('taskList.repo.ts remove called with id:', id);
const deleted = await prisma.taskList.deleteMany({ where: { id } });
console.log('taskList.repo.ts remove completed for id:', id);
return deleted.count > 0;
}
export default { getAll, getById, create, update, remove };

89
src/repos/user.repo.ts Normal file
View File

@@ -0,0 +1,89 @@
import { IUser } from '@src/models/user.model';
import { PrismaClient } from '../../generated/prisma';
const prisma = new PrismaClient();
// **** Functions **** //
/**
* Get one user.
*/
async function getOne(email: string): Promise<IUser | null> {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
return {
...user,
pwdHash: user.pwdHash === null ? undefined : user.pwdHash,
role: user.role === 'Admin' ? 1 : 0,
};
}
// removed stray closing brace
/**
* See if a user with the given id exists.
*/
async function persists(id: number): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id } });
return !!user;
}
/**
* Get all users.
*/
async function getAll(): Promise<IUser[]> {
const users = await prisma.user.findMany();
return users.map(user => ({
...user,
pwdHash: user.pwdHash === null ? undefined : user.pwdHash,
role: user.role === 'Admin' ? 1 : 0,
}));
}
/**
* Add one user.
*/
async function add(user: IUser): Promise<void> {
await prisma.user.create({
data: {
// removed leftover MockOrm code
name: user.name,
email: user.email,
pwdHash: user.pwdHash,
role: (user.role as unknown) === 1 ? 'Admin' : 'Standard',
},
});
}
/**
* Update a user.
*/
async function update(user: IUser): Promise<void> {
await prisma.user.update({
where: { id: user.id },
data: {
name: user.name,
email: user.email,
pwdHash: user.pwdHash,
role: (user.role as unknown) === 1 ? 'Admin' : 'Standard',
},
});
}
/**
* Delete one user.
*/
async function delete_(id: number): Promise<void> {
await prisma.user.delete({ where: { id } });
}
// **** Export default **** //
export default {
getOne,
persists,
getAll,
add,
update,
delete: delete_,
} as const;

12
src/routes/board.route.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Router } from 'express';
import BoardController from '../controllers/board.controller';
const router = Router();
router.get('/:userId', BoardController.getBoardsByUserId);
router.get('/:userId/:boardId', BoardController.getBoardByUserId);
router.post('/:userId', BoardController.createBoard);
router.put('/:userId/:boardId', BoardController.updateBoard);
router.delete('/:userId/:boardId', BoardController.deleteBoard);
export default router;

14
src/routes/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import boardRouter from './board.route';
import taskListRouter from './taskList.route';
import taskRouter from './task.route';
import userRouter from './user.route';
const router = Router();
router.use('/boards', boardRouter);
router.use('/tasks', taskRouter);
router.use('/users', userRouter);
router.use('/task-lists', taskListRouter);
export default router;

13
src/routes/task.route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import TaskController from '../controllers/task.controller';
const router = Router();
router.get('/:boardId', TaskController.getTasksByBoardId);
router.get('/:boardId/:taskId', TaskController.getTaskByBoardId);
router.post('/:boardId', TaskController.createTask);
router.put('/:boardId/:taskId', TaskController.updateTask);
router.delete('/:boardId/:taskId', TaskController.deleteTask);
export default router;

View File

@@ -0,0 +1,12 @@
import { Router } from 'express';
import TaskListController from '@src/controllers/taskList.controller';
const router = Router();
router.get('/', TaskListController.getAll);
router.get('/:id', TaskListController.getById);
router.post('/', TaskListController.create);
router.put('/:id', TaskListController.update);
router.delete('/:id', TaskListController.remove);
export default router;

11
src/routes/user.route.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import UserController from '../controllers/user.controller';
const router = Router();
router.get('/', UserController.getAll);
router.post('/', UserController.add);
router.put('/', UserController.update);
router.delete('/:id', UserController.delete);
export default router;

93
src/server.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Setup express server.
*/
import cookieParser from 'cookie-parser';
import cors from 'cors';
import morgan from 'morgan';
import path from 'path';
import helmet from 'helmet';
import express, { Request, Response, NextFunction } from 'express';
import logger from 'jet-logger';
import 'express-async-errors';
import BaseRouter from '@src/routes';
import EnvVars from '@src/constants/EnvVars';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { NodeEnvs } from '@src/constants/misc';
import { RouteError } from '@src/other/classes';
// **** Variables **** //
const app = express();
// **** Setup **** //
// Basic middleware
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser(EnvVars.CookieProps.Secret));
// Show routes called in console during development
if (EnvVars.NodeEnv === NodeEnvs.Dev) {
app.use(morgan('dev'));
}
// Security
if (EnvVars.NodeEnv === NodeEnvs.Production) {
app.use(helmet());
}
// Add APIs, must be after middleware
app.use('/', BaseRouter);
// Add error handler
app.use((
err: Error,
_: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
next: NextFunction,
) => {
if (EnvVars.NodeEnv !== NodeEnvs.Test) {
logger.err(err, true);
}
let status = HttpStatusCodes.BAD_REQUEST;
if (err instanceof RouteError) {
status = err.status;
}
return res.status(status).json({ error: err.message });
});
// ** Front-End Content ** //
// Set views directory (html)
const viewsDir = path.join(__dirname, 'views');
app.set('views', viewsDir);
// Set static directory (js and css).
const staticDir = path.join(__dirname, 'public');
app.use(express.static(staticDir));
// Nav to users pg by default
app.get('/', (_: Request, res: Response) => {
return res.redirect('/users');
});
// Redirect to login if not logged in.
app.get('/users', (_: Request, res: Response) => {
return res.sendFile('users.html', { root: viewsDir });
});
// **** Export default **** //
export default app;

View File

@@ -0,0 +1,56 @@
import UserRepo from '@src/repos/user.repo';
import PwdUtil from '@src/util/PwdUtil';
import { tick } from '@src/util/misc';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { RouteError } from '@src/other/classes';
import { IUser } from '@src/models/user.model';
// **** Variables **** //
// Errors
export const Errors = {
Unauth: 'Unauthorized',
EmailNotFound(email: string) {
return `User with email "${email}" not found`;
},
} as const;
// **** Functions **** //
/**
* Login a user.
*/
async function login(email: string, password: string): Promise<IUser> {
// Fetch user
const user = await UserRepo.getOne(email);
if (!user) {
throw new RouteError(
HttpStatusCodes.UNAUTHORIZED,
Errors.EmailNotFound(email),
);
}
// Check password
const hash = (user.pwdHash ?? ''),
pwdPassed = await PwdUtil.compare(password, hash);
if (!pwdPassed) {
// If password failed, wait 500ms this will increase security
await tick(500);
throw new RouteError(
HttpStatusCodes.UNAUTHORIZED,
Errors.Unauth,
);
}
// Return
return user;
}
// **** Export default **** //
export default {
login,
} as const;

View File

@@ -0,0 +1,66 @@
import UserRepo from '@src/repos/user.repo';
import { IUser } from '@src/models/user.model';
import { RouteError } from '@src/other/classes';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
// **** Variables **** //
export const USER_NOT_FOUND_ERR = 'User not found';
// **** Functions **** //
/**
* Get all users.
*/
function getAll(): Promise<IUser[]> {
return UserRepo.getAll();
}
/**
* Add one user.
*/
function addOne(user: IUser): Promise<void> {
return UserRepo.add(user);
}
/**
* Update one user.
*/
async function updateOne(user: IUser): Promise<void> {
const persists = await UserRepo.persists(user.id);
if (!persists) {
throw new RouteError(
HttpStatusCodes.NOT_FOUND,
USER_NOT_FOUND_ERR,
);
}
// Return user
return UserRepo.update(user);
}
/**
* Delete a user by their id.
*/
async function _delete(id: number): Promise<void> {
const persists = await UserRepo.persists(id);
if (!persists) {
throw new RouteError(
HttpStatusCodes.NOT_FOUND,
USER_NOT_FOUND_ERR,
);
}
// Delete user
return UserRepo.delete(id);
}
// **** Export default **** //
export default {
getAll,
addOne,
updateOne,
delete: _delete,
} as const;

39
src/util/PwdUtil.ts Normal file
View File

@@ -0,0 +1,39 @@
import bcrypt from 'bcrypt';
// **** Variables **** //
const SALT_ROUNDS = 12;
// **** Functions **** //
/**
* Get a hash from the password.
*/
function getHash(pwd: string): Promise<string> {
return bcrypt.hash(pwd, SALT_ROUNDS);
}
/**
* Useful for testing.
*/
function hashSync(pwd: string): string {
return bcrypt.hashSync(pwd, SALT_ROUNDS);
}
/**
* See if a password passes the hash.
*/
function compare(pwd: string, hash: string): Promise<boolean> {
return bcrypt.compare(pwd, hash);
}
// **** Export Default **** //
export default {
getHash,
hashSync,
compare,
} as const;

102
src/util/SessionUtil.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Request, Response } from 'express';
import HttpStatusCodes from '@src/constants/HttpStatusCodes';
import { RouteError } from '@src/other/classes';
import jsonwebtoken from 'jsonwebtoken';
import EnvVars from '../constants/EnvVars';
// **** Variables **** //
// Errors
const Errors = {
ParamFalsey: 'Param is falsey',
Validation: 'JSON-web-token validation failed.',
} as const;
// Options
const Options = {
expiresIn: EnvVars.Jwt.Exp,
};
// **** Functions **** //
/**
* Get session data from request object (i.e. ISessionUser)
*/
function getSessionData<T>(req: Request): Promise<string | T | undefined> {
const { Key } = EnvVars.CookieProps,
jwt = req.signedCookies[Key];
return _decode(jwt);
}
/**
* Add a JWT to the response
*/
async function addSessionData(
res: Response,
data: string | object,
): Promise<Response> {
if (!res || !data) {
throw new RouteError(HttpStatusCodes.BAD_REQUEST, Errors.ParamFalsey);
}
// Setup JWT
const jwt = await _sign(data),
{ Key, Options } = EnvVars.CookieProps;
// Return
return res.cookie(Key, jwt, Options);
}
/**
* Remove cookie
*/
function clearCookie(res: Response): Response {
const { Key, Options } = EnvVars.CookieProps;
return res.clearCookie(Key, Options);
}
// **** Helper Functions **** //
/**
* Encrypt data and return jwt.
*/
function _sign(data: string | object | Buffer): Promise<string> {
return new Promise((res, rej) => {
jsonwebtoken.sign(
data,
EnvVars.Jwt.Secret,
{ expiresIn: '1h' },
(err: Error | null, token?: string) => {
return err ? rej(err) : res(token || '');
},
);
});
}
/**
* Decrypt JWT and extract client data.
*/
function _decode<T>(jwt: string): Promise<string | undefined | T> {
return new Promise((res, rej) => {
jsonwebtoken.verify(
jwt,
EnvVars.Jwt.Secret,
undefined,
(err: Error | null, decoded?: string | jsonwebtoken.JwtPayload) => {
return err ? rej(Errors.Validation) : res(decoded as T);
},
);
});
}
// **** Export default **** //
export default {
addSessionData,
getSessionData,
clearCookie,
} as const;

22
src/util/misc.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Miscellaneous shared functions go here.
*/
/**
* Get a random number between 1 and 1,000,000,000,000
*/
export function getRandomInt(): number {
return Math.floor(Math.random() * 1_000_000_000_000);
}
/**
* Wait for a certain number of milliseconds.
*/
export function tick(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, milliseconds);
});
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"allowJs": true,
"baseUrl": ".",
"esModuleInterop": true,
"moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true,
"module": "nodenext",
"target": "es2019",
"noImplicitReturns": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"outDir": "lib",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"types": ["node", "express"],
"paths": {
"@src/*": ["src/*"]
}
},
"compileOnSave": true,
"include": ["src"]
}

12
tsconfig.prod.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"removeComments": true
},
"exclude": [
"spec",
"src/public/",
"build.ts"
]
}