Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ PLAYGROUND_ENABLE=false
# AMQP URL
AMQP_URL=amqp://guest:guest@rabbitmq

# Billing settings
BILLING_DEBUG=true
BILLING_COMPANY_EMAIL="team@hawk.so"

### Accounting module ###
# Accounting service URL
# CODEX_ACCOUNTING_URL=http://accounting:3999/graphql
Expand Down
4 changes: 0 additions & 4 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ SMTP_SENDER_ADDRESS=
# AMQP URL
AMQP_URL=amqp://guest:guest@rabbitmq:5672/

# Billing settings
BILLING_DEBUG=true
BILLING_COMPANY_EMAIL="team@hawk.so"

### Accounting module ###
# Accounting service URL
# CODEX_ACCOUNTING_URL=
Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ module.exports = {
*/
preset: '@shelf/jest-mongodb',

/**
* Setup file to provide global APIs needed by MongoDB driver
*/
setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],

/**
* TypeScript support
*/
Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.31",
"version": "1.2.32",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand All @@ -20,7 +20,8 @@
"test:integration:down": "docker compose -f docker-compose.test.yml down --volumes"
},
"devDependencies": {
"@shelf/jest-mongodb": "^1.2.2",
"@shelf/jest-mongodb": "^6.0.2",
"@swc/core": "^1.3.0",
"@types/jest": "^26.0.8",
"eslint": "^6.7.2",
"eslint-config-codex": "1.2.4",
Expand All @@ -43,15 +44,13 @@
"@hawk.so/types": "^0.1.37",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@types/amqp-connection-manager": "^2.0.4",
"@types/bson": "^4.0.5",
"@types/debug": "^4.1.5",
"@types/escape-html": "^1.0.0",
"@types/graphql-upload": "^8.0.11",
"@types/jsonwebtoken": "^8.3.5",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.mergewith": "^4.6.9",
"@types/mime-types": "^2.1.0",
"@types/mongodb": "^3.6.20",
"@types/morgan": "^1.9.10",
"@types/node": "^16.11.46",
"@types/safe-regex": "^1.1.6",
Expand All @@ -64,7 +63,6 @@
"aws-sdk": "^2.1174.0",
"axios": "^0.27.2",
"body-parser": "^1.19.0",
"bson": "^4.6.5",
"cloudpayments": "^6.0.1",
"codex-accounting-sdk": "https://github.com/codex-team/codex-accounting-sdk.git",
"dataloader": "^2.0.0",
Expand All @@ -81,13 +79,16 @@
"lodash.mergewith": "^4.6.2",
"migrate-mongo": "^7.0.1",
"mime-types": "^2.1.25",
"mongodb": "^3.7.3",
"mongodb": "^6.0.0",
"morgan": "^1.10.1",
"prom-client": "^15.1.3",
"redis": "^4.7.0",
"safe-regex": "^2.1.0",
"ts-node-dev": "^2.0.0",
"uuid": "^8.3.2",
"zod": "^3.25.76"
},
"resolutions": {
"bson": "^6.7.0"
}
}
38 changes: 24 additions & 14 deletions src/dataLoaders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import DataLoader from 'dataloader';
import { Db, ObjectId } from 'mongodb';
import { Db, ObjectId, WithId } from 'mongodb';
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme, EventData, EventAddons } from '@hawk.so/types';

type EventDbScheme = {
Expand Down Expand Up @@ -47,7 +47,7 @@
*/
public userByEmail = new DataLoader<string, UserDBScheme | null>(
(userEmails) =>
this.batchByField<UserDBScheme, string>('users', userEmails, 'email'),
this.batchByField<UserDBScheme, 'email'>('users', 'email', userEmails),
{ cache: false }
);

Expand All @@ -69,48 +69,58 @@
* @param collectionName - collection name to get entities
* @param ids - ids for resolving
*/
private async batchByIds<T extends { _id: ObjectId }>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
return this.batchByField<T, ObjectId>(collectionName, ids.map(id => new ObjectId(id)), '_id');
private async batchByIds<T extends { _id: ObjectId }>(
collectionName: string,
ids: ReadonlyArray<string>
): Promise<(WithId<T> | null)[]> {
return this.batchByField<T, '_id'>(collectionName, '_id', ids.map(id => new ObjectId(id)));
}

/**
* Batching function for resolving entities by certain field
* @param collectionName - collection name to get entities
* @param values - values for resolving
* @param fieldName - field name to resolve
* @param values - values for resolving
*/
private async batchByField<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends { [key: string]: any },
FieldType extends ObjectId | string
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
T extends Record<string, any>,

Check warning on line 86 in src/dataLoaders.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type
FieldType extends keyof T
>(
collectionName: string,
fieldName: FieldType,
values: ReadonlyArray<T[FieldType]>
): Promise<(WithId<T> | null)[]> {
type Doc = WithId<T>;
const valuesMap = new Map<string, FieldType>();

for (const value of values) {
valuesMap.set(value.toString(), value);
}

const queryResult = await this.dbConnection.collection(collectionName)
const queryResult = await this.dbConnection
.collection<T>(collectionName)
.find({
[fieldName]: { $in: Array.from(valuesMap.values()) },
})
} as any)

