diff --git a/__tests__/private.ts b/__tests__/private.ts index 941b2246d8..34d47fe15a 100644 --- a/__tests__/private.ts +++ b/__tests__/private.ts @@ -26,6 +26,10 @@ import { SubscriptionCycles } from '../src/paddle'; import { SubscriptionStatus } from '../src/common/plus'; import { OpportunityJob } from '../src/entity/opportunities/OpportunityJob'; import { OpportunityKeyword } from '../src/entity/OpportunityKeyword'; +import { OpportunityState } from '@dailydotdev/schema'; +import { generateShortId } from '../src/ids'; +import { OpportunityUser } from '../src/entity/opportunities/user'; +import { OpportunityUserType } from '../src/entity/opportunities/types'; jest.mock('../src/common/geo', () => ({ ...(jest.requireActual('../src/common/geo') as Record), @@ -779,6 +783,53 @@ describe('POST /p/newUser', () => { expect(body.status).toEqual('ok'); expect(body.userId).not.toEqual(deletedUser.id); }); + + it('should claim opportunities that user created as anonymous', async () => { + const anonUserId = await generateShortId(); + + const opportunity = await con.getRepository(OpportunityJob).save( + con.getRepository(OpportunityJob).create({ + title: 'Test', + tldr: 'Test', + state: OpportunityState.DRAFT, + flags: { + anonUserId, + }, + }), + ); + + const { body } = await request(app.server) + .post('/p/newUser') + .set('Content-type', 'application/json') + .set('authorization', `Service ${process.env.ACCESS_SECRET}`) + .send({ + id: opportunity.flags.anonUserId, + name: anonUserId, + image: usersFixture[0].image, + username: anonUserId, + email: `test+${anonUserId}@gmail.com`, + experienceLevel: 'LESS_THAN_1_YEAR', + }) + .expect(200); + + expect(body.status).toEqual('ok'); + expect(body.userId).toEqual(anonUserId); + + const updatedOpportunity = await con + .getRepository(OpportunityJob) + .findOneBy({ id: opportunity.id }); + expect(updatedOpportunity!.flags.anonUserId).toBeNull(); + + const opportunityUser = await con.getRepository(OpportunityUser).findOneBy({ + opportunityId: opportunity.id, + userId: anonUserId, + }); + expect(opportunityUser).toEqual({ + opportunityId: opportunity.id, + userId: anonUserId, + type: OpportunityUserType.Recruiter, + }); + }); }); describe('POST /p/checkUsername', () => { diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 23b104139b..b53d94e7cf 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -73,6 +73,7 @@ import * as gondulModule from '../../src/common/gondul'; import type { ServiceClient } from '../../src/types'; import { OpportunityJob } from '../../src/entity/opportunities/OpportunityJob'; import * as brokkrCommon from '../../src/common/brokkr'; +import { randomUUID } from 'node:crypto'; const deleteFileFromBucket = jest.spyOn(googleCloud, 'deleteFileFromBucket'); const uploadEmploymentAgreementFromBuffer = jest.spyOn( @@ -4319,6 +4320,216 @@ describe('mutation editOpportunity', () => { expect(userAfter?.title).toBe('Updated Title Only'); expect(userAfter?.bio).toBe('Initial bio that should remain'); }); + + it('should create organization for opportunity if missing', async () => { + loggedUser = '1'; + + const MUTATION_WITH_ORG = /* GraphQL */ ` + mutation EditOpportunityWithOrg( + $id: ID! + $payload: OpportunityEditInput! + ) { + editOpportunity(id: $id, payload: $payload) { + id + organization { + id + name + website + description + perks + founded + location + category + size + stage + } + } + } + `; + + const opportunityWithoutOrganization = await con + .getRepository(OpportunityJob) + .save({ + ...opportunitiesFixture[0], + id: randomUUID(), + state: OpportunityState.DRAFT, + organizationId: null, + }); + + await con.getRepository(OpportunityUser).save({ + opportunityId: opportunityWithoutOrganization.id, + userId: loggedUser, + type: OpportunityUserType.Recruiter, + }); + + const organizationBefore = await con.getRepository(Organization).findOne({ + where: { + name: 'Test Corp', + }, + }); + + expect(organizationBefore).toBeNull(); + + const res = await client.mutate(MUTATION_WITH_ORG, { + variables: { + id: opportunityWithoutOrganization.id, + payload: { + organization: { + name: 'Test Corp', + website: 'https://updated.dev', + description: 'Updated description', + perks: ['Remote work', 'Flexible hours'], + founded: 2021, + location: 'Berlin, Germany', + category: 'Technology', + size: CompanySize.COMPANY_SIZE_51_200, + stage: CompanyStage.SERIES_B, + }, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.editOpportunity.organization).toMatchObject({ + name: 'Test Corp', + website: 'https://updated.dev', + description: 'Updated description', + perks: ['Remote work', 'Flexible hours'], + founded: 2021, + location: 'Berlin, Germany', + category: 'Technology', + size: CompanySize.COMPANY_SIZE_51_200, + stage: CompanyStage.SERIES_B, + }); + + // Verify the organization was created in database + const organization = await con + .getRepository(Organization) + .findOneBy({ id: res.data.editOpportunity.organization.id }); + + expect(organization).toMatchObject({ + name: 'Test Corp', + website: 'https://updated.dev', + description: 'Updated description', + perks: ['Remote work', 'Flexible hours'], + founded: 2021, + location: 'Berlin, Germany', + category: 'Technology', + size: CompanySize.COMPANY_SIZE_51_200, + stage: CompanyStage.SERIES_B, + }); + + const opportunityAfter = await con + .getRepository(OpportunityJob) + .findOneBy({ id: opportunityWithoutOrganization.id }); + + expect(opportunityAfter!.organizationId).toBe( + res.data.editOpportunity.organization.id, + ); + }); + + it('should not update organization name on edit', async () => { + loggedUser = '1'; + + const MUTATION_WITH_ORG = /* GraphQL */ ` + mutation EditOpportunityWithOrg( + $id: ID! + $payload: OpportunityEditInput! + ) { + editOpportunity(id: $id, payload: $payload) { + id + organization { + id + name + } + } + } + `; + + const res = await client.mutate(MUTATION_WITH_ORG, { + variables: { + id: opportunitiesFixture[0].id, + payload: { + organization: { + name: 'Test update name', + }, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.editOpportunity.organization.name).toEqual( + organizationsFixture[0].name, + ); + + // Verify the organization was updated in database + const organization = await con + .getRepository(Organization) + .findOneBy({ id: organizationsFixture[0].id }); + + expect(organization!.name).toEqual(organizationsFixture[0].name); + }); + + it('should not allow duplicate organization names', async () => { + loggedUser = '1'; + + const MUTATION_WITH_ORG = /* GraphQL */ ` + mutation EditOpportunityWithOrg( + $id: ID! + $payload: OpportunityEditInput! + ) { + editOpportunity(id: $id, payload: $payload) { + id + organization { + id + name + } + } + } + `; + + const opportunityWithoutOrganization = await con + .getRepository(OpportunityJob) + .save({ + ...opportunitiesFixture[0], + id: randomUUID(), + state: OpportunityState.DRAFT, + organizationId: null, + }); + + await con.getRepository(OpportunityUser).save({ + opportunityId: opportunityWithoutOrganization.id, + userId: loggedUser, + type: OpportunityUserType.Recruiter, + }); + + const organizationBefore = await con.getRepository(Organization).findOne({ + where: { + name: 'Daily Dev Inc', + }, + }); + + expect(organizationBefore).not.toBeNull(); + + const res = await client.mutate(MUTATION_WITH_ORG, { + variables: { + id: opportunityWithoutOrganization.id, + payload: { + organization: { + name: 'Daily Dev Inc', + founded: 2021, + }, + }, + }, + }); + + expect(res.errors).toBeTruthy(); + + expect(res.errors![0].extensions.code).toEqual('CONFLICT'); + expect(res.errors![0].message).toEqual( + 'Organization with this name already exists', + ); + }); }); describe('mutation clearOrganizationImage', () => { diff --git a/src/common/opportunity/user.ts b/src/common/opportunity/user.ts new file mode 100644 index 0000000000..7a4498b67c --- /dev/null +++ b/src/common/opportunity/user.ts @@ -0,0 +1,79 @@ +import { IsNull, type EntityManager } from 'typeorm'; +import { OpportunityJob } from '../../entity/opportunities/OpportunityJob'; +import { updateFlagsStatement } from '../utils'; +import type { Opportunity } from '../../entity/opportunities/Opportunity'; +import { OpportunityUserRecruiter } from '../../entity/opportunities/user/OpportunityUserRecruiter'; +import { logger } from '../../logger'; + +export const claimAnonOpportunities = async ({ + anonUserId, + userId, + con, +}: { + anonUserId: string; + userId: string; + con: EntityManager; +}): Promise[]> => { + try { + if (!anonUserId || !userId) { + throw new Error('anonUserId and userId are required'); + } + + const result = await con.transaction(async (entityManager) => { + const opportunityUpdateResult = await entityManager + .getRepository(OpportunityJob) + .createQueryBuilder() + .update() + .set({ + flags: updateFlagsStatement({ + anonUserId: null, + }), + }) + .where("flags->>'anonUserId' = :anonUserId", { + anonUserId, + }) + .andWhere({ + organizationId: IsNull(), // only claim opportunities not linked to an organization yet + }) + .returning(['id']) + .execute(); + + const opportunities = opportunityUpdateResult.raw as { id: string }[]; + + const opportunityUserUpsertResult = await entityManager + .getRepository(OpportunityUserRecruiter) + .upsert( + opportunities.map((opportunity) => { + return entityManager + .getRepository(OpportunityUserRecruiter) + .create({ + opportunityId: opportunity.id, + userId, + }); + }), + { + conflictPaths: ['opportunityId', 'userId'], + }, + ); + + return opportunityUserUpsertResult.identifiers.map((item) => { + return { + id: item.opportunityId, + }; + }); + }); + + return result; + } catch (error) { + logger.error( + { + err: error, + anonUserId, + userId, + }, + 'Error claiming anon opportunities', + ); + + return []; + } +}; diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index 3db39fe8d8..e06f3a7136 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -183,6 +183,7 @@ export const opportunityEditSchema = z .min(1) .max(3), organization: z.object({ + name: z.string().nonempty().max(60).optional(), website: z.string().max(500).nullable().optional(), description: z.string().max(2000).nullable().optional(), perks: z.array(z.string().max(240)).max(50).nullable().optional(), diff --git a/src/entity/Organization.ts b/src/entity/Organization.ts index 004e5f776a..d34a55f661 100644 --- a/src/entity/Organization.ts +++ b/src/entity/Organization.ts @@ -34,6 +34,7 @@ export class Organization { updatedAt: Date; @Column({ type: 'text' }) + @Index('IDX_organization_name_unique', { unique: true }) name: string; @Column({ type: 'text', nullable: true }) diff --git a/src/entity/opportunities/Opportunity.ts b/src/entity/opportunities/Opportunity.ts index f50e828d0b..c2648226f4 100644 --- a/src/entity/opportunities/Opportunity.ts +++ b/src/entity/opportunities/Opportunity.ts @@ -21,7 +21,7 @@ import type { QuestionScreening } from '../questions/QuestionScreening'; import type { QuestionFeedback } from '../questions/QuestionFeedback'; export type OpportunityFlags = Partial<{ - anonUserId: string; + anonUserId: string | null; }>; @Entity() diff --git a/src/migration/1764600895705-OrganizationNameUnique.ts b/src/migration/1764600895705-OrganizationNameUnique.ts new file mode 100644 index 0000000000..55a4e32a90 --- /dev/null +++ b/src/migration/1764600895705-OrganizationNameUnique.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrganizationNameUnique1764600895705 implements MigrationInterface { + name = 'OrganizationNameUnique1764600895705'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_organization_name_unique" ON "organization" ("name") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_organization_name_unique"`, + ); + } +} diff --git a/src/routes/private.ts b/src/routes/private.ts index 6b4ca19574..cc6911f38c 100644 --- a/src/routes/private.ts +++ b/src/routes/private.ts @@ -29,6 +29,7 @@ import { import { OpportunityJob } from '../entity/opportunities/OpportunityJob'; import { OpportunityKeyword } from '../entity/OpportunityKeyword'; import { logger } from '../logger'; +import { claimAnonOpportunities } from '../common/opportunity/user'; interface SearchUsername { search: string; @@ -56,6 +57,25 @@ export default async function (fastify: FastifyInstance): Promise { await addClaimableItemsToUser(con, body); + if (body.id && operationResult.status === 'ok') { + const opportunities = await claimAnonOpportunities({ + anonUserId: body.id, + userId: operationResult.userId, + con: con.manager, + }); + + if (opportunities.length > 0) { + logger.info( + { + anonUserId: body.id, + userId: operationResult.userId, + opportunities, + }, + 'Claimed anon opportunities for new user', + ); + } + } + return res.status(200).send(operationResult); }); fastify.post<{ Body: UpdateUserEmailData }>( diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 9b59b2dace..46f513578c 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -37,7 +37,12 @@ import { import { OpportunityJob } from '../entity/opportunities/OpportunityJob'; import { OpportunityUserRecruiter } from '../entity/opportunities/user/OpportunityUserRecruiter'; import { ForbiddenError, ValidationError } from 'apollo-server-errors'; -import { ConflictError, NotFoundError } from '../errors'; +import { + ConflictError, + NotFoundError, + TypeOrmError, + type TypeORMQueryFailedError, +} from '../errors'; import { UserCandidateKeyword } from '../entity/user/UserCandidateKeyword'; import { User } from '../entity/user/User'; import { @@ -66,7 +71,7 @@ import { } from '../common/opportunity/accessControl'; import { markdown } from '../common/markdown'; import { QuestionScreening } from '../entity/questions/QuestionScreening'; -import { In, Not, type DeepPartial } from 'typeorm'; +import { In, Not, QueryFailedError, type DeepPartial } from 'typeorm'; import { Organization } from '../entity/Organization'; import { OrganizationLinkType, @@ -207,7 +212,7 @@ export const typeDefs = /* GraphQL */ ` content: OpportunityContent! meta: OpportunityMeta! location: [Location]! - organization: Organization! + organization: Organization recruiters: [User!]! keywords: [OpportunityKeyword]! questions: [OpportunityScreeningQuestion]! @@ -429,6 +434,7 @@ export const typeDefs = /* GraphQL */ ` } input OrganizationEditInput { + name: String website: String description: String perks: [String!] @@ -1344,31 +1350,71 @@ export const resolvers: IResolvers = traceResolvers< select: ['organizationId'], }); - if (opportunityJob?.organizationId) { - const organizationUpdate: Record = { - ...organization, - }; + let organizationId = opportunityJob?.organizationId; - // Handle image upload - if (organizationImage) { - const { createReadStream } = await organizationImage; - const stream = createReadStream(); - const { url: imageUrl } = await uploadOrganizationImage( - opportunityJob.organizationId, - stream, - ); - organizationUpdate.image = imageUrl; - } + let organizationUpdate: Record = { + ...organization, + }; - if (Object.keys(organizationUpdate).length > 0) { - await entityManager + if (organizationId) { + delete organizationUpdate.name; // prevent name updates on existing organizations + } + + if (!organizationId) { + // create new organization and assign to opportunity here inline + // TODO: ideally this should be refactored later to separate mutation + + try { + const organizationInsertResult = await entityManager .getRepository(Organization) - .update( - { id: opportunityJob.organizationId }, - organizationUpdate, - ); + .insert(organizationUpdate); + + organizationId = organizationInsertResult.identifiers[0] + .id as string; + + await entityManager + .getRepository(OpportunityJob) + .update({ id }, { organizationId }); + + // values were applied during insert + organizationUpdate = {}; + } catch (insertError) { + if (insertError instanceof QueryFailedError) { + const queryFailedError = insertError as TypeORMQueryFailedError; + + if (queryFailedError.code === TypeOrmError.DUPLICATE_ENTRY) { + if ( + insertError.message.indexOf( + 'IDX_organization_name_unique', + ) > -1 + ) { + throw new ConflictError( + 'Organization with this name already exists', + ); + } + } + } + + throw insertError; } } + + // Handle image upload + if (organizationImage) { + const { createReadStream } = await organizationImage; + const stream = createReadStream(); + const { url: imageUrl } = await uploadOrganizationImage( + organizationId, + stream, + ); + organizationUpdate.image = imageUrl; + } + + if (Object.keys(organizationUpdate).length > 0) { + await entityManager + .getRepository(Organization) + .update({ id: organizationId }, organizationUpdate); + } } if (Array.isArray(keywords)) {