@loomcore/api 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/common-test.utils.d.ts +35 -0
- package/dist/__tests__/common-test.utils.js +181 -0
- package/dist/__tests__/test-express-app.d.ts +16 -0
- package/dist/__tests__/test-express-app.js +83 -0
- package/dist/config/api-common-config.d.ts +3 -0
- package/dist/config/api-common-config.js +11 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/controllers/api-controller.utils.d.ts +1 -0
- package/dist/controllers/api-controller.utils.js +1 -0
- package/dist/controllers/api.controller.d.ts +22 -0
- package/dist/controllers/api.controller.js +71 -0
- package/dist/controllers/auth.controller.d.ts +16 -0
- package/dist/controllers/auth.controller.js +73 -0
- package/dist/controllers/index.d.ts +1 -0
- package/dist/controllers/index.js +1 -0
- package/dist/errors/bad-request.error.d.ts +9 -0
- package/dist/errors/bad-request.error.js +12 -0
- package/dist/errors/database-connection.error.d.ts +9 -0
- package/dist/errors/database-connection.error.js +12 -0
- package/dist/errors/duplicate-key.error.d.ts +9 -0
- package/dist/errors/duplicate-key.error.js +11 -0
- package/dist/errors/id-not-found.error.d.ts +9 -0
- package/dist/errors/id-not-found.error.js +11 -0
- package/dist/errors/index.d.ts +8 -0
- package/dist/errors/index.js +8 -0
- package/dist/errors/not-found.error.d.ts +9 -0
- package/dist/errors/not-found.error.js +12 -0
- package/dist/errors/server.error.d.ts +9 -0
- package/dist/errors/server.error.js +11 -0
- package/dist/errors/unauthenticated.error.d.ts +8 -0
- package/dist/errors/unauthenticated.error.js +11 -0
- package/dist/errors/unauthorized.error.d.ts +8 -0
- package/dist/errors/unauthorized.error.js +11 -0
- package/dist/middleware/ensure-user-context.d.ts +2 -0
- package/dist/middleware/ensure-user-context.js +7 -0
- package/dist/middleware/error-handler.d.ts +2 -0
- package/dist/middleware/error-handler.js +30 -0
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.js +3 -0
- package/dist/middleware/is-authenticated.d.ts +2 -0
- package/dist/middleware/is-authenticated.js +27 -0
- package/dist/models/api-common-config.interface.d.ts +22 -0
- package/dist/models/api-common-config.interface.js +1 -0
- package/dist/models/base-api-config.interface.d.ts +12 -0
- package/dist/models/base-api-config.interface.js +1 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.js +3 -0
- package/dist/models/types/index.d.ts +1 -0
- package/dist/models/types/index.js +1 -0
- package/dist/services/auth.service.d.ts +54 -0
- package/dist/services/auth.service.js +283 -0
- package/dist/services/email.service.d.ts +4 -0
- package/dist/services/email.service.js +24 -0
- package/dist/services/generic-api-service.interface.d.ts +18 -0
- package/dist/services/generic-api-service.interface.js +1 -0
- package/dist/services/generic-api.service.d.ts +44 -0
- package/dist/services/generic-api.service.js +378 -0
- package/dist/services/index.d.ts +8 -0
- package/dist/services/index.js +8 -0
- package/dist/services/jwt.service.d.ts +4 -0
- package/dist/services/jwt.service.js +18 -0
- package/dist/services/multi-tenant-api.service.d.ts +10 -0
- package/dist/services/multi-tenant-api.service.js +31 -0
- package/dist/services/password-reset-token.service.d.ts +8 -0
- package/dist/services/password-reset-token.service.js +20 -0
- package/dist/services/tenant-query-decorator.d.ts +14 -0
- package/dist/services/tenant-query-decorator.js +66 -0
- package/dist/utils/address.utils.d.ts +6 -0
- package/dist/utils/address.utils.js +15 -0
- package/dist/utils/api.utils.d.ts +17 -0
- package/dist/utils/api.utils.js +60 -0
- package/dist/utils/conversion.utils.d.ts +5 -0
- package/dist/utils/conversion.utils.js +14 -0
- package/dist/utils/db.utils.d.ts +27 -0
- package/dist/utils/db.utils.js +273 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/password.utils.d.ts +7 -0
- package/dist/utils/password.utils.js +23 -0
- package/dist/utils/string.utils.d.ts +9 -0
- package/dist/utils/string.utils.js +30 -0
- package/package.json +71 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Db, Collection, DeleteResult, Document, FindOptions } from 'mongodb';
|
|
2
|
+
import { ValueError } from '@sinclair/typebox/errors';
|
|
3
|
+
import { IUserContext, IEntity, QueryOptions, IPagedResult, IModelSpec } from '@loomcore/common/models';
|
|
4
|
+
import { IGenericApiService } from './generic-api-service.interface.js';
|
|
5
|
+
export declare class GenericApiService<T extends IEntity> implements IGenericApiService<T> {
|
|
6
|
+
protected db: Db;
|
|
7
|
+
protected pluralResourceName: string;
|
|
8
|
+
protected singularResourceName: string;
|
|
9
|
+
protected collection: Collection;
|
|
10
|
+
protected modelSpec?: IModelSpec;
|
|
11
|
+
constructor(db: Db, pluralResourceName: string, singularResourceName: string, modelSpec?: IModelSpec);
|
|
12
|
+
validate(doc: any, isPartial?: boolean): ValueError[] | null;
|
|
13
|
+
protected getAdditionalPipelineStages(): any[];
|
|
14
|
+
protected createAggregationPipeline(userContext: IUserContext, query: any, queryOptions?: QueryOptions): any[];
|
|
15
|
+
getAll(userContext: IUserContext): Promise<T[]>;
|
|
16
|
+
get(userContext: IUserContext, queryOptions?: QueryOptions): Promise<IPagedResult<T>>;
|
|
17
|
+
getById(userContext: IUserContext, id: string): Promise<T>;
|
|
18
|
+
getCount(userContext: IUserContext): Promise<number>;
|
|
19
|
+
create(userContext: IUserContext, entity: T | Partial<T>): Promise<T | null>;
|
|
20
|
+
createMany(userContext: IUserContext, entities: T[]): Promise<T[]>;
|
|
21
|
+
fullUpdateById(userContext: IUserContext, id: string, entity: T): Promise<T>;
|
|
22
|
+
partialUpdateById(userContext: IUserContext, id: string, entity: Partial<T>): Promise<T>;
|
|
23
|
+
partialUpdateByIdWithoutBeforeAndAfter(userContext: IUserContext, id: string, entity: T): Promise<T>;
|
|
24
|
+
update(userContext: IUserContext, queryObject: any, entity: Partial<T>): Promise<T[]>;
|
|
25
|
+
deleteById(userContext: IUserContext, id: string): Promise<DeleteResult>;
|
|
26
|
+
deleteMany(userContext: IUserContext, queryObject: any): Promise<DeleteResult>;
|
|
27
|
+
find(userContext: IUserContext, mongoQueryObject: any, options?: FindOptions<Document> | undefined): Promise<T[]>;
|
|
28
|
+
findOne(userContext: IUserContext, mongoQueryObject: any, options?: FindOptions<Document> | undefined): Promise<T>;
|
|
29
|
+
auditForCreate(userContext: IUserContext, doc: any): void;
|
|
30
|
+
auditForUpdate(userContext: IUserContext, doc: any): void;
|
|
31
|
+
onBeforeCreate<E extends T | T[] | Partial<T> | Partial<T>[]>(userContext: IUserContext, entities: E): Promise<E | E[]>;
|
|
32
|
+
onAfterCreate<E extends T | T[]>(userContext: IUserContext | undefined, entities: E): Promise<E | E[]>;
|
|
33
|
+
onBeforeUpdate<E extends T | T[] | Partial<T> | Partial<T>[]>(userContext: IUserContext, entities: E): Promise<E | E[]>;
|
|
34
|
+
onAfterUpdate<E extends T | T[] | Partial<T> | Partial<T>[]>(userContext: IUserContext | undefined, entities: E): Promise<E>;
|
|
35
|
+
onBeforeDelete(userContext: IUserContext, queryObject: any): Promise<any>;
|
|
36
|
+
onAfterDelete(userContext: IUserContext, queryObject: any): Promise<any>;
|
|
37
|
+
transformList(list: any[]): T[];
|
|
38
|
+
transformSingle(single: any): T;
|
|
39
|
+
private stripSenderProvidedSystemProperties;
|
|
40
|
+
protected preparePayload<E extends T | T[] | Partial<T> | Partial<T>[]>(userContext: IUserContext, entity: E, isCreate?: boolean): Promise<E | E[]>;
|
|
41
|
+
protected prepareEntity(userContext: IUserContext, entity: T | Partial<T>, isCreate: boolean): Promise<T | Partial<T>>;
|
|
42
|
+
protected prepareQuery(userContext: IUserContext | undefined, query: any): any;
|
|
43
|
+
protected prepareQueryOptions(userContext: IUserContext | undefined, queryOptions: QueryOptions): QueryOptions;
|
|
44
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { ObjectId } from 'mongodb';
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
import _ from 'lodash';
|
|
4
|
+
import { QueryOptions } from '@loomcore/common/models';
|
|
5
|
+
import { entityUtils } from '@loomcore/common/utils';
|
|
6
|
+
import { BadRequestError, DuplicateKeyError, IdNotFoundError, NotFoundError, ServerError } from '../errors/index.js';
|
|
7
|
+
import { apiUtils, dbUtils } from '../utils/index.js';
|
|
8
|
+
export class GenericApiService {
|
|
9
|
+
db;
|
|
10
|
+
pluralResourceName;
|
|
11
|
+
singularResourceName;
|
|
12
|
+
collection;
|
|
13
|
+
modelSpec;
|
|
14
|
+
constructor(db, pluralResourceName, singularResourceName, modelSpec) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
this.pluralResourceName = pluralResourceName;
|
|
17
|
+
this.singularResourceName = singularResourceName;
|
|
18
|
+
this.collection = db.collection(pluralResourceName);
|
|
19
|
+
this.modelSpec = modelSpec;
|
|
20
|
+
}
|
|
21
|
+
validate(doc, isPartial = false) {
|
|
22
|
+
if (!this.modelSpec) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const validator = isPartial ? this.modelSpec.partialValidator : this.modelSpec.validator;
|
|
26
|
+
return entityUtils.validate(validator, doc);
|
|
27
|
+
}
|
|
28
|
+
getAdditionalPipelineStages() {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
createAggregationPipeline(userContext, query, queryOptions) {
|
|
32
|
+
const match = { $match: query };
|
|
33
|
+
const additionalStages = this.getAdditionalPipelineStages();
|
|
34
|
+
let resultStages = [...additionalStages];
|
|
35
|
+
if (queryOptions) {
|
|
36
|
+
if (queryOptions.orderBy) {
|
|
37
|
+
resultStages.push({
|
|
38
|
+
$sort: {
|
|
39
|
+
[queryOptions.orderBy]: queryOptions.sortDirection === 'asc' ? 1 : -1
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (queryOptions.page && queryOptions.pageSize) {
|
|
44
|
+
resultStages.push({ $skip: (queryOptions.page - 1) * queryOptions.pageSize });
|
|
45
|
+
resultStages.push({ $limit: queryOptions.pageSize });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [match, ...resultStages];
|
|
49
|
+
}
|
|
50
|
+
async getAll(userContext) {
|
|
51
|
+
const query = this.prepareQuery(userContext, {});
|
|
52
|
+
let entities = [];
|
|
53
|
+
if (this.getAdditionalPipelineStages().length > 0) {
|
|
54
|
+
const pipeline = this.createAggregationPipeline(userContext, query);
|
|
55
|
+
entities = await this.collection.aggregate(pipeline).toArray();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const cursor = this.collection.find(query);
|
|
59
|
+
entities = await cursor.toArray();
|
|
60
|
+
}
|
|
61
|
+
return this.transformList(entities);
|
|
62
|
+
}
|
|
63
|
+
async get(userContext, queryOptions = new QueryOptions()) {
|
|
64
|
+
const preparedOptions = this.prepareQueryOptions(userContext, queryOptions);
|
|
65
|
+
const match = dbUtils.buildMongoMatchFromQueryOptions(preparedOptions);
|
|
66
|
+
const additionalStages = this.getAdditionalPipelineStages();
|
|
67
|
+
const results = [...additionalStages];
|
|
68
|
+
if (preparedOptions.orderBy) {
|
|
69
|
+
results.push({ $sort: { [preparedOptions.orderBy]: preparedOptions.sortDirection === 'asc' ? 1 : -1 } });
|
|
70
|
+
}
|
|
71
|
+
if (preparedOptions.page && preparedOptions.pageSize) {
|
|
72
|
+
results.push({ $skip: (preparedOptions.page - 1) * preparedOptions.pageSize });
|
|
73
|
+
results.push({ $limit: preparedOptions.pageSize });
|
|
74
|
+
}
|
|
75
|
+
const pipeline = [
|
|
76
|
+
match,
|
|
77
|
+
{
|
|
78
|
+
$facet: {
|
|
79
|
+
results: results,
|
|
80
|
+
total: [
|
|
81
|
+
{ $count: 'total' }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
let pagedResult = apiUtils.getPagedResult([], 0, preparedOptions);
|
|
87
|
+
const cursor = this.collection.aggregate(pipeline);
|
|
88
|
+
const aggregateResult = await cursor.next();
|
|
89
|
+
if (aggregateResult) {
|
|
90
|
+
let total = 0;
|
|
91
|
+
if (aggregateResult.total && aggregateResult.total.length > 0) {
|
|
92
|
+
total = aggregateResult.total[0].total;
|
|
93
|
+
}
|
|
94
|
+
const entities = this.transformList(aggregateResult.results);
|
|
95
|
+
pagedResult = apiUtils.getPagedResult(entities, total, preparedOptions);
|
|
96
|
+
}
|
|
97
|
+
return pagedResult;
|
|
98
|
+
}
|
|
99
|
+
async getById(userContext, id) {
|
|
100
|
+
if (!entityUtils.isValidObjectId(id)) {
|
|
101
|
+
throw new BadRequestError('id is not a valid ObjectId');
|
|
102
|
+
}
|
|
103
|
+
const baseQuery = { _id: new ObjectId(id) };
|
|
104
|
+
const query = this.prepareQuery(userContext, baseQuery);
|
|
105
|
+
let entity = null;
|
|
106
|
+
if (this.getAdditionalPipelineStages().length > 0) {
|
|
107
|
+
const pipeline = this.createAggregationPipeline(userContext, query);
|
|
108
|
+
entity = await this.collection.aggregate(pipeline).next();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
entity = await this.collection.findOne(query);
|
|
112
|
+
}
|
|
113
|
+
if (!entity) {
|
|
114
|
+
throw new IdNotFoundError();
|
|
115
|
+
}
|
|
116
|
+
return this.transformSingle(entity);
|
|
117
|
+
}
|
|
118
|
+
async getCount(userContext) {
|
|
119
|
+
const query = this.prepareQuery(userContext, {});
|
|
120
|
+
const count = await this.collection.countDocuments(query);
|
|
121
|
+
return count;
|
|
122
|
+
}
|
|
123
|
+
async create(userContext, entity) {
|
|
124
|
+
const validationErrors = this.validate(entity);
|
|
125
|
+
entityUtils.handleValidationResult(validationErrors, 'GenericApiService.create');
|
|
126
|
+
let createdEntity = null;
|
|
127
|
+
try {
|
|
128
|
+
const preparedEntity = await this.onBeforeCreate(userContext, entity);
|
|
129
|
+
const insertResult = await this.collection.insertOne(preparedEntity);
|
|
130
|
+
if (insertResult.insertedId) {
|
|
131
|
+
createdEntity = this.transformSingle(preparedEntity);
|
|
132
|
+
}
|
|
133
|
+
if (createdEntity) {
|
|
134
|
+
await this.onAfterCreate(userContext, createdEntity);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err.code === 11000) {
|
|
139
|
+
throw new DuplicateKeyError(`${this.singularResourceName} already exists`);
|
|
140
|
+
}
|
|
141
|
+
throw new BadRequestError(`Error creating ${this.singularResourceName}`);
|
|
142
|
+
}
|
|
143
|
+
return createdEntity;
|
|
144
|
+
}
|
|
145
|
+
async createMany(userContext, entities) {
|
|
146
|
+
let createdEntities = [];
|
|
147
|
+
if (entities.length) {
|
|
148
|
+
try {
|
|
149
|
+
for (const entity of entities) {
|
|
150
|
+
const validationErrors = this.validate(entity);
|
|
151
|
+
entityUtils.handleValidationResult(validationErrors, 'GenericApiService.createMany');
|
|
152
|
+
}
|
|
153
|
+
const preparedEntities = await this.onBeforeCreate(userContext, entities);
|
|
154
|
+
const insertResult = await this.collection.insertMany(preparedEntities);
|
|
155
|
+
if (insertResult.insertedIds) {
|
|
156
|
+
createdEntities = this.transformList(preparedEntities);
|
|
157
|
+
}
|
|
158
|
+
await this.onAfterCreate(userContext, createdEntities);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
if (err.code === 11000) {
|
|
162
|
+
throw new DuplicateKeyError(`One or more ${this.pluralResourceName} already exist`);
|
|
163
|
+
}
|
|
164
|
+
throw new BadRequestError(`Error creating ${this.pluralResourceName}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return createdEntities;
|
|
168
|
+
}
|
|
169
|
+
async fullUpdateById(userContext, id, entity) {
|
|
170
|
+
if (!entityUtils.isValidObjectId(id)) {
|
|
171
|
+
throw new BadRequestError('id is not a valid ObjectId');
|
|
172
|
+
}
|
|
173
|
+
const validationErrors = this.validate(entity);
|
|
174
|
+
entityUtils.handleValidationResult(validationErrors, 'GenericApiService.fullUpdateById');
|
|
175
|
+
const baseQuery = { _id: new ObjectId(id) };
|
|
176
|
+
const query = this.prepareQuery(userContext, baseQuery);
|
|
177
|
+
const existingEntity = await this.collection.findOne(query);
|
|
178
|
+
if (!existingEntity) {
|
|
179
|
+
throw new IdNotFoundError();
|
|
180
|
+
}
|
|
181
|
+
const auditProperties = {
|
|
182
|
+
_created: existingEntity._created,
|
|
183
|
+
_createdBy: existingEntity._createdBy,
|
|
184
|
+
};
|
|
185
|
+
const clone = await this.onBeforeUpdate(userContext, entity);
|
|
186
|
+
Object.assign(clone, auditProperties);
|
|
187
|
+
const mongoUpdateResult = await this.collection.replaceOne(query, clone);
|
|
188
|
+
if (mongoUpdateResult?.matchedCount <= 0) {
|
|
189
|
+
throw new IdNotFoundError();
|
|
190
|
+
}
|
|
191
|
+
await this.onAfterUpdate(userContext, clone);
|
|
192
|
+
const updatedEntity = await this.collection.findOne(query);
|
|
193
|
+
return this.transformSingle(updatedEntity);
|
|
194
|
+
}
|
|
195
|
+
async partialUpdateById(userContext, id, entity) {
|
|
196
|
+
if (!entityUtils.isValidObjectId(id)) {
|
|
197
|
+
throw new BadRequestError('id is not a valid ObjectId');
|
|
198
|
+
}
|
|
199
|
+
const validationErrors = this.validate(entity, true);
|
|
200
|
+
entityUtils.handleValidationResult(validationErrors, 'GenericApiService.partialUpdateById');
|
|
201
|
+
const clone = await this.onBeforeUpdate(userContext, entity);
|
|
202
|
+
const baseQuery = { _id: new ObjectId(id) };
|
|
203
|
+
const query = this.prepareQuery(userContext, baseQuery);
|
|
204
|
+
const updatedEntity = await this.collection.findOneAndUpdate(query, { $set: clone }, { returnDocument: 'after' });
|
|
205
|
+
if (!updatedEntity) {
|
|
206
|
+
throw new IdNotFoundError();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const typedEntity = updatedEntity;
|
|
210
|
+
await this.onAfterUpdate(userContext, typedEntity);
|
|
211
|
+
}
|
|
212
|
+
return this.transformSingle(updatedEntity);
|
|
213
|
+
}
|
|
214
|
+
async partialUpdateByIdWithoutBeforeAndAfter(userContext, id, entity) {
|
|
215
|
+
if (!entityUtils.isValidObjectId(id)) {
|
|
216
|
+
throw new BadRequestError('id is not a valid ObjectId');
|
|
217
|
+
}
|
|
218
|
+
const validationErrors = this.validate(entity, true);
|
|
219
|
+
entityUtils.handleValidationResult(validationErrors, 'GenericApiService.partialUpdateByIdWithoutBeforeAndAfter');
|
|
220
|
+
const preparedEntity = this.preparePayload(userContext, entity, false);
|
|
221
|
+
const baseQuery = { _id: new ObjectId(id) };
|
|
222
|
+
const query = this.prepareQuery(userContext, baseQuery);
|
|
223
|
+
const modifyResult = await this.collection.findOneAndUpdate(query, { $set: preparedEntity }, { returnDocument: 'after' });
|
|
224
|
+
let updatedEntity = null;
|
|
225
|
+
if (modifyResult?.ok === 1) {
|
|
226
|
+
updatedEntity = modifyResult.value;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
if (!modifyResult?.value) {
|
|
230
|
+
throw new IdNotFoundError();
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
throw new ServerError(`Error updating ${this.singularResourceName} - ${JSON.stringify(modifyResult.lastErrorObject)}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return this.transformSingle(updatedEntity);
|
|
237
|
+
}
|
|
238
|
+
async update(userContext, queryObject, entity) {
|
|
239
|
+
const clone = await this.onBeforeUpdate(userContext, entity);
|
|
240
|
+
const query = this.prepareQuery(userContext, queryObject);
|
|
241
|
+
const mongoUpdateResult = await this.collection.updateMany(query, { $set: clone });
|
|
242
|
+
if (mongoUpdateResult?.matchedCount <= 0) {
|
|
243
|
+
throw new NotFoundError('No records found matching update query');
|
|
244
|
+
}
|
|
245
|
+
await this.onAfterUpdate(userContext, clone);
|
|
246
|
+
const updatedEntities = await this.collection.find(query).toArray();
|
|
247
|
+
return this.transformList(updatedEntities);
|
|
248
|
+
}
|
|
249
|
+
async deleteById(userContext, id) {
|
|
250
|
+
if (!entityUtils.isValidObjectId(id)) {
|
|
251
|
+
throw new BadRequestError('id is not a valid ObjectId');
|
|
252
|
+
}
|
|
253
|
+
const baseQuery = { _id: new ObjectId(id) };
|
|
254
|
+
const query = this.prepareQuery(userContext, baseQuery);
|
|
255
|
+
await this.onBeforeDelete(userContext, query);
|
|
256
|
+
const deleteResult = await this.collection.deleteOne(query);
|
|
257
|
+
if (deleteResult.deletedCount <= 0) {
|
|
258
|
+
throw new IdNotFoundError();
|
|
259
|
+
}
|
|
260
|
+
await this.onAfterDelete(userContext, query);
|
|
261
|
+
return deleteResult;
|
|
262
|
+
}
|
|
263
|
+
async deleteMany(userContext, queryObject) {
|
|
264
|
+
const query = this.prepareQuery(userContext, queryObject);
|
|
265
|
+
await this.onBeforeDelete(userContext, query);
|
|
266
|
+
const deleteResult = await this.collection.deleteMany(query);
|
|
267
|
+
await this.onAfterDelete(userContext, query);
|
|
268
|
+
return deleteResult;
|
|
269
|
+
}
|
|
270
|
+
async find(userContext, mongoQueryObject, options) {
|
|
271
|
+
const query = this.prepareQuery(userContext, mongoQueryObject);
|
|
272
|
+
const cursor = this.collection.find(query, options);
|
|
273
|
+
const entities = await cursor.toArray();
|
|
274
|
+
return this.transformList(entities);
|
|
275
|
+
}
|
|
276
|
+
async findOne(userContext, mongoQueryObject, options) {
|
|
277
|
+
const query = this.prepareQuery(userContext, mongoQueryObject);
|
|
278
|
+
const entity = await this.collection.findOne(query, options);
|
|
279
|
+
return this.transformSingle(entity);
|
|
280
|
+
}
|
|
281
|
+
auditForCreate(userContext, doc) {
|
|
282
|
+
const now = moment().utc().toDate();
|
|
283
|
+
const userId = userContext.user?._id?.toString() ?? 'system';
|
|
284
|
+
doc._created = now;
|
|
285
|
+
doc._createdBy = userId;
|
|
286
|
+
doc._updated = now;
|
|
287
|
+
doc._updatedBy = userId;
|
|
288
|
+
}
|
|
289
|
+
auditForUpdate(userContext, doc) {
|
|
290
|
+
const userId = userContext.user?._id?.toString() ?? 'system';
|
|
291
|
+
doc._updated = moment().utc().toDate();
|
|
292
|
+
doc._updatedBy = userId;
|
|
293
|
+
}
|
|
294
|
+
async onBeforeCreate(userContext, entities) {
|
|
295
|
+
const preparedEntities = await this.preparePayload(userContext, entities, true);
|
|
296
|
+
return Promise.resolve(preparedEntities);
|
|
297
|
+
}
|
|
298
|
+
async onAfterCreate(userContext, entities) {
|
|
299
|
+
return Promise.resolve(entities);
|
|
300
|
+
}
|
|
301
|
+
async onBeforeUpdate(userContext, entities) {
|
|
302
|
+
const preparedEntities = await this.preparePayload(userContext, entities, false);
|
|
303
|
+
return Promise.resolve(preparedEntities);
|
|
304
|
+
}
|
|
305
|
+
onAfterUpdate(userContext, entities) {
|
|
306
|
+
return Promise.resolve(entities);
|
|
307
|
+
}
|
|
308
|
+
onBeforeDelete(userContext, queryObject) {
|
|
309
|
+
return Promise.resolve(queryObject);
|
|
310
|
+
}
|
|
311
|
+
onAfterDelete(userContext, queryObject) {
|
|
312
|
+
return Promise.resolve(queryObject);
|
|
313
|
+
}
|
|
314
|
+
transformList(list) {
|
|
315
|
+
if (!list)
|
|
316
|
+
return [];
|
|
317
|
+
return list.map(item => this.transformSingle(item));
|
|
318
|
+
}
|
|
319
|
+
transformSingle(single) {
|
|
320
|
+
if (!single)
|
|
321
|
+
return single;
|
|
322
|
+
if (!this.modelSpec?.fullSchema) {
|
|
323
|
+
throw new ServerError(`Cannot transform entity: No model specification with schema provided for ${this.pluralResourceName}`);
|
|
324
|
+
}
|
|
325
|
+
const transformedEntity = dbUtils.convertObjectIdsToStrings(single, this.modelSpec.fullSchema);
|
|
326
|
+
return transformedEntity;
|
|
327
|
+
}
|
|
328
|
+
stripSenderProvidedSystemProperties(doc) {
|
|
329
|
+
if (doc._created) {
|
|
330
|
+
delete doc._created;
|
|
331
|
+
}
|
|
332
|
+
if (doc._createdBy) {
|
|
333
|
+
delete doc._createdBy;
|
|
334
|
+
}
|
|
335
|
+
if (doc._updated) {
|
|
336
|
+
delete doc._updated;
|
|
337
|
+
}
|
|
338
|
+
if (doc._updatedBy) {
|
|
339
|
+
delete doc._updatedBy;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async preparePayload(userContext, entity, isCreate = false) {
|
|
343
|
+
let result;
|
|
344
|
+
if (Array.isArray(entity)) {
|
|
345
|
+
result = await Promise.all(entity.map(item => this.prepareEntity(userContext, item, isCreate)));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
result = await this.prepareEntity(userContext, entity, isCreate);
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
async prepareEntity(userContext, entity, isCreate) {
|
|
353
|
+
const preparedEntity = _.clone(entity);
|
|
354
|
+
this.stripSenderProvidedSystemProperties(preparedEntity);
|
|
355
|
+
if (this.modelSpec?.isAuditable) {
|
|
356
|
+
if (isCreate) {
|
|
357
|
+
this.auditForCreate(userContext, preparedEntity);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
this.auditForUpdate(userContext, preparedEntity);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
let cleanedEntity = preparedEntity;
|
|
364
|
+
if (this.modelSpec) {
|
|
365
|
+
cleanedEntity = this.modelSpec.decode(preparedEntity);
|
|
366
|
+
}
|
|
367
|
+
if (!this.modelSpec?.fullSchema) {
|
|
368
|
+
throw new ServerError(`Cannot prepare entity: No model specification with schema provided for ${this.pluralResourceName}`);
|
|
369
|
+
}
|
|
370
|
+
return dbUtils.convertStringsToObjectIds(cleanedEntity, this.modelSpec.fullSchema);
|
|
371
|
+
}
|
|
372
|
+
prepareQuery(userContext, query) {
|
|
373
|
+
return query;
|
|
374
|
+
}
|
|
375
|
+
prepareQueryOptions(userContext, queryOptions) {
|
|
376
|
+
return queryOptions;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './auth.service.js';
|
|
2
|
+
export * from './email.service.js';
|
|
3
|
+
export * from './generic-api.service.js';
|
|
4
|
+
export * from './generic-api-service.interface.js';
|
|
5
|
+
export * from './jwt.service.js';
|
|
6
|
+
export * from './multi-tenant-api.service.js';
|
|
7
|
+
export * from './password-reset-token.service.js';
|
|
8
|
+
export * from './tenant-query-decorator.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './auth.service.js';
|
|
2
|
+
export * from './email.service.js';
|
|
3
|
+
export * from './generic-api.service.js';
|
|
4
|
+
export * from './generic-api-service.interface.js';
|
|
5
|
+
export * from './jwt.service.js';
|
|
6
|
+
export * from './multi-tenant-api.service.js';
|
|
7
|
+
export * from './password-reset-token.service.js';
|
|
8
|
+
export * from './tenant-query-decorator.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
export class JwtService {
|
|
3
|
+
static sign(payload, secret, options) {
|
|
4
|
+
return jwt.sign(payload, secret, options);
|
|
5
|
+
}
|
|
6
|
+
static verify(token, secret) {
|
|
7
|
+
if (!secret) {
|
|
8
|
+
throw new Error('JWT secret is required for verification');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const decoded = jwt.verify(token, secret);
|
|
12
|
+
return decoded;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Db } from 'mongodb';
|
|
2
|
+
import { IUserContext, IEntity, QueryOptions, IModelSpec } from '@loomcore/common/models';
|
|
3
|
+
import { GenericApiService } from './generic-api.service.js';
|
|
4
|
+
export declare class MultiTenantApiService<T extends IEntity> extends GenericApiService<T> {
|
|
5
|
+
private tenantDecorator;
|
|
6
|
+
constructor(db: Db, pluralResourceName: string, singularResourceName: string, modelSpec?: IModelSpec);
|
|
7
|
+
protected prepareQuery(userContext: IUserContext, query: any): any;
|
|
8
|
+
protected prepareQueryOptions(userContext: IUserContext, queryOptions: QueryOptions): QueryOptions;
|
|
9
|
+
protected prepareEntity(userContext: IUserContext, entity: T, isCreate: boolean): Promise<T | Partial<T>>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { GenericApiService } from './generic-api.service.js';
|
|
2
|
+
import { TenantQueryDecorator } from './tenant-query-decorator.js';
|
|
3
|
+
import { BadRequestError } from '../errors/bad-request.error.js';
|
|
4
|
+
export class MultiTenantApiService extends GenericApiService {
|
|
5
|
+
tenantDecorator;
|
|
6
|
+
constructor(db, pluralResourceName, singularResourceName, modelSpec) {
|
|
7
|
+
super(db, pluralResourceName, singularResourceName, modelSpec);
|
|
8
|
+
this.tenantDecorator = new TenantQueryDecorator();
|
|
9
|
+
}
|
|
10
|
+
prepareQuery(userContext, query) {
|
|
11
|
+
if (!userContext || !userContext._orgId) {
|
|
12
|
+
throw new BadRequestError('A valid userContext was not provided to MultiTenantApiService.prepareQuery');
|
|
13
|
+
}
|
|
14
|
+
return this.tenantDecorator.applyTenantToQuery(userContext, query, this.pluralResourceName);
|
|
15
|
+
}
|
|
16
|
+
prepareQueryOptions(userContext, queryOptions) {
|
|
17
|
+
if (!userContext || !userContext._orgId) {
|
|
18
|
+
throw new BadRequestError('A valid userContext was not provided to MultiTenantApiService.prepareQueryOptions');
|
|
19
|
+
}
|
|
20
|
+
return this.tenantDecorator.applyTenantToQueryOptions(userContext, queryOptions, this.pluralResourceName);
|
|
21
|
+
}
|
|
22
|
+
async prepareEntity(userContext, entity, isCreate) {
|
|
23
|
+
if (!userContext || !userContext._orgId) {
|
|
24
|
+
throw new BadRequestError('A valid userContext was not provided to MultiTenantApiService.prepareEntity');
|
|
25
|
+
}
|
|
26
|
+
const preparedEntity = await super.prepareEntity(userContext, entity, isCreate);
|
|
27
|
+
const orgIdField = this.tenantDecorator.getOrgIdField();
|
|
28
|
+
preparedEntity[orgIdField] = userContext._orgId;
|
|
29
|
+
return preparedEntity;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Db } from 'mongodb';
|
|
2
|
+
import { IPasswordResetToken } from '@loomcore/common/models';
|
|
3
|
+
import { GenericApiService } from './generic-api.service.js';
|
|
4
|
+
export declare class PasswordResetTokenService extends GenericApiService<IPasswordResetToken> {
|
|
5
|
+
constructor(db: Db);
|
|
6
|
+
createPasswordResetToken(email: string, expiresOn: number): Promise<IPasswordResetToken | null>;
|
|
7
|
+
getByEmail(email: string): Promise<IPasswordResetToken | null>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { EmptyUserContext, PasswordResetTokenSpec } from '@loomcore/common/models';
|
|
3
|
+
import { GenericApiService } from './generic-api.service.js';
|
|
4
|
+
export class PasswordResetTokenService extends GenericApiService {
|
|
5
|
+
constructor(db) {
|
|
6
|
+
super(db, 'passwordResetTokens', 'passwordResetToken', PasswordResetTokenSpec);
|
|
7
|
+
}
|
|
8
|
+
async createPasswordResetToken(email, expiresOn) {
|
|
9
|
+
await this.collection.deleteMany({ email });
|
|
10
|
+
const passwordResetToken = {
|
|
11
|
+
email,
|
|
12
|
+
token: crypto.randomBytes(40).toString('hex'),
|
|
13
|
+
expiresOn: expiresOn,
|
|
14
|
+
};
|
|
15
|
+
return super.create(EmptyUserContext, passwordResetToken);
|
|
16
|
+
}
|
|
17
|
+
async getByEmail(email) {
|
|
18
|
+
return await super.findOne(EmptyUserContext, { email });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { IUserContext, QueryOptions, IEntity } from '@loomcore/common/models';
|
|
2
|
+
export interface ITenantQueryOptions {
|
|
3
|
+
orgIdField?: string;
|
|
4
|
+
excludedCollections?: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_TENANT_OPTIONS: ITenantQueryOptions;
|
|
7
|
+
export declare class TenantQueryDecorator {
|
|
8
|
+
private options;
|
|
9
|
+
constructor(options?: Partial<ITenantQueryOptions>);
|
|
10
|
+
applyTenantToQuery(userContext: IUserContext, queryObject: any, collectionName: string): any;
|
|
11
|
+
applyTenantToQueryOptions(userContext: IUserContext, queryOptions: QueryOptions, collectionName: string): QueryOptions;
|
|
12
|
+
applyTenantToEntity<T extends IEntity>(userContext: IUserContext, entity: T, collectionName: string): T;
|
|
13
|
+
getOrgIdField(): string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { QueryOptions } from '@loomcore/common/models';
|
|
2
|
+
import { ServerError } from '../errors/index.js';
|
|
3
|
+
export const DEFAULT_TENANT_OPTIONS = {
|
|
4
|
+
orgIdField: '_orgId',
|
|
5
|
+
excludedCollections: []
|
|
6
|
+
};
|
|
7
|
+
export class TenantQueryDecorator {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = { ...DEFAULT_TENANT_OPTIONS, ...options };
|
|
11
|
+
}
|
|
12
|
+
applyTenantToQuery(userContext, queryObject, collectionName) {
|
|
13
|
+
let result = queryObject;
|
|
14
|
+
const shouldApplyTenantFilter = !this.options.excludedCollections?.includes(collectionName) &&
|
|
15
|
+
userContext?._orgId;
|
|
16
|
+
if (shouldApplyTenantFilter) {
|
|
17
|
+
const orgIdField = this.options.orgIdField || '_orgId';
|
|
18
|
+
result = { ...queryObject, [orgIdField]: userContext._orgId };
|
|
19
|
+
}
|
|
20
|
+
else if (!userContext?._orgId) {
|
|
21
|
+
if (!this.options.excludedCollections?.includes(collectionName)) {
|
|
22
|
+
throw new ServerError('No _orgId found in userContext');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
applyTenantToQueryOptions(userContext, queryOptions, collectionName) {
|
|
28
|
+
let result = queryOptions;
|
|
29
|
+
const shouldApplyTenantFilter = !this.options.excludedCollections?.includes(collectionName) &&
|
|
30
|
+
userContext?._orgId;
|
|
31
|
+
if (shouldApplyTenantFilter) {
|
|
32
|
+
const modifiedQueryOptions = new QueryOptions(queryOptions);
|
|
33
|
+
if (!modifiedQueryOptions.filters) {
|
|
34
|
+
modifiedQueryOptions.filters = {};
|
|
35
|
+
}
|
|
36
|
+
const orgIdField = this.options.orgIdField || '_orgId';
|
|
37
|
+
modifiedQueryOptions.filters[orgIdField] = { eq: userContext._orgId };
|
|
38
|
+
result = modifiedQueryOptions;
|
|
39
|
+
}
|
|
40
|
+
else if (!userContext?._orgId) {
|
|
41
|
+
throw new ServerError('No _orgId found in userContext');
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
applyTenantToEntity(userContext, entity, collectionName) {
|
|
46
|
+
let result = entity;
|
|
47
|
+
const shouldApplyTenantFilter = !this.options.excludedCollections?.includes(collectionName) &&
|
|
48
|
+
userContext?._orgId;
|
|
49
|
+
if (shouldApplyTenantFilter) {
|
|
50
|
+
const orgIdField = this.options.orgIdField || '_orgId';
|
|
51
|
+
result = {
|
|
52
|
+
...entity,
|
|
53
|
+
[orgIdField]: userContext._orgId
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
else if (!userContext?._orgId) {
|
|
57
|
+
if (!this.options.excludedCollections?.includes(collectionName)) {
|
|
58
|
+
throw new ServerError('No _orgId found in userContext');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
getOrgIdField() {
|
|
64
|
+
return this.options.orgIdField || '_orgId';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function getSingleLineAddress(address) {
|
|
2
|
+
const street = address.address1;
|
|
3
|
+
let singleLineAddress = `${street}`;
|
|
4
|
+
if (address.address2) {
|
|
5
|
+
singleLineAddress += ` ${address.address2}`;
|
|
6
|
+
}
|
|
7
|
+
if (address.address3) {
|
|
8
|
+
singleLineAddress += ` ${address.address3}`;
|
|
9
|
+
}
|
|
10
|
+
singleLineAddress += `, ${address.city}, ${address.state} ${address.postalCode}`;
|
|
11
|
+
return singleLineAddress;
|
|
12
|
+
}
|
|
13
|
+
export const addressUtils = {
|
|
14
|
+
getSingleLineAddress
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Request, Response } from 'express';
|
|
2
|
+
import { TSchema } from '@sinclair/typebox';
|
|
3
|
+
import { IError, QueryOptions, IPagedResult, IModelSpec } from '@loomcore/common/models';
|
|
4
|
+
export interface IApiResponseOptions<T> {
|
|
5
|
+
messages?: string[];
|
|
6
|
+
errors?: IError[];
|
|
7
|
+
data?: T;
|
|
8
|
+
}
|
|
9
|
+
declare function apiResponse<T>(response: Response, status: number, options?: IApiResponseOptions<T>, modelSpec?: IModelSpec, publicSchema?: TSchema): Response;
|
|
10
|
+
declare function getQueryOptionsFromRequest(request: Request): QueryOptions;
|
|
11
|
+
declare function getPagedResult<T>(entities: T[], totalRows: number, queryOptions: QueryOptions): IPagedResult<T>;
|
|
12
|
+
export declare const apiUtils: {
|
|
13
|
+
apiResponse: typeof apiResponse;
|
|
14
|
+
getQueryOptionsFromRequest: typeof getQueryOptionsFromRequest;
|
|
15
|
+
getPagedResult: typeof getPagedResult;
|
|
16
|
+
};
|
|
17
|
+
export {};
|