Check warning on line 104 in src/dataLoaders.ts

View workflow job for this annotation

GitHub Actions / ESlint

Unexpected any. Specify a different type
.toArray();

/**
* Map for making associations between given id and fetched entity
* It's because MongoDB `find` mixed all entities
*/
const entitiesMap: Record<string, T> = {};
const entitiesMap: Record<string, Doc> = {};

queryResult.forEach((entity) => {
const key = entity[fieldName as keyof Doc];

queryResult.forEach((entity: T) => {
entitiesMap[entity[fieldName].toString()] = entity;
entitiesMap[key.toString()] = entity;
}, {});

return values.map((field) => entitiesMap[field.toString()] || null);
}
}

/**

Check warning on line 123 in src/dataLoaders.ts

View workflow job for this annotation

GitHub Actions / ESlint

Missing JSDoc @returns for function
* Create DataLoader for events in dynamic collections `events:<projectId>` stored in the events DB
*
* @param eventsDb - MongoDB connection to the events database
Expand Down
6 changes: 5 additions & 1 deletion src/metrics/mongodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,11 @@ export function setupMongoMetrics(client: MongoClient): void {
.observe(duration);

// Track error
const errorCode = event.failure?.code?.toString() || 'unknown';
/**
* MongoDB failure objects may have additional properties like 'code'
* that aren't part of the standard Error type
*/
const errorCode = (event.failure as any)?.code?.toString() || 'unknown';

mongoCommandErrors
.labels(metadata.commandName, errorCode)
Expand Down
27 changes: 17 additions & 10 deletions src/models/abstactModelFactory.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Collection, Db, ObjectID } from 'mongodb';
import { Collection, Db, Document, ObjectId } from 'mongodb';
import AbstractModel, { ModelConstructor } from './abstractModel';

/**
* Model Factory class
*/
export default abstract class AbstractModelFactory<DBScheme, Model extends AbstractModel<DBScheme>> {
export default abstract class AbstractModelFactory<DBScheme extends Document, Model extends AbstractModel<DBScheme>> {
/**
* Database connection to interact with
*/
Expand All @@ -17,11 +17,8 @@ export default abstract class AbstractModelFactory<DBScheme, Model extends Abstr

/**
* Collection to work with
* We can't use generic type for collection because of bug in TS
* @see {@link https://github.com/DefinitelyTyped/DefinitelyTyped/issues/39358#issuecomment-546559564}
* So we should override collection type in child classes
*/
protected abstract collection: Collection;
protected abstract collection: Collection<DBScheme>;

/**
* Creates factory instance
Expand All @@ -44,7 +41,12 @@ export default abstract class AbstractModelFactory<DBScheme, Model extends Abstr
return null;
}

return new this.Model(searchResult);
/**
* MongoDB returns WithId<DBScheme>, but Model constructor expects DBScheme.
* Since WithId<DBScheme> is DBScheme & { _id: ObjectId } and DBScheme already
* includes _id: ObjectId, they are structurally compatible.
*/
return new this.Model(searchResult as DBScheme);
}

/**
Expand All @@ -53,13 +55,18 @@ export default abstract class AbstractModelFactory<DBScheme, Model extends Abstr
*/
public async findById(id: string): Promise<Model | null> {
const searchResult = await this.collection.findOne({
_id: new ObjectID(id),
});
_id: new ObjectId(id),
} as any);

if (!searchResult) {
return null;
}

return new this.Model(searchResult);
/**
* MongoDB returns WithId<DBScheme>, but Model constructor expects DBScheme.
* Since WithId<DBScheme> is DBScheme & { _id: ObjectId } and DBScheme already
* includes _id: ObjectId, they are structurally compatible.
*/
return new this.Model(searchResult as DBScheme);
}
}
20 changes: 13 additions & 7 deletions src/models/abstractModel.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Collection, Db } from 'mongodb';
import { Collection, Db, Document } from 'mongodb';
import { databases } from '../mongo';

/**
* Model constructor type
*/
export type ModelConstructor<DBScheme, Model extends AbstractModel<DBScheme>> = new (modelData: DBScheme) => Model;
export type ModelConstructor<DBScheme extends Document, Model extends AbstractModel<DBScheme>> = new (modelData: DBScheme) => Model;

