Skip to content

Commit a9d9ee4

Browse files
committed
agreation pipeline
1 parent 2211557 commit a9d9ee4

11 files changed

Lines changed: 400 additions & 15 deletions

backend/bun.lock

Lines changed: 77 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"jsonwebtoken": "^9.0.2",
1717
"mongoose": "^9.2.3",
1818
"morgan": "^1.10.1",
19-
"multer": "^2.0.2"
19+
"multer": "^2.0.2",
20+
"ngrok": "^5.0.0-beta.2"
2021
},
2122
"devDependencies": {
2223
"eslint": "^9.25.0"

backend/src/controllers/crud.controller.js

Lines changed: 232 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import mongoose from "mongoose";
2+
3+
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
4+
15
const parsePositiveInt = (value) => {
26
if (value === undefined || value === null || value === "") return null;
37
const num = Number(value);
@@ -54,19 +58,206 @@ const buildSearchFilter = (searchTerm, searchFields = []) => {
5458
};
5559
};
5660

57-
const applyPopulate = (query, req, allowedPopulate = [], defaultPopulate = []) => {
61+
const getPopulatePaths = (req, allowedPopulate = [], defaultPopulate = []) => {
5862
const raw = req.query.populate;
5963
const requested = typeof raw === "string" ? parseCommaList(raw) : [];
60-
const populateFields = new Set([
64+
return [...new Set([
6165
...defaultPopulate,
6266
...requested.filter((path) => allowedPopulate.includes(path)),
63-
]);
64-
for (const path of populateFields) {
67+
])];
68+
};
69+
70+
const applyPopulate = (query, req, allowedPopulate = [], defaultPopulate = []) => {
71+
const populatePaths = getPopulatePaths(req, allowedPopulate, defaultPopulate);
72+
for (const path of populatePaths) {
6573
query = query.populate(path);
6674
}
6775
return query;
6876
};
6977

78+
const getFieldTypeForFilter = (Model, field) => {
79+
const schemaPath = Model?.schema?.path(field);
80+
if (!schemaPath) return null;
81+
82+
if (schemaPath.instance !== "Array") {
83+
return schemaPath.instance;
84+
}
85+
86+
const rawArrayType = Array.isArray(schemaPath.options?.type)
87+
? schemaPath.options.type[0]
88+
: schemaPath.options?.type;
89+
const itemType = rawArrayType?.type || rawArrayType;
90+
91+
if (itemType === mongoose.Schema.Types.ObjectId) return "ObjectId";
92+
if (itemType === Number) return "Number";
93+
if (itemType === Boolean) return "Boolean";
94+
if (itemType === Date) return "Date";
95+
if (itemType === String) return "String";
96+
return "Array";
97+
};
98+
99+
const castFilterScalar = (value, fieldType) => {
100+
if (value === undefined || value === null) return value;
101+
102+
switch (fieldType) {
103+
case "ObjectId":
104+
if (value instanceof mongoose.Types.ObjectId) return value;
105+
if (typeof value === "string" && OBJECT_ID_PATTERN.test(value)) {
106+
return new mongoose.Types.ObjectId(value);
107+
}
108+
return value;
109+
case "Number":
110+
if (typeof value === "number") return value;
111+
if (typeof value === "string" && value.trim() !== "") {
112+
const num = Number(value);
113+
if (Number.isFinite(num)) return num;
114+
}
115+
return value;
116+
case "Boolean":
117+
if (typeof value === "boolean") return value;
118+
if (typeof value === "string") {
119+
const normalized = value.trim().toLowerCase();
120+
if (normalized === "true") return true;
121+
if (normalized === "false") return false;
122+
}
123+
return value;
124+
case "Date":
125+
if (value instanceof Date) return value;
126+
if (typeof value === "string" || typeof value === "number") {
127+
const date = new Date(value);
128+
if (!Number.isNaN(date.getTime())) return date;
129+
}
130+
return value;
131+
default:
132+
return value;
133+
}
134+
};
135+
136+
const castFiltersForAggregation = (Model, filters = {}) => {
137+
const casted = {};
138+
139+
for (const [field, rawValue] of Object.entries(filters)) {
140+
const fieldType = getFieldTypeForFilter(Model, field);
141+
142+
if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
143+
const castedOperators = {};
144+
for (const [operator, operatorValue] of Object.entries(rawValue)) {
145+
if (Array.isArray(operatorValue)) {
146+
castedOperators[operator] = operatorValue.map((value) =>
147+
castFilterScalar(value, fieldType)
148+
);
149+
} else {
150+
castedOperators[operator] = castFilterScalar(operatorValue, fieldType);
151+
}
152+
}
153+
casted[field] = castedOperators;
154+
continue;
155+
}
156+
157+
casted[field] = castFilterScalar(rawValue, fieldType);
158+
}
159+
160+
return casted;
161+
};
162+
163+
const buildSortObject = (sortValue, fallbackSort) => {
164+
const raw = typeof sortValue === "string" && sortValue.trim()
165+
? sortValue
166+
: fallbackSort;
167+
168+
const sort = {};
169+
const tokens = String(raw || "")
170+
.split(/[,\s]+/)
171+
.map((token) => token.trim())
172+
.filter(Boolean);
173+
174+
for (const token of tokens) {
175+
if (token.startsWith("-")) {
176+
const field = token.slice(1);
177+
if (field) sort[field] = -1;
178+
continue;
179+
}
180+
if (token.startsWith("+")) {
181+
const field = token.slice(1);
182+
if (field) sort[field] = 1;
183+
continue;
184+
}
185+
sort[token] = 1;
186+
}
187+
188+
if (!Object.keys(sort).length) {
189+
sort.createdAt = -1;
190+
}
191+
192+
return sort;
193+
};
194+
195+
const isTextIndexMissingError = (error) => {
196+
const message = String(error?.message || "").toLowerCase();
197+
return (
198+
error?.codeName === "IndexNotFound" ||
199+
message.includes("text index required") ||
200+
message.includes("index not found for $text")
201+
);
202+
};
203+
204+
const buildListPipeline = ({
205+
Model,
206+
filters,
207+
searchTerm,
208+
searchFields,
209+
sortValue,
210+
defaultSort,
211+
skip,
212+
limit,
213+
preferTextSearch,
214+
}) => {
215+
const pipeline = [];
216+
const castedFilters = castFiltersForAggregation(Model, filters);
217+
218+
if (Object.keys(castedFilters).length) {
219+
pipeline.push({ $match: castedFilters });
220+
}
221+
222+
const trimmedSearch = (searchTerm || "").trim();
223+
const canUseTextSearch =
224+
Boolean(trimmedSearch) && Array.isArray(searchFields) && searchFields.length;
225+
const shouldUseTextSearch =
226+
canUseTextSearch && preferTextSearch && trimmedSearch.length >= 3;
227+
228+
if (canUseTextSearch) {
229+
if (shouldUseTextSearch) {
230+
pipeline.push({ $match: { $text: { $search: trimmedSearch } } });
231+
pipeline.push({ $addFields: { _searchScore: { $meta: "textScore" } } });
232+
} else {
233+
const searchFilter = buildSearchFilter(trimmedSearch, searchFields);
234+
if (searchFilter) {
235+
pipeline.push({ $match: searchFilter });
236+
}
237+
}
238+
}
239+
240+
const hasExplicitSort = typeof sortValue === "string" && sortValue.trim();
241+
let sortStage = buildSortObject(sortValue, defaultSort);
242+
if (shouldUseTextSearch && !hasExplicitSort) {
243+
sortStage = { _searchScore: -1, ...sortStage };
244+
}
245+
246+
pipeline.push({ $sort: sortStage });
247+
248+
if (skip) {
249+
pipeline.push({ $skip: skip });
250+
}
251+
if (limit) {
252+
pipeline.push({ $limit: limit });
253+
}
254+
255+
pipeline.push({ $addFields: { id: { $toString: "$_id" } } });
256+
pipeline.push({ $project: { __v: 0, _searchScore: 0 } });
257+
258+
return pipeline;
259+
};
260+
70261
const pickAllowed = (payload, allowedFields) => {
71262
if (!Array.isArray(allowedFields) || !allowedFields.length) {
72263
return { ...payload };
@@ -133,19 +324,46 @@ export const buildCrudController = (
133324
}
134325

135326
const searchTerm = (req.query.q || req.query.search || "").trim();
136-
const searchFilter = buildSearchFilter(searchTerm, searchFields);
327+
const populatePaths = getPopulatePaths(
328+
req,
329+
allowedPopulate,
330+
defaultPopulate
331+
);
137332

138-
let query = Model.find(filters);
139-
if (searchFilter) {
140-
query = query.find(searchFilter);
141-
}
333+
const runListAggregation = async (preferTextSearch) => {
334+
const pipeline = buildListPipeline({
335+
Model,
336+
filters,
337+
searchTerm,
338+
searchFields,
339+
sortValue: req.query.sort,
340+
defaultSort,
341+
skip,
342+
limit,
343+
preferTextSearch,
344+
});
142345

143-
if (skip) query = query.skip(skip);
144-
if (limit) query = query.limit(limit);
145-
query = query.sort(req.query.sort || defaultSort);
146-
query = applyPopulate(query, req, allowedPopulate, defaultPopulate);
346+
let docs = await Model.aggregate(pipeline).exec();
347+
if (populatePaths.length) {
348+
docs = await Model.populate(docs, populatePaths);
349+
}
350+
return docs;
351+
};
352+
353+
let docs;
354+
try {
355+
docs = await runListAggregation(true);
356+
if (searchTerm && searchFields.length && !docs.length) {
357+
docs = await runListAggregation(false);
358+
}
359+
} catch (error) {
360+
if (searchTerm && searchFields.length && isTextIndexMissingError(error)) {
361+
docs = await runListAggregation(false);
362+
} else {
363+
throw error;
364+
}
365+
}
147366

148-
const docs = await query.exec();
149367
res.json(docs);
150368
} catch (error) {
151369
next(error);

backend/src/models/award.model.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ const awardSchema = new mongoose.Schema(
3232
{ timestamps: true }
3333
);
3434

35+
awardSchema.index({ type: 1 });
36+
awardSchema.index({ projectId: 1 });
37+
awardSchema.index({ developerId: 1 });
38+
awardSchema.index({ year: -1 });
39+
awardSchema.index(
40+
{ name: "text", type: "text" },
41+
{
42+
weights: { name: 8, type: 4 },
43+
name: "award_text_search_idx",
44+
}
45+
);
46+
3547
const Award = mongoose.model("Award", awardSchema);
3648

3749
export default Award;

backend/src/models/category.model.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const categorySchema = new mongoose.Schema(
1616
{ timestamps: true }
1717
);
1818

19+
categorySchema.index(
20+
{ name: "text", description: "text" },
21+
{
22+
weights: { name: 10, description: 3 },
23+
name: "category_text_search_idx",
24+
}
25+
);
26+
1927
const Category = mongoose.model("Category", categorySchema);
2028

2129
export default Category;

backend/src/models/clientProfile.model.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ const clientProfileSchema = new mongoose.Schema(
3737
{ timestamps: true }
3838
);
3939

40+
clientProfileSchema.index({ industry: 1 });
41+
clientProfileSchema.index({ companySize: 1 });
42+
clientProfileSchema.index({ location: 1 });
43+
clientProfileSchema.index({ createdAt: -1 });
44+
clientProfileSchema.index(
45+
{
46+
companyName: "text",
47+
industry: "text",
48+
location: "text",
49+
description: "text",
50+
},
51+
{
52+
weights: { companyName: 7, industry: 5, location: 2, description: 3 },
53+
name: "client_profile_text_search_idx",
54+
}
55+
);
56+
4057
const ClientProfile = mongoose.model("ClientProfile", clientProfileSchema);
4158

4259
export default ClientProfile;

backend/src/models/developerProfile.model.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ const developerProfileSchema = new mongoose.Schema(
8585
);
8686

8787
developerProfileSchema.index({ skills: 1 });
88+
developerProfileSchema.index({ technologies: 1 });
89+
developerProfileSchema.index({ availability: 1 });
90+
developerProfileSchema.index({ location: 1 });
91+
developerProfileSchema.index({ createdAt: -1 });
92+
developerProfileSchema.index(
93+
{ title: "text", bio: "text", skills: "text", location: "text" },
94+
{
95+
weights: { title: 6, skills: 5, bio: 3, location: 1 },
96+
name: "developer_profile_text_search_idx",
97+
}
98+
);
8899

89100
const DeveloperProfile = mongoose.model(
90101
"DeveloperProfile",

backend/src/models/project.model.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ const projectSchema = new mongoose.Schema(
6666
);
6767

6868
projectSchema.index({ technologies: 1 });
69+
projectSchema.index({ developerId: 1 });
70+
projectSchema.index({ category: 1 });
71+
projectSchema.index({ awards: 1 });
72+
projectSchema.index({ createdAt: -1 });
73+
projectSchema.index(
74+
{ title: "text", description: "text" },
75+
{
76+
weights: { title: 8, description: 4 },
77+
name: "project_text_search_idx",
78+
}
79+
);
6980

7081
const normalizeImages = (value) => {
7182
if (!value) return [];

backend/src/models/projectRequest.model.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ const projectRequestSchema = new mongoose.Schema(
5050
);
5151

5252
projectRequestSchema.index({ technologies: 1 });
53+
projectRequestSchema.index({ clientId: 1 });
54+
projectRequestSchema.index({ category: 1 });
55+
projectRequestSchema.index({ status: 1 });
56+
projectRequestSchema.index({ createdAt: -1 });
57+
projectRequestSchema.index(
58+
{ title: "text", description: "text" },
59+
{
60+
weights: { title: 8, description: 4 },
61+
name: "project_request_text_search_idx",
62+
}
63+
);
5364

5465
const ProjectRequest = mongoose.model("ProjectRequest", projectRequestSchema);
5566

0 commit comments

Comments
 (0)