@loomcore/api 0.2.69 → 0.2.71
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/LICENSE +201 -201
- package/README.md +17 -0
- package/dist/__tests__/common-test.utils.d.ts +2 -0
- package/dist/__tests__/common-test.utils.js +9 -0
- package/dist/__tests__/mongo-db.test-database.js +1 -1
- package/dist/__tests__/postgres.test-database.js +0 -1
- package/dist/controllers/organizations.controller.d.ts +1 -0
- package/dist/controllers/organizations.controller.js +9 -0
- package/dist/databases/models/classified-filter-key.d.ts +8 -0
- package/dist/databases/models/classified-filter-key.js +1 -0
- package/dist/databases/models/index.d.ts +2 -0
- package/dist/databases/models/index.js +2 -0
- package/dist/databases/models/join-filter.interface.d.ts +8 -0
- package/dist/databases/models/join-filter.interface.js +1 -0
- package/dist/databases/mongo-db/queries/mongo-get-by-id.query.js +11 -4
- package/dist/databases/mongo-db/queries/mongo-get.query.js +11 -3
- package/dist/databases/mongo-db/utils/build-join-match.util.d.ts +4 -0
- package/dist/databases/mongo-db/utils/build-join-match.util.js +192 -0
- package/dist/databases/mongo-db/utils/convert-operations-to-pipeline.util.js +15 -5
- package/dist/databases/operations/index.d.ts +1 -0
- package/dist/databases/operations/index.js +1 -0
- package/dist/databases/operations/join-operation.d.ts +4 -0
- package/dist/databases/operations/join-operation.js +1 -0
- package/dist/databases/postgres/commands/postgres-batch-update.command.js +2 -3
- package/dist/databases/postgres/commands/postgres-delete-many.command.js +1 -1
- package/dist/databases/postgres/commands/postgres-full-update-by-id.command.js +1 -3
- package/dist/databases/postgres/commands/postgres-partial-update-by-id.command.js +1 -3
- package/dist/databases/postgres/commands/postgres-update.command.js +4 -7
- package/dist/databases/postgres/queries/postgres-find-one.query.js +2 -2
- package/dist/databases/postgres/queries/postgres-find.query.js +2 -2
- package/dist/databases/postgres/queries/postgres-get-all.query.js +1 -3
- package/dist/databases/postgres/queries/postgres-get-by-id.query.js +2 -5
- package/dist/databases/postgres/queries/postgres-get.query.js +6 -11
- package/dist/databases/postgres/utils/build-count-query.d.ts +6 -1
- package/dist/databases/postgres/utils/build-count-query.js +11 -3
- package/dist/databases/postgres/utils/build-join-clauses.d.ts +2 -4
- package/dist/databases/postgres/utils/build-join-clauses.js +28 -35
- package/dist/databases/postgres/utils/build-join-where.d.ts +9 -0
- package/dist/databases/postgres/utils/build-join-where.js +136 -0
- package/dist/databases/postgres/utils/build-order-by-clause.d.ts +1 -4
- package/dist/databases/postgres/utils/build-order-by-clause.js +2 -3
- package/dist/databases/postgres/utils/build-select-clause.js +2 -4
- package/dist/databases/postgres/utils/build-where-clause.d.ts +2 -1
- package/dist/databases/postgres/utils/build-where-clause.js +18 -70
- package/dist/databases/utils/is-join-operation.util.d.ts +3 -0
- package/dist/databases/utils/is-join-operation.util.js +6 -0
- package/dist/databases/utils/resolve-join-filter.d.ts +13 -0
- package/dist/databases/utils/resolve-join-filter.js +75 -0
- package/dist/services/organization.service.d.ts +17 -1
- package/dist/services/organization.service.js +205 -3
- package/dist/utils/password.utils.d.ts +2 -0
- package/dist/utils/password.utils.js +4 -0
- package/dist/utils/string.utils.d.ts +2 -0
- package/dist/utils/string.utils.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFilterValue } from '../../utils/nullish-filter-value.js';
|
|
2
|
+
import { findJoinOperation, parentAliasOf, } from '../../utils/resolve-join-filter.js';
|
|
3
|
+
import { collectOuterOneToOneAliases } from './build-join-clauses.js';
|
|
4
|
+
import { toPostgresStoreName, toSnakeCase } from './convert-keys.util.js';
|
|
5
|
+
export function toSqlColumnName(column) {
|
|
6
|
+
if (column.startsWith('_')) {
|
|
7
|
+
return column;
|
|
8
|
+
}
|
|
9
|
+
if (column === 'id') {
|
|
10
|
+
return '_id';
|
|
11
|
+
}
|
|
12
|
+
return toSnakeCase(column);
|
|
13
|
+
}
|
|
14
|
+
function quoteIdent(value) {
|
|
15
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
16
|
+
}
|
|
17
|
+
export function appendFilterConditions(qualifiedKey, value, conditions, values, paramIndex) {
|
|
18
|
+
if (value.eq !== undefined) {
|
|
19
|
+
const comparison = readFilterValue(value.eq);
|
|
20
|
+
if (comparison.kind === 'value') {
|
|
21
|
+
conditions.push(`(${qualifiedKey} = $${paramIndex.current} OR (${qualifiedKey} IS NULL AND $${paramIndex.current} IS NULL))`);
|
|
22
|
+
values.push(comparison.value);
|
|
23
|
+
paramIndex.current++;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
conditions.push(`${qualifiedKey} IS NULL`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (value.ne !== undefined) {
|
|
30
|
+
const comparison = readFilterValue(value.ne);
|
|
31
|
+
if (comparison.kind === 'value') {
|
|
32
|
+
conditions.push(`(${qualifiedKey} != $${paramIndex.current} OR (${qualifiedKey} IS NOT NULL AND $${paramIndex.current} IS NULL) OR (${qualifiedKey} IS NULL AND $${paramIndex.current} IS NOT NULL))`);
|
|
33
|
+
values.push(comparison.value);
|
|
34
|
+
paramIndex.current++;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
conditions.push(`${qualifiedKey} IS NOT NULL`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (value.in !== undefined && Array.isArray(value.in)) {
|
|
41
|
+
const placeholders = value.in.map(() => `$${paramIndex.current++}`).join(', ');
|
|
42
|
+
conditions.push(`${qualifiedKey} IN (${placeholders})`);
|
|
43
|
+
values.push(...value.in);
|
|
44
|
+
}
|
|
45
|
+
if (value.gte !== undefined) {
|
|
46
|
+
conditions.push(`${qualifiedKey} >= $${paramIndex.current}`);
|
|
47
|
+
values.push(value.gte);
|
|
48
|
+
paramIndex.current++;
|
|
49
|
+
}
|
|
50
|
+
if (value.lte !== undefined) {
|
|
51
|
+
conditions.push(`${qualifiedKey} <= $${paramIndex.current}`);
|
|
52
|
+
values.push(value.lte);
|
|
53
|
+
paramIndex.current++;
|
|
54
|
+
}
|
|
55
|
+
if (value.gt !== undefined) {
|
|
56
|
+
conditions.push(`${qualifiedKey} > $${paramIndex.current}`);
|
|
57
|
+
values.push(value.gt);
|
|
58
|
+
paramIndex.current++;
|
|
59
|
+
}
|
|
60
|
+
if (value.lt !== undefined) {
|
|
61
|
+
conditions.push(`${qualifiedKey} < $${paramIndex.current}`);
|
|
62
|
+
values.push(value.lt);
|
|
63
|
+
paramIndex.current++;
|
|
64
|
+
}
|
|
65
|
+
if (value.contains !== undefined) {
|
|
66
|
+
conditions.push(`LOWER(${qualifiedKey}) LIKE LOWER($${paramIndex.current})`);
|
|
67
|
+
values.push(`%${value.contains}%`);
|
|
68
|
+
paramIndex.current++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function qualifyLocalField(localField, mainTableName) {
|
|
72
|
+
const dot = localField.indexOf('.');
|
|
73
|
+
if (dot === -1) {
|
|
74
|
+
return mainTableName ? `${quoteIdent(mainTableName)}.${quoteIdent(localField)}` : quoteIdent(localField);
|
|
75
|
+
}
|
|
76
|
+
const alias = localField.slice(0, dot);
|
|
77
|
+
const column = localField.slice(dot + 1);
|
|
78
|
+
return `${quoteIdent(alias)}.${quoteIdent(column)}`;
|
|
79
|
+
}
|
|
80
|
+
function ancestorChain(target, operations, mainTableName, outerAliases) {
|
|
81
|
+
const chain = [target];
|
|
82
|
+
let current = target;
|
|
83
|
+
const seen = new Set([target.as]);
|
|
84
|
+
while (true) {
|
|
85
|
+
const parentAlias = parentAliasOf(current.localField);
|
|
86
|
+
if (parentAlias === null || parentAlias === mainTableName || outerAliases.has(parentAlias)) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(parentAlias)) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
const parent = findJoinOperation(operations, parentAlias);
|
|
93
|
+
if (!parent) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
seen.add(parent.as);
|
|
97
|
+
chain.unshift(parent);
|
|
98
|
+
current = parent;
|
|
99
|
+
}
|
|
100
|
+
return chain;
|
|
101
|
+
}
|
|
102
|
+
function existsSql(target, qualifiedColumn, filter, operations, mainTableName, outerAliases, values, paramIndex) {
|
|
103
|
+
const chain = ancestorChain(target, operations, mainTableName, outerAliases);
|
|
104
|
+
const first = chain[0];
|
|
105
|
+
let sql = `EXISTS (SELECT 1 FROM ${quoteIdent(toPostgresStoreName(first.from))} AS ${quoteIdent(first.as)}`;
|
|
106
|
+
for (let index = 1; index < chain.length; index++) {
|
|
107
|
+
const child = chain[index];
|
|
108
|
+
const fromTable = quoteIdent(toPostgresStoreName(child.from));
|
|
109
|
+
const childAlias = quoteIdent(child.as);
|
|
110
|
+
const onLeft = qualifyLocalField(child.localField, mainTableName);
|
|
111
|
+
sql += ` INNER JOIN ${fromTable} AS ${childAlias} ON ${onLeft} = ${childAlias}.${quoteIdent(child.foreignField)}`;
|
|
112
|
+
}
|
|
113
|
+
const conditions = [
|
|
114
|
+
`${qualifyLocalField(first.localField, mainTableName)} = ${quoteIdent(first.as)}.${quoteIdent(first.foreignField)}`,
|
|
115
|
+
];
|
|
116
|
+
appendFilterConditions(qualifiedColumn, filter, conditions, values, paramIndex);
|
|
117
|
+
sql += ` WHERE ${conditions.join(' AND ')})`;
|
|
118
|
+
return sql;
|
|
119
|
+
}
|
|
120
|
+
export function joinFilterSql(joinFilter, operations, mainTableName, values, paramIndex) {
|
|
121
|
+
const target = findJoinOperation(operations, joinFilter.aliases[joinFilter.aliases.length - 1]);
|
|
122
|
+
if (!target) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const outerAliases = collectOuterOneToOneAliases(operations, mainTableName);
|
|
126
|
+
const qualified = `${quoteIdent(target.as)}.${quoteIdent(toSqlColumnName(joinFilter.column))}`;
|
|
127
|
+
if (!joinFilter.crossesMany && outerAliases.has(target.as)) {
|
|
128
|
+
const conditions = [];
|
|
129
|
+
appendFilterConditions(qualified, joinFilter.filter, conditions, values, paramIndex);
|
|
130
|
+
if (joinFilter.filter.ne !== undefined) {
|
|
131
|
+
conditions.push(`${quoteIdent(target.as)}."_id" IS NOT NULL`);
|
|
132
|
+
}
|
|
133
|
+
return conditions.join(' AND ');
|
|
134
|
+
}
|
|
135
|
+
return existsSql(target, qualified, joinFilter.filter, operations, mainTableName, outerAliases, values, paramIndex);
|
|
136
|
+
}
|
|
@@ -1,5 +1,2 @@
|
|
|
1
1
|
import { IQueryOptions } from '@loomcore/common/models';
|
|
2
|
-
export
|
|
3
|
-
tablePrefix?: string;
|
|
4
|
-
}
|
|
5
|
-
export declare function buildOrderByClause(queryObject: IQueryOptions, options?: BuildOrderByClauseOptions): string;
|
|
2
|
+
export declare function buildOrderByClause(queryObject: IQueryOptions, tablePrefix: string): string;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
export function buildOrderByClause(queryObject,
|
|
1
|
+
export function buildOrderByClause(queryObject, tablePrefix) {
|
|
2
2
|
if (!queryObject.orderBy) {
|
|
3
3
|
return '';
|
|
4
4
|
}
|
|
5
5
|
const orderBy = queryObject.orderBy;
|
|
6
6
|
const sortDirection = queryObject.sortDirection || 'asc';
|
|
7
|
-
|
|
8
|
-
return `ORDER BY ${qualified} ${sortDirection}`;
|
|
7
|
+
return `ORDER BY "${tablePrefix}"."${orderBy}" ${sortDirection}`;
|
|
9
8
|
}
|
|
@@ -2,6 +2,7 @@ import { LeftJoin } from '../../operations/left-join.operation.js';
|
|
|
2
2
|
import { InnerJoin } from '../../operations/inner-join.operation.js';
|
|
3
3
|
import { LeftJoinMany } from '../../operations/left-join-many.operation.js';
|
|
4
4
|
import { toPostgresStoreName } from './convert-keys.util.js';
|
|
5
|
+
import { isJoinOperation } from '../../utils/is-join-operation.util.js';
|
|
5
6
|
const PK = '_id';
|
|
6
7
|
export async function getTableColumns(client, tableName) {
|
|
7
8
|
const storeName = toPostgresStoreName(tableName);
|
|
@@ -172,10 +173,7 @@ async function buildJoinDataObject(client, operations, mainTableName) {
|
|
|
172
173
|
return jsonbBuildObject(entries);
|
|
173
174
|
}
|
|
174
175
|
export async function buildSelectClause(client, mainTableName, operations) {
|
|
175
|
-
const
|
|
176
|
-
const innerJoinOperations = operations.filter(op => op instanceof InnerJoin);
|
|
177
|
-
const leftJoinManyOperations = operations.filter(op => op instanceof LeftJoinMany);
|
|
178
|
-
const hasJoins = leftJoinOperations.length > 0 || innerJoinOperations.length > 0 || leftJoinManyOperations.length > 0;
|
|
176
|
+
const hasJoins = operations.some(isJoinOperation);
|
|
179
177
|
if (!hasJoins) {
|
|
180
178
|
return '*';
|
|
181
179
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IQueryOptions } from '@loomcore/common/models';
|
|
2
|
-
|
|
2
|
+
import type { Operation } from '../../operations/operation.js';
|
|
3
|
+
export declare function buildWhereClause(queryObject: IQueryOptions, tablePrefix: string, values?: any[], operations?: Operation[]): {
|
|
3
4
|
whereClause: string;
|
|
4
5
|
values: any[];
|
|
5
6
|
};
|
|
@@ -1,76 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
export function buildWhereClause(queryObject, values = [],
|
|
4
|
-
const
|
|
1
|
+
import { splitFiltersByType } from '../../utils/resolve-join-filter.js';
|
|
2
|
+
import { appendFilterConditions, joinFilterSql, toSqlColumnName } from './build-join-where.js';
|
|
3
|
+
export function buildWhereClause(queryObject, tablePrefix, values = [], operations) {
|
|
4
|
+
const useJoinFilters = operations !== undefined && operations.length > 0;
|
|
5
|
+
const { rootOptions, joinFilters } = useJoinFilters
|
|
6
|
+
? splitFiltersByType(queryObject, operations, tablePrefix)
|
|
7
|
+
: { rootOptions: queryObject, joinFilters: [] };
|
|
8
|
+
const filters = rootOptions.filters || {};
|
|
5
9
|
const conditions = [];
|
|
6
|
-
|
|
7
|
-
const qualifyColumn = (columnName) => {
|
|
8
|
-
return tablePrefix ? `"${tablePrefix}"."${columnName}"` : `"${columnName}"`;
|
|
9
|
-
};
|
|
10
|
+
const paramIndex = { current: values.length + 1 };
|
|
10
11
|
for (const [key, value] of Object.entries(filters)) {
|
|
11
12
|
if (value) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
const qualifiedKey = qualifyColumn(snakeKey);
|
|
23
|
-
if (value.eq !== undefined) {
|
|
24
|
-
const comparison = readFilterValue(value.eq);
|
|
25
|
-
if (comparison.kind === 'value') {
|
|
26
|
-
conditions.push(`(${qualifiedKey} = $${paramIndex} OR (${qualifiedKey} IS NULL AND $${paramIndex} IS NULL))`);
|
|
27
|
-
values.push(comparison.value);
|
|
28
|
-
paramIndex++;
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
conditions.push(`${qualifiedKey} IS NULL`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (value.ne !== undefined) {
|
|
35
|
-
const comparison = readFilterValue(value.ne);
|
|
36
|
-
if (comparison.kind === 'value') {
|
|
37
|
-
conditions.push(`(${qualifiedKey} != $${paramIndex} OR (${qualifiedKey} IS NOT NULL AND $${paramIndex} IS NULL) OR (${qualifiedKey} IS NULL AND $${paramIndex} IS NOT NULL))`);
|
|
38
|
-
values.push(comparison.value);
|
|
39
|
-
paramIndex++;
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
conditions.push(`${qualifiedKey} IS NOT NULL`);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
if (value.in !== undefined && Array.isArray(value.in)) {
|
|
46
|
-
const placeholders = value.in.map(() => `$${paramIndex++}`).join(', ');
|
|
47
|
-
conditions.push(`${qualifiedKey} IN (${placeholders})`);
|
|
48
|
-
values.push(...value.in);
|
|
49
|
-
}
|
|
50
|
-
if (value.gte !== undefined) {
|
|
51
|
-
conditions.push(`${qualifiedKey} >= $${paramIndex}`);
|
|
52
|
-
values.push(value.gte);
|
|
53
|
-
paramIndex++;
|
|
54
|
-
}
|
|
55
|
-
if (value.lte !== undefined) {
|
|
56
|
-
conditions.push(`${qualifiedKey} <= $${paramIndex}`);
|
|
57
|
-
values.push(value.lte);
|
|
58
|
-
paramIndex++;
|
|
59
|
-
}
|
|
60
|
-
if (value.gt !== undefined) {
|
|
61
|
-
conditions.push(`${qualifiedKey} > $${paramIndex}`);
|
|
62
|
-
values.push(value.gt);
|
|
63
|
-
paramIndex++;
|
|
64
|
-
}
|
|
65
|
-
if (value.lt !== undefined) {
|
|
66
|
-
conditions.push(`${qualifiedKey} < $${paramIndex}`);
|
|
67
|
-
values.push(value.lt);
|
|
68
|
-
paramIndex++;
|
|
69
|
-
}
|
|
70
|
-
if (value.contains !== undefined) {
|
|
71
|
-
conditions.push(`LOWER(${qualifiedKey}) LIKE LOWER($${paramIndex})`);
|
|
72
|
-
values.push(`%${value.contains}%`);
|
|
73
|
-
paramIndex++;
|
|
13
|
+
const qualifiedKey = `"${tablePrefix}"."${toSqlColumnName(key)}"`;
|
|
14
|
+
appendFilterConditions(qualifiedKey, value, conditions, values, paramIndex);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (useJoinFilters) {
|
|
18
|
+
for (const joinFilter of joinFilters) {
|
|
19
|
+
const predicate = joinFilterSql(joinFilter, operations, tablePrefix, values, paramIndex);
|
|
20
|
+
if (predicate) {
|
|
21
|
+
conditions.push(predicate);
|
|
74
22
|
}
|
|
75
23
|
}
|
|
76
24
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { InnerJoin } from "../operations/inner-join.operation.js";
|
|
2
|
+
import { LeftJoin } from "../operations/left-join.operation.js";
|
|
3
|
+
import { LeftJoinMany } from "../operations/left-join-many.operation.js";
|
|
4
|
+
export function isJoinOperation(operation) {
|
|
5
|
+
return operation instanceof LeftJoin || operation instanceof InnerJoin || operation instanceof LeftJoinMany;
|
|
6
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IQueryOptions } from '@loomcore/common/models';
|
|
2
|
+
import type { ClassifiedFilterKey } from '../models/classified-filter-key.js';
|
|
3
|
+
import type { Operation } from '../operations/operation.js';
|
|
4
|
+
import type { JoinOperation } from '../operations/join-operation.js';
|
|
5
|
+
import { JoinFilter } from '../models/join-filter.interface.js';
|
|
6
|
+
export declare function parentAliasOf(localField: string): string | null;
|
|
7
|
+
export declare function findJoinOperation(operations: Operation[], alias: string): JoinOperation | undefined;
|
|
8
|
+
export declare function logicalParentAlias(operation: JoinOperation, operations: Operation[], mainTableName: string): string | null;
|
|
9
|
+
export declare function classifyFilterKey(key: string, operations: Operation[], mainTableName: string): ClassifiedFilterKey;
|
|
10
|
+
export declare function splitFiltersByType(queryOptions: IQueryOptions, operations: Operation[], mainTableName: string): {
|
|
11
|
+
rootOptions: IQueryOptions;
|
|
12
|
+
joinFilters: JoinFilter[];
|
|
13
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BadRequestError } from '../../errors/index.js';
|
|
2
|
+
import { LeftJoinMany } from '../operations/left-join-many.operation.js';
|
|
3
|
+
import { isJoinOperation } from './is-join-operation.util.js';
|
|
4
|
+
export function parentAliasOf(localField) {
|
|
5
|
+
const dot = localField.indexOf('.');
|
|
6
|
+
if (dot === -1) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return localField.slice(0, dot);
|
|
10
|
+
}
|
|
11
|
+
export function findJoinOperation(operations, alias) {
|
|
12
|
+
return operations.find((operation) => isJoinOperation(operation) && operation.as === alias);
|
|
13
|
+
}
|
|
14
|
+
export function logicalParentAlias(operation, operations, mainTableName) {
|
|
15
|
+
const rawParent = parentAliasOf(operation.localField);
|
|
16
|
+
if (rawParent === null || rawParent === mainTableName) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const parent = findJoinOperation(operations, rawParent);
|
|
20
|
+
return parent ? parent.as : null;
|
|
21
|
+
}
|
|
22
|
+
export function classifyFilterKey(key, operations, mainTableName) {
|
|
23
|
+
if (!key.includes('.') || operations.length === 0) {
|
|
24
|
+
return { type: 'root' };
|
|
25
|
+
}
|
|
26
|
+
const parts = key.split('.');
|
|
27
|
+
if (parts.some(part => part.length === 0)) {
|
|
28
|
+
return { type: 'root' };
|
|
29
|
+
}
|
|
30
|
+
if (!findJoinOperation(operations, parts[0])) {
|
|
31
|
+
return { type: 'root' };
|
|
32
|
+
}
|
|
33
|
+
const column = parts[parts.length - 1];
|
|
34
|
+
const aliases = parts.slice(0, -1);
|
|
35
|
+
for (let index = 0; index < aliases.length; index++) {
|
|
36
|
+
const alias = aliases[index];
|
|
37
|
+
const operation = findJoinOperation(operations, alias);
|
|
38
|
+
if (!operation) {
|
|
39
|
+
throw new BadRequestError(`Invalid join filter "${key}": "${alias}" is not a joined table`);
|
|
40
|
+
}
|
|
41
|
+
const expectedParent = index === 0 ? null : aliases[index - 1];
|
|
42
|
+
const actualParent = logicalParentAlias(operation, operations, mainTableName);
|
|
43
|
+
if (actualParent !== expectedParent) {
|
|
44
|
+
const where = actualParent ? `"${actualParent}"` : 'the root collection';
|
|
45
|
+
throw new BadRequestError(`Invalid join filter "${key}": "${alias}" is nested under ${where}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const crossesMany = aliases.some(alias => findJoinOperation(operations, alias) instanceof LeftJoinMany);
|
|
49
|
+
return { type: 'join', aliases, column, crossesMany };
|
|
50
|
+
}
|
|
51
|
+
export function splitFiltersByType(queryOptions, operations, mainTableName) {
|
|
52
|
+
const rootFilters = {};
|
|
53
|
+
const joinFilters = [];
|
|
54
|
+
for (const [key, filter] of Object.entries(queryOptions.filters ?? {})) {
|
|
55
|
+
if (!filter) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const classified = classifyFilterKey(key, operations, mainTableName);
|
|
59
|
+
if (classified.type === 'root') {
|
|
60
|
+
rootFilters[key] = filter;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
joinFilters.push({
|
|
64
|
+
key,
|
|
65
|
+
filter,
|
|
66
|
+
aliases: classified.aliases,
|
|
67
|
+
column: classified.column,
|
|
68
|
+
crossesMany: classified.crossesMany,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
rootOptions: { ...queryOptions, filters: rootFilters },
|
|
73
|
+
joinFilters,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import { type IOrganization, type IUserContext } from '@loomcore/common/models';
|
|
1
|
+
import { type IOrganization, type IUser, type IUserContext } from '@loomcore/common/models';
|
|
2
2
|
import type { AppIdType } from '@loomcore/common/types';
|
|
3
3
|
import type { IDatabase } from '../databases/models/index.js';
|
|
4
4
|
import { GenericApiService } from './generic-api-service/generic-api.service.js';
|
|
5
|
+
export interface ICreateOrganizationWithAdminInput {
|
|
6
|
+
name: string;
|
|
7
|
+
code: string;
|
|
8
|
+
adminEmail: string;
|
|
9
|
+
adminDisplayName?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ICreateOrganizationWithAdminResult {
|
|
12
|
+
organization: IOrganization;
|
|
13
|
+
adminUser: IUser;
|
|
14
|
+
}
|
|
15
|
+
export declare function buildOrganizationTenantDomain(code: string): string;
|
|
16
|
+
export declare function buildOrganizationClientBaseUrl(code: string): string;
|
|
5
17
|
export declare class OrganizationService extends GenericApiService<IOrganization> {
|
|
6
18
|
private organizationDomainService;
|
|
7
19
|
constructor(database: IDatabase);
|
|
@@ -10,4 +22,8 @@ export declare class OrganizationService extends GenericApiService<IOrganization
|
|
|
10
22
|
validateRepoAuthToken(userContext: IUserContext, orgCode: string, authToken: string): Promise<AppIdType | null>;
|
|
11
23
|
getMetaOrg(userContext: IUserContext): Promise<IOrganization | null>;
|
|
12
24
|
findByDomain(userContext: IUserContext, domain: string): Promise<IOrganization | null>;
|
|
25
|
+
createOrganizationWithAdmin(userContext: IUserContext, input: ICreateOrganizationWithAdminInput): Promise<ICreateOrganizationWithAdminResult>;
|
|
26
|
+
private parseCreateOrganizationWithAdminInput;
|
|
27
|
+
private assertCreateOrganizationWithAdminUniqueness;
|
|
28
|
+
private cleanupCreatedOrganizationWithAdmin;
|
|
13
29
|
}
|
|
@@ -1,7 +1,31 @@
|
|
|
1
|
-
import { OrganizationSpec, } from '@loomcore/common/models';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { getSystemUserContext, OrganizationSpec, } from '@loomcore/common/models';
|
|
2
|
+
import { config } from '../config/index.js';
|
|
3
|
+
import { BadRequestError, ServerError } from '../errors/index.js';
|
|
4
|
+
import { initiatePasswordReset } from '../utils/auth/initiate-password-reset.util.js';
|
|
5
|
+
import { logger } from '../utils/logger.utils.js';
|
|
6
|
+
import { passwordUtils } from '../utils/password.utils.js';
|
|
7
|
+
import { stringUtils } from '../utils/string.utils.js';
|
|
8
|
+
import { AuthorizationService } from './authorization.service.js';
|
|
9
|
+
import { FeatureService } from './feature.service.js';
|
|
4
10
|
import { GenericApiService } from './generic-api-service/generic-api.service.js';
|
|
11
|
+
import { OrganizationDomainService } from './organization-domain.service.js';
|
|
12
|
+
import { PasswordResetTokenService } from './password-reset-token.service.js';
|
|
13
|
+
import { RoleService } from './role.service.js';
|
|
14
|
+
import { UserRoleService } from './user-role.service.js';
|
|
15
|
+
import { UserService } from './user.service.js';
|
|
16
|
+
const ADMIN_ROLE_AND_FEATURE_NAME = 'admin';
|
|
17
|
+
const DEFAULT_ADMIN_DISPLAY_NAME = 'Admin User';
|
|
18
|
+
const DEFAULT_ORGANIZATION_STATUS = 1;
|
|
19
|
+
export function buildOrganizationTenantDomain(code) {
|
|
20
|
+
const hostName = config.network?.hostName;
|
|
21
|
+
if (!hostName) {
|
|
22
|
+
throw new ServerError('network.hostName is not configured');
|
|
23
|
+
}
|
|
24
|
+
return `${code}.${hostName}`;
|
|
25
|
+
}
|
|
26
|
+
export function buildOrganizationClientBaseUrl(code) {
|
|
27
|
+
return `https://${buildOrganizationTenantDomain(code)}`;
|
|
28
|
+
}
|
|
5
29
|
export class OrganizationService extends GenericApiService {
|
|
6
30
|
organizationDomainService;
|
|
7
31
|
constructor(database) {
|
|
@@ -54,4 +78,182 @@ export class OrganizationService extends GenericApiService {
|
|
|
54
78
|
filters: { _id: { eq: organizationDomain.organizationId } },
|
|
55
79
|
});
|
|
56
80
|
}
|
|
81
|
+
async createOrganizationWithAdmin(userContext, input) {
|
|
82
|
+
const parsed = this.parseCreateOrganizationWithAdminInput(input);
|
|
83
|
+
const domain = buildOrganizationTenantDomain(parsed.code);
|
|
84
|
+
await this.assertCreateOrganizationWithAdminUniqueness(userContext, parsed, domain);
|
|
85
|
+
const created = {};
|
|
86
|
+
const userService = new UserService(this.database);
|
|
87
|
+
const roleService = new RoleService(this.database);
|
|
88
|
+
const featureService = new FeatureService(this.database);
|
|
89
|
+
const userRoleService = new UserRoleService(this.database);
|
|
90
|
+
const authorizationService = new AuthorizationService(this.database);
|
|
91
|
+
try {
|
|
92
|
+
const organization = await super.create(userContext, {
|
|
93
|
+
name: parsed.name,
|
|
94
|
+
code: parsed.code,
|
|
95
|
+
status: DEFAULT_ORGANIZATION_STATUS,
|
|
96
|
+
isMetaOrg: false,
|
|
97
|
+
});
|
|
98
|
+
if (!organization?._id) {
|
|
99
|
+
throw new ServerError('Failed to create organization');
|
|
100
|
+
}
|
|
101
|
+
created.organizationId = organization._id;
|
|
102
|
+
const organizationDomain = await this.organizationDomainService.create(userContext, {
|
|
103
|
+
organizationId: organization._id,
|
|
104
|
+
domain,
|
|
105
|
+
});
|
|
106
|
+
if (!organizationDomain?._id) {
|
|
107
|
+
throw new ServerError('Failed to create organization domain');
|
|
108
|
+
}
|
|
109
|
+
created.domainId = organizationDomain._id;
|
|
110
|
+
const adminUser = await userService.create(userContext, {
|
|
111
|
+
_orgId: organization._id,
|
|
112
|
+
email: parsed.adminEmail,
|
|
113
|
+
displayName: parsed.adminDisplayName,
|
|
114
|
+
password: passwordUtils.generateRandomPassword(),
|
|
115
|
+
});
|
|
116
|
+
if (!adminUser?._id) {
|
|
117
|
+
throw new ServerError('Failed to create organization admin user');
|
|
118
|
+
}
|
|
119
|
+
created.userId = adminUser._id;
|
|
120
|
+
const role = await roleService.create(userContext, {
|
|
121
|
+
_orgId: organization._id,
|
|
122
|
+
name: ADMIN_ROLE_AND_FEATURE_NAME,
|
|
123
|
+
});
|
|
124
|
+
if (!role?._id) {
|
|
125
|
+
throw new ServerError('Failed to create organization admin role');
|
|
126
|
+
}
|
|
127
|
+
created.roleId = role._id;
|
|
128
|
+
const feature = await featureService.create(userContext, {
|
|
129
|
+
_orgId: organization._id,
|
|
130
|
+
name: ADMIN_ROLE_AND_FEATURE_NAME,
|
|
131
|
+
});
|
|
132
|
+
if (!feature?._id) {
|
|
133
|
+
throw new ServerError('Failed to create organization admin feature');
|
|
134
|
+
}
|
|
135
|
+
created.featureId = feature._id;
|
|
136
|
+
const userRole = await userRoleService.create(userContext, {
|
|
137
|
+
_orgId: organization._id,
|
|
138
|
+
userId: adminUser._id,
|
|
139
|
+
roleId: role._id,
|
|
140
|
+
});
|
|
141
|
+
if (!userRole?._id) {
|
|
142
|
+
throw new ServerError('Failed to create organization admin user role');
|
|
143
|
+
}
|
|
144
|
+
created.userRoleId = userRole._id;
|
|
145
|
+
const authorization = await authorizationService.create(userContext, {
|
|
146
|
+
_orgId: organization._id,
|
|
147
|
+
roleId: role._id,
|
|
148
|
+
featureId: feature._id,
|
|
149
|
+
});
|
|
150
|
+
if (!authorization?._id) {
|
|
151
|
+
throw new ServerError('Failed to create organization admin authorization');
|
|
152
|
+
}
|
|
153
|
+
created.authorizationId = authorization._id;
|
|
154
|
+
await initiatePasswordReset(getSystemUserContext(), this.database, organization._id, parsed.adminEmail, buildOrganizationClientBaseUrl(parsed.code), 'welcome');
|
|
155
|
+
logger.info(`Created organization ${parsed.code} with admin ${parsed.adminEmail}`);
|
|
156
|
+
const { password: _password, ...adminUserWithoutPassword } = adminUser;
|
|
157
|
+
return {
|
|
158
|
+
organization,
|
|
159
|
+
adminUser: adminUserWithoutPassword,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
await this.cleanupCreatedOrganizationWithAdmin(created, parsed.adminEmail);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
parseCreateOrganizationWithAdminInput(input) {
|
|
168
|
+
if (!input || typeof input !== 'object') {
|
|
169
|
+
throw new BadRequestError('Request body is required.');
|
|
170
|
+
}
|
|
171
|
+
const rawInput = input;
|
|
172
|
+
if ('password' in rawInput && rawInput.password !== undefined) {
|
|
173
|
+
throw new BadRequestError('Password must not be provided. The admin will set their password via the welcome email.');
|
|
174
|
+
}
|
|
175
|
+
if ('adminPassword' in rawInput && rawInput.adminPassword !== undefined) {
|
|
176
|
+
throw new BadRequestError('Password must not be provided. The admin will set their password via the welcome email.');
|
|
177
|
+
}
|
|
178
|
+
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
179
|
+
const code = typeof input.code === 'string' ? input.code : '';
|
|
180
|
+
const adminEmail = typeof input.adminEmail === 'string' ? input.adminEmail.trim().toLowerCase() : '';
|
|
181
|
+
if (input.adminDisplayName !== undefined && typeof input.adminDisplayName !== 'string') {
|
|
182
|
+
throw new BadRequestError('Admin display name must be a string.');
|
|
183
|
+
}
|
|
184
|
+
const adminDisplayName = input.adminDisplayName === undefined ? DEFAULT_ADMIN_DISPLAY_NAME : input.adminDisplayName;
|
|
185
|
+
if (!name) {
|
|
186
|
+
throw new BadRequestError('Organization name is required.');
|
|
187
|
+
}
|
|
188
|
+
if (!code) {
|
|
189
|
+
throw new BadRequestError('Organization code is required.');
|
|
190
|
+
}
|
|
191
|
+
if (!stringUtils.isValidDnsLabel(code)) {
|
|
192
|
+
throw new BadRequestError('Organization code must be a valid DNS label: lowercase letters, digits, and hyphens, with no leading or trailing hyphen.');
|
|
193
|
+
}
|
|
194
|
+
if (!adminEmail) {
|
|
195
|
+
throw new BadRequestError('Admin email is required.');
|
|
196
|
+
}
|
|
197
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminEmail)) {
|
|
198
|
+
throw new BadRequestError('Admin email is not a valid email address.');
|
|
199
|
+
}
|
|
200
|
+
return { name, code, adminEmail, adminDisplayName };
|
|
201
|
+
}
|
|
202
|
+
async assertCreateOrganizationWithAdminUniqueness(userContext, parsed, domain) {
|
|
203
|
+
const existingByName = await this.findOne(userContext, {
|
|
204
|
+
filters: { name: { eq: parsed.name } },
|
|
205
|
+
});
|
|
206
|
+
if (existingByName) {
|
|
207
|
+
throw new BadRequestError('Organization name is already in use.');
|
|
208
|
+
}
|
|
209
|
+
const existingByCode = await this.findOne(userContext, {
|
|
210
|
+
filters: { code: { eq: parsed.code } },
|
|
211
|
+
});
|
|
212
|
+
if (existingByCode) {
|
|
213
|
+
throw new BadRequestError('Organization code is already in use.');
|
|
214
|
+
}
|
|
215
|
+
const existingDomain = await this.organizationDomainService.findOne(userContext, {
|
|
216
|
+
filters: { domain: { eq: domain } },
|
|
217
|
+
});
|
|
218
|
+
if (existingDomain) {
|
|
219
|
+
throw new BadRequestError('Organization domain is already in use.');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async cleanupCreatedOrganizationWithAdmin(created, adminEmail) {
|
|
223
|
+
const systemUserContext = getSystemUserContext();
|
|
224
|
+
const userService = new UserService(this.database);
|
|
225
|
+
const roleService = new RoleService(this.database);
|
|
226
|
+
const featureService = new FeatureService(this.database);
|
|
227
|
+
const userRoleService = new UserRoleService(this.database);
|
|
228
|
+
const authorizationService = new AuthorizationService(this.database);
|
|
229
|
+
const passwordResetTokenService = new PasswordResetTokenService(this.database);
|
|
230
|
+
const deleteByIdIfPresent = async (label, deleteById, id) => {
|
|
231
|
+
if (id === undefined) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
await deleteById(systemUserContext, id);
|
|
236
|
+
}
|
|
237
|
+
catch (cleanupError) {
|
|
238
|
+
logger.error(`Failed to clean up ${label} ${String(id)} after createOrganizationWithAdmin error`, cleanupError);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
if (created.organizationId !== undefined) {
|
|
242
|
+
try {
|
|
243
|
+
await passwordResetTokenService.deleteMany(systemUserContext, {
|
|
244
|
+
filters: { email: { eq: adminEmail }, _orgId: { eq: created.organizationId } },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (cleanupError) {
|
|
248
|
+
logger.error('Failed to clean up password reset tokens after createOrganizationWithAdmin error', cleanupError);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
await deleteByIdIfPresent('authorization', authorizationService.deleteById.bind(authorizationService), created.authorizationId);
|
|
252
|
+
await deleteByIdIfPresent('userRole', userRoleService.deleteById.bind(userRoleService), created.userRoleId);
|
|
253
|
+
await deleteByIdIfPresent('feature', featureService.deleteById.bind(featureService), created.featureId);
|
|
254
|
+
await deleteByIdIfPresent('role', roleService.deleteById.bind(roleService), created.roleId);
|
|
255
|
+
await deleteByIdIfPresent('user', userService.deleteById.bind(userService), created.userId);
|
|
256
|
+
await deleteByIdIfPresent('organizationDomain', this.organizationDomainService.deleteById.bind(this.organizationDomainService), created.domainId);
|
|
257
|
+
await deleteByIdIfPresent('organization', this.deleteById.bind(this), created.organizationId);
|
|
258
|
+
}
|
|
57
259
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
declare function hashPassword(password: string): Promise<string>;
|
|
2
|
+
declare function generateRandomPassword(): string;
|
|
2
3
|
declare function comparePasswords(storedPassword: string, suppliedPassword: string): Promise<boolean>;
|
|
3
4
|
export declare const passwordUtils: {
|
|
4
5
|
hashPassword: typeof hashPassword;
|
|
5
6
|
comparePasswords: typeof comparePasswords;
|
|
7
|
+
generateRandomPassword: typeof generateRandomPassword;
|
|
6
8
|
};
|
|
7
9
|
export {};
|
|
@@ -6,6 +6,9 @@ async function hashPassword(password) {
|
|
|
6
6
|
const buf = (await scryptAsync(password, salt, 64));
|
|
7
7
|
return `${buf.toString('hex')}.${salt}`;
|
|
8
8
|
}
|
|
9
|
+
function generateRandomPassword() {
|
|
10
|
+
return randomBytes(16).toString('hex');
|
|
11
|
+
}
|
|
9
12
|
async function comparePasswords(storedPassword, suppliedPassword) {
|
|
10
13
|
if (!storedPassword?.includes('.')) {
|
|
11
14
|
return false;
|
|
@@ -20,4 +23,5 @@ async function comparePasswords(storedPassword, suppliedPassword) {
|
|
|
20
23
|
export const passwordUtils = {
|
|
21
24
|
hashPassword,
|
|
22
25
|
comparePasswords,
|
|
26
|
+
generateRandomPassword,
|
|
23
27
|
};
|