Skip to content

Commit 2483ed5

Browse files
committed
fix(organizations): implemented organizations search endpoints
CLOSES: JOB-877
1 parent 878e766 commit 2483ed5

File tree

6 files changed

+170
-24
lines changed

6 files changed

+170
-24
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ApiPropertyOptional } from "@nestjs/swagger";
2+
import { Transform, Type } from "class-transformer";
3+
import { IsNumber, IsOptional } from "class-validator";
4+
import { toList } from "src/shared/helpers";
5+
6+
export class SearchOrganizationsInput {
7+
@ApiPropertyOptional()
8+
@IsOptional()
9+
@Type(() => String)
10+
@Transform(toList)
11+
locations?: string[] | null = null;
12+
13+
@ApiPropertyOptional()
14+
@IsOptional()
15+
@Type(() => String)
16+
@Transform(toList)
17+
investors?: string[] | null = null;
18+
19+
@ApiPropertyOptional()
20+
@IsOptional()
21+
@Type(() => String)
22+
@Transform(toList)
23+
fundingRounds?: string[] | null = null;
24+
25+
@ApiPropertyOptional()
26+
@IsOptional()
27+
@Type(() => String)
28+
@Transform(toList)
29+
tags?: string[] | null = null;
30+
31+
@ApiPropertyOptional()
32+
@IsOptional()
33+
@IsNumber()
34+
@Type(() => Number)
35+
page?: number | null = null;
36+
37+
@ApiPropertyOptional()
38+
@IsOptional()
39+
@IsNumber()
40+
@Type(() => Number)
41+
limit?: number | null = null;
42+
}

src/organizations/organizations.controller.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { randomUUID } from "crypto";
7979
import { Session } from "src/shared/decorators";
8080
import { UserService } from "src/user/user.service";
8181
import { ImportOrgJobsiteInput } from "./dto/import-organization-jobsites.input";
82+
import { SearchOrganizationsInput } from "./dto/search-organizations.input";
8283
// eslint-disable-next-line @typescript-eslint/no-var-requires
8384
const mime = require("mime");
8485

@@ -261,6 +262,29 @@ export class OrganizationsController {
261262
return this.organizationsService.getFilterConfigs(community);
262263
}
263264

265+
@Get("/search")
266+
@UseGuards(PBACGuard)
267+
@Permissions(CheckWalletPermissions.SUPER_ADMIN)
268+
@Header("Cache-Control", CACHE_CONTROL_HEADER(CACHE_DURATION))
269+
@Header("Expires", CACHE_EXPIRY(CACHE_DURATION))
270+
@ApiOkResponse({
271+
description: "Returns a list of orgs that match the search criteria",
272+
type: Response<ShortOrg[]>,
273+
})
274+
@ApiBadRequestResponse({
275+
description:
276+
"Returns an error message with a list of values that failed validation",
277+
type: ValidationError,
278+
})
279+
async searchOrganizations(
280+
@Query(new ValidationPipe({ transform: true }))
281+
params: SearchOrganizationsInput,
282+
@Headers(COMMUNITY_HEADER) community: string | undefined,
283+
): Promise<PaginatedData<ShortOrg>> {
284+
this.logger.log(`/organizations/search ${JSON.stringify({ params })}`);
285+
return this.organizationsService.searchOrganizations(params, community);
286+
}
287+
264288
@Get("/featured")
265289
@UseGuards(PBACGuard)
266290
@Permissions(CheckWalletPermissions.SUPER_ADMIN)

