Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added userChallenge pagination #33

Merged
merged 10 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions db.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/api/passkeys/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 36 additions & 1 deletion src/api/userChallenge/challenge.crud.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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: {
Expand Down
112 changes: 108 additions & 4 deletions src/api/userChallenge/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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
Expand All @@ -48,7 +73,7 @@ const route = express.Router();
* details:
* type: object
* properties:
* challenges:
* data:
* type: array
* description: List of challenges
* items:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -110,16 +183,47 @@ 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',
statusCode: 200,
message: 'Challenge list fetched successfully',
isSuccess: true,
details: {
challenges: dbresult,
data,
meta: pagination,
},
});
} catch (error: unknown) {
Expand Down
9 changes: 8 additions & 1 deletion src/api/userChallenge/validation.schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import z from 'zod';

import { ChallengeTypeValues } from '~/shared/userChallenge';
import { ChallengeStatus, ChallengeTypeValues } from '~/shared/userChallenge';

export interface FindByParams {
id: string;
Expand Down Expand Up @@ -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<typeof ChallengeStatusFilterSchema>;
1 change: 1 addition & 0 deletions src/core/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './response';
export * from './pagination';
1 change: 1 addition & 0 deletions src/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './generateOTP';
export * from './jwt';
export * from './buffer';
export * from './modelToPlain';
export * from './pagination';
export * from './env';
38 changes: 38 additions & 0 deletions src/core/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PaginationParamsSchema>;
Original file line number Diff line number Diff line change
@@ -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');
},
};
Loading
Loading