diff --git a/api/.env.example b/api/.env.example index f893ff28..64a0ef49 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,4 +1,4 @@ -DB_URL=postgres://postgres:postgres@localhost:5432/postgres +DB_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" NODE_ENV=development PORT=8080 JWT_SECRET='' diff --git a/api/Makefile b/api/Makefile index 65fa59f6..5840f5bc 100644 --- a/api/Makefile +++ b/api/Makefile @@ -29,17 +29,3 @@ migrate: undo-migrate: yarn run migrate:undo - -# move along, this is for mocking -create-mock-network: - docker network create queuer || true - -start-mock-deps: - docker run --network=queuer --name transcription-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres - docker run --network=queuer --name transcription-redis -p 6379:6379 -d redis - -build-mock: - docker build -t transcription . - -run-mock: - docker run --network=queuer transcription diff --git a/api/app/controllers/review.controller.ts b/api/app/controllers/review.controller.ts index f419ec0c..8d815b22 100644 --- a/api/app/controllers/review.controller.ts +++ b/api/app/controllers/review.controller.ts @@ -2,20 +2,8 @@ import { Request, Response } from "express"; import { Op } from "sequelize"; import { Review, Transaction, Transcript, User } from "../db/models"; -import { - DB_QUERY_LIMIT, - DB_START_PAGE, - QUERY_REVIEW_STATUS, - HOUR_END_OF_DAY, - MINUTE_END_OF_DAY, - SECOND_END_OF_DAY, - MILLISECOND_END_OF_DAY, -} from "../utils/constants"; -import { - buildIsActiveCondition, - buildIsInActiveCondition, - buildIsPendingCondition, -} from "../utils/review.inference"; +import { DB_QUERY_LIMIT, DB_START_PAGE } from "../utils/constants"; +import { buildCondition, buildReviewResponse, computeReviewStatus } from "../utils/review.inference"; import { parseMdToJSON } from "../helpers/transcript"; import axios from "axios"; import { BaseParsedMdContent, TranscriptAttributes } from "../types/transcript"; @@ -95,10 +83,10 @@ export async function create(req: Request, res: Response) { // Retrieve all reviews from the database. export async function findAll(req: Request, res: Response) { - let queryStatus = req.query.status; + const queryStatus = req.query.status as string | undefined; const userId = Number(req.body.userId); - const page: number = Number(req.query.page) || 1; - const limit: number = Number(req.query.limit) || 5; + const page: number = Number(req.query.page) || DB_START_PAGE; + const limit: number = Number(req.query.limit) || DB_QUERY_LIMIT; const offset: number = (page - 1) * limit; const user = await User.findOne({ @@ -114,59 +102,26 @@ export async function findAll(req: Request, res: Response) { return; } - let groupedCondition = {}; - const currentTime = new Date().getTime(); - - const userIdCondition = { userId: { [Op.eq]: user.id } }; - - // add condition if query exists - if (Boolean(user.id)) { - groupedCondition = { ...groupedCondition, ...userIdCondition }; - } - if (queryStatus) { - switch (queryStatus) { - case QUERY_REVIEW_STATUS.ACTIVE: - const activeCondition = buildIsActiveCondition(currentTime); - groupedCondition = { ...groupedCondition, ...activeCondition }; - break; - case QUERY_REVIEW_STATUS.PENDING: - const pendingCondition = buildIsPendingCondition(); - groupedCondition = { ...groupedCondition, ...pendingCondition }; - break; - case QUERY_REVIEW_STATUS.INACTIVE: - const inActiveCondition = buildIsInActiveCondition(currentTime); - groupedCondition = { ...groupedCondition, ...inActiveCondition }; - break; - default: - break; - } - } + const { condition } = buildCondition({ + status: queryStatus, + userId: user.id, + }); try { const totalItems = await Review.count({ - where: groupedCondition, + where: condition, }); - const totalPages = Math.ceil(totalItems / limit); - const hasNextPage = page < totalPages; - const hasPreviousPage = page > 1; const data = await Review.findAll({ - where: groupedCondition, - limit: limit, - offset: offset, + where: condition, + limit, + offset, order: [["createdAt", "DESC"]], include: { model: Transcript }, }); - const response = { - totalItems: totalItems, - itemsPerPage: limit, - totalPages: totalPages, - currentPage: page, - hasNextPage, - hasPreviousPage, - data, - }; + const response = buildReviewResponse(data, page, limit, totalItems); + res.status(200).send(response); } catch (error) { console.log(error); @@ -206,7 +161,8 @@ export async function findOne(req: Request, res: Response) { const branchUrl = data.branchUrl; const transcriptData = data.transcript.dataValues; const transcript = await transcriptWrapper(transcriptData, branchUrl); - return res.status(200).send({ ...data.dataValues, transcript }); + const reviewWithComputedStatus = computeReviewStatus(data); + return res.status(200).send({ ...reviewWithComputedStatus.dataValues, transcript }); } catch (err) { res.status(500).send({ message: "Error retrieving review with id=" + id, @@ -279,124 +235,28 @@ export const getAllReviewsForAdmin = async (req: Request, res: Response) => { const transcriptId = Number(req.query.transcriptId); const userId = Number(req.query.userId); const mergedAt = req.query.mergedAt as string; + const submittedAt = req.query.submittedAt as string; const status = req.query.status as string; const userSearch = req.query.user as string; const page: number = Number(req.query.page) || DB_START_PAGE; const limit: number = Number(req.query.limit) || DB_QUERY_LIMIT; - - const condition: { - [key: string | number]: any; - } = {}; - - const userCondition: { - [Op.or]?: { - email?: { [Op.iLike]: string }; - githubUsername?: { [Op.iLike]: string }; - }[]; - } = {}; - - if (status) { - const currentTime = new Date().getTime(); - switch (status) { - case QUERY_REVIEW_STATUS.ACTIVE: - const activeCondition = buildIsActiveCondition(currentTime); - condition[Op.and as unknown as keyof typeof Op] = activeCondition; - break; - - case "expired": - const expiredCondition = buildIsInActiveCondition(currentTime); - condition[Op.and as unknown as keyof typeof Op] = expiredCondition; - break; - - case QUERY_REVIEW_STATUS.PENDING: - const pendingCondition = buildIsPendingCondition(); - condition[Op.and as unknown as keyof typeof Op] = pendingCondition; - break; - - default: - break; - } - } - - // Check if the mergedAt parameter is provided in the query - if (Boolean(mergedAt)) { - // Convert the mergedAt string to a Date object - const date = new Date(mergedAt as string); - - // Calculate the start of the day (00:00:00.000) for the mergedAt date - const startOfDay = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - ); - - // Calculate the end of the day (23:59:59.999) for the mergedAt date - const endOfDay = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - HOUR_END_OF_DAY, - MINUTE_END_OF_DAY, - SECOND_END_OF_DAY, - MILLISECOND_END_OF_DAY - ); - - // Set the condition for mergedAt to filter records within the specified day - condition.mergedAt = { - [Op.gte]: startOfDay, - [Op.lte]: endOfDay, - }; - } - - if (Boolean(transcriptId)) { - condition.transcriptId = { [Op.eq]: transcriptId }; - } - if (Boolean(userId)) { - condition.userId = { [Op.eq]: userId }; - } - - // Check if the mergedAt parameter is provided in the query for all time zone support - if (Boolean(mergedAt)) { - // Convert the mergedAt string to a Date object - const date = new Date(mergedAt as string); - - // Calculate the start of the day (00:00:00.000) for the mergedAt date - const startOfDay = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - ); - - // Calculate the end of the day (23:59:59.999) for the mergedAt date - const endOfDay = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - HOUR_END_OF_DAY, - MINUTE_END_OF_DAY, - SECOND_END_OF_DAY, - MILLISECOND_END_OF_DAY - ); - - // Set the condition for mergedAt to filter records within the specified day - condition.mergedAt = { - [Op.gte]: startOfDay, - [Op.lte]: endOfDay, - }; - } - - if (userSearch) { - const searchCondition = { [Op.iLike]: `%${userSearch.toLowerCase()}%` }; - userCondition[Op.or] = [ - { email: searchCondition }, - { githubUsername: searchCondition }, - ]; - } + const offset = Math.max(0, (page - 1) * limit); + + const { condition, userCondition } = buildCondition({ + status, + transcriptId, + userId, + mergedAt, + userSearch, + submittedAt, + }); try { const reviews = await Review.findAll({ where: condition, order: [["createdAt", "DESC"]], + offset, + limit, include: [ { model: Transcript, required: true, attributes: { exclude: ["id"] } }, { @@ -421,19 +281,7 @@ export const getAllReviewsForAdmin = async (req: Request, res: Response) => { ], }); - const totalPages = Math.ceil(reviewCount / limit); - const hasNextPage = page < totalPages; - const hasPreviousPage = page > 1; - - const response = { - totalItems: reviewCount, - totalPages, - currentPage: page, - itemsPerPage: limit, - hasNextPage, - hasPreviousPage, - data: reviews, - }; + const response = buildReviewResponse(reviews, page, limit, reviewCount); res.status(200).json(response); } catch (error) { @@ -478,10 +326,11 @@ export const getReviewsByPaymentStatus = async ( ); const unpaidMergedReviews = mergedReviews.filter( (review) => !creditTransactionReviewIds.includes(review.id) - ); + ).map((review) => computeReviewStatus(review)); const paidMergedReviews = mergedReviews.filter((review) => creditTransactionReviewIds.includes(review.id) - ); + ).map((review) => computeReviewStatus(review)) + let response: { totalItems: number; diff --git a/api/app/controllers/transcript.controller.ts b/api/app/controllers/transcript.controller.ts index 7cd3cd71..3572c6bc 100644 --- a/api/app/controllers/transcript.controller.ts +++ b/api/app/controllers/transcript.controller.ts @@ -9,7 +9,13 @@ import { buildIsPendingCondition, getTotalWords, } from "../utils/review.inference"; -import { DB_QUERY_LIMIT, MAXPENDINGREVIEWS } from "../utils/constants"; +import { + DB_QUERY_LIMIT, + DB_START_PAGE, + MAX_PENDING_REVIEWS, + MERGED_REVIEWS_THRESHOLD, +} from "../utils/constants"; + import { generateUniqueHash } from "../helpers/transcript"; import { redis } from "../db"; import { @@ -96,7 +102,7 @@ export async function create(req: Request, res: Response) { // Retrieve all unarchived and queued transcripts from the database. export async function findAll(req: Request, res: Response) { - const page: number = Number(req.query.page) || 1; + const page: number = Number(req.query.page) || DB_START_PAGE; const limit: number = Number(req.query.limit) || DB_QUERY_LIMIT; const offset: number = (page - 1) * limit; let condition = { @@ -135,6 +141,7 @@ export async function findAll(req: Request, res: Response) { totalItems, itemsPerPage: limit, totalPages, + currentPage: Number(page), hasNextPage, hasPreviousPage, data: cachedTranscripts, @@ -194,7 +201,7 @@ export async function findAll(req: Request, res: Response) { totalItems: totalItems, itemsPerPage: limit, totalPages: totalPages, - currentPage: page, + currentPage: Number(page), hasNextPage, hasPreviousPage, data, @@ -347,7 +354,7 @@ export async function claim(req: Request, res: Response) { const transcriptId = req.params.id; const uid = req.body.claimedBy; - const branchUrl = req.body.branchUrl + const branchUrl = req.body.branchUrl; const currentTime = new Date().getTime(); const activeCondition = buildIsActiveCondition(currentTime); const pendingCondition = buildIsPendingCondition(); @@ -366,12 +373,28 @@ export async function claim(req: Request, res: Response) { return; } + // if user has successfully reviewed fewer than 3 transcripts + // allow to claim only 1 transcript and return if user has already has a pending review + // if user has successfully reviewed 3 or more transcripts, allow to have 6 pending reviews + const successfulReviews = await Review.findAll({ + where: { ...userCondition, mergedAt: { [Op.ne]: null } }, + }); const pendingReview = await Review.findAll({ where: { ...userCondition, ...pendingCondition }, }); - if (pendingReview.length >= MAXPENDINGREVIEWS) { + if ( + successfulReviews.length <= MERGED_REVIEWS_THRESHOLD && + pendingReview.length + ) { + res.status(500).send({ + message: + "You have a pending review, finish it first before claiming another!", + }); + return; + } + if (pendingReview.length >= MAX_PENDING_REVIEWS) { res.status(500).send({ - message: "User has too many pending reviews, clear some and try again!", + message: `You have ${pendingReview.length} pending reviews, clear some and try again!`, }); return; } @@ -382,7 +405,7 @@ export async function claim(req: Request, res: Response) { }; if (branchUrl) { - review.branchUrl = branchUrl + review.branchUrl = branchUrl; } try { diff --git a/api/app/controllers/user.controller.ts b/api/app/controllers/user.controller.ts index d20ecae6..f77d67d7 100644 --- a/api/app/controllers/user.controller.ts +++ b/api/app/controllers/user.controller.ts @@ -124,7 +124,7 @@ export function findOne(req: Request, res: Response) { } // Find a public user with github username export async function findByPublicProfile(req: Request, res: Response) { - const username = req.body.username; + const username = req.query.username as string; if (!username) throw new Error("Username is required"); @@ -143,7 +143,7 @@ export async function findByPublicProfile(req: Request, res: Response) { attributes: { exclude: baseExclusion }, }); if (!user) { - return res.status(500).send({ + return res.status(400).send({ message: "No User with username=" + username, }); } @@ -177,24 +177,57 @@ export async function findByPublicProfile(req: Request, res: Response) { // Update a User by the id in the request export function update(req: Request, res: Response) { - const id = Number(req.params.id); + const { permissions, githubUsername } = req.body; + const id = req.params.id; - User.update(req.body, { - where: { id: id }, + if (!id) { + return res.status(400).send({ + message: "User id can not be empty!", + }); + } + if (!permissions && !githubUsername) { + return res.status(400).send({ + message: "Either permissions or githubUsername should be present!", + }); + } + + const userExists = User.findByPk(id); + if (!userExists) { + return res.status(404).send({ + message: "User not found", + }); + } + + let updateData: { + permissions?: USER_PERMISSIONS; + githubUsername?: string; + } = {}; + + if (permissions) { + updateData.permissions = permissions; + } + if (githubUsername) { + updateData.githubUsername = githubUsername?.toLowerCase(); + } + + User.update(updateData, { + where: { + id: Number(id), + }, }) - .then(async (num) => { + .then((num) => { if (Array.isArray(num) && num[0] == 1) { - return res.status(200).send({ + res.send({ message: "User was updated successfully.", }); } else { - return res.status(200).send({ - message: `Cannot update User with id=${id}. Maybe User was not found or req.body is empty!`, + res.status(500).send({ + message: `Cannot update User with id=${id}.`, }); } }) - .catch((err) => { - return res.status(500).send({ + .catch((_err) => { + res.status(500).send({ message: "Error updating User with id=" + id, }); }); diff --git a/api/app/controllers/webhook.controller.ts b/api/app/controllers/webhook.controller.ts index 38b620f7..b1dcbdcb 100644 --- a/api/app/controllers/webhook.controller.ts +++ b/api/app/controllers/webhook.controller.ts @@ -297,4 +297,4 @@ export async function handlePushEvent(req: Request, res: Response) { return handleError(error, res); } return res.sendStatus(200); -} \ No newline at end of file +} diff --git a/api/app/db/migrations/20240528111348-add-evaluator-user-permission.js b/api/app/db/migrations/20240528111348-add-evaluator-user-permission.js new file mode 100644 index 00000000..dfe03cc3 --- /dev/null +++ b/api/app/db/migrations/20240528111348-add-evaluator-user-permission.js @@ -0,0 +1,36 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + // update the user table to add the evaluator permission to permissions enum column + await queryInterface.sequelize.query(` + ALTER TYPE "enum_users_permissions" ADD VALUE 'evaluator'; + `); + }, + + async down (queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + // remove the evaluator permission from the permissions enum column + await queryInterface.sequelize.query(` + DELETE FROM pg_enum + WHERE enumlabel = 'evaluator' + AND enumtypid = ( + SELECT oid + FROM pg_type + WHERE typname = 'enum_users_permissions' + ); + `); + } +}; diff --git a/api/app/middleware/auth.ts b/api/app/middleware/auth.ts index ea0db7d1..2e52b40f 100644 --- a/api/app/middleware/auth.ts +++ b/api/app/middleware/auth.ts @@ -40,9 +40,11 @@ export const auth = async (req: Request, res: Response, next: NextFunction) => { } }; -export const admin = (req: Request, res: Response, next: NextFunction) => { - if (!req.body.userId || req.body.userPermissions !== USER_PERMISSIONS.ADMIN) { - return res.status(403).json({ error: "Admin role required" }); - } - next(); +export const authorizeRoles = (allowedRoles: USER_PERMISSIONS[]) => { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.body.userId || !allowedRoles.includes(req.body.userPermissions)) { + return res.status(403).json({ error: `${allowedRoles.join(' or ')} role required` }); + } + next(); + }; }; diff --git a/api/app/routes/review.routes.ts b/api/app/routes/review.routes.ts index b218da55..e2f842af 100644 --- a/api/app/routes/review.routes.ts +++ b/api/app/routes/review.routes.ts @@ -1,7 +1,8 @@ import type { Express } from "express"; import express from "express"; import * as reviews from "../controllers/review.controller"; -import { admin, auth } from "../middleware/auth"; +import { authorizeRoles, auth } from "../middleware/auth"; +import { USER_PERMISSIONS } from "../types/user"; export function reviewRoutes(app: Express) { const router = express.Router(); @@ -208,7 +209,7 @@ export function reviewRoutes(app: Express) { * name: status * schema: * type: string - * enum: [expired, pending, active] + * enum: [expired, pending, active, merged, rejected] * description: Filter reviews based on status * - in: query * name: transcriptId @@ -231,6 +232,11 @@ export function reviewRoutes(app: Express) { * type: string * description: Filter reviews based on mergedAt * - in: query + * name: submittedAt + * schema: + * type: string + * description: Filter reviews based on submittedAt + * - in: query * name: page * schema: * $ref: '#/components/schemas/Pagination' @@ -314,9 +320,9 @@ export function reviewRoutes(app: Express) { router.get("/", reviews.findAll); // Retrieve reviews for admin - router.get("/all", admin, reviews.getAllReviewsForAdmin); + router.get("/all", authorizeRoles([USER_PERMISSIONS.ADMIN, USER_PERMISSIONS.EVALUATOR]), reviews.getAllReviewsForAdmin); // get paid or unpaid reviews - router.get("/payment", admin, reviews.getReviewsByPaymentStatus); + router.get("/payment", authorizeRoles([USER_PERMISSIONS.ADMIN]), reviews.getReviewsByPaymentStatus); // Retrieve a single review with id router.get("/:id", reviews.findOne); @@ -327,7 +333,7 @@ export function reviewRoutes(app: Express) { // Submit a review with id router.put("/:id/submit", reviews.submit); - router.post("/:id/reset", admin, reviews.resetReviews); + router.post("/:id/reset", authorizeRoles([USER_PERMISSIONS.ADMIN]), reviews.resetReviews); app.use("/api/reviews", auth, router); } diff --git a/api/app/routes/transaction.routes.ts b/api/app/routes/transaction.routes.ts index 85b4ab37..cd5e4eee 100644 --- a/api/app/routes/transaction.routes.ts +++ b/api/app/routes/transaction.routes.ts @@ -1,7 +1,8 @@ import type { Express } from "express"; import express from "express"; import * as transactions from "../controllers/transaction.controller"; -import { admin, auth } from "../middleware/auth"; +import { auth, authorizeRoles } from "../middleware/auth"; +import { USER_PERMISSIONS } from "../types/user"; export function transactionRoutes(app: Express) { const router = express.Router(); @@ -115,10 +116,10 @@ export function transactionRoutes(app: Express) { router.post("/", transactions.create); // Get all Transactions for Admin - router.get("/all", admin, transactions.getAllTransactions); + router.get("/all", authorizeRoles([USER_PERMISSIONS.ADMIN]), transactions.getAllTransactions); // Process unpaid review transactions - router.post("/credit", admin, transactions.processUnpaidReviewTransaction); + router.post("/credit", authorizeRoles([USER_PERMISSIONS.ADMIN]), transactions.processUnpaidReviewTransaction); app.use("/api/transactions", auth, router); } diff --git a/api/app/routes/transcript.routes.ts b/api/app/routes/transcript.routes.ts index 12268843..07235bd0 100644 --- a/api/app/routes/transcript.routes.ts +++ b/api/app/routes/transcript.routes.ts @@ -1,7 +1,8 @@ import type { Express } from "express"; import express from "express"; import * as transcripts from "../controllers/transcript.controller"; -import { admin, auth } from "../middleware/auth"; +import { auth, authorizeRoles } from "../middleware/auth"; +import { USER_PERMISSIONS } from "../types/user"; export function transcriptRoutes(app: Express) { const router = express.Router(); @@ -183,7 +184,7 @@ export function transcriptRoutes(app: Express) { */ // Create a new transcript - router.post("/", auth, admin, transcripts.create); + router.post("/", auth, authorizeRoles([USER_PERMISSIONS.ADMIN]), transcripts.create); // Retrieve all transcripts router.get("/", transcripts.findAll); @@ -195,7 +196,7 @@ export function transcriptRoutes(app: Express) { router.put("/:id", auth, transcripts.update); // Archive a transcript with id - router.put("/:id/archive", auth, admin, transcripts.archive); + router.put("/:id/archive", auth, authorizeRoles([USER_PERMISSIONS.ADMIN]), transcripts.archive); // Claim a transcript with id router.put("/:id/claim", auth, transcripts.claim); diff --git a/api/app/routes/user.routes.ts b/api/app/routes/user.routes.ts index 286f0f5f..152dcc0c 100644 --- a/api/app/routes/user.routes.ts +++ b/api/app/routes/user.routes.ts @@ -1,8 +1,9 @@ import type { Express } from "express"; import express from "express"; import * as users from "../controllers/user.controller"; -import { admin, auth } from "../middleware/auth"; +import { auth, authorizeRoles } from "../middleware/auth"; import validateGitHubToken from "../middleware/validate-github-token"; +import { USER_PERMISSIONS } from "../types/user"; export function userRoutes(app: Express) { const router = express.Router(); @@ -11,9 +12,25 @@ export function userRoutes(app: Express) { /** * @swagger - * /api/users: + * /api/users/signin: * post: - * summary: Create a JSONPlaceholder user. + * security: + * - apiKeyAuth: [] + * tags: [Users] + * summary: Creates or signs in a user. + * description: Creates or signs in a user. If the user is already in the database, the user is signed in. If the user is not in the database, the user is created and signed in. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * description: The user's Github email. + * example: example@email.com + * required: true * responses: * 200: * description: Successfully signed in @@ -22,17 +39,20 @@ export function userRoutes(app: Express) { * schema: * type: object * properties: - * data: - * type: object - * properties: - * username: - * type: string - * description: The user's github username. - * example: glozow - * permissions: - * type: string - * description: The user's permissions. - * enum: [admin, reviewer] + * jwt: + * type: string + * description: The user's JWT token. + * example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImVtYWlsIjoiZXhhbXBsZUBlbWFpbC5jb20iLCJwZXJtaXNzaW9ucyI6ImFkbWluIiwiaWF0IjoxNjI5MjIwNjI4LCJleHAiOjE2MjkzMDcxMjh9. + * 500: + * description: An error occurred while signing in. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Unable to sign in. Some error occurred while signing in. */ router.post("/signin", validateGitHubToken, users.signIn); @@ -42,8 +62,11 @@ export function userRoutes(app: Express) { * @swagger * /api/users: * get: - * summary: Retrieve a list of JSONPlaceholder users. - * description: Retrieve a list of users from JSONPlaceholder. Can be used to populate a list of fake users when prototyping or testing an API. + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Retrieve a list of users. + * description: Retrieve a list of users. Accessible only to admins. The data returned exculdes the user's JWT token, email, and updatedAt date. * responses: * 200: * description: A list of users. @@ -65,10 +88,6 @@ export function userRoutes(app: Express) { * type: string * description: The user's Github username. * example: ryanofsky - * authToken: - * type: string - * description: The user's authentication token. - * example: Thsdfk3j3kflfjdkfjfj * permissions: * type: string * description: The user's permissions. @@ -81,27 +100,26 @@ export function userRoutes(app: Express) { * type: datetime * description: Date when a user is created * example: 2023-03-08T13:42:08.699Z - * updatedAt: - * type: datetime - * description: Date when a user record is updated. - * example: 2023-03-08T13:42:08.699Z */ - router.get("/", auth, admin, users.findAll); + router.get("/", auth, authorizeRoles([USER_PERMISSIONS.ADMIN]), users.findAll); // Retrieve a single User by their public profile (Github username) /** * @swagger * /api/users/public: * get: - * summary: Retrieve a single JSONPlaceholder user. - * description: Retrieve a single JSONPlaceholder user. + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Retrieve a single user by their username. + * description: Retrieve a single user by their username. The data returned is limited to the user's Github username, permissions, and archivedAt date. * parameters: * - in: query * name: username - * required: true * schema: - * type: string - * description: The user's github username. + * type: string + * description: The user's Github username. + * example: ryanofsky * responses: * 200: * description: A single user. @@ -141,13 +159,16 @@ export function userRoutes(app: Express) { * @swagger * /api/users/{id}: * get: - * summary: Retrieve a single JSONPlaceholder user. - * description: Retrieve a single JSONPlaceholder user. Can be used to populate a user profile when prototyping or testing an API. + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Retrieve a single user. + * description: Retrieve a single user. Returns all user data. * parameters: * - in: path * name: id * required: true - * description: Numeric ID of the user to retrieve. + * description: ID of the user to retrieve. * schema: * type: integer * responses: @@ -201,64 +222,65 @@ export function userRoutes(app: Express) { /** * @swagger * /api/users/{id}: - * tags: [Users] - * put: - * summary: Updates a JSONPlaceholder user. - * description: Updates a single JSONPlaceholder user. Can be used to update a user profile when prototyping or testing an API. + * put: + * security: + * - bearerAuth: [] + * tags: [Users] + * summary: Updates a user profile. + * description: Updates a user profile. Accessible only to admins. Can be used to update user permissions or Github username or both. * parameters: * - in: path * name: id * required: true + * description: ID of the user to update. * schema: - * type: integer - * description: Numeric ID of the user to update. - * - in: query - * name: username - * required: true - * schema: - * type: string - * description: The user's github username. + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * permissions: + * type: string + * description: The user's permissions - either "admin" or "reviewer" or "evaluator". + * enum: [admin, reviewer, evaluator] + * githubUsername: + * type: string + * description: The user's Github username. + * example: adamJonas * responses: * 200: * description: Update the records of a single user. * content: * application/json: + * schema: + * type: String + * example: User updated successfully + * 404: + * description: The user was not found. + * content: + * application/json: + * schema: + * type: String + * example: User not found + * 400: + * description: The user id is missing or the username is missing. + * content: + * application/json: * schema: - * type: object - * properties: - * data: - * type: object - * properties: - * id: - * type: integer - * description: The user ID. - * example: 1 - * githubUsername: - * type: string - * description: The user's Github username. - * example: ryanofsky - * authToken: - * type: string - * description: The user's authentication token. - * example: Thsdfk3j3kflfjdkfjfj - * permissions: - * type: string - * description: The user's permissions. - * enum: [admin, reviewer] - * archivedAt: - * type: datetime - * description: Date when a user is marked as inactive. - * example: 2023-03-08T13:42:08.699Z - * createdAt: - * type: datetime - * description: Date when a user is created - * example: 2023-03-08T13:42:08.699Z - * updatedAt: - * type: datetime - * description: Date when a user record is updated. - * example: 2023-03-08T13:42:08.699Z + * type: String + * example: Either permissions or githubUsername should be present! + * 500: + * description: An error occurred while updating the user. + * content: + * application/json: + * schema: + * type: String + * example: Cannot update User with id=1. */ - router.put("/:id", auth, users.update); + router.put("/:id", auth, authorizeRoles([USER_PERMISSIONS.ADMIN]), users.update); app.use("/api/users", router); } diff --git a/api/app/types/review.ts b/api/app/types/review.ts index 21e33029..dea78bf3 100644 --- a/api/app/types/review.ts +++ b/api/app/types/review.ts @@ -1,3 +1,11 @@ +import { Review } from "../db/models"; + +// This type combines the ReviewAttributes and Review model +// because the Review model which extends the Model class +// does not have the status field in the database schema. +// It is only present in the API level and is not stored in the database. +export type IReview = Review & ReviewAttributes; + export interface ReviewAttributes { id?: number; submittedAt?: Date | null; @@ -7,4 +15,14 @@ export interface ReviewAttributes { transcriptId: number; pr_url?: string | null; branchUrl?: string | null; + status?: string; +} + +export interface BuildConditionArgs { + status?: string; + transcriptId?: number; + userId?: number; + mergedAt?: string; + userSearch?: string; + submittedAt?: string; } diff --git a/api/app/types/user.ts b/api/app/types/user.ts index 7fbbef07..07735669 100644 --- a/api/app/types/user.ts +++ b/api/app/types/user.ts @@ -1,6 +1,7 @@ export enum USER_PERMISSIONS { REVIEWER = "reviewer", ADMIN = "admin", + EVALUATOR = "evaluator", } export interface UserAttributes { @@ -9,6 +10,6 @@ export interface UserAttributes { email: string; jwt?: string | null; albyToken?: string; - permissions: "reviewer" | "admin"; + permissions: USER_PERMISSIONS; archivedAt?: Date | null; } diff --git a/api/app/utils/constants.ts b/api/app/utils/constants.ts index 5e4a601c..1fb90f57 100644 --- a/api/app/utils/constants.ts +++ b/api/app/utils/constants.ts @@ -8,6 +8,7 @@ const QUERY_REVIEW_STATUS = { ACTIVE: "active", PENDING: "pending", INACTIVE: "inactive", + MERGED: "merged", } as const; const SATS_REWARD_RATE_PER_WORD = 0.5; @@ -16,25 +17,24 @@ const expiresInHours = 24; const currentTime = Math.floor(Date.now() / 1000); -const JWTEXPIRYTIMEINHOURS = currentTime + (expiresInHours * 60 * 60); - +const JWTEXPIRYTIMEINHOURS = currentTime + expiresInHours * 60 * 60; // This is a random number that is used to note the number of pages to be cached const PAGE_COUNT = 100; - const EXPIRYTIMEINHOURS = 24; -const MAXPENDINGREVIEWS = 3; -const INVOICEEXPIRYTIME = 5 * 60 * 1000 -const FEE_LIMIT_SAT=100 -const INVOICE_TIME_OUT = 60 +const MAX_PENDING_REVIEWS = 6; +const MERGED_REVIEWS_THRESHOLD = 3; +const INVOICEEXPIRYTIME = 5 * 60 * 1000; +const FEE_LIMIT_SAT = 100; +const INVOICE_TIME_OUT = 60; const PICO_BTC_TO_SATS = 10000; const DB_QUERY_LIMIT = 10; const DB_TXN_QUERY_LIMIT = 20; -const DB_START_PAGE = 0; +const DB_START_PAGE = 1; -const PUBLIC_PROFILE_REVIEW_LIMIT = 5 +const PUBLIC_PROFILE_REVIEW_LIMIT = 5; const LOG_LEVEL = process.env.LOG_LEVEL || "info"; @@ -50,7 +50,7 @@ export { QUERY_REVIEW_STATUS, SATS_REWARD_RATE_PER_WORD, EXPIRYTIMEINHOURS, - MAXPENDINGREVIEWS, + MAX_PENDING_REVIEWS, JWTEXPIRYTIMEINHOURS, INVOICEEXPIRYTIME, FEE_LIMIT_SAT, @@ -66,9 +66,6 @@ export { SECOND_END_OF_DAY, MILLISECOND_END_OF_DAY, PAGE_COUNT, - DELAY_IN_BETWEEN_REQUESTS + DELAY_IN_BETWEEN_REQUESTS, + MERGED_REVIEWS_THRESHOLD, }; - - - - diff --git a/api/app/utils/review.inference.ts b/api/app/utils/review.inference.ts index d90707d7..643c5a30 100644 --- a/api/app/utils/review.inference.ts +++ b/api/app/utils/review.inference.ts @@ -3,7 +3,15 @@ import { Op } from "sequelize"; import { TranscriptAttributes } from "../types/transcript"; import { wordCount } from "./functions"; -import { EXPIRYTIMEINHOURS } from "./constants"; +import { + EXPIRYTIMEINHOURS, + HOUR_END_OF_DAY, + MILLISECOND_END_OF_DAY, + MINUTE_END_OF_DAY, + QUERY_REVIEW_STATUS, + SECOND_END_OF_DAY, +} from "./constants"; +import { BuildConditionArgs, IReview } from "../types/review"; const unixEpochTimeInMilliseconds = getUnixTimeFromHours(EXPIRYTIMEINHOURS); @@ -26,19 +34,16 @@ const buildIsPendingCondition = () => { }; }; -const buildIsInActiveCondition = (currentTime: number) => { +const buildIsExpiredAndArchivedCondition = (currentTime: number) => { const timeStringAt24HoursPrior = new Date( currentTime - unixEpochTimeInMilliseconds ).toISOString(); return { - [Op.or]: { - mergedAt: { [Op.not]: null }, // has been merged + [Op.and]: { + mergedAt: { [Op.eq]: null }, // has not been merged archivedAt: { [Op.not]: null }, // has been archived - // inactive conditions when review has expired - [Op.and]: { - createdAt: { [Op.lt]: timeStringAt24HoursPrior }, // expired - submittedAt: { [Op.eq]: null }, // has not been submitted - }, + submittedAt: { [Op.eq]: null }, // has not been submitted + createdAt: { [Op.lt]: timeStringAt24HoursPrior }, // expired }, }; }; @@ -57,6 +62,39 @@ const buildIsExpiredAndNotArchivedCondition = (currentTime: number) => { }; }; +const buildIsRejectedCondition = () => { + return { + [Op.and]: [ + { archivedAt: { [Op.not]: null } }, // has been archived + { submittedAt: { [Op.not]: null } }, // has been submitted + { mergedAt: { [Op.eq]: null } }, // has not been merged + ], + }; +}; + +// This condition is used to get all expired reviews, whether they are archived or not. +// Because we don't want to ignore expired reviews that has not yet been archived by the +// daily cron job. +const buildIsExpiredCondition = (currentTime: number) => { + const expiredAndArchivedCondition = + buildIsExpiredAndArchivedCondition(currentTime); + const expiredAndNotArchivedCondition = + buildIsExpiredAndNotArchivedCondition(currentTime); + return { + [Op.or]: [expiredAndArchivedCondition, expiredAndNotArchivedCondition], + }; +}; + +const buildIsMergedCondition = () => { + const mergedQuery = { + [Op.and]: [ + //ensuring all conditions are met + { mergedAt: { [Op.not]: null } }, // has been merged + ], + }; + return mergedQuery; +}; + function getUnixTimeFromHours(hours: number) { const millisecondsInHour = 60 * 60 * 1000; const unixTimeInMilliseconds = hours * millisecondsInHour; @@ -143,12 +181,205 @@ async function calculateWordDiff(data: TranscriptAttributes) { return { totalDiff, totalWords, addedWords, removedWords }; } +export const buildCondition = ({ + status, + transcriptId, + userId, + mergedAt, + userSearch, + submittedAt, +}: BuildConditionArgs) => { + const condition: { [key: string | number]: any } = {}; + const userCondition: { + [Op.or]?: { + email?: { [Op.iLike]: string }; + githubUsername?: { [Op.iLike]: string }; + }[]; + } = {}; + + if (status) { + const currentTime = new Date().getTime(); + switch (status?.toLowerCase()) { + case QUERY_REVIEW_STATUS.ACTIVE: + const activeCondition = buildIsActiveCondition(currentTime); + condition[Op.and as unknown as keyof typeof Op] = activeCondition; + break; + + case "expired": + const expiredCondition = buildIsExpiredCondition(currentTime); + condition[Op.and as unknown as keyof typeof Op] = expiredCondition; + break; + + case "rejected": + const rejectedCondition = buildIsRejectedCondition(); + condition[Op.and as unknown as keyof typeof Op] = rejectedCondition; + break; + + case QUERY_REVIEW_STATUS.PENDING: + const pendingCondition = buildIsPendingCondition(); + condition[Op.and as unknown as keyof typeof Op] = pendingCondition; + break; + + case QUERY_REVIEW_STATUS.MERGED: + const mergedCondition = buildIsMergedCondition(); + condition[Op.and as unknown as keyof typeof Op] = mergedCondition; + break; + + default: + break; + } + } + + if (mergedAt) { + const date = new Date(mergedAt); + const startOfDay = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + const endOfDay = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + HOUR_END_OF_DAY, + MINUTE_END_OF_DAY, + SECOND_END_OF_DAY, + MILLISECOND_END_OF_DAY + ); + + condition.mergedAt = { + [Op.gte]: startOfDay, + [Op.lte]: endOfDay, + }; + } + + if (submittedAt) { + const date = new Date(submittedAt); + const startOfDay = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + const endOfDay = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + HOUR_END_OF_DAY, + MINUTE_END_OF_DAY, + SECOND_END_OF_DAY, + MILLISECOND_END_OF_DAY + ); + + condition.submittedAt = { + [Op.gte]: startOfDay, + [Op.lte]: endOfDay, + }; + } + + if (transcriptId) { + condition.transcriptId = { [Op.eq]: transcriptId }; + } + + if (userId) { + condition.userId = { [Op.eq]: userId }; + } + + if (userSearch) { + const searchCondition = { [Op.iLike]: `%${userSearch.toLowerCase()}%` }; + userCondition[Op.or] = [ + { email: searchCondition }, + { githubUsername: searchCondition }, + ]; + } + + return { condition, userCondition }; +}; + +export const buildReviewResponse = ( + reviews: IReview[], + page: number, + limit: number, + totalItems: number +) => { + const totalPages = Math.ceil(totalItems / limit); + const hasNextPage = page < totalPages; + const hasPreviousPage = page > 1; + + reviews.forEach((review) => { + computeReviewStatus(review); + }); + + return { + totalItems, + totalPages, + currentPage: page, + itemsPerPage: limit, + hasNextPage, + hasPreviousPage, + data: reviews, + }; +}; + +export const computeReviewStatus = (review: IReview) => { + const currentTime = new Date().getTime(); + const isExpiredReview = (review: IReview) => { + return ( + (!review.submittedAt && + !review.mergedAt && + !review.archivedAt && + review.createdAt < + new Date(currentTime - unixEpochTimeInMilliseconds)) || + (!review.submittedAt && + !review.mergedAt && + review.archivedAt && + review.createdAt < new Date(currentTime - unixEpochTimeInMilliseconds)) + ); + }; + + const isActiveReview = (review: IReview) => { + const timeStringAt24HoursPrior = new Date( + currentTime - unixEpochTimeInMilliseconds + ).toISOString(); + return ( + !review.submittedAt && + !review.mergedAt && + !review.archivedAt && + review.createdAt > timeStringAt24HoursPrior + ); + }; + + const isPendingReview = (review: IReview) => { + return review.submittedAt && !review.mergedAt && !review.archivedAt; + }; + + const isRejectedReview = (review: IReview) => { + return review.archivedAt && review.submittedAt && !review.mergedAt; + }; + + if (isPendingReview(review)) { + review.dataValues.status = QUERY_REVIEW_STATUS.PENDING; + } else if (isActiveReview(review)) { + review.dataValues.status = QUERY_REVIEW_STATUS.ACTIVE; + } else if (isExpiredReview(review)) { + review.dataValues.status = "expired"; + } else if (review.mergedAt && !review.archivedAt) { + review.dataValues.status = QUERY_REVIEW_STATUS.MERGED; + } else if (isRejectedReview(review)) { + review.dataValues.status = "rejected"; + } else { + review.dataValues.status = "unknown"; + } + + return review; +}; + export { getUnixTimeFromHours, buildIsActiveCondition, buildIsPendingCondition, - buildIsInActiveCondition, + buildIsExpiredAndArchivedCondition, buildIsExpiredAndNotArchivedCondition, calculateWordDiff, getTotalWords, + buildIsMergedCondition, }; diff --git a/api/server.ts b/api/server.ts index 70762af0..07bdf437 100644 --- a/api/server.ts +++ b/api/server.ts @@ -54,6 +54,21 @@ const options = { url: serverUrl, }, ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + in: "header", + bearerFormat: "JWT", + }, + apiKeyAuth: { + type: "apiKey", + in: "header", + name: "x-github-token", + }, + }, + }, }, apis: ["./app/routes/*.ts"], }; diff --git a/docker-compose.yml b/docker-compose.yml index d5c4c1a5..6578992c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,41 @@ -version: "3.9" +version: '3.9' services: - api: + nodeapi: build: ./api ports: - "8080:8080" + environment: + - POSTGRES_HOST=transcription-api-postgres-1 + - REDIS_HOST=transcription-api-redis-1 + - NODE_ENV=development + depends_on: + - postgres + - redis + restart: unless-stopped + volumes: + - .:/app + - /app/node_modules - nginx: - build: ./nginx - container_name: nginx + postgres: + image: postgres:latest + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres ports: - - "80:80" + - "5432:5432" # Map PostgreSQL port to host volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf - depends_on: - - api \ No newline at end of file + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:latest + ports: + - "6379:6379" # Map Redis port to host + volumes: + - redis_data:/data + +volumes: + postgres_data: + redis_data: +