diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d4b135..3c0c8c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,7 +91,7 @@ workflows: only: - develop - security - - PM-3327 + - PM-3351 - "build-qa": context: org-global diff --git a/app-constants.js b/app-constants.js index 0c6f361..5c8d576 100644 --- a/app-constants.js +++ b/app-constants.js @@ -151,6 +151,14 @@ const PhaseFact = { UNRECOGNIZED: -1 } +const PhaseChangeNotificationSettings = { + PHASE_CHANGE: { + sendgridTemplateId: config.PHASE_CHANGE_SENDGRID_TEMPLATE_ID, + cc: [], + }, +}; + + const auditFields = [ 'createdAt', 'createdBy', 'updatedAt', 'updatedBy' ] @@ -168,4 +176,5 @@ module.exports = { SelfServiceNotificationSettings, PhaseFact, auditFields, + PhaseChangeNotificationSettings, }; diff --git a/app.js b/app.js index 4ac3927..e666553 100644 --- a/app.js +++ b/app.js @@ -70,15 +70,7 @@ app.use( app.use( cors({ - origin: (origin, callback) => { - if (!origin) { - console.log("No origin - probably curl or server to server request"); - // disable cors if service to service request - callback(null, false); - } else { - callback(null, '*') - } - }, + origin: "*", exposedHeaders: [ "X-Prev-Page", "X-Next-Page", diff --git a/config/default.js b/config/default.js index cc0da6e..93e524f 100644 --- a/config/default.js +++ b/config/default.js @@ -133,4 +133,6 @@ module.exports = { RESOURCES_DB_SCHEMA: process.env.RESOURCES_DB_SCHEMA || "resources", REVIEW_DB_SCHEMA: process.env.REVIEW_DB_SCHEMA || "reviews", CHALLENGE_SERVICE_PRISMA_TIMEOUT: process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT ? parseInt(process.env.CHALLENGE_SERVICE_PRISMA_TIMEOUT, 10) : 10000, + CHALLENGE_URL: process.env.CHALLENGE_URL || 'https://www.topcoder-dev.com/challenges' , + PHASE_CHANGE_SENDGRID_TEMPLATE_ID: process.env.PHASE_CHANGE_SENDGRID_TEMPLATE_ID || "", }; diff --git a/data-migration/prisma/schema.prisma b/data-migration/prisma/schema.prisma index 1b94c0a..e01ca4f 100644 --- a/data-migration/prisma/schema.prisma +++ b/data-migration/prisma/schema.prisma @@ -74,6 +74,7 @@ model Challenge { currentPhaseNames String[] // current phase names wiproAllowed Boolean @default(false) + funChallenge Boolean @default(false) // simple arrays for tags and groups (PostgreSQL native array type) tags String[] diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3ab3bdc..434463d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2538,6 +2538,10 @@ definitions: type: boolean default: false description: Flag to indicate whether Wipro employees can join challenge + funChallenge: + type: boolean + default: false + description: Flag to indicate this challenge contributes to leaderboard scoring without individual prize payouts descriptionFormat: type: string default: markup @@ -3597,6 +3601,18 @@ definitions: type: string description: The timeline template id. format: UUID + typeId: + type: string + description: The challenge type id mapped to this timeline template row. + format: UUID + trackId: + type: string + description: The challenge track id mapped to this timeline template row. + format: UUID + isDefault: + type: boolean + description: Indicates this timeline template is the default for the mapped type/track. + default: false - $ref: "#/definitions/TimelineTemplateData" required: - id diff --git a/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql b/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql new file mode 100644 index 0000000..57bc492 --- /dev/null +++ b/prisma/migrations/20260222100000_add_fun_challenge_flag/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Challenge" +ADD COLUMN "funChallenge" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql b/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql new file mode 100644 index 0000000..2d7465d --- /dev/null +++ b/prisma/migrations/20260227120000_add_challenge_term_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "ChallengeTerm_challengeId_idx" ON "ChallengeTerm"("challengeId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 036da57..e24bbb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,6 +92,7 @@ model Challenge { currentPhaseNames String[] // current phase names wiproAllowed Boolean @default(false) + funChallenge Boolean @default(false) // simple arrays for tags and groups (PostgreSQL native array type) tags String[] @@ -375,6 +376,8 @@ model ChallengeTerm { createdBy String updatedAt DateTime @updatedAt updatedBy String + + @@index([challengeId]) } ////////////////////////////////////////// @@ -732,4 +735,4 @@ model TimelineTemplatePhase { @@index([timelineTemplateId]) @@index([timelineTemplateId, phaseId]) -} \ No newline at end of file +} diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index c2b6d81..b3a2047 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -91,7 +91,6 @@ class ChallengeHelper { promises.push( (async () => { const group = await helper.getGroupById(g); - console.log("group", group); if (!group) { throw new errors.BadRequestError("The groups provided are invalid " + g); } diff --git a/src/common/helper.js b/src/common/helper.js index a7453fb..74b19f8 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1428,22 +1428,48 @@ function sumOfPrizes(prizes) { } /** - * Get group by id - * @param {String} groupId the group id + * Get group by id, with oldId fallback for backward compatibility. + * @param {String} groupId the group id or oldId * @returns {Promise} the group */ async function getGroupById(groupId) { + const normalizedGroupId = _.toString(groupId || "").trim(); + if (!normalizedGroupId) { + return; + } + const token = await m2mHelper.getM2MToken(); + const requestHeaders = { Authorization: `Bearer ${token}` }; try { - const result = await axios.get(`${config.GROUPS_API_URL}/${groupId}`, { - headers: { Authorization: `Bearer ${token}` }, + const result = await axios.get(`${config.GROUPS_API_URL}/${encodeURIComponent(normalizedGroupId)}`, { + headers: requestHeaders, }); return result.data; } catch (err) { - if (err.response.status === HttpStatus.NOT_FOUND) { - return; + const status = _.get(err, "response.status"); + if (status !== HttpStatus.NOT_FOUND) { + throw err; + } + } + + try { + const result = await axios.get(config.GROUPS_API_URL, { + headers: requestHeaders, + params: { + page: 1, + perPage: 1, + oldId: normalizedGroupId, + }, + }); + const groups = _.get(result, "data", []); + if (groups.length > 0) { + return groups[0]; + } + } catch (err) { + const status = _.get(err, "response.status"); + if (status !== HttpStatus.NOT_FOUND) { + throw err; } - throw err; } } @@ -1638,6 +1664,72 @@ async function sendSelfServiceNotification(type, recipients, data) { } } +/** + * Build payload for phase change email notification + * @param {String} challenge Id + * @param {String} challenge name + * @param {String} challenge phase name + * @param {String} operation to be performed on the phase - open | close | reopen + * @param {String|Date} at - The date/time when the phase opened/closed + */ +function buildPhaseChangeEmailData({ challengeId, challengeName, phaseName, operation, at }) { + const isOpen = operation === 'open' || operation === 'reopen'; + const isClose = operation === 'close'; + + return { + challengeURL: `${config.CHALLENGE_URL}/${challengeId}`, + challengeName, + phaseOpen: isOpen ? phaseName : null, + phaseOpenDate: isOpen ? at : null, + phaseClose: isClose ? phaseName : null, + phaseCloseDate: isClose ? at : null, + }; +} + + +/** + * Send phase change notification + * @param {String} type the notification type + * @param {Array} recipients the array of recipients emails + * @param {Object} data the data + */ +async function sendPhaseChangeNotification(type, recipients, data) { + try { + const settings = constants.PhaseChangeNotificationSettings?.[type]; + + if (!settings) { + logger.debug(`sendPhaseChangeNotification: unknown type ${type}`); + return; + } + + if (!settings.sendgridTemplateId) { + logger.debug( + `sendPhaseChangeNotification: sendgridTemplateId not configured for type ${type}` + ); + return; + } + const safeRecipients = Array.isArray(recipients) ? recipients.filter(Boolean) : []; + + if (!safeRecipients.length) { + logger.debug(`sendPhaseChangeNotification: no recipients for type ${type}`); + return; + } + + await postBusEvent('external.action.email', + { + from: config.EMAIL_FROM, + replyTo: config.EMAIL_FROM, + recipients: safeRecipients, + data: data, + sendgrid_template_id: settings.sendgridTemplateId, + version: 'v3', + }, + ); + } catch (e) { + logger.debug(`Failed to post notification ${type}: ${e.message}`); + } +} + /** * Submit a request to zendesk * @param {Object} request the request @@ -1756,6 +1848,8 @@ module.exports = { setToInternalCache, flushInternalCache, removeNullProperties, + buildPhaseChangeEmailData, + sendPhaseChangeNotification }; logger.buildService(module.exports); diff --git a/src/common/prisma-helper.js b/src/common/prisma-helper.js index 8f4898b..e20a8b2 100644 --- a/src/common/prisma-helper.js +++ b/src/common/prisma-helper.js @@ -95,6 +95,7 @@ function convertChallengeSchemaToPrisma(currentUser, challenge) { "groups", "legacyId", "wiproAllowed", + "funChallenge", "numOfRegistrants", "numOfSubmissions", "numOfCheckpointSubmissions", diff --git a/src/services/ChallengePhaseService.js b/src/services/ChallengePhaseService.js index 507eb2f..4c6c4da 100644 --- a/src/services/ChallengePhaseService.js +++ b/src/services/ChallengePhaseService.js @@ -813,9 +813,74 @@ async function partiallyUpdateChallengePhase(currentUser, challengeId, id, data) _.assignIn({ id: result.id }, data) ); await postChallengeUpdatedNotification(challengeId); + + // send notification logic + try { + const shouldNotifyClose = Boolean(isClosingPhase); + const shouldNotifyOpen = Boolean(isOpeningPhase); // includes reopen + + if (shouldNotifyClose || shouldNotifyOpen) { + // Single template - single type + const notificationType = "PHASE_CHANGE"; + + const operation = shouldNotifyClose + ? "close" + : (isReopeningPhase ? "reopen" : "open"); + + const at = shouldNotifyClose + ? (result.actualEndDate || new Date().toISOString()) + : (result.actualStartDate || new Date().toISOString()); + + // fetch challenge name + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + select: { name: true }, + }); + + const challengeName = challenge?.name; + + // build recipients + const resources = await helper.getChallengeResources(challengeId); + + const recipients = Array.from( + new Set( + (resources || []) + .map(r => r?.email || r?.memberEmail) + .filter(Boolean) + .map(e => String(e).trim().toLowerCase()) + ) + ); + + if (!recipients.length) { + logger.debug( + `phase change notification skipped: no recipients for challenge ${challengeId}` + ); + return _.omit(result, constants.auditFields); + } + + // build payload that matches the SendGrid HTML template + const phaseName = result.name || data.name || challengePhase.name; + + const payload = helper.buildPhaseChangeEmailData({ + challengeId, + challengeName, + phaseName, + operation, + at, + }); + + await helper.sendPhaseChangeNotification(notificationType, recipients, payload); + } + } catch (e) { + logger.debug( + `phase change notification failed for challenge ${challengeId}, phase ${id}: ${e.message}` + ); + } + return _.omit(result, constants.auditFields); } + partiallyUpdateChallengePhase.schema = { currentUser: Joi.any(), challengeId: Joi.id(), diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 61af425..db8c42b 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -47,6 +47,37 @@ const allowedSortByValues = _.uniq([ ...Object.keys(sortByAliases), ]); +function normalizeStatusSortValue(statusValue) { + if (_.isNil(statusValue)) { + return null; + } + + const normalizedStatus = _.toString(statusValue).trim().toUpperCase(); + if (!normalizedStatus) { + return null; + } + + return normalizedStatus; +} + +function compareStatusSortValues(aStatusValue, bStatusValue) { + const normalizedA = normalizeStatusSortValue(aStatusValue); + const normalizedB = normalizeStatusSortValue(bStatusValue); + + if (normalizedA === normalizedB) { + return _.toString(aStatusValue).localeCompare(_.toString(bStatusValue)); + } + + if (_.isNil(normalizedA)) { + return 1; + } + if (_.isNil(normalizedB)) { + return -1; + } + + return normalizedA.localeCompare(normalizedB); +} + // Minimal domain adapter for PhaseAdvancer to fetch phase-specific facts. // For now this returns an empty factResponses array which makes the // PhaseAdvancer default to conservative behavior when such facts are needed. @@ -482,8 +513,11 @@ async function searchChallengesViaMemberAccess({ }) { const chunkSize = Number(process.env.SEARCH_MEMBER_CHUNK_SIZE || 500); const memberChallengeIdStart = Date.now(); - const memberChallengeIdRows = - await prisma.$queryRaw`SELECT DISTINCT r."challengeId" FROM resources."Resource" r WHERE r."memberId" = ${requestedMemberId} AND r."challengeId" IS NOT NULL`; + const memberChallengeIdRows = await prisma.memberChallengeAccess.findMany({ + where: { memberId: requestedMemberId }, + select: { challengeId: true }, + distinct: ["challengeId"], + }); const memberChallengeIds = memberChallengeIdRows .map((row) => row.challengeId) .filter((id) => !_.isNil(id)); @@ -527,6 +561,9 @@ async function searchChallengesViaMemberAccess({ if (aValue === bValue) { return 0; } + if (sortByProp === "status") { + return compareStatusSortValues(aValue, bValue); + } if (_.isNil(aValue)) { return 1; } @@ -1274,6 +1311,53 @@ async function searchChallenges(currentUser, criteria) { challengeInclude, markTiming, })); + } else if (sortByProp === "status") { + const summaryStart = Date.now(); + const summaryRecords = await prisma.challenge.findMany({ + where: prismaFilter.where, + select: { + id: true, + status: true, + }, + }); + markTiming("statusSortSummaryScan", { + durationMs: Date.now() - summaryStart, + candidateCount: summaryRecords.length, + }); + + const sortDirection = sortOrderProp === "asc" ? 1 : -1; + summaryRecords.sort( + (a, b) => compareStatusSortValues(a.status, b.status) * sortDirection + ); + + total = summaryRecords.length; + const offset = (page - 1) * perPage; + const pageSummaries = summaryRecords.slice(offset, offset + perPage); + const pageIds = pageSummaries.map((summary) => summary.id); + if (pageIds.length === 0) { + challenges = []; + } else { + const fetchWhere = _.cloneDeep(prismaFilter.where); + fetchWhere.AND = [...(fetchWhere.AND || []), { id: { in: pageIds } }]; + + const findManyStart = Date.now(); + const fetchedChallenges = await prisma.challenge.findMany({ + where: fetchWhere, + include: challengeInclude, + }); + markTiming("statusSortFetch", { + durationMs: Date.now() - findManyStart, + resultCount: fetchedChallenges.length, + }); + + const challengesById = new Map(); + fetchedChallenges.forEach((challenge) => { + challengesById.set(challenge.id, challenge); + }); + challenges = pageIds + .map((challengeId) => challengesById.get(challengeId)) + .filter((challenge) => !!challenge); + } } else { const countStart = Date.now(); total = await prisma.challenge.count({ ...prismaFilter }); @@ -1516,8 +1600,17 @@ async function createChallenge(currentUser, challenge, userToken) { logger.debug(`createChallenge: fetching project details ${buildLogContext()}`); const { directProjectId } = await projectHelper.getProject(projectId, currentUser); + let normalizedDirectProjectId = directProjectId; + if (!_.isNil(directProjectId)) { + normalizedDirectProjectId = _.toNumber(directProjectId); + if (!Number.isInteger(normalizedDirectProjectId)) { + throw new errors.BadRequestError( + `Project with id: ${projectId} has invalid directProjectId: ${directProjectId}` + ); + } + } logger.debug( - `createChallenge: fetched project details (directProjectId=${directProjectId}) ${buildLogContext()}` + `createChallenge: fetched project details (directProjectId=${normalizedDirectProjectId}) ${buildLogContext()}` ); logger.debug(`createChallenge: fetching billing information ${buildLogContext()}`); const { billingAccountId, markup } = await projectHelper.getProjectBillingInformation( @@ -1529,7 +1622,7 @@ async function createChallenge(currentUser, challenge, userToken) { }, markup=${markup}) ${buildLogContext()}` ); - _.set(challenge, "legacy.directProjectId", directProjectId); + _.set(challenge, "legacy.directProjectId", normalizedDirectProjectId); // Ensure billingAccountId is a string or null to match Prisma schema if (billingAccountId !== null && billingAccountId !== undefined) { _.set(challenge, "billing.billingAccountId", String(billingAccountId)); @@ -1862,6 +1955,7 @@ createChallenge.schema = { privateDescription: Joi.string(), descriptionFormat: Joi.string(), wiproAllowed: Joi.boolean().optional(), + funChallenge: Joi.boolean().optional(), challengeSource: Joi.string(), numOfRegistrants: Joi.number().integer().min(0).optional(), numOfSubmissions: Joi.number().integer().min(0).optional(), @@ -3140,6 +3234,7 @@ updateChallenge.schema = { privateDescription: Joi.string().allow("").optional(), descriptionFormat: Joi.string().optional(), wiproAllowed: Joi.boolean().optional(), + funChallenge: Joi.boolean().optional(), challengeSource: Joi.string().optional(), numOfRegistrants: Joi.number().integer().min(0).optional(), numOfSubmissions: Joi.number().integer().min(0).optional(), @@ -3632,6 +3727,7 @@ function sanitizeChallenge(challenge) { "skills", "reviewers", "wiproAllowed", + "funChallenge", "numOfRegistrants", "numOfSubmissions", "numOfCheckpointSubmissions", diff --git a/src/services/ChallengeTrackService.js b/src/services/ChallengeTrackService.js index 6c82529..6f03104 100644 --- a/src/services/ChallengeTrackService.js +++ b/src/services/ChallengeTrackService.js @@ -11,6 +11,30 @@ const constants = require("../../app-constants"); const { getClient, ChallengeTrackEnum } = require("../common/prisma"); const prisma = getClient(); +// Backward compatible aliases kept for payloads still using legacy track enum values. +const legacyTrackAliases = { + DEVELOP: ChallengeTrackEnum.DEVELOPMENT, + QA: ChallengeTrackEnum.QUALITY_ASSURANCE, +}; + +const supportedTrackValues = _.uniq([ + ..._.values(ChallengeTrackEnum), + ...Object.keys(legacyTrackAliases), +]); + +/** + * Normalize legacy track aliases to current enum values. + * @param {String} track raw track value + * @returns {String|null|undefined} normalized track value + */ +function normalizeTrackValue(track) { + if (_.isNil(track)) { + return track; + } + const normalized = _.toUpper(_.trim(track)); + return legacyTrackAliases[normalized] || normalized; +} + /** * Search challenge types * @param {Object} criteria the search criteria @@ -60,7 +84,7 @@ function getSearchFilter(criteria) { ret.legacyId = { equals: criteria.legacyId }; } if (!_.isEmpty(criteria.track)) { - ret.track = { equals: criteria.track }; + ret.track = { equals: normalizeTrackValue(criteria.track) }; } return ret; } @@ -74,7 +98,7 @@ searchChallengeTracks.schema = { isActive: Joi.boolean(), abbreviation: Joi.string(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }), }; @@ -117,9 +141,11 @@ async function checkTrackAbrv(abbreviation) { async function createChallengeTrack(authUser, type) { await checkTrackName(type.name); await checkTrackAbrv(type.abbreviation); + const normalizedTrack = normalizeTrackValue(type.track); let ret = await prisma.challengeTrack.create({ data: { ...type, + track: normalizedTrack, createdBy: authUser.userId, updatedBy: authUser.userId, }, @@ -140,7 +166,7 @@ createChallengeTrack.schema = { isActive: Joi.boolean().required(), abbreviation: Joi.string().required(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; @@ -186,6 +212,8 @@ async function fullyUpdateChallengeTrack(authUser, id, data) { } if (_.isUndefined(data.track)) { data.track = null; + } else { + data.track = normalizeTrackValue(data.track); } data.updatedBy = authUser.userId; let ret = await prisma.challengeTrack.update({ @@ -209,7 +237,7 @@ fullyUpdateChallengeTrack.schema = { isActive: Joi.boolean().required(), abbreviation: Joi.string().required(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; @@ -229,6 +257,9 @@ async function partiallyUpdateChallengeTrack(authUser, id, data) { if (data.abbreviation && type.abbreviation.toLowerCase() !== data.abbreviation.toLowerCase()) { await checkTrackAbrv(data.abbreviation); } + if (!_.isUndefined(data.track)) { + data.track = normalizeTrackValue(data.track); + } data.updatedBy = authUser.userId; let ret = await prisma.challengeTrack.update({ where: { id }, @@ -251,7 +282,7 @@ partiallyUpdateChallengeTrack.schema = { isActive: Joi.boolean(), abbreviation: Joi.string(), legacyId: Joi.number().integer().positive(), - track: Joi.string().valid(..._.values(ChallengeTrackEnum)), + track: Joi.string().uppercase().valid(...supportedTrackValues), }) .required(), }; diff --git a/src/services/TimelineTemplateService.js b/src/services/TimelineTemplateService.js index 447c6ac..86d1f68 100644 --- a/src/services/TimelineTemplateService.js +++ b/src/services/TimelineTemplateService.js @@ -26,12 +26,28 @@ async function searchTimelineTemplates(criteria) { const perPage = criteria.perPage || 50; let items = await prisma.timelineTemplate.findMany({ where: searchFilter, - include: { phases: true }, + include: { phases: true, challengeTimelineTemplates: true }, }); - items = _.map(items, (r) => _.omit(r, constants.auditFields)); - items.forEach((item) => { - item.phases = _.map(item.phases, (p) => _.omit(p, constants.auditFields)); + items = _.flatMap(items, (template) => { + const phases = _.map(template.phases, (phase) => _.omit(phase, constants.auditFields)); + const baseTemplate = { + ..._.omit(template, [...constants.auditFields, "phases", "challengeTimelineTemplates"]), + phases, + }; + const challengeTimelineTemplates = template.challengeTimelineTemplates || []; + + if (!challengeTimelineTemplates.length) { + return [baseTemplate]; + } + + return _.map(challengeTimelineTemplates, (challengeTimelineTemplate) => ({ + ...baseTemplate, + isDefault: challengeTimelineTemplate.isDefault === true, + trackId: challengeTimelineTemplate.trackId, + typeId: challengeTimelineTemplate.typeId, + })); }); + const total = items.length; const result = items.slice((page - 1) * perPage, page * perPage); diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 722d4c0..3bdcca6 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -14,6 +14,7 @@ const chai = require('chai') const constants = require('../../app-constants') const service = require('../../src/services/ChallengeService') const helper = require('../../src/common/helper') +const projectHelper = require('../../src/common/project-helper') const testHelper = require('../testHelper') const { getClient, ChallengeStatusEnum, PrizeSetTypeEnum } = require('../../src/common/prisma') const { getReviewClient } = require('../../src/common/review-prisma') @@ -96,6 +97,7 @@ describe('challenge service unit tests', () => { description: 'Prisma Test Challenge', privateDescription: 'Prisma Test Challenge', descriptionFormat: 'html', + funChallenge: true, metadata: [ { name: 'meta-name', @@ -153,9 +155,16 @@ describe('challenge service unit tests', () => { }) after(async () => { - await prisma.challenge.deleteMany({ - where: {id} - }) + const idsToDelete = _.compact([id, id2]) + if (idsToDelete.length > 0) { + await prisma.challenge.deleteMany({ + where: { + id: { + in: idsToDelete + } + } + }) + } await testHelper.clearData() }) @@ -200,6 +209,7 @@ describe('challenge service unit tests', () => { should.equal(result.legacyId, testChallengeData.legacyId) should.equal(result.forumId, testChallengeData.forumId) should.equal(result.status, testChallengeData.status) + should.equal(result.funChallenge, testChallengeData.funChallenge) should.equal(result.createdBy, 'testuser') should.exist(result.startDate) should.exist(result.created) @@ -207,6 +217,23 @@ describe('challenge service unit tests', () => { should.equal(result.numOfRegistrants, 0) }) + it('create challenge successfully when project directProjectId is a numeric string', async () => { + const challengeData = _.cloneDeep(testChallengeData) + const originalGetProject = projectHelper.getProject + projectHelper.getProject = async () => ({ directProjectId: '33541' }) + try { + const result = await service.createChallenge( + { isMachine: true, sub: 'sub', userId: 'testuser' }, + challengeData, + config.M2M_FULL_ACCESS_TOKEN + ) + id2 = result.id + should.equal(_.get(result, 'legacy.directProjectId'), 33541) + } finally { + projectHelper.getProject = originalGetProject + } + }) + it('create challenge - type not found', async () => { const challengeData = _.clone(testChallengeData) challengeData.typeId = notFoundId @@ -395,6 +422,123 @@ describe('challenge service unit tests', () => { should.equal(result.numOfSubmissions, 0) should.equal(result.numOfRegistrants, 0) }) + + it('search challenges sorts status alphabetically for member and non-member searches', async () => { + const statusChallenges = [ + { + id: uuid(), + name: `Status Sort Cancelled ${Date.now()}`, + status: ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST + }, + { + id: uuid(), + name: `Status Sort New ${Date.now()}`, + status: ChallengeStatusEnum.NEW + }, + { + id: uuid(), + name: `Status Sort Active ${Date.now()}`, + status: ChallengeStatusEnum.ACTIVE + }, + { + id: uuid(), + name: `Status Sort Completed ${Date.now()}`, + status: ChallengeStatusEnum.COMPLETED + } + ] + const statusChallengeIds = statusChallenges.map(challengeRow => challengeRow.id) + const originalMemberChallengeAccessFindMany = prisma.memberChallengeAccess.findMany + + try { + await Promise.all(statusChallenges.map(challengeRow => prisma.challenge.create({ + data: { + id: challengeRow.id, + name: challengeRow.name, + description: 'status-sort', + privateDescription: 'status-sort', + challengeSource: 'Topcoder', + descriptionFormat: 'html', + timelineTemplate: { connect: { id: data.timelineTemplate.id } }, + type: { connect: { id: data.challenge.typeId } }, + track: { connect: { id: data.challenge.trackId } }, + tags: [], + groups: [], + status: challengeRow.status, + createdBy: 'testuser', + updatedBy: 'testuser' + } + }))) + + prisma.memberChallengeAccess.findMany = async () => + statusChallenges.map(challengeRow => ({ challengeId: challengeRow.id })) + + const ascRes = await service.searchChallenges({ isMachine: true }, { + memberId: 'status-sort-member', + sortBy: 'status', + sortOrder: 'asc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(ascRes.result, 'status'), [ + ChallengeStatusEnum.ACTIVE, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.NEW + ]) + + const ascResNoMember = await service.searchChallenges({ isMachine: true }, { + ids: statusChallengeIds, + sortBy: 'status', + sortOrder: 'asc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(ascResNoMember.result, 'status'), [ + ChallengeStatusEnum.ACTIVE, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.NEW + ]) + + const descRes = await service.searchChallenges({ isMachine: true }, { + memberId: 'status-sort-member', + sortBy: 'status', + sortOrder: 'desc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(descRes.result, 'status'), [ + ChallengeStatusEnum.NEW, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.ACTIVE, + ]) + + const descResNoMember = await service.searchChallenges({ isMachine: true }, { + ids: statusChallengeIds, + sortBy: 'status', + sortOrder: 'desc', + page: 1, + perPage: 10 + }) + should.deepEqual(_.map(descResNoMember.result, 'status'), [ + ChallengeStatusEnum.NEW, + ChallengeStatusEnum.COMPLETED, + ChallengeStatusEnum.CANCELLED_CLIENT_REQUEST, + ChallengeStatusEnum.ACTIVE + ]) + } finally { + prisma.memberChallengeAccess.findMany = originalMemberChallengeAccessFindMany + await prisma.challenge.deleteMany({ + where: { + id: { + in: statusChallengeIds + } + } + }) + } + }) + it('search challenges successfully 1', async () => { const res = await service.searchChallenges({ isMachine: true }, { page: 1, @@ -861,6 +1005,7 @@ describe('challenge service unit tests', () => { should.equal(result.legacyId, challengeData.legacyId) should.equal(result.forumId, challengeData.forumId) should.equal(result.status, challengeData.status) + should.equal(result.funChallenge, challengeData.funChallenge) should.equal(!result.attachments || result.attachments.length === 0, true) should.equal(result.createdBy, 'testuser') should.equal(result.updatedBy, '22838965') diff --git a/test/unit/ChallengeTrackService.test.js b/test/unit/ChallengeTrackService.test.js new file mode 100644 index 0000000..cebd4ec --- /dev/null +++ b/test/unit/ChallengeTrackService.test.js @@ -0,0 +1,118 @@ +/* + * Unit tests of challenge track service + */ + +require('../../app-bootstrap') +const { v4: uuid } = require('uuid') +const chai = require('chai') + +const service = require('../../src/services/ChallengeTrackService') +const prisma = require('../../src/common/prisma').getClient() + +const should = chai.should() + +describe('challenge track service unit tests', () => { + let originalFindMany + let originalCreate + let originalFindUnique + let originalUpdate + + beforeEach(() => { + originalFindMany = prisma.challengeTrack.findMany + originalCreate = prisma.challengeTrack.create + originalFindUnique = prisma.challengeTrack.findUnique + originalUpdate = prisma.challengeTrack.update + }) + + afterEach(() => { + prisma.challengeTrack.findMany = originalFindMany + prisma.challengeTrack.create = originalCreate + prisma.challengeTrack.findUnique = originalFindUnique + prisma.challengeTrack.update = originalUpdate + }) + + it('create challenge track - accepts DEVELOP alias', async () => { + let createdPayload + prisma.challengeTrack.findMany = async () => [] + prisma.challengeTrack.create = async ({ data }) => { + createdPayload = data + return { + id: uuid(), + name: data.name, + description: data.description || null, + isActive: data.isActive, + abbreviation: data.abbreviation, + legacyId: data.legacyId || null, + track: data.track, + createdAt: new Date(), + createdBy: data.createdBy, + updatedAt: new Date(), + updatedBy: data.updatedBy + } + } + + const result = await service.createChallengeTrack({ userId: 'test-user' }, { + name: `track-${Date.now()}`, + isActive: true, + abbreviation: `abbr-${Date.now()}`, + track: 'DEVELOP' + }) + + should.equal(createdPayload.track, 'DEVELOPMENT') + should.equal(result.track, 'DEVELOPMENT') + }) + + it('search challenge tracks - accepts QA alias', async () => { + let receivedFilter + prisma.challengeTrack.findMany = async ({ where }) => { + receivedFilter = where + return [] + } + + const result = await service.searchChallengeTracks({ + page: 1, + perPage: 10, + track: 'QA' + }) + + should.equal(result.total, 0) + should.equal(receivedFilter.track.equals, 'QUALITY_ASSURANCE') + }) + + it('partially update challenge track - accepts QA alias', async () => { + let updatedPayload + const id = uuid() + prisma.challengeTrack.findUnique = async () => ({ + id, + name: 'Design', + description: null, + isActive: true, + abbreviation: 'DS', + legacyId: null, + track: 'DESIGN', + createdAt: new Date(), + createdBy: 'seed-user', + updatedAt: new Date(), + updatedBy: 'seed-user' + }) + prisma.challengeTrack.findMany = async () => [] + prisma.challengeTrack.update = async ({ data }) => { + updatedPayload = data + return { + id, + ...data, + createdAt: new Date(), + createdBy: 'seed-user', + updatedAt: new Date(), + updatedBy: data.updatedBy + } + } + + const result = await service.partiallyUpdateChallengeTrack({ userId: 'test-user' }, id, { + track: 'QA' + }) + + should.equal(updatedPayload.track, 'QUALITY_ASSURANCE') + should.equal(result.track, 'QUALITY_ASSURANCE') + }) +}) diff --git a/test/unit/TimelineTemplateService.test.js b/test/unit/TimelineTemplateService.test.js index 390bce1..37f9641 100644 --- a/test/unit/TimelineTemplateService.test.js +++ b/test/unit/TimelineTemplateService.test.js @@ -207,6 +207,62 @@ describe('timeline template service unit tests', () => { should.equal(result.result[0].phases[0].defaultDuration, 123) }) + it('search timeline templates includes challenge timeline mapping data', async () => { + const challengeTypeId = uuid() + const challengeTrackId = uuid() + const challengeTimelineTemplateId = uuid() + + await prisma.challengeType.create({ + data: { + id: challengeTypeId, + name: `type-${new Date().getTime()}`, + description: 'desc', + isActive: true, + abbreviation: `tp-${new Date().getTime()}`, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + await prisma.challengeTrack.create({ + data: { + id: challengeTrackId, + name: `track-${new Date().getTime()}`, + description: 'desc', + isActive: true, + abbreviation: `tr-${new Date().getTime()}`, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + try { + await prisma.challengeTimelineTemplate.create({ + data: { + id: challengeTimelineTemplateId, + isDefault: true, + timelineTemplateId: id, + trackId: challengeTrackId, + typeId: challengeTypeId, + createdBy: authUser.userId, + updatedBy: authUser.userId + } + }) + + const result = await service.searchTimelineTemplates({ page: 1, perPage: 10, name }) + should.equal(result.total, 1) + should.equal(result.result.length, 1) + should.equal(result.result[0].id, id) + should.equal(result.result[0].isDefault, true) + should.equal(result.result[0].trackId, challengeTrackId) + should.equal(result.result[0].typeId, challengeTypeId) + } finally { + await prisma.challengeTimelineTemplate.deleteMany({ where: { id: challengeTimelineTemplateId } }) + await prisma.challengeType.deleteMany({ where: { id: challengeTypeId } }) + await prisma.challengeTrack.deleteMany({ where: { id: challengeTrackId } }) + } + }) + it('search timeline templates successfully 2', async () => { const result = await service.searchTimelineTemplates({ name: 'xyzxyz123' }) should.equal(result.total, 0)