diff --git a/apps/backend/src/db/migrations/20240827170622_onboarding_answers_to_users_table.ts b/apps/backend/src/db/migrations/20240827170622_onboarding_answers_to_users_table.ts new file mode 100644 index 000000000..8f95c7e9b --- /dev/null +++ b/apps/backend/src/db/migrations/20240827170622_onboarding_answers_to_users_table.ts @@ -0,0 +1,47 @@ +import { type Knex } from "knex"; + +const TableName = { + ONBOARDING_ANSWERS: "onboarding_answers", + ONBOARDING_ANSWERS_TO_USERS: "onboarding_answers_to_users", + USERS: "users", +} as const; + +const ColumnName = { + ANSWER_ID: "answer_id", + CREATED_AT: "created_at", + ID: "id", + UPDATED_AT: "updated_at", + USER_ID: "user_id", +} as const; + +const DELETE_STRATEGY = "CASCADE"; + +function up(knex: Knex): Promise { + return knex.schema.createTable( + TableName.ONBOARDING_ANSWERS_TO_USERS, + (table) => { + table.increments(ColumnName.ID).primary(); + table + .integer(ColumnName.USER_ID) + .notNullable() + .references(ColumnName.ID) + .inTable(TableName.USERS) + .onDelete(DELETE_STRATEGY); + table + .integer(ColumnName.ANSWER_ID) + .notNullable() + .references(ColumnName.ID) + .inTable(TableName.ONBOARDING_ANSWERS) + .onDelete(DELETE_STRATEGY); + table.timestamp(ColumnName.CREATED_AT).defaultTo(knex.fn.now()); + table.timestamp(ColumnName.UPDATED_AT).defaultTo(knex.fn.now()); + table.unique([ColumnName.USER_ID, ColumnName.ANSWER_ID]); + }, + ); +} + +function down(knex: Knex): Promise { + return knex.schema.dropTableIfExists(TableName.ONBOARDING_ANSWERS_TO_USERS); +} + +export { down, up }; diff --git a/apps/backend/src/libs/enums/relation-name.enum.ts b/apps/backend/src/libs/enums/relation-name.enum.ts index 6bab3163f..0ee55fc6a 100644 --- a/apps/backend/src/libs/enums/relation-name.enum.ts +++ b/apps/backend/src/libs/enums/relation-name.enum.ts @@ -1,5 +1,8 @@ const RelationName = { + ONBOARDING_ANSWERS: "answers", + ONBOARDING_QUESTION: "question", USER_DETAILS: "userDetails", + USERS: "users", } as const; export { RelationName }; diff --git a/apps/backend/src/libs/modules/controller/libs/types/api-handler-options.type.ts b/apps/backend/src/libs/modules/controller/libs/types/api-handler-options.type.ts index 8e138748a..31ca9ba5e 100644 --- a/apps/backend/src/libs/modules/controller/libs/types/api-handler-options.type.ts +++ b/apps/backend/src/libs/modules/controller/libs/types/api-handler-options.type.ts @@ -11,7 +11,7 @@ type APIHandlerOptions< body: T["body"]; params: T["params"]; query: T["query"]; - user?: T["user"]; + user: T["user"]; }; export { type APIHandlerOptions }; diff --git a/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts b/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts index abdbed814..5539a305e 100644 --- a/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts +++ b/apps/backend/src/libs/modules/database/libs/enums/database-table-name.enum.ts @@ -1,6 +1,9 @@ const DatabaseTableName = { CATEGORIES: "categories", MIGRATIONS: "migrations", + ONBOARDING_ANSWERS: "onboarding_answers", + ONBOARDING_ANSWERS_TO_USERS: "onboarding_answers_to_users", + ONBOARDING_QUESTIONS: "onboarding_questions", USER_DETAILS: "user_details", USERS: "users", } as const; diff --git a/apps/backend/src/libs/modules/server-application/server-application.ts b/apps/backend/src/libs/modules/server-application/server-application.ts index e424bcb4a..9edd800b8 100644 --- a/apps/backend/src/libs/modules/server-application/server-application.ts +++ b/apps/backend/src/libs/modules/server-application/server-application.ts @@ -2,6 +2,7 @@ import { config } from "~/libs/modules/config/config.js"; import { database } from "~/libs/modules/database/database.js"; import { logger } from "~/libs/modules/logger/logger.js"; import { authController } from "~/modules/auth/auth.js"; +import { onboardingController } from "~/modules/onboarding/onboarding.js"; import { userController } from "~/modules/users/users.js"; import { BaseServerApplication } from "./base-server-application.js"; @@ -12,6 +13,7 @@ const apiV1 = new BaseServerApplicationApi( config, ...authController.routes, ...userController.routes, + ...onboardingController.routes, ); const serverApplication = new BaseServerApplication({ apis: [apiV1], diff --git a/apps/backend/src/libs/types/repository.type.ts b/apps/backend/src/libs/types/repository.type.ts index b16860fe6..884bc7026 100644 --- a/apps/backend/src/libs/types/repository.type.ts +++ b/apps/backend/src/libs/types/repository.type.ts @@ -1,9 +1,9 @@ type Repository = { create(payload: unknown): Promise; - delete(): Promise; + delete(id: number): Promise; find(id: number): Promise; findAll(): Promise; - update(): Promise; + update(id: number, payload: unknown): Promise; }; export { type Repository }; diff --git a/apps/backend/src/libs/types/service.type.ts b/apps/backend/src/libs/types/service.type.ts index bacb0103a..8939c3d62 100644 --- a/apps/backend/src/libs/types/service.type.ts +++ b/apps/backend/src/libs/types/service.type.ts @@ -1,11 +1,11 @@ type Service = { create(payload: unknown): Promise; - delete(): Promise; + delete(id: number): Promise; find(id: number): Promise; findAll(): Promise<{ items: T[]; }>; - update(): Promise; + update(id: number, payload: unknown): Promise; }; export { type Service }; diff --git a/apps/backend/src/modules/onboarding/libs/constants/constants.ts b/apps/backend/src/modules/onboarding/libs/constants/constants.ts new file mode 100644 index 000000000..f7a5ab77f --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/constants/constants.ts @@ -0,0 +1,4 @@ +const DEFAULT_COUNT_VALUE = 0; +const FIRST_ELEMENT_INDEX = 0; + +export { DEFAULT_COUNT_VALUE, FIRST_ELEMENT_INDEX }; diff --git a/apps/backend/src/modules/onboarding/libs/enums/enums.ts b/apps/backend/src/modules/onboarding/libs/enums/enums.ts new file mode 100644 index 000000000..754d7f0a5 --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/enums/enums.ts @@ -0,0 +1 @@ +export { OnboardingApiPath } from "shared"; diff --git a/apps/backend/src/modules/onboarding/libs/exceptions/exceptions.ts b/apps/backend/src/modules/onboarding/libs/exceptions/exceptions.ts new file mode 100644 index 000000000..60e71ab08 --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/exceptions/exceptions.ts @@ -0,0 +1 @@ +export { OnboardingError } from "shared"; diff --git a/apps/backend/src/modules/onboarding/libs/types/count-result.type.ts b/apps/backend/src/modules/onboarding/libs/types/count-result.type.ts new file mode 100644 index 000000000..139823f98 --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/types/count-result.type.ts @@ -0,0 +1,5 @@ +type CountResult = { + count?: string; +}; + +export { type CountResult }; diff --git a/apps/backend/src/modules/onboarding/libs/types/types.ts b/apps/backend/src/modules/onboarding/libs/types/types.ts new file mode 100644 index 000000000..c9227d1b7 --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/types/types.ts @@ -0,0 +1,7 @@ +export { type CountResult } from "./count-result.type.js"; +export { + type OnboardingAnswerDto, + type OnboardingAnswerRequestBodyDto, + type OnboardingAnswerRequestDto, + type OnboardingAnswerResponseDto, +} from "shared"; diff --git a/apps/backend/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts b/apps/backend/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts new file mode 100644 index 000000000..7d87a9c04 --- /dev/null +++ b/apps/backend/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts @@ -0,0 +1 @@ +export { onboardingAnswersValidationSchema } from "shared"; diff --git a/apps/backend/src/modules/onboarding/onboarding-answer.entity.ts b/apps/backend/src/modules/onboarding/onboarding-answer.entity.ts new file mode 100644 index 000000000..ac61e3dca --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding-answer.entity.ts @@ -0,0 +1,113 @@ +import { type Entity } from "~/libs/types/types.js"; + +class OnboardingAnswerEntity implements Entity { + private createdAt: string; + + private id: null | number; + + private label: string; + + private questionId: number; + + private updatedAt: string; + + private userId: null | number; + + private constructor({ + createdAt, + id, + label, + questionId, + updatedAt, + userId, + }: { + createdAt: string; + id: null | number; + label: string; + questionId: number; + updatedAt: string; + userId: null | number; + }) { + this.createdAt = createdAt; + this.id = id; + this.label = label; + this.questionId = questionId; + this.updatedAt = updatedAt; + this.userId = userId; + } + + public static initialize({ + createdAt, + id, + label, + questionId, + updatedAt, + userId, + }: { + createdAt: string; + id: number; + label: string; + questionId: number; + updatedAt: string; + userId: number; + }): OnboardingAnswerEntity { + return new OnboardingAnswerEntity({ + createdAt, + id, + label, + questionId, + updatedAt, + userId, + }); + } + + public static initializeNew({ + label, + questionId, + }: { + label: string; + questionId: number; + }): OnboardingAnswerEntity { + return new OnboardingAnswerEntity({ + createdAt: "", + id: null, + label, + questionId, + updatedAt: "", + userId: null, + }); + } + + public toNewObject(): { + createdAt: string; + label: string; + questionId: number; + updatedAt: string; + } { + return { + createdAt: this.createdAt, + label: this.label, + questionId: this.questionId, + updatedAt: this.updatedAt, + }; + } + public toObject(): { + createdAt: string; + id: number; + label: string; + questionId: number; + updatedAt: string; + userId: number; + } { + return { + createdAt: this.createdAt, + id: this.id as number, + label: this.label, + questionId: this.questionId, + updatedAt: this.updatedAt, + userId: this.userId as number, + }; + } +} + +export { OnboardingAnswerEntity }; diff --git a/apps/backend/src/modules/onboarding/onboarding-answer.model.ts b/apps/backend/src/modules/onboarding/onboarding-answer.model.ts new file mode 100644 index 000000000..151023d7b --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding-answer.model.ts @@ -0,0 +1,50 @@ +import { Model, type RelationMappings } from "objection"; + +import { + AbstractModel, + DatabaseTableName, +} from "~/libs/modules/database/database.js"; + +import { UserModel } from "../users/users.js"; +import { OnboardingQuestionModel } from "./onboarding-question.model.js"; + +class OnboardingAnswerModel extends AbstractModel { + public label!: string; + + public questionId!: number; + + public userId!: number; + + public users!: UserModel[]; + + static get relationMappings(): RelationMappings { + return { + question: { + join: { + from: `${DatabaseTableName.ONBOARDING_ANSWERS}.questionId`, + to: `${DatabaseTableName.ONBOARDING_QUESTIONS}.id`, + }, + modelClass: OnboardingQuestionModel, + relation: Model.BelongsToOneRelation, + }, + users: { + join: { + from: `${DatabaseTableName.ONBOARDING_ANSWERS}.id`, + through: { + from: `${DatabaseTableName.ONBOARDING_ANSWERS_TO_USERS}.answerId`, + to: `${DatabaseTableName.ONBOARDING_ANSWERS_TO_USERS}.userId`, + }, + to: `${DatabaseTableName.USERS}.id`, + }, + modelClass: UserModel, + relation: Model.ManyToManyRelation, + }, + }; + } + + public static override get tableName(): string { + return DatabaseTableName.ONBOARDING_ANSWERS; + } +} + +export { OnboardingAnswerModel }; diff --git a/apps/backend/src/modules/onboarding/onboarding-question.model.ts b/apps/backend/src/modules/onboarding/onboarding-question.model.ts new file mode 100644 index 000000000..0309a1a3f --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding-question.model.ts @@ -0,0 +1,33 @@ +import { Model, type RelationMappings } from "objection"; + +import { + AbstractModel, + DatabaseTableName, +} from "~/libs/modules/database/database.js"; + +import { OnboardingAnswerModel } from "./onboarding-answer.model.js"; + +class OnboardingQuestionModel extends AbstractModel { + public answers!: OnboardingAnswerModel[]; + + public label!: string; + + static get relationMappings(): RelationMappings { + return { + answers: { + join: { + from: `${DatabaseTableName.ONBOARDING_QUESTIONS}.id`, + to: `${DatabaseTableName.ONBOARDING_ANSWERS}.questionId`, + }, + modelClass: OnboardingAnswerModel, + relation: Model.HasManyRelation, + }, + }; + } + + public static override get tableName(): string { + return DatabaseTableName.ONBOARDING_QUESTIONS; + } +} + +export { OnboardingQuestionModel }; diff --git a/apps/backend/src/modules/onboarding/onboarding.controller.ts b/apps/backend/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 000000000..45f262536 --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,108 @@ +import { APIPath } from "~/libs/enums/enums.js"; +import { + type APIHandlerOptions, + type APIHandlerResponse, + BaseController, +} from "~/libs/modules/controller/controller.js"; +import { HTTPCode } from "~/libs/modules/http/http.js"; +import { type Logger } from "~/libs/modules/logger/logger.js"; +import { type UserDto } from "~/modules/users/users.js"; + +import { OnboardingApiPath } from "./libs/enums/enums.js"; +import { type OnboardingAnswerRequestBodyDto } from "./libs/types/types.js"; +import { onboardingAnswersValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; +import { type OnboardingService } from "./onboarding.service.js"; + +class OnboardingController extends BaseController { + private onboardingService: OnboardingService; + + public constructor(logger: Logger, onboardingService: OnboardingService) { + super(logger, APIPath.ONBOARDING); + + this.onboardingService = onboardingService; + + this.addRoute({ + handler: (options) => + this.saveOnboardingAnswers( + options as APIHandlerOptions<{ + body: OnboardingAnswerRequestBodyDto; + user: UserDto; + }>, + ), + method: "POST", + path: OnboardingApiPath.ANSWER, + validation: { + body: onboardingAnswersValidationSchema, + }, + }); + } + + /** + * @swagger + * /onboarding/answer: + * post: + * description: Saves user answers for onboarding questions + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * answerIds: + * type: array + * items: + * type: number + * user: + * type: object + * properties: + * id: + * type: number + * email: + * type: string + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * addedAnswers: + * type: array + * items: + * type: object + * properties: + * id: + * type: number + * label: + * type: string + * questionId: + * type: number + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + */ + + private async saveOnboardingAnswers( + options: APIHandlerOptions<{ + body: OnboardingAnswerRequestBodyDto; + user: UserDto; + }>, + ): Promise { + const { answerIds } = options.body; + + return { + payload: await this.onboardingService.create({ + answerIds, + userId: options.user.id, + }), + status: HTTPCode.CREATED, + }; + } +} + +export { OnboardingController }; diff --git a/apps/backend/src/modules/onboarding/onboarding.repository.ts b/apps/backend/src/modules/onboarding/onboarding.repository.ts new file mode 100644 index 000000000..2675fdb48 --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding.repository.ts @@ -0,0 +1,173 @@ +import { RelationName } from "~/libs/enums/enums.js"; +import { DatabaseTableName } from "~/libs/modules/database/database.js"; +import { type Repository } from "~/libs/types/types.js"; + +import { + DEFAULT_COUNT_VALUE, + FIRST_ELEMENT_INDEX, +} from "./libs/constants/constants.js"; +import { + type CountResult, + type OnboardingAnswerDto, +} from "./libs/types/types.js"; +import { OnboardingAnswerEntity } from "./onboarding-answer.entity.js"; +import { type OnboardingAnswerModel } from "./onboarding-answer.model.js"; +import { type OnboardingQuestionModel } from "./onboarding-question.model.js"; + +class OnboardingRepository implements Repository { + private onboardingAnswerModel: typeof OnboardingAnswerModel; + + private onboardingQuestionModel: typeof OnboardingQuestionModel; + + public constructor( + onboardingAnswerModel: typeof OnboardingAnswerModel, + onboardingQuestionModel: typeof OnboardingQuestionModel, + ) { + this.onboardingAnswerModel = onboardingAnswerModel; + this.onboardingQuestionModel = onboardingQuestionModel; + } + + public async countQuestions(): Promise { + const result = await this.onboardingQuestionModel + .query() + .count("* as count"); + + const countResult: CountResult[] = result as CountResult[]; + + const countString = + countResult[FIRST_ELEMENT_INDEX]?.count ?? DEFAULT_COUNT_VALUE.toString(); + + return Number.parseInt(countString, 10); + } + + public async create( + entity: OnboardingAnswerEntity, + ): Promise { + const { label, questionId } = entity.toNewObject(); + const answer = await this.onboardingAnswerModel + .query() + .insert({ label, questionId }) + .returning("*"); + + return OnboardingAnswerEntity.initialize({ + createdAt: answer.createdAt, + id: answer.id, + label: answer.label, + questionId: answer.questionId, + updatedAt: answer.updatedAt, + userId: answer.userId, + }); + } + + public async createUserAnswers( + userId: number, + answerIds: number[], + ): Promise { + await Promise.all( + answerIds.map((answerId) => { + return this.onboardingAnswerModel + .relatedQuery(RelationName.USERS) + .for(answerId) + .relate(userId); + }), + ); + + const savedAnswers = await this.onboardingAnswerModel + .query() + .whereIn("id", answerIds); + + return savedAnswers.map((answer) => { + return OnboardingAnswerEntity.initialize({ + createdAt: answer.createdAt, + id: answer.id, + label: answer.label, + questionId: answer.questionId, + updatedAt: answer.updatedAt, + userId: answer.userId, + }); + }); + } + + public async delete(id: number): Promise { + const rowsDeleted = await this.onboardingAnswerModel.query().deleteById(id); + + return Boolean(rowsDeleted); + } + + public async deleteUserAnswers(userId: number): Promise { + return await this.onboardingAnswerModel + .query() + .from(DatabaseTableName.ONBOARDING_ANSWERS_TO_USERS) + .where({ userId }) + .delete(); + } + + public async find(id: number): Promise { + const result = await this.onboardingAnswerModel.query().findById(id); + + if (!result) { + return null; + } + + return OnboardingAnswerEntity.initialize({ + createdAt: result.createdAt, + id: result.id, + label: result.label, + questionId: result.questionId, + updatedAt: result.updatedAt, + userId: result.userId, + }); + } + + public async findAll(): Promise { + const results = await this.onboardingAnswerModel.query(); + + return results.map((result) => { + return OnboardingAnswerEntity.initialize({ + createdAt: result.createdAt, + id: result.id, + label: result.label, + questionId: result.questionId, + updatedAt: result.updatedAt, + userId: result.userId, + }); + }); + } + + public async findAnswersByIds( + ids: number[], + ): Promise { + const results = await this.onboardingAnswerModel.query().whereIn("id", ids); + + return results.map((result) => { + return OnboardingAnswerEntity.initialize({ + createdAt: result.createdAt, + id: result.id, + label: result.label, + questionId: result.questionId, + updatedAt: result.updatedAt, + userId: result.userId, + }); + }); + } + + public async update( + id: number, + payload: Partial, + ): Promise { + const answer = await this.onboardingAnswerModel + .query() + .patchAndFetchById(id, { ...payload }); + + return OnboardingAnswerEntity.initialize({ + createdAt: answer.createdAt, + id: answer.id, + label: answer.label, + questionId: answer.questionId, + updatedAt: answer.updatedAt, + userId: answer.userId, + }); + } +} + +export { OnboardingRepository }; diff --git a/apps/backend/src/modules/onboarding/onboarding.service.ts b/apps/backend/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 000000000..6ac216202 --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,90 @@ +import { ErrorMessage } from "~/libs/enums/enums.js"; +import { HTTPCode } from "~/libs/modules/http/http.js"; +import { type Service } from "~/libs/types/types.js"; + +import { OnboardingError } from "./libs/exceptions/exceptions.js"; +import { + type OnboardingAnswerDto, + type OnboardingAnswerRequestDto, + type OnboardingAnswerResponseDto, +} from "./libs/types/types.js"; +import { type OnboardingRepository } from "./onboarding.repository.js"; +import { type OnboardingAnswerEntity } from "./onboarding-answer.entity.js"; + +class OnboardingService implements Service { + private onboardingRepository: OnboardingRepository; + + public constructor(onboardingRepository: OnboardingRepository) { + this.onboardingRepository = onboardingRepository; + } + + public async create({ + answerIds, + userId, + }: OnboardingAnswerRequestDto): Promise { + const answers = await this.findAnswersByIds(answerIds); + + if (answers.length !== answerIds.length) { + throw new OnboardingError({ + message: ErrorMessage.REQUESTED_ENTITY_NOT_FOUND, + status: HTTPCode.NOT_FOUND, + }); + } + + const totalQuestions = await this.onboardingRepository.countQuestions(); + + if (answerIds.length !== totalQuestions) { + throw new OnboardingError({ + message: ErrorMessage.INSUFFICIENT_ANSWERS, + status: HTTPCode.BAD_REQUEST, + }); + } + + const addedAnswers = await this.onboardingRepository.createUserAnswers( + userId, + answerIds, + ); + + return { + answers: addedAnswers.map((answer) => { + return answer.toObject(); + }), + }; + } + + public delete(id: number): Promise { + return this.onboardingRepository.delete(id); + } + + public async find(id: number): Promise { + const answer = await this.onboardingRepository.find(id); + + return answer ? answer.toObject() : null; + } + + public async findAll(): Promise<{ items: OnboardingAnswerDto[] }> { + const answers = await this.onboardingRepository.findAll(); + + return { + items: answers.map((answer) => { + return answer.toObject(); + }), + }; + } + + public async findAnswersByIds( + ids: number[], + ): Promise { + return await this.onboardingRepository.findAnswersByIds(ids); + } + + public async update( + id: number, + payload: Partial, + ): Promise { + const answer = await this.onboardingRepository.update(id, payload); + + return answer.toObject(); + } +} +export { OnboardingService }; diff --git a/apps/backend/src/modules/onboarding/onboarding.ts b/apps/backend/src/modules/onboarding/onboarding.ts new file mode 100644 index 000000000..7a039ec18 --- /dev/null +++ b/apps/backend/src/modules/onboarding/onboarding.ts @@ -0,0 +1,22 @@ +import { logger } from "~/libs/modules/logger/logger.js"; + +import { OnboardingController } from "./onboarding.controller.js"; +import { OnboardingRepository } from "./onboarding.repository.js"; +import { OnboardingService } from "./onboarding.service.js"; +import { OnboardingAnswerModel } from "./onboarding-answer.model.js"; +import { OnboardingQuestionModel } from "./onboarding-question.model.js"; + +const onboardingRepository = new OnboardingRepository( + OnboardingAnswerModel, + OnboardingQuestionModel, +); +const onboardingService = new OnboardingService(onboardingRepository); +const onboardingController = new OnboardingController( + logger, + onboardingService, +); + +export { OnboardingApiPath } from "./libs/enums/enums.js"; +export { OnboardingError } from "./libs/exceptions/exceptions.js"; +export { OnboardingAnswerModel } from "./onboarding-answer.model.js"; +export { onboardingController }; diff --git a/apps/backend/src/modules/users/user.model.ts b/apps/backend/src/modules/users/user.model.ts index b8f3a8c8a..200faaf00 100644 --- a/apps/backend/src/modules/users/user.model.ts +++ b/apps/backend/src/modules/users/user.model.ts @@ -5,11 +5,14 @@ import { DatabaseTableName, } from "~/libs/modules/database/database.js"; +import { OnboardingAnswerModel } from "../onboarding/onboarding.js"; import { UserDetailsModel } from "./user-details.model.js"; class UserModel extends AbstractModel { public email!: string; + public onboardingAnswers!: OnboardingAnswerModel[]; + public passwordHash!: string; public passwordSalt!: string; @@ -18,6 +21,18 @@ class UserModel extends AbstractModel { static get relationMappings(): RelationMappings { return { + onboardingAnswers: { + join: { + from: `${DatabaseTableName.USERS}.id`, + through: { + from: `${DatabaseTableName.ONBOARDING_ANSWERS_TO_USERS}.userId`, + to: `${DatabaseTableName.ONBOARDING_ANSWERS_TO_USERS}.answerId`, + }, + to: `${DatabaseTableName.ONBOARDING_ANSWERS}.id`, + }, + modelClass: OnboardingAnswerModel, + relation: Model.ManyToManyRelation, + }, userDetails: { join: { from: `${DatabaseTableName.USERS}.id`, diff --git a/apps/backend/src/modules/users/users.ts b/apps/backend/src/modules/users/users.ts index 62513574f..c867cdb90 100644 --- a/apps/backend/src/modules/users/users.ts +++ b/apps/backend/src/modules/users/users.ts @@ -23,5 +23,6 @@ export { userSignInValidationSchema, userSignUpValidationSchema, } from "./libs/validation-schemas/validation-schemas.js"; +export { UserModel } from "./user.model.js"; export { UserService } from "./user.service.js"; export { userController, userService }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a5c3ce620..9c40dd7c4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export { export { AuthError, HTTPError, + OnboardingError, ValidationError, } from "./libs/exceptions/exceptions.js"; export { configureString } from "./libs/helpers/helpers.js"; @@ -33,6 +34,14 @@ export { AuthApiPath, ConfirmPasswordCustomValidation, } from "./modules/auth/auth.js"; +export { + type OnboardingAnswerDto, + type OnboardingAnswerRequestBodyDto, + type OnboardingAnswerRequestDto, + type OnboardingAnswerResponseDto, + onboardingAnswersValidationSchema, + OnboardingApiPath, +} from "./modules/onboarding/onboarding.js"; export { type UserDto, type UserGetAllResponseDto, diff --git a/packages/shared/src/libs/enums/api-path.enum.ts b/packages/shared/src/libs/enums/api-path.enum.ts index 32f87e1be..e3ba254a4 100644 --- a/packages/shared/src/libs/enums/api-path.enum.ts +++ b/packages/shared/src/libs/enums/api-path.enum.ts @@ -1,5 +1,6 @@ const APIPath = { AUTH: "/auth", + ONBOARDING: "/onboarding", USERS: "/users", } as const; diff --git a/packages/shared/src/libs/enums/error-message.enum.ts b/packages/shared/src/libs/enums/error-message.enum.ts index 120e1a8dd..5a970a5ba 100644 --- a/packages/shared/src/libs/enums/error-message.enum.ts +++ b/packages/shared/src/libs/enums/error-message.enum.ts @@ -1,5 +1,7 @@ const ErrorMessage = { INCORRECT_CREDENTIALS: "Incorrect credentials.", + INSUFFICIENT_ANSWERS: "You must provide answers for all questions.", + REQUESTED_ENTITY_NOT_FOUND: "The requested entity was not found.", UNAUTHORIZED: "You are unauthorized to access the requested resource.", } as const; diff --git a/packages/shared/src/libs/exceptions/exceptions.ts b/packages/shared/src/libs/exceptions/exceptions.ts index 0f066eb7b..d41cc367b 100644 --- a/packages/shared/src/libs/exceptions/exceptions.ts +++ b/packages/shared/src/libs/exceptions/exceptions.ts @@ -1,3 +1,4 @@ export { AuthError } from "./auth-error/auth-error.exception.js"; export { HTTPError } from "./http-error/http-error.exception.js"; +export { OnboardingError } from "./onboarding-error/onboarding-error.exception.js"; export { ValidationError } from "./validation-error/validation-error.exception.js"; diff --git a/packages/shared/src/libs/exceptions/onboarding-error/onboarding-error.exception.ts b/packages/shared/src/libs/exceptions/onboarding-error/onboarding-error.exception.ts new file mode 100644 index 000000000..2907724dc --- /dev/null +++ b/packages/shared/src/libs/exceptions/onboarding-error/onboarding-error.exception.ts @@ -0,0 +1,17 @@ +import { type HTTPCode } from "../../modules/http/http.js"; +import { type ValueOf } from "../../types/types.js"; +import { HTTPError } from "../http-error/http-error.exception.js"; + +type Constructor = { + cause?: unknown; + message: string; + status: ValueOf; +}; + +class OnboardingError extends HTTPError { + public constructor({ cause, message, status }: Constructor) { + super({ cause, message, status }); + } +} + +export { OnboardingError }; diff --git a/packages/shared/src/libs/modules/http/libs/enums/http-code.enum.ts b/packages/shared/src/libs/modules/http/libs/enums/http-code.enum.ts index 71a26daf4..a282f30cf 100644 --- a/packages/shared/src/libs/modules/http/libs/enums/http-code.enum.ts +++ b/packages/shared/src/libs/modules/http/libs/enums/http-code.enum.ts @@ -2,6 +2,7 @@ const HTTPCode = { BAD_REQUEST: 400, CREATED: 201, INTERNAL_SERVER_ERROR: 500, + NOT_FOUND: 404, OK: 200, UNAUTHORIZED: 401, UNPROCESSED_ENTITY: 422, diff --git a/packages/shared/src/modules/onboarding/libs/enums/enums.ts b/packages/shared/src/modules/onboarding/libs/enums/enums.ts new file mode 100644 index 000000000..878f83a53 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/enums/enums.ts @@ -0,0 +1,2 @@ +export { OnboardingApiPath } from "./onboarding-api-path.enum.js"; +export { OnboardingValidationMessage } from "./onboarding-validation-message.enum.js"; diff --git a/packages/shared/src/modules/onboarding/libs/enums/onboarding-api-path.enum.ts b/packages/shared/src/modules/onboarding/libs/enums/onboarding-api-path.enum.ts new file mode 100644 index 000000000..b2e10e7aa --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/enums/onboarding-api-path.enum.ts @@ -0,0 +1,5 @@ +const OnboardingApiPath = { + ANSWER: "/answer", +} as const; + +export { OnboardingApiPath }; diff --git a/packages/shared/src/modules/onboarding/libs/enums/onboarding-validation-message.enum.ts b/packages/shared/src/modules/onboarding/libs/enums/onboarding-validation-message.enum.ts new file mode 100644 index 000000000..6fcf49c8a --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/enums/onboarding-validation-message.enum.ts @@ -0,0 +1,6 @@ +const OnboardingValidationMessage = { + NUMBER_ID_REQUIRED: "Required answer IDs must be numbers.", + UNIQUE_ANSWERS: "Answers must be unique.", +} as const; + +export { OnboardingValidationMessage }; diff --git a/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-dto.type.ts b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-dto.type.ts new file mode 100644 index 000000000..7ece02b2f --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-dto.type.ts @@ -0,0 +1,10 @@ +type OnboardingAnswerDto = { + createdAt: string; + id: number; + label: string; + questionId: number; + updatedAt: string; + userId: number; +}; + +export { type OnboardingAnswerDto }; diff --git a/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-body-dto.type.ts b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-body-dto.type.ts new file mode 100644 index 000000000..363b419f5 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-body-dto.type.ts @@ -0,0 +1,5 @@ +type OnboardingAnswerRequestBodyDto = { + answerIds: number[]; +}; + +export { type OnboardingAnswerRequestBodyDto }; diff --git a/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-dto.type.ts b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-dto.type.ts new file mode 100644 index 000000000..9aea2018d --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-request-dto.type.ts @@ -0,0 +1,6 @@ +type OnboardingAnswerRequestDto = { + answerIds: number[]; + userId: number; +}; + +export { type OnboardingAnswerRequestDto }; diff --git a/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-response-dto.type.ts b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-response-dto.type.ts new file mode 100644 index 000000000..f3583e220 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/types/onboarding-answer-response-dto.type.ts @@ -0,0 +1,7 @@ +import { type OnboardingAnswerDto } from "./onboarding-answer-dto.type.js"; + +type OnboardingAnswerResponseDto = { + answers: OnboardingAnswerDto[]; +}; + +export { type OnboardingAnswerResponseDto }; diff --git a/packages/shared/src/modules/onboarding/libs/types/types.ts b/packages/shared/src/modules/onboarding/libs/types/types.ts new file mode 100644 index 000000000..62e59a244 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/types/types.ts @@ -0,0 +1,4 @@ +export { type OnboardingAnswerDto } from "./onboarding-answer-dto.type.js"; +export { type OnboardingAnswerRequestBodyDto } from "./onboarding-answer-request-body-dto.type.js"; +export { type OnboardingAnswerRequestDto } from "./onboarding-answer-request-dto.type.js"; +export { type OnboardingAnswerResponseDto } from "./onboarding-answer-response-dto.type.js"; diff --git a/packages/shared/src/modules/onboarding/libs/validation-schemas/onboarding-answers.validation-schema.ts b/packages/shared/src/modules/onboarding/libs/validation-schemas/onboarding-answers.validation-schema.ts new file mode 100644 index 000000000..b4fee1862 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/validation-schemas/onboarding-answers.validation-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { OnboardingValidationMessage } from "../enums/enums.js"; + +type OnboardingAnswersValidationDto = { + answerIds: z.ZodEffects, number[], number[]>; +}; + +const onboardingAnswers = z.object({ + answerIds: z + .array( + z.number({ message: OnboardingValidationMessage.NUMBER_ID_REQUIRED }), + ) + .refine((ids) => new Set(ids).size === ids.length, { + message: OnboardingValidationMessage.UNIQUE_ANSWERS, + }), +}); + +export { onboardingAnswers }; diff --git a/packages/shared/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts b/packages/shared/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts new file mode 100644 index 000000000..2e13ecf71 --- /dev/null +++ b/packages/shared/src/modules/onboarding/libs/validation-schemas/validation-schemas.ts @@ -0,0 +1 @@ +export { onboardingAnswers } from "./onboarding-answers.validation-schema.js"; diff --git a/packages/shared/src/modules/onboarding/onboarding.ts b/packages/shared/src/modules/onboarding/onboarding.ts new file mode 100644 index 000000000..a001a74ab --- /dev/null +++ b/packages/shared/src/modules/onboarding/onboarding.ts @@ -0,0 +1,8 @@ +export { OnboardingApiPath } from "./libs/enums/enums.js"; +export { + type OnboardingAnswerDto, + type OnboardingAnswerRequestBodyDto, + type OnboardingAnswerRequestDto, + type OnboardingAnswerResponseDto, +} from "./libs/types/types.js"; +export { onboardingAnswers as onboardingAnswersValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; diff --git a/readme.md b/readme.md index 3c3e41827..541a4ce44 100644 --- a/readme.md +++ b/readme.md @@ -84,6 +84,16 @@ erDiagram text label int question_id FK } + + onboarding_answers_to_users }o--|| onboarding_answers : answer_id + onboarding_answers_to_users }o--|| users : user_id + onboarding_answers_to_users { + int id PK + dateTime created_at + dateTime updated_at + int answer_id FK + int user_id FK + } ``` ## 5. Architecture