diff --git a/db.config.js b/db.config.js index 2765a05..2d4eeea 100644 --- a/db.config.js +++ b/db.config.js @@ -1,6 +1,10 @@ +const storagePath = process.env.SQLITE_DB_PATH; + +console.log(`[db.config.js] Storage path: ${storagePath}`); + module.exports = { development: { - dialect: "sqlite", - storage: process.env.SQLITE_DB_PATH - } -} + dialect: 'sqlite', + storage: storagePath, + }, +}; diff --git a/package.json b/package.json index 56ad143..3b99c81 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "migration:generate": "sequelize-cli model:generate", "db:migrate": "dotenvx run -- sequelize db:migrate", "db:migrate:undo": "dotenvx run -- sequelize db:migrate:undo", + "migration:do": "dotenvx run -- sequelize-cli db:migrate", + "migration:undo": "dotenvx run -- sequelize-cli db:migrate:undo", "test": "dotenvx run -f .env.test -- jest", "lint": "eslint .", "generateSwaggerDocs": "node ./generateSwaggerDocs.mjs" diff --git a/src/api/passkeys/controller.ts b/src/api/passkeys/controller.ts index 67c78a2..8c37e53 100644 --- a/src/api/passkeys/controller.ts +++ b/src/api/passkeys/controller.ts @@ -806,7 +806,7 @@ route.post( /** * @swagger - * /: + * /api/protected/passkeys/: * get: * summary: Get user passkeys * description: Retrieve a list of passkeys associated with the authenticated user. @@ -892,7 +892,7 @@ route.get( /** * @swagger - * /{passkeyId}: + * /api/protected/passkeys/{passkeyId}: * delete: * summary: Delete a user passkey * description: Deletes a specific passkey associated with the authenticated user. diff --git a/src/api/userChallenge/challenge.crud.ts b/src/api/userChallenge/challenge.crud.ts index 1b793fc..7545af0 100644 --- a/src/api/userChallenge/challenge.crud.ts +++ b/src/api/userChallenge/challenge.crud.ts @@ -1,7 +1,19 @@ import { CreateChallengeDBPayload, FindByParams } from './validation.schema'; -import { UserChallengeProgress } from '../../database/models/UserChallengeProgress'; +import { getPaginationMeta, PaginationParams } from '~/core/utils'; import { UserChallenge } from '~/database/models/UserChallenge'; +import { UserChallengeProgress } from '~/database/models/UserChallengeProgress'; +import { ChallengeStatus } from '~/shared/userChallenge'; + +interface WhereClause { + userId: string; + status?: ChallengeStatus; +} + +interface FindManyParams { + whereClause: WhereClause; + paginationParams: PaginationParams; +} export class UserChallengeCrud { static findManyByUserId(userId: string) { @@ -12,6 +24,29 @@ export class UserChallengeCrud { }); } + static async findMany({ whereClause, paginationParams }: FindManyParams) { + const { page, limit } = paginationParams; + + const offset = (page - 1) * limit; + + const { rows: challenges, count: totalRecords } = + await UserChallenge.findAndCountAll({ + where: { + userId: whereClause.userId, + status: whereClause.status, + }, + limit, + offset, + }); + + const paginationMeta = getPaginationMeta({ page, limit, totalRecords }); + + return { + data: challenges, + pagination: paginationMeta, + }; + } + static findOneByParams(params: FindByParams) { return UserChallenge.findOne({ where: { diff --git a/src/api/userChallenge/controller.ts b/src/api/userChallenge/controller.ts index cb73897..bf45d47 100644 --- a/src/api/userChallenge/controller.ts +++ b/src/api/userChallenge/controller.ts @@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from 'express'; import { UserChallengeCrud } from './challenge.crud'; import { UserChallengeProgressCrud } from './challengeProgress.crud'; import { + ChallengeStatusFilterSchema, CreateChallengeProgressSchema, CreateChallengeSchema, } from './validation.schema'; @@ -13,7 +14,9 @@ import { UnauthorizedError, UnprocessableEntityError, } from '~/core/errors'; +import { PaginationParamsSchema } from '~/core/utils'; import { isAuthenticated } from '~/shared/user'; +import { ChallengeStatus } from '~/shared/userChallenge'; const route = express.Router(); @@ -25,6 +28,28 @@ const route = express.Router(); * tags: [Challenges] * security: * - bearerAuth: [] # Indicates that authentication is required + * parameters: + * - in: query + * name: status + * schema: + * type: string + * enum: [ACTIVE, COMPLETED] + * description: Filter challenges by their status (ACTIVE or COMPLETED) + * required: false + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: The page number for pagination + * required: false + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * description: The number of items per page + * required: false * responses: * 200: * description: Successfully fetched the list of challenges @@ -48,7 +73,7 @@ const route = express.Router(); * details: * type: object * properties: - * challenges: + * data: * type: array * description: List of challenges * items: @@ -65,11 +90,43 @@ const route = express.Router(); * example: "A challenge to complete a 5-kilometer run" * status: * type: string - * example: "active" + * example: "ACTIVE" * createdAt: * type: string * format: date-time * example: "2024-01-01T12:00:00Z" + * meta: + * type: object + * description: Pagination metadata + * properties: + * currentPage: + * type: integer + * example: 1 + * totalPages: + * type: integer + * example: 5 + * totalItems: + * type: integer + * example: 100 + * itemsPerPage: + * type: integer + * example: 20 + * 400: + * description: Invalid request parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * example: ERROR + * statusCode: + * type: integer + * example: 400 + * message: + * type: string + * example: Invalid pagination or filter parameters * 401: * description: Unauthorized - User is not authenticated * content: @@ -86,6 +143,22 @@ const route = express.Router(); * message: * type: string * example: Unauthorized + * 422: + * description: Unprocessable Entity - Invalid filter or pagination parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * example: VALIDATION_ERROR + * statusCode: + * type: integer + * example: 422 + * message: + * type: string + * example: Invalid filter parameter * 500: * description: Internal server error * content: @@ -110,8 +183,38 @@ route.get('/', async (req: Request, res: Response, next: NextFunction) => { return next(new UnauthorizedError(ErrorMessages.unauthorized)); } + const { status = ChallengeStatus.ACTIVE, page = 1, limit = 20 } = req.query; + + const parsedPagination = PaginationParamsSchema.safeParse({ + page: Number(page), + limit: Number(limit), + }); + + if (parsedPagination.error) { + return next( + new UnprocessableEntityError(parsedPagination.error.errors[0].message), + ); + } + + const parsedFilter = ChallengeStatusFilterSchema.safeParse(status); + + if (parsedFilter.error) { + return next( + new UnprocessableEntityError(parsedFilter.error.errors[0].message), + ); + } + try { - const dbresult = await UserChallengeCrud.findManyByUserId(user.id); + const { data, pagination } = await UserChallengeCrud.findMany({ + whereClause: { + userId: user.id, + status: parsedFilter.data, + }, + paginationParams: { + page: parsedPagination.data?.page, + limit: parsedPagination.data?.limit, + }, + }); return res.status(200).json({ type: 'CHALLENGE_LIST_FETCHED', @@ -119,7 +222,8 @@ route.get('/', async (req: Request, res: Response, next: NextFunction) => { message: 'Challenge list fetched successfully', isSuccess: true, details: { - challenges: dbresult, + data, + meta: pagination, }, }); } catch (error: unknown) { diff --git a/src/api/userChallenge/validation.schema.ts b/src/api/userChallenge/validation.schema.ts index 1c61044..581e848 100644 --- a/src/api/userChallenge/validation.schema.ts +++ b/src/api/userChallenge/validation.schema.ts @@ -1,6 +1,6 @@ import z from 'zod'; -import { ChallengeTypeValues } from '~/shared/userChallenge'; +import { ChallengeStatus, ChallengeTypeValues } from '~/shared/userChallenge'; export interface FindByParams { id: string; @@ -32,3 +32,10 @@ export type CreateChallengeProgressReqPayload = z.infer< export type CreateChallengeProgressDBPayload = CreateChallengeProgressReqPayload; + +export const ChallengeStatusFilterSchema = z.enum([ + ChallengeStatus.ACTIVE, + ChallengeStatus.COMPLETED, +]); + +export type ChallengeStatusFilter = z.infer; diff --git a/src/core/interfaces/index.ts b/src/core/interfaces/index.ts index dbc1ea0..64ec73b 100644 --- a/src/core/interfaces/index.ts +++ b/src/core/interfaces/index.ts @@ -1 +1,2 @@ export * from './response'; +export * from './pagination'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 11c1d75..9e64c3f 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -2,4 +2,5 @@ export * from './generateOTP'; export * from './jwt'; export * from './buffer'; export * from './modelToPlain'; +export * from './pagination'; export * from './env'; diff --git a/src/core/utils/pagination.ts b/src/core/utils/pagination.ts new file mode 100644 index 0000000..e30703e --- /dev/null +++ b/src/core/utils/pagination.ts @@ -0,0 +1,38 @@ +import z from 'zod'; + +interface PaginationInput { + page: number; + limit: number; + totalRecords: number; +} + +export interface PaginationMeta { + totalRecords: number; + totalPages: number; + currentPage: number; + nextPage: number | null; + prevPage: number | null; +} + +export const getPaginationMeta = ({ + page, + limit, + totalRecords, +}: PaginationInput): PaginationMeta => { + const totalPages = Math.ceil(totalRecords / limit); + + return { + totalRecords, + totalPages, + currentPage: page, + nextPage: page < totalPages ? page + 1 : null, + prevPage: page > 1 ? page - 1 : null, + }; +}; + +export const PaginationParamsSchema = z.object({ + page: z.number().min(1), + limit: z.number().min(10).max(50), +}); + +export type PaginationParams = z.infer; diff --git a/src/database/migrations/20241208081622-add-status-to-userChallenge.js b/src/database/migrations/20241208081622-add-status-to-userChallenge.js new file mode 100644 index 0000000..3955ba0 --- /dev/null +++ b/src/database/migrations/20241208081622-add-status-to-userChallenge.js @@ -0,0 +1,46 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const { Op } = Sequelize; + + await queryInterface.addColumn('userChallenge', 'status', { + type: Sequelize.TEXT, + allowNull: true, + unique: false, + defaultValue: null, + }); + + const currentDate = new Date(); + const currentMonth = currentDate.getMonth(); + const currentYear = currentDate.getFullYear(); + + const formattedCurrentMonth = ('0' + currentMonth).slice(-2); + + await queryInterface.bulkUpdate( + 'userChallenge', + { status: 'ACTIVE' }, + Sequelize.and( + Sequelize.where( + Sequelize.fn('substr', Sequelize.col('startedAtDate'), 6, 2), // Extract month from string with format YYYY-MM-DD + formattedCurrentMonth, + ), + Sequelize.where( + Sequelize.fn('substr', Sequelize.col('startedAtDate'), 1, 4), // Extract year from string with format YYYY-MM-DD + currentYear.toString(), + ), + ), + ); + + await queryInterface.bulkUpdate( + 'userChallenge', + { status: 'COMPLETED' }, + { status: null }, + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('userChallenge', 'status'); + }, +}; diff --git a/src/database/models/UserChallenge.ts b/src/database/models/UserChallenge.ts index 1851fad..4d19d87 100644 --- a/src/database/models/UserChallenge.ts +++ b/src/database/models/UserChallenge.ts @@ -3,7 +3,10 @@ import { DataTypes } from 'sequelize'; import Sequelize from '../connection'; import { User } from './User'; -import { ChallengeTypeValues } from '~/shared/userChallenge'; +import { + ChallengeStatusValues, + ChallengeTypeValues, +} from '~/shared/userChallenge'; export const UserChallenge = Sequelize.define( 'userChallenge', @@ -55,6 +58,14 @@ export const UserChallenge = Sequelize.define( }, }, + status: { + type: DataTypes.ENUM(...ChallengeStatusValues), + allowNull: true, + validate: { + isIn: [ChallengeStatusValues], + }, + }, + userId: { type: DataTypes.UUIDV4, allowNull: false, diff --git a/src/shared/userChallenge/constants.ts b/src/shared/userChallenge/constants.ts index 7907d34..626157a 100644 --- a/src/shared/userChallenge/constants.ts +++ b/src/shared/userChallenge/constants.ts @@ -1,3 +1,4 @@ -import { ChallengeType } from './types'; +import { ChallengeStatus, ChallengeType } from './types'; export const ChallengeTypeValues = Object.values(ChallengeType); +export const ChallengeStatusValues = Object.values(ChallengeStatus); diff --git a/src/shared/userChallenge/types.ts b/src/shared/userChallenge/types.ts index 85b6a9d..004d742 100644 --- a/src/shared/userChallenge/types.ts +++ b/src/shared/userChallenge/types.ts @@ -6,3 +6,8 @@ export enum ChallengeType { Sleep = 'SLEEP', Other = 'OTHER', } + +export enum ChallengeStatus { + 'ACTIVE' = 'ACTIVE', + 'COMPLETED' = 'COMPLETED', +}