diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index 45b5d594c..98a857747 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -3720,6 +3720,214 @@ describe('mutation editOpportunity', () => { 'Only opportunities in draft state can be edited', ); }); + + it('should edit opportunity with organization data', async () => { + loggedUser = '1'; + + const MUTATION_WITH_ORG = /* GraphQL */ ` + mutation EditOpportunityWithOrg( + $id: ID! + $payload: OpportunityEditInput! + ) { + editOpportunity(id: $id, payload: $payload) { + id + organization { + id + website + description + perks + founded + location + category + size + stage + } + } + } + `; + + const res = await client.mutate(MUTATION_WITH_ORG, { + variables: { + id: opportunitiesFixture[0].id, + payload: { + organization: { + 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({ + 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 updated in database + const organization = await con + .getRepository(Organization) + .findOneBy({ id: organizationsFixture[0].id }); + + expect(organization).toMatchObject({ + 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, + }); + }); +}); + +describe('mutation clearOrganizationImage', () => { + beforeEach(async () => { + await con.getRepository(OpportunityJob).update( + { + id: opportunitiesFixture[0].id, + }, + { + state: OpportunityState.DRAFT, + }, + ); + }); + + const MUTATION = /* GraphQL */ ` + mutation ClearOrganizationImage($id: ID!) { + clearOrganizationImage(id: $id) { + _ + } + } + `; + + it('should require authentication', async () => { + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: opportunitiesFixture[0].id, + }, + }, + 'UNAUTHENTICATED', + ); + }); + + it('should throw error when user is not a recruiter for opportunity', async () => { + loggedUser = '2'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: opportunitiesFixture[0].id, + }, + }, + 'FORBIDDEN', + 'Access denied!', + ); + }); + + it('should throw error when opportunity does not exist', async () => { + loggedUser = '1'; + + await testMutationErrorCode( + client, + { + mutation: MUTATION, + variables: { + id: '660e8400-e29b-41d4-a716-446655440999', + }, + }, + 'FORBIDDEN', + ); + }); + + it('should clear organization image', async () => { + loggedUser = '1'; + + // First set an image on the organization + await con + .getRepository(Organization) + .update( + { id: organizationsFixture[0].id }, + { image: 'https://example.com/old-image.png' }, + ); + + // Verify image is set + let organization = await con + .getRepository(Organization) + .findOneBy({ id: organizationsFixture[0].id }); + expect(organization?.image).toBe('https://example.com/old-image.png'); + + // Clear the image + const res = await client.mutate(MUTATION, { + variables: { + id: opportunitiesFixture[0].id, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.clearOrganizationImage).toEqual({ _: true }); + + // Verify image was cleared in database + organization = await con + .getRepository(Organization) + .findOneBy({ id: organizationsFixture[0].id }); + expect(organization?.image).toBeNull(); + }); + + it('should work with opportunity permissions not direct organization permissions', async () => { + loggedUser = '3'; + + // User 3 is not a recruiter for opportunity 0, but let's make them one + await saveFixtures(con, OpportunityUser, [ + { + opportunityId: opportunitiesFixture[0].id, + userId: '3', + type: OpportunityUserType.Recruiter, + }, + ]); + + // Set an image on the organization + await con + .getRepository(Organization) + .update( + { id: organizationsFixture[0].id }, + { image: 'https://example.com/test-image.png' }, + ); + + // Should be able to clear the image through opportunity permissions + const res = await client.mutate(MUTATION, { + variables: { + id: opportunitiesFixture[0].id, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.clearOrganizationImage).toEqual({ _: true }); + + // Verify image was cleared + const organization = await con + .getRepository(Organization) + .findOneBy({ id: organizationsFixture[0].id }); + expect(organization?.image).toBeNull(); + }); }); describe('mutation recommendOpportunityScreeningQuestions', () => { @@ -3955,6 +4163,10 @@ describe('mutation updateOpportunityState', () => { 'content.responsibilities', 'content.requirements', 'questions', + 'organization.links.0.socialType', + 'organization.links.1.socialType', + 'organization.links.2.title', + 'organization.links.3.socialType', ]); }, ); diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index c78c291d2..6ed7762dd 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -1,5 +1,6 @@ import { OpportunityState } from '@dailydotdev/schema'; import z from 'zod'; +import { organizationLinksSchema } from './organizations'; export const opportunityMatchDescriptionSchema = z.object({ reasoning: z.string(), @@ -143,6 +144,17 @@ export const opportunityEditSchema = z ) .min(1) .max(3), + organization: z.object({ + 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(), + founded: z.number().int().min(1800).max(2100).nullable().optional(), + location: z.string().max(500).nullable().optional(), + category: z.string().max(240).nullable().optional(), + size: z.number().int().nullable().optional(), + stage: z.number().int().nullable().optional(), + links: z.array(organizationLinksSchema).max(50).optional(), + }), }) .partial(); diff --git a/src/entity/Organization.ts b/src/entity/Organization.ts index 24e05db0b..004e5f776 100644 --- a/src/entity/Organization.ts +++ b/src/entity/Organization.ts @@ -37,7 +37,7 @@ export class Organization { name: string; @Column({ type: 'text', nullable: true }) - image: string; + image: string | null; @Column({ type: 'smallint', default: 1 }) seats: number; diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index e07c72e70..56f1fe45e 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -1,5 +1,7 @@ import z from 'zod'; import { IResolvers } from '@graphql-tools/utils'; +// @ts-expect-error - no types +import { FileUpload } from 'graphql-upload/GraphQLUpload.js'; import { traceResolvers } from './trace'; import { AuthContext, BaseContext, type Context } from '../Context'; import graphorm from '../graphorm'; @@ -37,6 +39,7 @@ import { generateResumeSignedUrl, uploadEmploymentAgreementFromBuffer, } from '../common/googleCloud'; +import { uploadOrganizationImage } from '../common/cloudinary'; import { opportunityEditSchema, opportunityStateLiveSchema, @@ -50,6 +53,11 @@ import { import { markdown } from '../common/markdown'; import { QuestionScreening } from '../entity/questions/QuestionScreening'; import { In, Not } from 'typeorm'; +import { Organization } from '../entity/Organization'; +import { + OrganizationLinkType, + SocialMediaType, +} from '../common/schema/organizations'; import { getGondulClient } from '../common/gondul'; import { createOpportunityPrompt } from '../common/opportunity/prompt'; import { queryPaginatedByDate } from '../common/datePageGenerator'; @@ -91,6 +99,8 @@ export type GQLOpportunityScreeningQuestion = Pick< export const typeDefs = /* GraphQL */ ` ${toGQLEnum(OpportunityMatchStatus, 'OpportunityMatchStatus')} + ${toGQLEnum(OrganizationLinkType, 'OrganizationLinkType')} + ${toGQLEnum(SocialMediaType, 'SocialMediaType')} type OpportunityContentBlock { content: String @@ -376,6 +386,25 @@ export const typeDefs = /* GraphQL */ ` placeholder: String } + input OrganizationLinkInput { + type: OrganizationLinkType! + socialType: SocialMediaType + title: String! + link: String! + } + + input OrganizationEditInput { + website: String + description: String + perks: [String!] + founded: Int + location: String + category: String + size: Int + stage: Int + links: [OrganizationLinkInput!] + } + input OpportunityEditInput { title: String tldr: String @@ -384,6 +413,7 @@ export const typeDefs = /* GraphQL */ ` keywords: [OpportunityKeywordInput] content: OpportunityContentInput questions: [OpportunityScreeningQuestionInput!] + organization: OrganizationEditInput } extend type Mutation { @@ -490,8 +520,23 @@ export const typeDefs = /* GraphQL */ ` Opportunity data to update """ payload: OpportunityEditInput! + + """ + Organization image to upload + """ + organizationImage: Upload ): Opportunity! @auth + """ + Clear the organization image for an opportunity + """ + clearOrganizationImage( + """ + Id of the Opportunity + """ + id: ID! + ): EmptyResponse @auth + recommendOpportunityScreeningQuestions( """ Id of the Opportunity @@ -1150,7 +1195,12 @@ export const resolvers: IResolvers = traceResolvers< { id, payload, - }: { id: string; payload: z.infer }, + organizationImage, + }: { + id: string; + payload: z.infer; + organizationImage?: Promise; + }, ctx: AuthContext, info, ): Promise => { @@ -1165,8 +1215,13 @@ export const resolvers: IResolvers = traceResolvers< }); await ctx.con.transaction(async (entityManager) => { - const { keywords, content, questions, ...opportunityUpdate } = - opportunity; + const { + keywords, + content, + questions, + organization, + ...opportunityUpdate + } = opportunity; const renderedContent: Record< string, @@ -1199,6 +1254,41 @@ export const resolvers: IResolvers = traceResolvers< .setParameter('metaJson', JSON.stringify(opportunity.meta || {})) .execute(); + if (organization || organizationImage) { + const opportunityJob = await entityManager + .getRepository(OpportunityJob) + .findOne({ + where: { id }, + select: ['organizationId'], + }); + + if (opportunityJob?.organizationId) { + const organizationUpdate: Record = { + ...organization, + }; + + // Handle image upload + if (organizationImage) { + const { createReadStream } = await organizationImage; + const stream = createReadStream(); + const { url: imageUrl } = await uploadOrganizationImage( + opportunityJob.organizationId, + stream, + ); + organizationUpdate.image = imageUrl; + } + + if (Object.keys(organizationUpdate).length > 0) { + await entityManager + .getRepository(Organization) + .update( + { id: opportunityJob.organizationId }, + organizationUpdate, + ); + } + } + } + if (Array.isArray(keywords)) { await entityManager.getRepository(OpportunityKeyword).delete({ opportunityId: id, @@ -1251,6 +1341,36 @@ export const resolvers: IResolvers = traceResolvers< return builder; }); }, + clearOrganizationImage: async ( + _, + { id }: { id: string }, + ctx: AuthContext, + ): Promise => { + await ensureOpportunityPermissions({ + con: ctx.con.manager, + userId: ctx.userId, + opportunityId: id, + permission: OpportunityPermissions.Edit, + isTeamMember: ctx.isTeamMember, + }); + + const opportunityJob = await ctx.con + .getRepository(OpportunityJob) + .findOne({ + where: { id }, + select: ['organizationId'], + }); + + if (!opportunityJob?.organizationId) { + throw new NotFoundError('Opportunity not found'); + } + + await ctx.con + .getRepository(Organization) + .update(opportunityJob.organizationId, { image: null }); + + return { _: true }; + }, recommendOpportunityScreeningQuestions: async ( _, { id }: { id: string },