src/organizations/organizations.service.ts

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Injectable } from "@nestjs/common";
22
import {
3-
ShortOrgEntity,
43
ShortOrg,
54
Repository,
65
PaginatedData,
@@ -56,6 +55,7 @@ import { ConfigService } from "@nestjs/config";
5655
import axios from "axios";
5756
import { Auth0Service } from "src/auth0/auth0.service";
5857
import { ImportOrgJobsiteInput } from "./dto/import-organization-jobsites.input";
58+
import { SearchOrganizationsInput } from "./dto/search-organizations.input";
5959

6060
@Injectable()
6161
export class OrganizationsService {
@@ -109,7 +109,8 @@ export class OrganizationsService {
109109
classification: [(structured_jobpost)-[:HAS_CLASSIFICATION]->(classification) | classification.name ][0],
110110
commitment: [(structured_jobpost)-[:HAS_COMMITMENT]->(commitment) | commitment.name ][0],
111111
locationType: [(structured_jobpost)-[:HAS_LOCATION_TYPE]->(locationType) | locationType.name ][0],
112-
timestamp: CASE WHEN structured_jobpost.publishedTimestamp IS NULL THEN structured_jobpost.firstSeenTimestamp ELSE structured_jobpost.publishedTimestamp END
112+
timestamp: CASE WHEN structured_jobpost.publishedTimestamp IS NULL THEN structured_jobpost.firstSeenTimestamp ELSE structured_jobpost.publishedTimestamp END,
113+
tags: [(organization)-[:HAS_JOBSITE|HAS_JOBPOST|HAS_STRUCTURED_JOBPOST|HAS_TAG*4]->(tag: Tag)-[:HAS_TAG_DESIGNATION]->(:AllowedDesignation|DefaultDesignation) | tag.name ]
113114
}
114115
],
115116
projects: [
@@ -242,9 +243,16 @@ export class OrganizationsService {
242243
}
243244

244245
const orgFilters = (org: OrgDetailsResult): boolean => {
245-
const { headcountEstimate, jobCount, projectCount, location, name } =
246-
toShortOrg(org);
247-
const { fundingRounds, investors, community, aliases } = org;
246+
const [jobCount, projectCount] = [org.jobs.length, org.projects.length];
247+
const {
248+
fundingRounds,
249+
investors,
250+
community,
251+
aliases,
252+
headcountEstimate,
253+
location,
254+
name,
255+
} = org;
248256
const isValidSearchResult =
249257
name.match(query) || aliases.some(alias => alias.match(query));
250258
return (
@@ -275,11 +283,10 @@ export class OrganizationsService {
275283
const filtered = results.filter(orgFilters);
276284

277285
const getSortParam = (org: OrgDetailsResult): number | null => {
278-
const shortOrg = toShortOrg(org);
279286
const lastJob = sort(org.jobs).desc(x => x.timestamp)[0];
280287
switch (orderBy) {
281288
case "recentFundingDate":
282-
return shortOrg?.lastFundingDate ?? 0;
289+
return org.lastFundingDate() ?? 0;
283290
case "recentJobDate":
284291
return lastJob?.timestamp ?? 0;
285292
case "headcountEstimate":
@@ -302,16 +309,14 @@ export class OrganizationsService {
302309
if (!order || order === "desc") {
303310
final = naturalSort<OrgDetailsResult>(filtered).by([
304311
{
305-
desc: x =>
306-
params.orderBy ? getSortParam(x) : toShortOrg(x).lastFundingDate,
312+
desc: x => (params.orderBy ? getSortParam(x) : x.lastFundingDate()),
307313
},
308314
{ asc: x => x.name },
309315
]);
310316
} else {
311317
final = naturalSort<OrgDetailsResult>(filtered).by([
312318
{
313-
asc: x =>
314-
params.orderBy ? getSortParam(x) : toShortOrg(x).lastFundingDate,
319+
asc: x => (params.orderBy ? getSortParam(x) : x.lastFundingDate()),
315320
},
316321
{ asc: x => x.name },
317322
]);
@@ -320,7 +325,7 @@ export class OrganizationsService {
320325
return paginate<ShortOrg>(
321326
page,
322327
limit,
323-
final.map(x => new ShortOrgEntity(toShortOrg(x)).getProperties()),
328+
final.map(x => toShortOrg(x)),
324329
);
325330
}
326331

@@ -370,7 +375,7 @@ export class OrganizationsService {
370375
now <= job.featureEndDate,
371376
),
372377
)
373-
.map(x => new ShortOrgEntity(toShortOrg(x)).getProperties()),
378+
.map(x => toShortOrg(x)),
374379
};
375380
} catch (err) {
376381
Sentry.withScope(scope => {
@@ -795,9 +800,7 @@ export class OrganizationsService {
795800

796801
async getAll(): Promise<ShortOrg[]> {
797802
try {
798-
return (await this.getOrgListResults()).map(org =>
799-
new ShortOrgEntity(toShortOrg(org)).getProperties(),
800-
);
803+
return (await this.getOrgListResults()).map(org => toShortOrg(org));
801804
} catch (err) {
802805
Sentry.withScope(scope => {
803806
scope.setTags({
@@ -811,18 +814,75 @@ export class OrganizationsService {
811814
}
812815
}
813816

814-
async searchOrganizations(query: string): Promise<ShortOrg[]> {
815-
const parsedQuery = new RegExp(query, "gi");
817+
async searchOrganizations(
818+
params: SearchOrganizationsInput,
819+
community: string | undefined,
820+
): Promise<PaginatedData<ShortOrg>> {
816821
try {
817-
const all = await this.getAll();
818-
return all.filter(x => x.name.match(parsedQuery));
822+
const {
823+
locations: locationFilterList,
824+
investors: investorFilterList,
825+
fundingRounds: fundingRoundFilterList,
826+
tags: tagFilterList,
827+
page: page = 1,
828+
limit: limit = 20,
829+
} = params;
830+
831+
const communityFilterList = community ? [community] : null;
832+
833+
const all = await this.getOrgListResults();
834+
835+
const orgFilters = (org: OrgDetailsResult): boolean => {
836+
const { fundingRounds, investors, community, location } = org;
837+
const tags = org.jobs.flatMap(x => x.tags);
838+
return (
839+
(!locationFilterList ||
840+
locationFilterList.includes(slugify(location))) &&
841+
(!investorFilterList ||
842+
investors.filter(investor =>
843+
investorFilterList.includes(slugify(investor.name)),
844+
).length > 0) &&
845+
(!communityFilterList ||
846+
community.filter(community =>
847+
communityFilterList.includes(slugify(community)),
848+
).length > 0) &&
849+
(!fundingRoundFilterList ||
850+
fundingRoundFilterList.includes(
851+
slugify(
852+
sort<FundingRound>(fundingRounds).desc(x => x.date)[0]
853+
?.roundName,
854+
),
855+
)) &&
856+
(!tagFilterList ||
857+
tags.filter(tag => tagFilterList.includes(slugify(tag))).length > 0)
858+
);
859+
};
860+
861+
const filtered = all.filter(orgFilters).map(toShortOrg);
862+
863+
const naturalSort = createNewSortInstance({
864+
comparer: new Intl.Collator(undefined, {
865+
numeric: true,
866+
sensitivity: "base",
867+
}).compare,
868+
inPlaceSorting: true,
869+
});
870+
871+
const sorted = naturalSort<ShortOrg>(filtered).by([
872+
{
873+
desc: x => x.lastFundingDate,
874+
},
875+
{ asc: x => x.name },
876+
]);
877+
878+
return paginate<ShortOrg>(page, limit, sorted);
819879
} catch (err) {
820880
Sentry.withScope(scope => {
821881
scope.setTags({
822882
action: "db-call",
823883
source: "organizations.service",
824884
});
825-
scope.setExtra("input", query);
885+
scope.setExtra("input", params);
826886
Sentry.captureException(err);
827887
});
828888
this.logger.error(

src/shared/helpers/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { PrivyService } from "src/auth/privy/privy.service";
4343
import { UserService } from "src/user/user.service";
4444
import { WalletWithMetadata } from "@privy-io/server-auth";
4545
import _slugify from "slugify";
46+
import { ShortOrgEntity } from "../entities";
4647

4748
/*
4849
optionalMinMaxFilter is a function that conditionally applies a filter to a cypher query if min or max numeric values are set.
@@ -419,7 +420,7 @@ export const toShortOrg = (org: OrgDetailsResult): ShortOrg => {
419420
grants,
420421
} = org;
421422
const lastFundingRound = sort(org.fundingRounds).desc(x => x.date)[0];
422-
return {
423+
return new ShortOrgEntity({
423424
orgId,
424425
url: website,
425426
name,
@@ -436,7 +437,7 @@ export const toShortOrg = (org: OrgDetailsResult): ShortOrg => {
436437
projectCount: org.projects.length,
437438
lastFundingAmount: lastFundingRound?.raisedAmount ?? 0,
438439
lastFundingDate: lastFundingRound?.date ?? 0,
439-
};
440+
}).getProperties();
440441
};
441442

442443
export function propertiesMatch<T extends object, U extends object>(

src/shared/interfaces/org-details-result.interface.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { FundingRound } from "./funding-round.interface";
1919
import { Investor } from "./investor.interface";
2020
import { LeanOrgReview } from "./org-review.interface";
2121
import { OrgProject } from "./project-details-result.interface";
22+
import { sort } from "fast-sort";
2223

2324
@ApiExtraModels(OrganizationWithRelations, OrgDetailsResult, OrgJob)
2425
export class OrgDetailsResult extends Organization {
@@ -122,7 +123,9 @@ export class OrgDetailsResult extends Organization {
122123
})
123124
tags: Tag[];
124125

125-
constructor(raw: OrgDetailsResult) {
126+
constructor(
127+
raw: Omit<OrgDetailsResult, "lastFundingAmount" | "lastFundingDate">,
128+
) {
126129
const {
127130
aggregateRating,
128131
aggregateRatings,
@@ -176,4 +179,14 @@ export class OrgDetailsResult extends Organization {
176179
});
177180
}
178181
}
182+
183+
lastFundingDate(): number | null {
184+
const lastFundingRound = sort(this.fundingRounds).desc(x => x.date)[0];
185+
return lastFundingRound?.date ?? null;
186+
}
187+
188+
lastFundingAmount(): number | null {
189+
const lastFundingRound = sort(this.fundingRounds).desc(x => x.date)[0];
190+
return lastFundingRound?.raisedAmount ?? null;
191+
}
179192
}

src/shared/interfaces/org-job.interface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class OrgJob {
2424
commitment: t.union([t.string, t.null]),
2525
timestamp: t.union([t.number, t.null]),
2626
locationType: t.union([t.string, t.null]),
27+
tags: t.array(t.string),
2728
});
2829

2930
@ApiProperty()
@@ -83,6 +84,9 @@ export class OrgJob {
8384
@ApiPropertyOptional()
8485
locationType: string | null;
8586

87+
@ApiPropertyOptional()
88+
tags: string[];
89+
8690
constructor(raw: OrgJob) {
8791
const {
8892
id,
@@ -104,6 +108,7 @@ export class OrgJob {
104108
featureEndDate,
105109
timestamp,
106110
locationType,
111+
tags,
107112
} = raw;
108113
this.id = id;
109114
this.shortUUID = shortUUID;
@@ -124,6 +129,7 @@ export class OrgJob {
124129
this.featured = featured;
125130
this.featureStartDate = featureStartDate;
126131
this.featureEndDate = featureEndDate;
132+
this.tags = tags;
127133

128134
const result = OrgJob.OrgJobType.decode(raw);
129135

0 commit comments

Comments
 (0)