/**
* Base model
*/
export default abstract class AbstractModel<DBScheme> {
export default abstract class AbstractModel<DBScheme extends Document> {
/**
* Database connection to interact with DB
*/
Expand All @@ -19,7 +19,7 @@ export default abstract class AbstractModel<DBScheme> {
/**
* Model's collection
*/
protected abstract collection: Collection;
protected abstract collection: Collection<DBScheme>;

/**
* Creates model instance
Expand All @@ -32,10 +32,16 @@ export default abstract class AbstractModel<DBScheme> {
/**
* Update entity data
* @param query - query to match
* @param data - update data
* @param data - update data (supports MongoDB dot notation for nested fields)
* @return number of documents modified
*/
public async update(query: object, data: object): Promise<number> {
return (await this.collection.updateOne(query, { $set: data })).modifiedCount;
public async update(query: object, data: Partial<DBScheme> | Record<string, any>): Promise<number> {
/**
* Type assertion is needed because MongoDB's updateOne accepts both
* Partial<DBScheme> (for regular updates) and Record<string, any>
* (for dot notation like 'identities.workspaceId.saml.id'), but the
* type system requires MatchKeysAndValues<DBScheme>.
*/
return (await this.collection.updateOne(query, { $set: data as any })).modifiedCount;
}
}
20 changes: 10 additions & 10 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ChartDataService from '../services/chartDataService';
const Factory = require('./modelFactory');
const mongo = require('../mongo');
const Event = require('../models/event');
const { ObjectID } = require('mongodb');
const { ObjectId } = require('mongodb');
const { composeEventPayloadByRepetition } = require('../utils/merge');

const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
Expand Down Expand Up @@ -174,7 +174,7 @@ class EventsFactory extends Factory {
/**
* Find event by id
*
* @param {string|ObjectID} id - event's id
* @param {string|ObjectId} id - event's id
* @returns {Event|null}
*/
async findById(id) {
Expand Down Expand Up @@ -282,7 +282,7 @@ class EventsFactory extends Factory {
$and: [
{ groupingTimestamp: paginationCursor.groupingTimestampBoundary },
{ [sort]: paginationCursor.sortValueBoundary },
{ _id: { $lte: new ObjectID(paginationCursor.idBoundary) } },
{ _id: { $lte: new ObjectId(paginationCursor.idBoundary) } },
],
},
],
Expand Down Expand Up @@ -654,7 +654,7 @@ class EventsFactory extends Factory {
/**
* Returns Event repetitions
*
* @param {string|ObjectID} originalEventId - id of the original event
* @param {string|ObjectId} originalEventId - id of the original event
* @param {Number} limit - count limitations
* @param {Number} cursor - pointer to the next repetition
*
Expand All @@ -663,7 +663,7 @@ class EventsFactory extends Factory {
async getEventRepetitions(originalEventId, limit = 10, cursor = null) {
limit = this.validateLimit(limit);

cursor = cursor ? new ObjectID(cursor) : null;
cursor = cursor ? new ObjectId(cursor) : null;

const result = {
repetitions: [],
Expand Down Expand Up @@ -766,7 +766,7 @@ class EventsFactory extends Factory {
*/
const repetition = await this.getCollection(this.TYPES.REPETITIONS)
.findOne({
_id: ObjectID(repetitionId),
_id: new ObjectId(repetitionId),
});

const originalEvent = await this.eventsDataLoader.load(originalEventId);
Expand Down Expand Up @@ -828,8 +828,8 @@ class EventsFactory extends Factory {
async visitEvent(eventId, userId) {
const result = await this.getCollection(this.TYPES.EVENTS)
.updateOne(
{ _id: new ObjectID(eventId) },
{ $addToSet: { visitedBy: new ObjectID(userId) } }
{ _id: new ObjectId(eventId) },
{ $addToSet: { visitedBy: new ObjectId(userId) } }
);

if (result.matchedCount === 0) {
Expand All @@ -856,7 +856,7 @@ class EventsFactory extends Factory {
throw new Error(`Event not found for eventId: ${eventId}`);
}

const query = { _id: new ObjectID(event._id) };
const query = { _id: new ObjectId(event._id) };

const markKey = `marks.${mark}`;

Expand Down Expand Up @@ -908,7 +908,7 @@ class EventsFactory extends Factory {
async updateAssignee(eventId, assignee) {
const collection = this.getCollection(this.TYPES.EVENTS);

const query = { _id: new ObjectID(eventId) };
const query = { _id: new ObjectId(eventId) };

const update = {
$set: { assignee: assignee },
Expand Down
Loading
Loading